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: . La risposta potrebbe essere sparsa nel dialogo di più persone o in un collegamento a risorse esterne. Trova messaggi pertinenti . C'è molto spam e off-topic, che dovremmo imparare a identificare e filtrare Ignorare l'offtopic . Le informazioni diventano obsolete. Come fai a sapere la risposta corretta fino ad oggi? Prioritizzazione che implementeremo Flusso di lavoro di base del chatbot L'utente pone una domanda al bot Il bot trova le risposte più vicine nella cronologia dei messaggi Il bot riassume i risultati della ricerca con l'aiuto di LLM 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. . Più è alta, più sfumature il modello può apprendere dai dati. La ricerca sarà più accurata ma richiederà più memoria e risorse computazionali. Dimensione degli incorporamenti su cui è stato addestrato il modello di incorporamento. Questo determinerà, ad esempio, quanto bene supporta il linguaggio di cui hai bisogno. Set di dati 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 per classificare i messaggi per argomento. BERTopic Avremo anche bisogno di un database vettoriale per memorizzare embedding e meta-informazioni (link a post originali, categorie, date). Esistono molti archivi vettoriali, come , o , per questo scopo. Anche un normale PostgreSQL con l'estensione funzionerà. FAISS Milvus Pinecone pgvector 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 (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. HyDE 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: . Nel tempo, le informazioni diventano obsolete e, per dare priorità ai nuovi messaggi, puoi calcolare il punteggio di recency utilizzando la semplice formula Punteggio di recency 1 / (today - date_of_message + 1) (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 Filtraggio dei metadati. . La classica ricerca full-text, ben supportata da tutti i database più diffusi, può a volte essere utile. Ricerca full-text . 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 , oppure possiamo usare l'API di reranking, ad esempio, di . Reranking CrossEncoder 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: 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. I System Prompt : 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. Lunghezza del contesto : ad esempio, la temperatura è responsabile della creatività del modello nelle sue risposte. Iperparametri 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. La scelta del modello Implementazione Ora proviamo a implementare questi passaggi con NodeJS. Ecco lo stack tecnologico che utilizzerò: e NodeJS TypeScript - Framework per bot Telegram Grammy - come archivio primario per tutti i nostri dati PostgreSQL - Estensione PostgreSQL per l'archiviazione di incorporamenti di testo e messaggi pgvector - LLM e modelli di incorporamento OpenAI API - per semplificare le interazioni con i database Mikro-ORM 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 , disponibili nella libreria Langchain, non tengono conto di tutte le peculiarità specifiche della chat. Tuttavia, nel caso di Telegram, possiamo sfruttare i di Telegram che contengono messaggi correlati e le risposte inviate dagli utenti. RecursiveCharacterTextSplitter threads 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. : 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. Query-Routing 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. : dobbiamo impostare una pipeline per valutare la qualità delle risposte del nostro bot Valutazione RAG