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:
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
Esaminiamo le fasi principali di questo flusso utente e mettiamo in evidenza le principali sfide che dovremo affrontare.
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:
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.
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à.
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.
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
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.
Ora proviamo a implementare questi passaggi con NodeJS. Ecco lo stack tecnologico che utilizzerò:
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 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:
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; } // .... }
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(); }
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.. `; }
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