ชุมชน ห้องสนทนา และฟอรัมเป็นแหล่งข้อมูลมากมายในหลากหลายหัวข้อ Slack มักจะเข้ามาแทนที่เอกสารทางเทคนิค และชุมชน Telegram และ Discord ก็เข้ามาช่วยเหลือในประเด็นเกี่ยวกับเกม สตาร์ทอัพ คริปโต และการเดินทาง แม้ว่าข้อมูลโดยตรงจะมีความเกี่ยวข้อง แต่ข้อมูลเหล่านี้มักไม่มีโครงสร้างที่ชัดเจน ทำให้ค้นหาได้ยาก ในบทความนี้ เราจะมาสำรวจความซับซ้อนของการใช้งานบ็อต Telegram ที่จะค้นหาคำตอบสำหรับคำถามต่างๆ โดยดึงข้อมูลจากประวัติข้อความแชท นี่คือความท้าทายที่รอเราอยู่: คำตอบอาจกระจัดกระจายอยู่ในบทสนทนาของหลายๆ คนหรืออยู่ในลิงก์ไปยังแหล่งข้อมูลภายนอก ค้นหาข้อความที่เกี่ยวข้อง มีสแปมและเรื่องนอกหัวข้อมากมายที่เราควรเรียนรู้ที่จะระบุและกรองออก การไม่สนใจเรื่องนอกหัวข้อ ข้อมูลเริ่มล้าสมัย คุณจะทราบคำตอบที่ถูกต้องได้อย่างไร การจัดลำดับความสำคัญ ที่เราจะดำเนินการ ขั้นตอนการใช้งานแชทบอทขั้นพื้นฐาน ผู้ใช้ถามคำถามกับบอท บอทค้นหาคำตอบที่ใกล้เคียงที่สุดในประวัติข้อความ บอทสรุปผลลัพธ์การค้นหาด้วยความช่วยเหลือของ LLM ส่งคืนคำตอบสุดท้ายพร้อมลิงค์ไปยังข้อความที่เกี่ยวข้องให้กับผู้ใช้ มาดูขั้นตอนหลักของกระบวนการผู้ใช้และเน้นย้ำถึงความท้าทายหลักที่เราต้องเผชิญ การจัดเตรียมข้อมูล ในการเตรียมประวัติข้อความสำหรับการค้นหา เราจำเป็นต้องสร้างการฝังข้อความเหล่านี้ - การแสดงข้อความแบบเวกเตอร์ ในขณะที่จัดการกับบทความวิกิหรือเอกสาร PDF เราจะแบ่งข้อความออกเป็นย่อหน้าและคำนวณการฝังประโยคสำหรับแต่ละย่อหน้า อย่างไรก็ตาม เราควรคำนึงถึงลักษณะเฉพาะที่มักพบในแชทและไม่ใช่ในข้อความที่มีโครงสร้างที่ดี: ข้อความสั้น ๆ หลายข้อความจากผู้ใช้รายเดียว ในกรณีดังกล่าว ควรรวมข้อความเป็นบล็อกข้อความขนาดใหญ่ ข้อความบางข้อความยาวมากและครอบคลุมหัวข้อต่างๆ หลายหัวข้อ ข้อความไร้สาระและสแปมที่เราควรกรองออก ผู้ใช้สามารถตอบกลับได้โดยไม่ต้องแท็กข้อความต้นฉบับ คำถามและคำตอบสามารถแยกออกจากกันในประวัติการแชทด้วยข้อความอื่นๆ ได้ ผู้ใช้สามารถตอบกลับด้วยลิงก์ไปยังทรัพยากรภายนอก (เช่น บทความหรือเอกสาร) ต่อไปเราควรเลือกรูปแบบการฝังตัว มีรูปแบบต่างๆ มากมายสำหรับการสร้างการฝังตัว และต้องพิจารณาปัจจัยหลายประการเมื่อเลือกรูปแบบที่เหมาะสม ยิ่งมีมิติสูงขึ้น แบบจำลองสามารถเรียนรู้รายละเอียดจากข้อมูลได้มากขึ้น การค้นหาจะแม่นยำมากขึ้น แต่ต้องใช้หน่วยความจำและทรัพยากรการคำนวณมากขึ้น มิติการฝังตัว ที่ใช้ฝึกโมเดลการฝังตัว ซึ่งจะกำหนดว่าโมเดลจะรองรับภาษาที่คุณต้องการได้ดีเพียงใด ชุดข้อมูล เพื่อปรับปรุงคุณภาพของผลการค้นหา เราสามารถจัดหมวดหมู่ข้อความตามหัวข้อได้ ตัวอย่างเช่น ในแชทที่เน้นไปที่การพัฒนาฟรอนต์เอนด์ ผู้ใช้สามารถพูดคุยเกี่ยวกับหัวข้อต่างๆ เช่น CSS เครื่องมือ React, Vue เป็นต้น คุณสามารถใช้ LLM (ราคาแพงกว่า) หรือเมธอดการสร้างแบบจำลองหัวข้อแบบคลาสสิกจากไลบรารี เช่น เพื่อจัดหมวดหมู่ข้อความตามหัวข้อ BERTopic นอกจากนี้ เรายังต้องการฐานข้อมูลเวกเตอร์เพื่อจัดเก็บไฟล์ที่ฝังไว้และข้อมูลเมตา (ลิงก์ไปยังโพสต์ต้นฉบับ หมวดหมู่ วันที่) มีพื้นที่จัดเก็บเวกเตอร์มากมาย เช่น หรือ ที่มีไว้เพื่อจุดประสงค์นี้ PostgreSQL ทั่วไปที่มีส่วนขยาย ก็สามารถใช้งานได้เช่นกัน FAISS Milvus Pinecone pgvector การประมวลผลคำถามของผู้ใช้ เพื่อตอบคำถามของผู้ใช้ เราจำเป็นต้องแปลงคำถามให้เป็นรูปแบบที่สามารถค้นหาได้ จากนั้นจึงคำนวณการฝังคำถาม ตลอดจนกำหนดจุดประสงค์ของคำถามด้วย ผลลัพธ์ของการค้นหาความหมายของคำถามอาจเป็นคำถามที่คล้ายกันจากประวัติการแชทแต่ไม่ใช่คำตอบของคำถามเหล่านั้น เพื่อปรับปรุงสิ่งนี้ เราสามารถใช้เทคนิคเพิ่มประสิทธิภาพ (การฝังเอกสารสมมติ) ที่ได้รับความนิยมเทคนิคหนึ่ง แนวคิดคือการสร้างคำตอบสมมติสำหรับคำถามโดยใช้ LLM จากนั้นจึงคำนวณการฝังคำตอบ วิธีนี้ช่วยให้ค้นหาข้อความที่เกี่ยวข้องในคำตอบได้แม่นยำและมีประสิทธิภาพมากขึ้นในบางกรณี แทนที่จะค้นหาคำถาม HyDE การค้นหาข้อความที่มีความเกี่ยวข้องมากที่สุด เมื่อเรามีคำถามฝังไว้แล้ว เราสามารถค้นหาข้อความที่ใกล้ที่สุดในฐานข้อมูลได้ LLM มีหน้าต่างบริบทที่จำกัด ดังนั้น เราอาจไม่สามารถเพิ่มผลลัพธ์การค้นหาทั้งหมดได้หากมีมากเกินไป คำถามที่เกิดขึ้นคือจะจัดลำดับความสำคัญของคำตอบอย่างไร มีหลายวิธีสำหรับสิ่งนี้: เมื่อเวลาผ่านไป ข้อมูลจะล้าสมัย และเพื่อจัดลำดับความสำคัญของข้อความใหม่ คุณสามารถคำนวณคะแนนความใหม่ล่าสุดได้โดยใช้สูตรง่ายๆ คะแนนความใหม่ล่าสุด 1 / (today - date_of_message + 1) (คุณต้องระบุหัวข้อคำถามและโพสต์) ซึ่งจะช่วยจำกัดการค้นหาของคุณให้แคบลง โดยเหลือเฉพาะโพสต์ที่เกี่ยวข้องกับหัวข้อที่คุณกำลังค้นหาเท่านั้น การกรองข้อมูลเมตา การค้นหาข้อความแบบเต็มแบบคลาสสิกซึ่งได้รับการสนับสนุนเป็นอย่างดีจากฐานข้อมูลยอดนิยมทั้งหมด อาจเป็นประโยชน์ได้ในบางครั้ง การค้นหาข้อความแบบเต็ม เมื่อเราพบคำตอบแล้ว เราสามารถจัดเรียงคำตอบตามระดับความ "ใกล้เคียง" กับคำถาม โดยปล่อยเฉพาะคำตอบที่เกี่ยวข้องมากที่สุดไว้เท่านั้น การจัดอันดับใหม่จะต้องใช้โมเดล หรือเราสามารถใช้ API การจัดอันดับใหม่ เช่น จาก การจัดอันดับใหม่ CrossEncoder Cohere การสร้างการตอบสนองขั้นสุดท้าย หลังจากค้นหาและจัดเรียงในขั้นตอนก่อนหน้าแล้ว เราสามารถเก็บโพสต์ที่เกี่ยวข้องที่สุดจำนวน 50-100 โพสต์ที่จะพอดีกับบริบท LLM ได้ ขั้นตอนต่อไปคือการสร้างข้อความแจ้งที่ชัดเจนและกระชับสำหรับ LLM โดยใช้แบบสอบถามและผลลัพธ์การค้นหาเดิมของผู้ใช้ ข้อความควรระบุให้ LLM ทราบถึงวิธีตอบคำถาม คำถามของผู้ใช้ และบริบท รวมทั้งข้อความที่เกี่ยวข้องที่เราพบ เพื่อจุดประสงค์นี้ จำเป็นต้องพิจารณาประเด็นเหล่านี้: คือคำแนะนำสำหรับโมเดลที่อธิบายถึงวิธีการประมวลผลข้อมูล ตัวอย่างเช่น คุณสามารถสั่งให้ LLM ค้นหาคำตอบในข้อมูลที่ให้มาเท่านั้น ข้อความแจ้งระบบ - ความยาวสูงสุดของข้อความที่สามารถใช้เป็นอินพุตได้ เราสามารถคำนวณจำนวนโทเค็นได้โดยใช้ตัวสร้างโทเค็นที่สอดคล้องกับโมเดลที่เราใช้ ตัวอย่างเช่น OpenAI ใช้ TikTok ความยาวบริบท - ตัวอย่างเช่น อุณหภูมิมีความรับผิดชอบต่อความสร้างสรรค์ของโมเดลในการตอบสนอง ไฮเปอร์พารามิเตอร์ของโมเดล ไม่จำเป็นต้องจ่ายเงินมากเกินไปสำหรับแบบจำลองที่มีขนาดใหญ่และทรงพลังที่สุดเสมอไป ควรทำการทดสอบหลายๆ ครั้งด้วยแบบจำลองที่แตกต่างกันและเปรียบเทียบผลลัพธ์ของแบบจำลองเหล่านั้น ในบางกรณี แบบจำลองที่ใช้ทรัพยากรน้อยกว่าจะสามารถทำงานได้หากไม่ต้องการความแม่นยำสูง การเลือกแบบจำลอง การนำไปปฏิบัติ ตอนนี้เรามาลองใช้ขั้นตอนเหล่านี้กับ NodeJS กัน นี่คือเทคสแต็กที่ฉันจะใช้: และ NodeJS TypeScript - เฟรมเวิร์กบอทของ Telegram Grammy - เป็นที่จัดเก็บข้อมูลหลักของเราทั้งหมด PostgreSQL - ส่วนขยาย PostgreSQL สำหรับจัดเก็บข้อความฝังตัวและข้อความ pgvector - LLM และโมเดลการฝังตัว OpenAI API - เพื่อลดความซับซ้อนของการโต้ตอบฐานข้อมูล Mikro-ORM ข้ามขั้นตอนพื้นฐานในการติดตั้งสิ่งที่ต้องพึ่งพาและการตั้งค่าบอทของ Telegram แล้วไปที่ฟีเจอร์ที่สำคัญที่สุดกันเลยดีกว่า โครงร่างฐานข้อมูลซึ่งจะต้องใช้ในภายหลัง: 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; } แยกกล่องโต้ตอบของผู้ใช้ออกเป็นส่วนๆ การแบ่งบทสนทนายาวๆ ระหว่างผู้ใช้หลายรายออกเป็นส่วนๆ ไม่ใช่เรื่องง่ายเลย น่าเสียดายที่แนวทางเริ่มต้น เช่น ซึ่งมีอยู่ในไลบรารี Langchain ไม่ได้คำนึงถึงลักษณะเฉพาะทั้งหมดที่เฉพาะเจาะจงสำหรับการแชท อย่างไรก็ตาม ในกรณีของ Telegram เราสามารถใช้ประโยชน์จาก ของ Telegram ที่ประกอบด้วยข้อความที่เกี่ยวข้องและการตอบกลับที่ส่งโดยผู้ใช้ RecursiveCharacterTextSplitter threads ทุกครั้งที่มีข้อความชุดใหม่เข้ามาจากห้องแชท บอทของเราจะต้องดำเนินการตามขั้นตอนต่อไปนี้: กรองข้อความสั้น ๆ ตามรายการคำหยุด (เช่น 'สวัสดี', 'ลาก่อน' เป็นต้น) รวมข้อความจากผู้ใช้หนึ่งรายหากส่งติดต่อกันภายในระยะเวลาสั้นๆ จัดกลุ่มข้อความทั้งหมดที่อยู่ในเธรดเดียวกัน รวมกลุ่มข้อความที่ได้รับเป็นบล็อกข้อความที่ใหญ่ขึ้นและแบ่งบล็อกข้อความเหล่านี้ออกเป็นส่วนๆ โดยใช้ 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; } // .... } การฝังตัว ต่อไปเราต้องคำนวณการฝังตัวสำหรับแต่ละชิ้นส่วน สำหรับสิ่งนี้ เราสามารถใช้โมเดล 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(); } การตอบคำถามของผู้ใช้งาน ในการตอบคำถามของผู้ใช้ เราจะนับการฝังคำถามก่อน จากนั้นจึงค้นหาข้อความที่เกี่ยวข้องที่สุดในประวัติการแชท 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); } จากนั้นเราจะจัดอันดับผลการค้นหาใหม่โดยใช้โมเดล การจัดอันดับใหม่ของ 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; } จากนั้น ให้ขอให้ LLM ตอบคำถามของผู้ใช้โดยสรุปผลการค้นหา การประมวลผลคำค้นหาแบบง่ายจะมีลักษณะดังนี้: 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.. `; } การปรับปรุงเพิ่มเติม แม้ว่าเราจะปรับแต่งทุกอย่างแล้ว แต่เราอาจรู้สึกว่าคำตอบของบอทที่ขับเคลื่อนโดย LLM นั้นไม่เหมาะสมและไม่สมบูรณ์ อะไรอีกที่สามารถปรับปรุงได้? สำหรับโพสต์ของผู้ใช้ที่มีลิงก์ เราสามารถแยกวิเคราะห์เนื้อหาหน้าเว็บและเอกสาร PDF ได้ด้วย — การกำหนดเส้นทางแบบสอบถามของผู้ใช้ไปยังแหล่งข้อมูล โมเดล หรือดัชนีที่เหมาะสมที่สุดโดยอิงตามจุดประสงค์และบริบทของแบบสอบถามเพื่อเพิ่มประสิทธิภาพความแม่นยำ ประสิทธิภาพ และต้นทุน การกำหนดเส้นทางแบบสอบถาม เราสามารถรวมทรัพยากรที่เกี่ยวข้องกับหัวข้อของห้องสนทนาไว้ในดัชนีการค้นหาได้ — ในการทำงาน อาจเป็นเอกสารจาก Confluence สำหรับการสนทนาเรื่องวีซ่า เว็บไซต์สถานกงสุลที่มีกฎเกณฑ์ ฯลฯ - เราจำเป็นต้องตั้งค่ากระบวนการเพื่อประเมินคุณภาพการตอบสนองของบอทของเรา การประเมิน RAG