Communities, chats en forums zijn een eindeloze bron van informatie over een veelheid aan onderwerpen. Slack vervangt vaak technische documentatie en Telegram- en Discord-community's helpen met vragen over gaming, startups, crypto en reizen. Ondanks de relevantie van informatie uit de eerste hand, is deze vaak zeer ongestructureerd, waardoor het moeilijk is om erdoorheen te zoeken. In dit artikel onderzoeken we de complexiteit van het implementeren van een Telegram-bot die antwoorden op vragen vindt door informatie uit de geschiedenis van chatberichten te halen.
Dit zijn de uitdagingen die ons te wachten staan:
Offtopic negeren . Er is veel spam en offtopics, die we moeten leren identificeren en eruit filteren.
Prioritering . Informatie raakt verouderd. Hoe weet u het juiste antwoord tot nu toe?
Basischatbot-gebruikersstroom die we gaan implementeren
Laten we de belangrijkste fasen van de gebruikersstroom eens doornemen en de grootste uitdagingen belichten waarmee we te maken krijgen.
Om een berichtengeschiedenis voor zoekopdrachten voor te bereiden, moeten we de embeddings van deze berichten maken - vectorized text representations. Bij het werken met een wiki-artikel of PDF-document zouden we de tekst in paragrafen splitsen en Sentence Embedding voor elk berekenen.
We moeten echter rekening houden met de eigenaardigheden die typisch zijn voor chats en niet voor goed gestructureerde tekst:
Vervolgens moeten we het embeddingmodel kiezen. Er zijn veel verschillende modellen voor het bouwen van embeddings, en er moeten verschillende factoren in overweging worden genomen bij het kiezen van het juiste model.
Om de kwaliteit van de zoekresultaten te verbeteren, kunnen we berichten categoriseren op onderwerp. Bijvoorbeeld, in een chat gewijd aan frontend development, kunnen gebruikers onderwerpen bespreken zoals: CSS, tooling, React, Vue, etc. U kunt LLM (duurdere) of klassieke topic-modelleringsmethoden van bibliotheken zoals BERTopic gebruiken om berichten te classificeren op onderwerp.
We hebben ook een vectordatabase nodig om embeddings en meta-informatie (links naar originele posts, categorieën, data) op te slaan. Er bestaan veel vectoropslagen voor dit doel, zoals FAISS , Milvus of Pinecone . Een gewone PostgreSQL met de pgvector- extensie werkt ook.
Om de vraag van een gebruiker te kunnen beantwoorden, moeten we de vraag omzetten in een doorzoekbare vorm. Op die manier kunnen we de inbedding van de vraag berekenen en het doel ervan bepalen.
Het resultaat van een semantische zoekopdracht op een vraag kan bestaan uit soortgelijke vragen uit de chatgeschiedenis, maar niet uit de antwoorden daarop.
Om dit te verbeteren, kunnen we een van de populaire HyDE (hypothetical document embeddings) optimalisatietechnieken gebruiken. Het idee is om een hypothetisch antwoord op een vraag te genereren met behulp van LLM en vervolgens de embedding van het antwoord te berekenen. Deze aanpak maakt in sommige gevallen een nauwkeurigere en efficiëntere zoekopdracht naar relevante berichten tussen antwoorden mogelijk in plaats van vragen.
Zodra we de vraag hebben ingebed, kunnen we zoeken naar de dichtstbijzijnde berichten in de database. LLM heeft een beperkt contextvenster, dus we kunnen mogelijk niet alle zoekresultaten toevoegen als er te veel zijn. De vraag rijst hoe we de antwoorden kunnen prioriteren. Hiervoor zijn verschillende benaderingen:
Recentiescore . Informatie raakt na verloop van tijd verouderd en om nieuwe berichten te prioriteren, kunt u de recentiescore berekenen met de eenvoudige formule 1 / (today - date_of_message + 1)
Metadata filteren. (je moet het onderwerp van de vraag en posts identificeren). Dit helpt om je zoekopdracht te verfijnen, zodat alleen de posts overblijven die relevant zijn voor het onderwerp waarnaar je op zoek bent.
Nadat we in de vorige stap hebben gezocht en gesorteerd, kunnen we de 50-100 meest relevante berichten behouden die passen bij de LLM-context.
De volgende stap is om een duidelijke en beknopte prompt voor LLM te maken met behulp van de oorspronkelijke query van de gebruiker en zoekresultaten. Het moet de LLM specificeren hoe de vraag, de query van de gebruiker en de context te beantwoorden - de relevante berichten die we hebben gevonden. Voor dit doel is het essentieel om deze aspecten te overwegen:
Systeemprompts zijn instructies aan het model die uitleggen hoe het informatie moet verwerken. U kunt de LLM bijvoorbeeld vertellen om alleen in de verstrekte gegevens naar een antwoord te zoeken.
Contextlengte - de maximale lengte van de berichten die we als invoer kunnen gebruiken. We kunnen het aantal tokens berekenen met behulp van de tokenizer die overeenkomt met het model dat we gebruiken. OpenAI gebruikt bijvoorbeeld Tiktoken.
Hyperparameters van het model , zoals de temperatuur, bepalen hoe creatief het model reageert.
De keuze van het model . Het is niet altijd de moeite waard om te veel te betalen voor het grootste en krachtigste model. Het is zinvol om verschillende tests met verschillende modellen uit te voeren en hun resultaten te vergelijken. In sommige gevallen zullen minder resource-intensieve modellen de klus klaren als ze geen hoge nauwkeurigheid vereisen.
Laten we nu proberen deze stappen te implementeren met NodeJS. Dit is de tech stack die ik ga gebruiken:
Laten we de basisstappen van het installeren van dependencies en telegram bot setup overslaan en direct doorgaan naar de belangrijkste features. Het database schema, dat later nodig zal zijn:
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; }
Het opsplitsen van lange dialogen tussen meerdere gebruikers in stukken is geen eenvoudige opgave.
Helaas houden standaardbenaderingen zoals RecursiveCharacterTextSplitter , beschikbaar in de Langchain-bibliotheek, geen rekening met alle eigenaardigheden die specifiek zijn voor chatten. In het geval van Telegram kunnen we echter profiteren van Telegram- threads
die gerelateerde berichten en de antwoorden van gebruikers bevatten.
Elke keer dat er een nieuwe batch berichten uit de chatroom binnenkomt, moet onze bot een paar stappen uitvoeren:
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; } // .... }
Vervolgens moeten we de embeddings voor elk van de chunks berekenen. Hiervoor kunnen we het OpenAI-model text-embedding-3-large
gebruiken
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(); }
Om de vraag van een gebruiker te beantwoorden, tellen we eerst de inbedding van de vraag en zoeken we vervolgens de meest relevante berichten in de chatgeschiedenis
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); }
Vervolgens rangschikken we de zoekresultaten opnieuw met behulp van het herrangschikkingsmodel van 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; }
Vraag de LLM vervolgens om de vraag van de gebruiker te beantwoorden door de zoekresultaten samen te vatten. De vereenvoudigde versie van het verwerken van een zoekopdracht ziet er als volgt uit:
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.. `; }
Zelfs na alle optimalisaties vinden we de LLM-aangedreven botantwoorden misschien niet ideaal en onvolledig. Wat kan er nog meer verbeterd worden?
Voor gebruikersberichten die links bevatten, kunnen we ook de inhoud van webpagina's en pdf-documenten analyseren.
Query-Routing : gebruikersquery's doorsturen naar de meest geschikte gegevensbron, het meest geschikte model of de meest geschikte index, op basis van de bedoeling en context van de query, om de nauwkeurigheid, efficiëntie en kosten te optimaliseren.
We kunnen bronnen die relevant zijn voor het onderwerp van de chatroom opnemen in de zoekindex. Op het werk kan dit documentatie zijn van Confluence, voor visumchats, websites van consulaten met regels, etc.
RAG-evaluatie - We moeten een pijplijn opzetten om de kwaliteit van de reacties van onze bot te evalueren