paint-brush
AI Chatbot axuda a xestionar as comunidades de Telegram como un profesionalpor@slavasobolev
Nova historia

AI Chatbot axuda a xestionar as comunidades de Telegram como un profesional

por Iaroslav Sobolev12m2025/01/09
Read on Terminal Reader

Demasiado longo; Ler

O chatbot de Telegram atopará respostas ás preguntas extraendo información do historial de mensaxes de chat. Buscará respostas relevantes atopando as respostas máis próximas na historia. O bot resume os resultados da busca coa axuda de LLM e devolve ao usuario a resposta final con ligazóns a mensaxes relevantes.
featured image - AI Chatbot axuda a xestionar as comunidades de Telegram como un profesional
Iaroslav Sobolev HackerNoon profile picture
0-item
1-item


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:

  • Busca mensaxes relevantes . A resposta pode estar espallada polo diálogo de varias persoas ou nunha ligazón a recursos externos.

  • 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

  1. O usuario fai unha pregunta ao bot
  2. O bot atopa as respostas máis próximas no historial de mensaxes
  3. O bot resume os resultados da busca coa axuda de LLM
  4. 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.


  • Dimensión de incrustacións . 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.
  • Conxunto de datos no que se adestrou o modelo de incorporación. Isto determinará, por exemplo, o ben que admite o idioma que necesitas.


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 .

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 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.


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:


  • 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


  • Busca de texto completo . A busca clásica de texto completo, que está ben apoiada por todas as bases de datos populares, ás veces pode ser útil.


  • Recalificación . 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 CrossEncoder , ou podemos usar a API de reranking, por exemplo, de 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:


  • 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.


Implementación

Agora imos tentar implementar estes pasos con NodeJS. Aquí está a pila de tecnoloxía que vou usar:


  • NodeJS e TypeScript
  • Grammy : marco de bots de Telegram
  • PostgreSQL : como almacenamento principal para todos os nosos datos
  • pgvector - Extensión PostgreSQL para almacenar incrustacións de texto e mensaxes
  • OpenAI API - LLM и e modelos de incorporacións
  • Mikro-ORM - para simplificar as interaccións de base de datos


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 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:


  • 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.

  • 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