As comunidades, os chats e os foros son unha fonte infinita de información sobre multitude de temas. Slack adoita substituír a documentación técnica e as comunidades de Telegram e Discord axudan con xogos, startups, criptografía e preguntas de viaxes. A pesar da relevancia da información de primeira man, adoita estar moi desestructurada, o que dificulta a súa busca. Neste artigo, exploraremos as complexidades de implementar un bot de Telegram que atopará respostas ás preguntas extraendo información do historial de mensaxes de chat. Estes son os retos que nos esperan: . A resposta pode estar espallada polo diálogo de varias persoas ou nunha ligazón a recursos externos. Busca mensaxes relevantes . Hai moito spam e fóra de temas, que debemos aprender a identificar e filtrar Ignorando offtopic . A información queda desactualizada. Como sabes a resposta correcta ata a data? Priorización que imos implementar Fluxo de usuario básico de chatbot O usuario fai unha pregunta ao bot O bot atopa as respostas máis próximas no historial de mensaxes O bot resume os resultados da busca coa axuda de LLM Devolve ao usuario a resposta final con ligazóns a mensaxes relevantes Percorremos as principais etapas deste fluxo de usuarios e destacamos os principais retos aos que nos enfrontaremos. Preparación de datos Para preparar un historial de mensaxes para a busca, necesitamos crear as incrustacións destas mensaxes: representacións de texto vectorizados. Ao tratar cun artigo wiki ou un documento PDF, dividiríamos o texto en parágrafos e calcularíamos a incorporación de frases para cada un. Non obstante, debemos ter en conta as peculiaridades típicas dos chats e non dos textos ben estruturados: Múltiples mensaxes curtas posteriores dun só usuario. Nestes casos, paga a pena combinar mensaxes en bloques de texto máis grandes Algunhas das mensaxes son moi longas e abarcan varios temas diferentes Mensaxes sen sentido e spam que debemos filtrar O usuario pode responder sen etiquetar a mensaxe orixinal. Unha pregunta e unha resposta pódense separar no historial de chat por moitas outras mensaxes O usuario pode responder cunha ligazón a un recurso externo (por exemplo, un artigo ou documento) A continuación, debemos escoller o modelo de incorporación. Hai moitos modelos diferentes para construír incrustacións e hai que ter en conta varios factores á hora de elixir o modelo correcto. . Canto máis alto sexa, máis matices pode aprender o modelo dos datos. A busca será máis precisa pero requirirá máis memoria e recursos computacionais. Dimensión de incrustacións no que se adestrou o modelo de incorporación. Isto determinará, por exemplo, o ben que admite o idioma que necesitas. Conxunto de datos Para mellorar a calidade dos resultados da busca, podemos clasificar as mensaxes por tema. Por exemplo, nun chat dedicado ao desenvolvemento de frontend, os usuarios poden discutir temas como: CSS, ferramentas, React, Vue, etc. Podes usar LLM (máis caro) ou métodos clásicos de modelado de temas de bibliotecas como para clasificar as mensaxes por temas. BERTopic Tamén necesitaremos unha base de datos vectorial para almacenar incrustacións e metainformación (enlaces a publicacións orixinais, categorías, datas). Existen moitos almacenamentos de vectores, como , ou , para este fin. Tamén funcionará un PostgreSQL normal coa extensión . FAISS Milvus Pinecone pgvector Procesando unha pregunta dun usuario Para responder a pregunta dun usuario, necesitamos converter a pregunta nun formulario de busca e, así, calcular a incrustación da pregunta, así como determinar a súa intención. O resultado dunha busca semántica nunha pregunta podería ser preguntas similares do historial de chat pero non as respostas a elas. Para mellorar isto, podemos utilizar unha das populares técnicas de optimización (incrustacións de documentos hipotéticos). A idea é xerar unha resposta hipotética a unha pregunta usando LLM e despois calcular a incorporación da resposta. Este enfoque permite nalgúns casos unha busca máis precisa e eficiente de mensaxes relevantes entre as respostas en lugar de entre as preguntas. HyDE Busca as mensaxes máis relevantes Unha vez que teñamos a pregunta incrustada, podemos buscar as mensaxes máis próximas na base de datos. LLM ten unha xanela de contexto limitada, polo que é posible que non poidamos engadir todos os resultados da busca se hai demasiados. Xorde a pregunta de como priorizar as respostas. Hai varios enfoques para iso: . Co paso do tempo, a información queda desactualizada e, para priorizar as novas mensaxes, podes calcular a puntuación de recentes utilizando a fórmula sinxela Puntuación recente 1 / (today - date_of_message + 1) (cómpre identificar o tema da pregunta e as publicacións). Isto axuda a restrinxir a túa busca, deixando só as publicacións que sexan relevantes para o tema que buscas Filtrado de metadatos. . A busca clásica de texto completo, que está ben apoiada por todas as bases de datos populares, ás veces pode ser útil. Busca de texto completo . Unha vez atopadas as respostas, podemos ordenalas segundo o grao de "proximidade" á pregunta, deixando só as máis relevantes. A recalificación requirirá un modelo , ou podemos usar a API de reranking, por exemplo, de . Recalificación CrossEncoder Cohere Xeración da resposta final Despois de buscar e ordenar no paso anterior, podemos manter as 50-100 publicacións máis relevantes que encaixarán no contexto de LLM. O seguinte paso é crear unha solicitude clara e concisa para LLM usando a consulta orixinal do usuario e os resultados da busca. Debería especificar ao LLM como responder á pregunta, a consulta do usuario e o contexto: as mensaxes relevantes que atopamos. Para iso, é fundamental ter en conta os seguintes aspectos: son instrucións para o modelo que explican como debe procesar a información. Por exemplo, pode dicirlle ao LLM que busque unha resposta só nos datos proporcionados. Os avisos do sistema : a lonxitude máxima das mensaxes que podemos usar como entrada. Podemos calcular o número de fichas mediante o tokenizer correspondente ao modelo que utilizamos. Por exemplo, OpenAI usa Tiktoken. Lonxitude do contexto : por exemplo, a temperatura é responsable da creatividade do modelo nas súas respostas. Hiperparámetros do modelo . Non sempre paga a pena pagar de máis polo modelo máis grande e poderoso. Ten sentido realizar varias probas con modelos diferentes e comparar os seus resultados. Nalgúns casos, os modelos menos intensivos en recursos farán o traballo se non requiren unha alta precisión. A elección do modelo Implementación Agora imos tentar implementar estes pasos con NodeJS. Aquí está a pila de tecnoloxía que vou usar: e NodeJS TypeScript : marco de bots de Telegram Grammy : como almacenamento principal para todos os nosos datos PostgreSQL - Extensión PostgreSQL para almacenar incrustacións de texto e mensaxes pgvector - LLM и e modelos de incorporacións OpenAI API - para simplificar as interaccións de base de datos Mikro-ORM Omitamos os pasos básicos da instalación de dependencias e da configuración do bot de telegram e pasemos directamente ás funcións máis importantes. O esquema da base de datos, que será necesario máis adiante: import { Entity, Enum, Property, Unique } from '@mikro-orm/core'; @Entity({ tableName: 'groups' }) export class Group extends BaseEntity { @PrimaryKey() id!: number; @Property({ type: 'bigint' }) channelId!: number; @Property({ type: 'text', nullable: true }) title?: string; @Property({ type: 'json' }) attributes!: Record<string, unknown>; } @Entity({ tableName: 'messages' }) export class Message extends BaseEntity { @PrimaryKey() id!: number; @Property({ type: 'bigint' }) messageId!: number; @Property({ type: TextType }) text!: string; @Property({ type: DateTimeType }) date!: Date; @ManyToOne(() => Group, { onDelete: 'cascade' }) group!: Group; @Property({ type: 'string', nullable: true }) fromUserName?: string; @Property({ type: 'bigint', nullable: true }) replyToMessageId?: number; @Property({ type: 'bigint', nullable: true }) threadId?: number; @Property({ type: 'json' }) attributes!: { raw: Record<any, any>; }; } @Entity({ tableName: 'content_chunks' }) export class ContentChunk extends BaseEntity { @PrimaryKey() id!: number; @ManyToOne(() => Group, { onDelete: 'cascade' }) group!: Group; @Property({ type: TextType }) text!: string; @Property({ type: VectorType, length: 1536, nullable: true }) embeddings?: number[]; @Property({ type: 'int' }) tokens!: number; @Property({ type: new ArrayType<number>((i: string) => +i), nullable: true }) messageIds?: number[]; @Property({ persist: false, nullable: true }) distance?: number; } Divide os diálogos de usuario en anacos Dividir diálogos longos entre varios usuarios en anacos non é a tarefa máis trivial. Desafortunadamente, enfoques predeterminados como , dispoñible na biblioteca Langchain, non teñen en conta todas as peculiaridades específicas do chat. Non obstante, no caso de Telegram, podemos aproveitar os de Telegram que conteñen mensaxes relacionadas e as respostas enviadas polos usuarios. RecursiveCharacterTextSplitter threads Cada vez que chega un novo lote de mensaxes da sala de chat, o noso bot debe realizar algúns pasos: Filtra as mensaxes curtas por unha lista de palabras limitadas (por exemplo, "ola", "adeus", etc.) Combina as mensaxes dun usuario se foron enviadas consecutivamente nun curto período de tempo Agrupa todas as mensaxes que pertencen ao mesmo fío Combina os grupos de mensaxes recibidas en bloques de texto máis grandes e divide estes bloques de texto en anacos usando RecursiveCharacterTextSplitter Calcula as incrustacións para cada anaco Persiste os fragmentos de texto na base de datos xunto coas súas incorporacións e ligazóns ás mensaxes orixinais class ChatContentSplitter { constructor( private readonly splitter RecursiveCharacterTextSplitter, private readonly longMessageLength = 200 ) {} public async split(messages: EntityDTO<Message>[]): Promise<ContentChunk[]> { const filtered = this.filterMessage(messages); const merged = this.mergeUserMessageSeries(filtered); const threads = this.toThreads(merged); const chunks = await this.threadsToChunks(threads); return chunks; } toThreads(messages: EntityDTO<Message>[]): EntityDTO<Message>[][] { const threads = new Map<number, EntityDTO<Message>[]>(); const orphans: EntityDTO<Message>[][] = []; for (const message of messages) { if (message.threadId) { let thread = threads.get(message.threadId); if (!thread) { thread = []; threads.set(message.threadId, thread); } thread.push(message); } else { orphans.push([message]); } } return [Array.from(threads.values()), ...orphans]; } private async threadsToChunks( threads: EntityDTO<Message>[][], ): Promise<ContentChunk[]> { const result: ContentChunk[] = []; for await (const thread of threads) { const content = thread.map((m) => this.dtoToString(m)) .join('\n') const texts = await this.splitter.splitText(content); const messageIds = thread.map((m) => m.id); const chunks = texts.map((text) => new ContentChunk(text, messageIds) ); result.push(...chunks); } return result; } mergeMessageSeries(messages: EntityDTO<Message>[]): EntityDTO<Message>[] { const result: EntityDTO<Message>[] = []; let next = messages[0]; for (const message of messages.slice(1)) { const short = message.text.length < this.longMessageLength; const sameUser = current.fromId === message.fromId; const subsequent = differenceInMinutes(current.date, message.date) < 10; if (sameUser && subsequent && short) { next.text += `\n${message.text}`; } else { result.push(current); next = message; } } return result; } // .... } Incrustacións A continuación, necesitamos calcular as incrustacións para cada un dos anacos. Para iso podemos utilizar o modelo OpenAI text-embedding-3-large public async getEmbeddings(chunks: ContentChunks[]) { const chunked = groupArray(chunks, 100); for await (const chunk of chunks) { const res = await this.openai.embeddings.create({ input: c.text, model: 'text-embedding-3-large', encoding_format: "float" }); chunk.embeddings = res.data[0].embedding } await this.orm.em.flush(); } Responder preguntas dos usuarios Para responder a pregunta dun usuario, primeiro contamos a incrustación da pregunta e despois atopamos as mensaxes máis relevantes no historial de chat public async similaritySearch(embeddings: number[], groupId; number): Promise<ContentChunk[]> { return this.orm.em.qb(ContentChunk) .where({ embeddings: { $ne: null }, group: this.orm.em.getReference(Group, groupId) }) .orderBy({[l2Distance('embedding', embedding)]: 'ASC'}) .limit(100); } Despois volvemos clasificar os resultados da busca coa axuda do modelo de reclasificación de Cohere public async rerank(query: string, chunks: ContentChunk[]): Promise<ContentChunk> { const { results } = await cohere.v2.rerank({ documents: chunks.map(c => c.text), query, model: 'rerank-v3.5', }); const reranked = Array(results.length).fill(null); for (const { index } of results) { reranked[index] = chunks[index]; } return reranked; } A continuación, pídelle ao LLM que responda á pregunta do usuario resumindo os resultados da busca. A versión simplificada do procesamento dunha consulta de busca terá o seguinte aspecto: public async search(query: string, group: Group) { const queryEmbeddings = await this.getEmbeddings(query); const chunks = this.chunkService.similaritySearch(queryEmbeddings, group.id); const reranked = this.cohereService.rerank(query, chunks); const completion = await this.openai.chat.completions.create({ model: 'gpt-4-turbo', temperature: 0, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: this.userPromptTemplate(query, reranked) }, ] ] return completion.choices[0].message; } // naive prompt public userPromptTemplate(query: string, chunks: ContentChunk[]) { const history = chunks .map((c) => `${c.text}`) .join('\n----------------------------\n') return ` Answer the user's question: ${query} By summarizing the following content: ${history} Keep your answer direct and concise. Provide refernces to the corresponding messages.. `; } Máis melloras Mesmo despois de todas as optimizacións, podemos sentir que as respostas do bot alimentado por LLM non son ideais e incompletas. Que máis se podería mellorar? Para as publicacións dos usuarios que inclúen ligazóns, tamén podemos analizar o contido das páxinas web e dos documentos pdf. : dirixir as consultas dos usuarios á fonte de datos, modelo ou índice máis axeitado en función da intención e do contexto da consulta para optimizar a precisión, a eficiencia e o custo. Enrutamento de consultas Podemos incluír recursos relevantes para o tema da sala de chat no índice de busca: no traballo, pode ser documentación de Confluence, para chats de visados, sitios web de consulados con regras, etc. - Necesitamos configurar unha canalización para avaliar a calidade das respostas do noso bot Avaliación RAG