paint-brush
AI Chatbot helpt Telegram-communities als een pro te beherendoor@slavasobolev
Nieuwe geschiedenis

AI Chatbot helpt Telegram-communities als een pro te beheren

door Iaroslav Sobolev12m2025/01/09
Read on Terminal Reader

Te lang; Lezen

De Telegram chatbot vindt antwoorden op vragen door informatie uit de geschiedenis van chatberichten te halen. Hij zoekt naar relevante antwoorden door de dichtstbijzijnde antwoorden in de geschiedenis te vinden. De bot vat de zoekresultaten samen met behulp van LLM en geeft de gebruiker het uiteindelijke antwoord terug met links naar relevante berichten.
featured image - AI Chatbot helpt Telegram-communities als een pro te beheren
Iaroslav Sobolev HackerNoon profile picture
0-item
1-item


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:

  • Vind relevante berichten . Het antwoord kan verspreid zijn over de dialoog van meerdere mensen of in een link naar externe bronnen.

  • 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

  1. De gebruiker stelt de bot een vraag
  2. De bot vindt de dichtstbijzijnde antwoorden in de geschiedenis van berichten
  3. De bot vat de zoekresultaten samen met behulp van LLM
  4. Geeft de gebruiker het definitieve antwoord terug met links naar relevante berichten


Laten we de belangrijkste fasen van de gebruikersstroom eens doornemen en de grootste uitdagingen belichten waarmee we te maken krijgen.

Gegevensvoorbereiding

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:


  • Meerdere opeenvolgende korte berichten van een enkele gebruiker. In dergelijke gevallen is het de moeite waard om berichten te combineren tot grotere tekstblokken
  • Sommige berichten zijn erg lang en behandelen verschillende onderwerpen
  • Zinloze berichten en spam moeten we eruit filteren
  • De gebruiker kan antwoorden zonder het originele bericht te taggen. Een vraag en antwoord kunnen in de chatgeschiedenis worden gescheiden door vele andere berichten
  • De gebruiker kan reageren met een link naar een externe bron (bijvoorbeeld een artikel of document)


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.


  • Embeddings-dimensie . Hoe hoger, hoe meer nuances het model uit de data kan leren. De zoekopdracht zal nauwkeuriger zijn, maar vereist meer geheugen en rekenkracht.
  • Dataset waarop het embeddingmodel is getraind. Dit bepaalt bijvoorbeeld hoe goed het de taal ondersteunt die u nodig hebt.


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.

Een vraag van een gebruiker verwerken

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.


De meest relevante berichten vinden

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.


  • Full-text search . Klassiek full-text search, dat goed ondersteund wordt door alle populaire databases, kan soms handig zijn.


  • Reranking . Zodra we de antwoorden hebben gevonden, kunnen we ze sorteren op de mate van 'nabijheid' van de vraag, zodat alleen de meest relevante overblijven. Reranking vereist een CrossEncoder -model, of we kunnen de reranking-API gebruiken, bijvoorbeeld van Cohere .


Het genereren van het definitieve antwoord

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.


Uitvoering

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


Gebruikersdialogen in stukken splitsen

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:


  • Filter korte berichten op een lijst met stopwoorden (bijv. 'hallo', 'dag', enz.)
  • Voeg berichten van één gebruiker samen als ze binnen een korte periode achter elkaar zijn verzonden
  • Groepeer alle berichten die tot dezelfde thread behoren
  • Voeg de ontvangen berichtgroepen samen in grotere tekstblokken en splits deze tekstblokken verder op in stukken met behulp van RecursiveCharacterTextSplitter
  • Bereken de inbeddingen voor elk stuk
  • Bewaar de tekstfragmenten in de database, samen met hun insluitingen en links naar de originele berichten


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


Inbeddingen

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



Beantwoorden van gebruikersvragen

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



Verdere verbeteringen

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