Komunitetet, bisedat dhe forumet janë një burim i pafund informacioni për një mori temash. Slack shpesh zëvendëson dokumentacionin teknik dhe komunitetet Telegram dhe Discord ndihmojnë me pyetjet e lojrave, startup-eve, kriptove dhe udhëtimeve. Pavarësisht nga rëndësia e informacionit të dorës së parë, ai shpesh është shumë i pastrukturuar, duke e bërë të vështirë kërkimin. Në këtë artikull, ne do të eksplorojmë kompleksitetin e zbatimit të një roboti Telegram që do të gjejë përgjigje për pyetjet duke nxjerrë informacion nga historia e mesazheve të bisedës.
Këtu janë sfidat që na presin:
Duke injoruar offtopic . Ka shumë spam dhe jashtë temave, të cilat duhet të mësojmë t'i identifikojmë dhe filtrojmë
Prioritizimi . Informacioni bëhet i vjetëruar. Si e dini përgjigjen e saktë deri më sot?
Rrjedha bazë e përdoruesit të chatbot që do të zbatojmë
Le të ecim nëpër fazat kryesore të këtij fluksi të përdoruesve dhe të nxjerrim në pah sfidat kryesore me të cilat do të përballemi.
Për të përgatitur një histori mesazhesh për kërkim, ne duhet të krijojmë ngulitje të këtyre mesazheve - paraqitje të tekstit të vektorizuar. Ndërkohë që kemi të bëjmë me një artikull wiki ose dokument PDF, ne do ta ndajmë tekstin në paragrafë dhe do të llogarisim ngulitjen e fjalive për secilin.
Sidoqoftë, duhet të kemi parasysh veçoritë që janë tipike për bisedat dhe jo për tekstin e strukturuar mirë:
Më pas, duhet të zgjedhim modelin e ngulitjes. Ka shumë modele të ndryshme për ndërtimin e ngulitjeve, dhe disa faktorë duhet të merren parasysh kur zgjidhni modelin e duhur.
Për të përmirësuar cilësinë e rezultateve të kërkimit, ne mund t'i kategorizojmë mesazhet sipas temës. Për shembull, në një bisedë kushtuar zhvillimit të frontendit, përdoruesit mund të diskutojnë tema të tilla si: CSS, vegla pune, React, Vue, etj. Ju mund të përdorni LLM (më të shtrenjta) ose metoda klasike të modelimit të temave nga bibliotekat si BERTopic për të klasifikuar mesazhet sipas temave.
Do të na duhet gjithashtu një bazë të dhënash vektoriale për të ruajtur ngulitje dhe meta-informacione (lidhje me postimet origjinale, kategoritë, datat). Shumë depo vektoriale, të tilla si FAISS , Milvus ose Pinecone , ekzistojnë për këtë qëllim. Një PostgreSQL e rregullt me shtrirjen pgvector do të funksionojë gjithashtu.
Për t'iu përgjigjur pyetjes së një përdoruesi, ne duhet ta konvertojmë pyetjen në një formë të kërkueshme, dhe kështu të llogarisim futjen e pyetjes, si dhe të përcaktojmë qëllimin e saj.
Rezultati i një kërkimi semantik për një pyetje mund të jetë pyetje të ngjashme nga historia e bisedës, por jo përgjigjet e tyre.
Për ta përmirësuar këtë, ne mund të përdorim një nga teknikat më të njohura të optimizimit HyDE (ngulitje hipotetike të dokumenteve). Ideja është të gjenerohet një përgjigje hipotetike për një pyetje duke përdorur LLM dhe më pas të llogaritet përfshirja e përgjigjes. Kjo qasje në disa raste lejon kërkim më të saktë dhe efikas të mesazheve përkatëse midis përgjigjeve sesa pyetjeve.
Pasi të kemi futjen e pyetjes, mund të kërkojmë mesazhet më të afërta në bazën e të dhënave. LLM ka një dritare konteksti të kufizuar, kështu që mund të mos jemi në gjendje të shtojmë të gjitha rezultatet e kërkimit nëse ka shumë. Shtrohet pyetja se si t'u jepet përparësi përgjigjeve. Ka disa qasje për këtë:
Rezultati i fundit . Me kalimin e kohës, informacioni bëhet i vjetëruar dhe për t'i dhënë përparësi mesazheve të reja, mund të llogaritni rezultatin e fundit duke përdorur formulën e thjeshtë 1 / (today - date_of_message + 1)
Filtrimi i meta të dhënave. (duhet të identifikoni temën e pyetjes dhe postimet). Kjo ndihmon për të ngushtuar kërkimin tuaj, duke lënë vetëm ato postime që janë të rëndësishme për temën që kërkoni
Pas kërkimit dhe renditjes në hapin e mëparshëm, ne mund të mbajmë 50-100 postimet më të rëndësishme që do të përshtaten në kontekstin LLM.
Hapi tjetër është të krijoni një kërkesë të qartë dhe koncize për LLM duke përdorur pyetjen origjinale të përdoruesit dhe rezultatet e kërkimit. Ai duhet t'i specifikojë LLM-së se si t'i përgjigjet pyetjes, pyetjes së përdoruesit dhe kontekstit - mesazhet përkatëse që gjetëm. Për këtë qëllim, është thelbësore të merren parasysh këto aspekte:
System Prompt janë udhëzime për modelin që shpjegojnë se si duhet të përpunojë informacionin. Për shembull, mund t'i thoni LLM të kërkojë një përgjigje vetëm në të dhënat e dhëna.
Gjatësia e kontekstit - gjatësia maksimale e mesazheve që mund të përdorim si hyrje. Ne mund të llogarisim numrin e shenjave duke përdorur tokenizuesin që korrespondon me modelin që përdorim. Për shembull, OpenAI përdor Tiktoken.
Hiperparametrat e modelit - për shembull, temperatura është përgjegjëse për sa krijues do të jetë modeli në përgjigjet e tij.
Zgjedhja e modelit . Nuk ia vlen gjithmonë të paguani shumë për modelin më të madh dhe më të fuqishëm. Ka kuptim të kryhen disa teste me modele të ndryshme dhe të krahasohen rezultatet e tyre. Në disa raste, modelet me më pak burime intensive do ta bëjnë punën nëse nuk kërkojnë saktësi të lartë.
Tani le të përpiqemi t'i zbatojmë këto hapa me NodeJS. Këtu është grupi i teknologjisë që do të përdor:
Le të kapërcejmë hapat bazë të instalimit të varësive dhe konfigurimit të botit të telegramit dhe të kalojmë drejtpërdrejt te veçoritë më të rëndësishme. Skema e bazës së të dhënave, e cila do të nevojitet më vonë:
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; }
Ndarja e dialogëve të gjatë midis përdoruesve të shumtë në copa nuk është detyra më e parëndësishme.
Fatkeqësisht, qasjet e paracaktuar si RecursiveCharacterTextSplitter , të disponueshme në bibliotekën Langchain, nuk marrin parasysh të gjitha veçoritë specifike të bisedës. Megjithatë, në rastin e Telegramit, ne mund të përfitojmë nga threads
e Telegramit që përmbajnë mesazhe të lidhura dhe përgjigjet e dërguara nga përdoruesit.
Sa herë që vjen një grup i ri mesazhesh nga dhoma e bisedës, roboti ynë duhet të kryejë disa hapa:
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; } // .... }
Tjetra, ne duhet të llogarisim ngulitje për secilën prej pjesëve. Për këtë ne mund të përdorim modelin 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(); }
Për t'iu përgjigjur pyetjes së një përdoruesi, ne fillimisht numërojmë futjen e pyetjes dhe më pas gjejmë mesazhet më të rëndësishme në historikun e bisedës
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); }
Pastaj ne i rirenditim rezultatet e kërkimit me ndihmën e modelit të rirenditjes së 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; }
Më pas, kërkoni nga LLM t'i përgjigjet pyetjes së përdoruesit duke përmbledhur rezultatet e kërkimit. Versioni i thjeshtuar i përpunimit të një pyetje kërkimi do të duket si ky:
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.. `; }
Edhe pas të gjitha optimizimeve, ne mund të ndiejmë se përgjigjet e robotëve me fuqi LLM janë jo ideale dhe jo të plota. Çfarë tjetër mund të përmirësohet?
Për postimet e përdoruesve që përfshijnë lidhje, ne gjithashtu mund të analizojmë faqet e internetit dhe përmbajtjen e dokumenteve pdf.
Query-Routing - drejtimi i pyetjeve të përdoruesve drejt burimit, modelit ose indeksit më të përshtatshëm të të dhënave bazuar në qëllimin dhe kontekstin e pyetjes për të optimizuar saktësinë, efikasitetin dhe koston.
Ne mund të përfshijmë burime të rëndësishme për temën e dhomës së bisedës në indeksin e kërkimit - në punë, mund të jetë dokumentacion nga Confluence, për bisedat për viza, faqet e internetit të konsullatës me rregulla, etj.
RAG-Evaluation - Ne duhet të krijojmë një tubacion për të vlerësuar cilësinë e përgjigjeve të robotit tonë