paint-brush
Il chatbot AI aiuta a gestire le community di Telegram come un professionistadi@slavasobolev
Nuova storia

Il chatbot AI aiuta a gestire le community di Telegram come un professionista

di Iaroslav Sobolev12m2025/01/09
Read on Terminal Reader

Troppo lungo; Leggere

Il chatbot di Telegram troverà le risposte alle domande estraendo informazioni dalla cronologia dei messaggi di chat. Cercherà le risposte pertinenti trovando le risposte più vicine nella cronologia. Il bot riassume i risultati della ricerca con l'aiuto di LLM e restituisce all'utente la risposta finale con link ai messaggi pertinenti.
featured image - Il chatbot AI aiuta a gestire le community di Telegram come un professionista
Iaroslav Sobolev HackerNoon profile picture
0-item
1-item


Community, chat e forum sono una fonte infinita di informazioni su una moltitudine di argomenti. Slack spesso sostituisce la documentazione tecnica e le community di Telegram e Discord aiutano con domande su giochi, startup, criptovalute e viaggi. Nonostante la rilevanza delle informazioni di prima mano, sono spesso altamente non strutturate, il che rende difficile la ricerca. In questo articolo, esploreremo le complessità dell'implementazione di un bot di Telegram che troverà risposte alle domande estraendo informazioni dalla cronologia dei messaggi di chat.


Ecco le sfide che ci attendono:

  • Trova messaggi pertinenti . La risposta potrebbe essere sparsa nel dialogo di più persone o in un collegamento a risorse esterne.

  • Ignorare l'offtopic . C'è molto spam e off-topic, che dovremmo imparare a identificare e filtrare

  • Prioritizzazione . Le informazioni diventano obsolete. Come fai a sapere la risposta corretta fino ad oggi?


Flusso di lavoro di base del chatbot che implementeremo

  1. L'utente pone una domanda al bot
  2. Il bot trova le risposte più vicine nella cronologia dei messaggi
  3. Il bot riassume i risultati della ricerca con l'aiuto di LLM
  4. Restituisce all'utente la risposta finale con link ai messaggi pertinenti


Esaminiamo le fasi principali di questo flusso utente e mettiamo in evidenza le principali sfide che dovremo affrontare.

Preparazione dei dati

Per preparare una cronologia dei messaggi per la ricerca, dobbiamo creare gli incorporamenti di questi messaggi, ovvero rappresentazioni di testo vettorializzate. Quando si ha a che fare con un articolo wiki o un documento PDF, si dividerebbe il testo in paragrafi e si calcolerebbe l'Embedding di frasi per ciascuno.


Tuttavia, dobbiamo tenere conto delle peculiarità che sono tipiche delle chat e non dei testi ben strutturati:


  • Più messaggi brevi successivi da un singolo utente. In questi casi, vale la pena combinare i messaggi in blocchi di testo più grandi
  • Alcuni messaggi sono molto lunghi e coprono diversi argomenti
  • Messaggi senza senso e spam che dovremmo filtrare
  • L'utente può rispondere senza taggare il messaggio originale. Una domanda e una risposta possono essere separate nella cronologia della chat da molti altri messaggi
  • L'utente può rispondere con un collegamento a una risorsa esterna (ad esempio, un articolo o un documento)


Successivamente, dovremmo scegliere il modello di embedding. Esistono molti modelli diversi per la costruzione di embedding e diversi fattori devono essere considerati quando si sceglie il modello giusto.


  • Dimensione degli incorporamenti . Più è alta, più sfumature il modello può apprendere dai dati. La ricerca sarà più accurata ma richiederà più memoria e risorse computazionali.
  • Set di dati su cui è stato addestrato il modello di incorporamento. Questo determinerà, ad esempio, quanto bene supporta il linguaggio di cui hai bisogno.


Per migliorare la qualità dei risultati di ricerca, possiamo categorizzare i messaggi per argomento. Ad esempio, in una chat dedicata allo sviluppo frontend, gli utenti possono discutere argomenti come: CSS, tooling, React, Vue, ecc. Puoi usare LLM (più costoso) o i classici metodi di modellazione degli argomenti da librerie come BERTopic per classificare i messaggi per argomento.


Avremo anche bisogno di un database vettoriale per memorizzare embedding e meta-informazioni (link a post originali, categorie, date). Esistono molti archivi vettoriali, come FAISS , Milvus o Pinecone , per questo scopo. Anche un normale PostgreSQL con l'estensione pgvector funzionerà.

Elaborazione della domanda di un utente

Per rispondere alla domanda di un utente, dobbiamo convertire la domanda in un modulo ricercabile e quindi calcolare l'incorporamento della domanda, nonché determinarne l'intento.


Il risultato di una ricerca semantica su una domanda potrebbe essere costituito da domande simili presenti nella cronologia della chat, ma non dalle relative risposte.


Per migliorare questo, possiamo usare una delle popolari tecniche di ottimizzazione HyDE (hypothetical document embedding). L'idea è di generare una risposta ipotetica a una domanda usando LLM e poi calcolare l'embedding della risposta. Questo approccio in alcuni casi consente una ricerca più accurata ed efficiente di messaggi rilevanti tra le risposte piuttosto che tra le domande.


Trovare i messaggi più pertinenti

