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:
Ignorando offtopic . Hai moito spam e fóra de temas, que debemos aprender a identificar e filtrar
Priorización . A información queda desactualizada. Como sabes a resposta correcta ata a data?
Fluxo de usuario básico de chatbot que imos implementar
Percorremos as principais etapas deste fluxo de usuarios e destacamos os principais retos aos que nos enfrontaremos.
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:
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.
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 BERTopic para clasificar as mensaxes por temas.
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 FAISS , Milvus ou Pinecone , para este fin. Tamén funcionará un PostgreSQL normal coa extensión pgvector .
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 HyDE (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.
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:
Puntuación recente . 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 1 / (today - date_of_message + 1)
Filtrado de metadatos. (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
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:
Os avisos do sistema 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.
Lonxitude do contexto : 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.
Hiperparámetros do modelo : por exemplo, a temperatura é responsable da creatividade do modelo nas súas respostas.
A elección 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.
Agora imos tentar implementar estes pasos con NodeJS. Aquí está a pila de tecnoloxía que vou usar:
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; }
Dividir diálogos longos entre varios usuarios en anacos non é a tarefa máis trivial.
Desafortunadamente, enfoques predeterminados como RecursiveCharacterTextSplitter , 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 threads
de Telegram que conteñen mensaxes relacionadas e as respostas enviadas polos usuarios.
Cada vez que chega un novo lote de mensaxes da sala de chat, o noso bot debe realizar algúns pasos:
RecursiveCharacterTextSplitter
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; } // .... }
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(); }
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.. `; }
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.
Enrutamento de consultas : 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.
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.
Avaliación RAG - Necesitamos configurar unha canalización para avaliar a calidade das respostas do noso bot