Una volta che abbiamo l'incorporamento della domanda, possiamo cercare i messaggi più vicini nel database. LLM ha una finestra di contesto limitata, quindi potremmo non essere in grado di aggiungere tutti i risultati della ricerca se ce ne sono troppi. Sorge la domanda su come dare priorità alle risposte. Ci sono diversi approcci per questo:


  • Punteggio di recency . Nel tempo, le informazioni diventano obsolete e, per dare priorità ai nuovi messaggi, puoi calcolare il punteggio di recency utilizzando la semplice formula 1 / (today - date_of_message + 1)


  • Filtraggio dei metadati. (devi identificare l'argomento della domanda e dei post). Questo aiuta a restringere la ricerca, lasciando solo i post che sono pertinenti all'argomento che stai cercando


  • Ricerca full-text . La classica ricerca full-text, ben supportata da tutti i database più diffusi, può a volte essere utile.


  • Reranking . Una volta trovate le risposte, possiamo ordinarle in base al grado di "vicinanza" alla domanda, lasciando solo quelle più rilevanti. Il reranking richiederà un modello CrossEncoder , oppure possiamo usare l'API di reranking, ad esempio, di Cohere .


Generazione della risposta finale

Dopo aver effettuato la ricerca e l'ordinamento nel passaggio precedente, possiamo conservare i 50-100 post più pertinenti che si adatteranno al contesto LLM.


Il passo successivo è creare un prompt chiaro e conciso per LLM utilizzando la query originale dell'utente e i risultati della ricerca. Dovrebbe specificare a LLM come rispondere alla domanda, alla query dell'utente e al contesto, ovvero i messaggi rilevanti che abbiamo trovato. A questo scopo, è essenziale considerare questi aspetti:


  • I System Prompt sono istruzioni al modello che spiegano come dovrebbe elaborare le informazioni. Ad esempio, puoi dire all'LLM di cercare una risposta solo nei dati forniti.


  • Lunghezza del contesto : la lunghezza massima dei messaggi che possiamo usare come input. Possiamo calcolare il numero di token usando il tokenizzatore corrispondente al modello che utilizziamo. Ad esempio, OpenAI usa Tiktoken.


  • Iperparametri del modello : ad esempio, la temperatura è responsabile della creatività del modello nelle sue risposte.


  • La scelta del modello . Non vale sempre la pena pagare troppo per il modello più grande e potente. Ha senso condurre diversi test con modelli diversi e confrontarne i risultati. In alcuni casi, modelli meno dispendiosi in termini di risorse faranno il lavoro se non richiedono un'elevata accuratezza.


Implementazione

Ora proviamo a implementare questi passaggi con NodeJS. Ecco lo stack tecnologico che utilizzerò:


  • NodeJS e TypeScript
  • Grammy - Framework per bot Telegram
  • PostgreSQL - come archivio primario per tutti i nostri dati
  • pgvector - Estensione PostgreSQL per l'archiviazione di incorporamenti di testo e messaggi
  • OpenAI API - LLM e modelli di incorporamento
  • Mikro-ORM - per semplificare le interazioni con i database


Saltiamo i passaggi di base dell'installazione delle dipendenze e della configurazione del bot di Telegram e passiamo direttamente alle funzionalità più importanti. Lo schema del database, che sarà necessario in seguito:


 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; }


Suddividere i dialoghi utente in blocchi

Suddividere in blocchi lunghi dialoghi tra più utenti non è un compito banale.


Sfortunatamente, gli approcci predefiniti come RecursiveCharacterTextSplitter , disponibili nella libreria Langchain, non tengono conto di tutte le peculiarità specifiche della chat. Tuttavia, nel caso di Telegram, possiamo sfruttare i threads di Telegram che contengono messaggi correlati e le risposte inviate dagli utenti.


Ogni volta che arriva un nuovo batch di messaggi dalla chat room, il nostro bot deve eseguire alcuni passaggi:


  • Filtra i messaggi brevi in base a un elenco di parole inutili (ad esempio "ciao", "arrivederci", ecc.)
  • Unisci i messaggi di un utente se sono stati inviati consecutivamente in un breve lasso di tempo
  • Raggruppa tutti i messaggi appartenenti allo stesso thread
  • Unisci i gruppi di messaggi ricevuti in blocchi di testo più grandi e dividi ulteriormente questi blocchi di testo in blocchi utilizzando RecursiveCharacterTextSplitter
  • Calcola gli incorporamenti per ogni blocco
  • Mantieni i blocchi di testo nel database insieme ai loro incorporamenti e collegamenti ai messaggi originali


 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; } // .... }


Incorporamenti

Successivamente, dobbiamo calcolare gli embedding per ciascuno dei chunk. Per questo possiamo usare il modello 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(); }



Rispondere alle domande degli utenti

Per rispondere alla domanda di un utente, prima contiamo l'incorporamento della domanda e poi troviamo i messaggi più pertinenti nella cronologia della 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); }



Quindi riclassifichiamo i risultati della ricerca con l'aiuto del modello di riclassificazione di 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; }



Successivamente, chiedi all'LLM di rispondere alla domanda dell'utente riassumendo i risultati della ricerca. La versione semplificata dell'elaborazione di una query di ricerca sarà simile a questa:


 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.. `; }



Ulteriori miglioramenti

Anche dopo tutte le ottimizzazioni, potremmo pensare che le risposte del bot basato su LLM non siano ideali e incomplete. Cos'altro potrebbe essere migliorato?


  • Per i post degli utenti che includono link, possiamo anche analizzare il contenuto delle pagine web e dei documenti PDF.

  • Query-Routing : indirizzamento delle query degli utenti alla fonte dati, al modello o all'indice più appropriati in base all'intento e al contesto della query, per ottimizzare accuratezza, efficienza e costi.

  • Possiamo includere nell'indice di ricerca risorse pertinenti all'argomento della chat room: ad esempio, può trattarsi di documentazione di Confluence, per le chat sui visti, siti web dei consolati con regole, ecc.

  • Valutazione RAG : dobbiamo impostare una pipeline per valutare la qualità delle risposte del nostro bot