paint-brush
AI Chatbot ช่วยจัดการชุมชน Telegram ได้อย่างมืออาชีพโดย@slavasobolev
ประวัติศาสตร์ใหม่

AI Chatbot ช่วยจัดการชุมชน Telegram ได้อย่างมืออาชีพ

โดย Iaroslav Sobolev12m2025/01/09
Read on Terminal Reader

นานเกินไป; อ่าน

แชทบอทของ Telegram จะค้นหาคำตอบของคำถามต่างๆ โดยดึงข้อมูลจากประวัติข้อความแชท มันจะค้นหาคำตอบที่เกี่ยวข้องโดยค้นหาคำตอบที่ใกล้เคียงที่สุดในประวัติ บอทจะสรุปผลการค้นหาด้วยความช่วยเหลือของ LLM และส่งคำตอบสุดท้ายพร้อมลิงก์ไปยังข้อความที่เกี่ยวข้องให้กับผู้ใช้
featured image - AI Chatbot ช่วยจัดการชุมชน Telegram ได้อย่างมืออาชีพ
Iaroslav Sobolev HackerNoon profile picture
0-item
1-item


ชุมชน ห้องสนทนา และฟอรัมเป็นแหล่งข้อมูลมากมายในหลากหลายหัวข้อ Slack มักจะเข้ามาแทนที่เอกสารทางเทคนิค และชุมชน Telegram และ Discord ก็เข้ามาช่วยเหลือในประเด็นเกี่ยวกับเกม สตาร์ทอัพ คริปโต และการเดินทาง แม้ว่าข้อมูลโดยตรงจะมีความเกี่ยวข้อง แต่ข้อมูลเหล่านี้มักไม่มีโครงสร้างที่ชัดเจน ทำให้ค้นหาได้ยาก ในบทความนี้ เราจะมาสำรวจความซับซ้อนของการใช้งานบ็อต Telegram ที่จะค้นหาคำตอบสำหรับคำถามต่างๆ โดยดึงข้อมูลจากประวัติข้อความแชท


นี่คือความท้าทายที่รอเราอยู่:

  • ค้นหาข้อความที่เกี่ยวข้อง คำตอบอาจกระจัดกระจายอยู่ในบทสนทนาของหลายๆ คนหรืออยู่ในลิงก์ไปยังแหล่งข้อมูลภายนอก

  • การไม่สนใจเรื่องนอกหัวข้อ มีสแปมและเรื่องนอกหัวข้อมากมายที่เราควรเรียนรู้ที่จะระบุและกรองออก

  • การจัดลำดับความสำคัญ ข้อมูลเริ่มล้าสมัย คุณจะทราบคำตอบที่ถูกต้องได้อย่างไร


ขั้นตอนการใช้งานแชทบอทขั้นพื้นฐาน ที่เราจะดำเนินการ

  1. ผู้ใช้ถามคำถามกับบอท
  2. บอทค้นหาคำตอบที่ใกล้เคียงที่สุดในประวัติข้อความ
  3. บอทสรุปผลลัพธ์การค้นหาด้วยความช่วยเหลือของ LLM
  4. ส่งคืนคำตอบสุดท้ายพร้อมลิงค์ไปยังข้อความที่เกี่ยวข้องให้กับผู้ใช้


มาดูขั้นตอนหลักของกระบวนการผู้ใช้และเน้นย้ำถึงความท้าทายหลักที่เราต้องเผชิญ

การจัดเตรียมข้อมูล

ในการเตรียมประวัติข้อความสำหรับการค้นหา เราจำเป็นต้องสร้างการฝังข้อความเหล่านี้ - การแสดงข้อความแบบเวกเตอร์ ในขณะที่จัดการกับบทความวิกิหรือเอกสาร PDF เราจะแบ่งข้อความออกเป็นย่อหน้าและคำนวณการฝังประโยคสำหรับแต่ละย่อหน้า


อย่างไรก็ตาม เราควรคำนึงถึงลักษณะเฉพาะที่มักพบในแชทและไม่ใช่ในข้อความที่มีโครงสร้างที่ดี:


  • ข้อความสั้น ๆ หลายข้อความจากผู้ใช้รายเดียว ในกรณีดังกล่าว ควรรวมข้อความเป็นบล็อกข้อความขนาดใหญ่
  • ข้อความบางข้อความยาวมากและครอบคลุมหัวข้อต่างๆ หลายหัวข้อ
  • ข้อความไร้สาระและสแปมที่เราควรกรองออก
  • ผู้ใช้สามารถตอบกลับได้โดยไม่ต้องแท็กข้อความต้นฉบับ คำถามและคำตอบสามารถแยกออกจากกันในประวัติการแชทด้วยข้อความอื่นๆ ได้
  • ผู้ใช้สามารถตอบกลับด้วยลิงก์ไปยังทรัพยากรภายนอก (เช่น บทความหรือเอกสาร)


ต่อไปเราควรเลือกรูปแบบการฝังตัว มีรูปแบบต่างๆ มากมายสำหรับการสร้างการฝังตัว และต้องพิจารณาปัจจัยหลายประการเมื่อเลือกรูปแบบที่เหมาะสม


  • มิติการฝังตัว ยิ่งมีมิติสูงขึ้น แบบจำลองสามารถเรียนรู้รายละเอียดจากข้อมูลได้มากขึ้น การค้นหาจะแม่นยำมากขึ้น แต่ต้องใช้หน่วยความจำและทรัพยากรการคำนวณมากขึ้น
  • ชุดข้อมูล ที่ใช้ฝึกโมเดลการฝังตัว ซึ่งจะกำหนดว่าโมเดลจะรองรับภาษาที่คุณต้องการได้ดีเพียงใด


เพื่อปรับปรุงคุณภาพของผลการค้นหา เราสามารถจัดหมวดหมู่ข้อความตามหัวข้อได้ ตัวอย่างเช่น ในแชทที่เน้นไปที่การพัฒนาฟรอนต์เอนด์ ผู้ใช้สามารถพูดคุยเกี่ยวกับหัวข้อต่างๆ เช่น CSS เครื่องมือ React, Vue เป็นต้น คุณสามารถใช้ LLM (ราคาแพงกว่า) หรือเมธอดการสร้างแบบจำลองหัวข้อแบบคลาสสิกจากไลบรารี เช่น BERTopic เพื่อจัดหมวดหมู่ข้อความตามหัวข้อ


นอกจากนี้ เรายังต้องการฐานข้อมูลเวกเตอร์เพื่อจัดเก็บไฟล์ที่ฝังไว้และข้อมูลเมตา (ลิงก์ไปยังโพสต์ต้นฉบับ หมวดหมู่ วันที่) มีพื้นที่จัดเก็บเวกเตอร์มากมาย เช่น FAISS Milvus หรือ Pinecone ที่มีไว้เพื่อจุดประสงค์นี้ PostgreSQL ทั่วไปที่มีส่วนขยาย pgvector ก็สามารถใช้งานได้เช่นกัน

การประมวลผลคำถามของผู้ใช้

เพื่อตอบคำถามของผู้ใช้ เราจำเป็นต้องแปลงคำถามให้เป็นรูปแบบที่สามารถค้นหาได้ จากนั้นจึงคำนวณการฝังคำถาม ตลอดจนกำหนดจุดประสงค์ของคำถามด้วย


ผลลัพธ์ของการค้นหาความหมายของคำถามอาจเป็นคำถามที่คล้ายกันจากประวัติการแชทแต่ไม่ใช่คำตอบของคำถามเหล่านั้น


เพื่อปรับปรุงสิ่งนี้ เราสามารถใช้เทคนิคเพิ่มประสิทธิภาพ HyDE (การฝังเอกสารสมมติ) ที่ได้รับความนิยมเทคนิคหนึ่ง แนวคิดคือการสร้างคำตอบสมมติสำหรับคำถามโดยใช้ LLM จากนั้นจึงคำนวณการฝังคำตอบ วิธีนี้ช่วยให้ค้นหาข้อความที่เกี่ยวข้องในคำตอบได้แม่นยำและมีประสิทธิภาพมากขึ้นในบางกรณี แทนที่จะค้นหาคำถาม


การค้นหาข้อความที่มีความเกี่ยวข้องมากที่สุด

เมื่อเรามีคำถามฝังไว้แล้ว เราสามารถค้นหาข้อความที่ใกล้ที่สุดในฐานข้อมูลได้ LLM มีหน้าต่างบริบทที่จำกัด ดังนั้น เราอาจไม่สามารถเพิ่มผลลัพธ์การค้นหาทั้งหมดได้หากมีมากเกินไป คำถามที่เกิดขึ้นคือจะจัดลำดับความสำคัญของคำตอบอย่างไร มีหลายวิธีสำหรับสิ่งนี้:


  • คะแนนความใหม่ล่าสุด เมื่อเวลาผ่านไป ข้อมูลจะล้าสมัย และเพื่อจัดลำดับความสำคัญของข้อความใหม่ คุณสามารถคำนวณคะแนนความใหม่ล่าสุดได้โดยใช้สูตรง่ายๆ 1 / (today - date_of_message + 1)


  • การกรองข้อมูลเมตา (คุณต้องระบุหัวข้อคำถามและโพสต์) ซึ่งจะช่วยจำกัดการค้นหาของคุณให้แคบลง โดยเหลือเฉพาะโพสต์ที่เกี่ยวข้องกับหัวข้อที่คุณกำลังค้นหาเท่านั้น


  • การค้นหาข้อความแบบเต็ม การค้นหาข้อความแบบเต็มแบบคลาสสิกซึ่งได้รับการสนับสนุนเป็นอย่างดีจากฐานข้อมูลยอดนิยมทั้งหมด อาจเป็นประโยชน์ได้ในบางครั้ง


  • การจัดอันดับใหม่ เมื่อเราพบคำตอบแล้ว เราสามารถจัดเรียงคำตอบตามระดับความ "ใกล้เคียง" กับคำถาม โดยปล่อยเฉพาะคำตอบที่เกี่ยวข้องมากที่สุดไว้เท่านั้น การจัดอันดับใหม่จะต้องใช้โมเดล CrossEncoder หรือเราสามารถใช้ API การจัดอันดับใหม่ เช่น จาก Cohere


การสร้างการตอบสนองขั้นสุดท้าย

หลังจากค้นหาและจัดเรียงในขั้นตอนก่อนหน้าแล้ว เราสามารถเก็บโพสต์ที่เกี่ยวข้องที่สุดจำนวน 50-100 โพสต์ที่จะพอดีกับบริบท LLM ได้


ขั้นตอนต่อไปคือการสร้างข้อความแจ้งที่ชัดเจนและกระชับสำหรับ LLM โดยใช้แบบสอบถามและผลลัพธ์การค้นหาเดิมของผู้ใช้ ข้อความควรระบุให้ LLM ทราบถึงวิธีตอบคำถาม คำถามของผู้ใช้ และบริบท รวมทั้งข้อความที่เกี่ยวข้องที่เราพบ เพื่อจุดประสงค์นี้ จำเป็นต้องพิจารณาประเด็นเหล่านี้:


  • ข้อความแจ้งระบบ คือคำแนะนำสำหรับโมเดลที่อธิบายถึงวิธีการประมวลผลข้อมูล ตัวอย่างเช่น คุณสามารถสั่งให้ LLM ค้นหาคำตอบในข้อมูลที่ให้มาเท่านั้น


  • ความยาวบริบท - ความยาวสูงสุดของข้อความที่สามารถใช้เป็นอินพุตได้ เราสามารถคำนวณจำนวนโทเค็นได้โดยใช้ตัวสร้างโทเค็นที่สอดคล้องกับโมเดลที่เราใช้ ตัวอย่างเช่น OpenAI ใช้ TikTok


  • ไฮเปอร์พารามิเตอร์ของโมเดล - ตัวอย่างเช่น อุณหภูมิมีความรับผิดชอบต่อความสร้างสรรค์ของโมเดลในการตอบสนอง


  • การเลือกแบบจำลอง ไม่จำเป็นต้องจ่ายเงินมากเกินไปสำหรับแบบจำลองที่มีขนาดใหญ่และทรงพลังที่สุดเสมอไป ควรทำการทดสอบหลายๆ ครั้งด้วยแบบจำลองที่แตกต่างกันและเปรียบเทียบผลลัพธ์ของแบบจำลองเหล่านั้น ในบางกรณี แบบจำลองที่ใช้ทรัพยากรน้อยกว่าจะสามารถทำงานได้หากไม่ต้องการความแม่นยำสูง


การนำไปปฏิบัติ

ตอนนี้เรามาลองใช้ขั้นตอนเหล่านี้กับ NodeJS กัน นี่คือเทคสแต็กที่ฉันจะใช้:


  • NodeJS และ TypeScript
  • Grammy - เฟรมเวิร์กบอทของ Telegram
  • PostgreSQL - เป็นที่จัดเก็บข้อมูลหลักของเราทั้งหมด
  • pgvector - ส่วนขยาย PostgreSQL สำหรับจัดเก็บข้อความฝังตัวและข้อความ
  • OpenAI API - LLM และโมเดลการฝังตัว
  • 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; }


แยกกล่องโต้ตอบของผู้ใช้ออกเป็นส่วนๆ

การแบ่งบทสนทนายาวๆ ระหว่างผู้ใช้หลายรายออกเป็นส่วนๆ ไม่ใช่เรื่องง่ายเลย


น่าเสียดายที่แนวทางเริ่มต้น เช่น RecursiveCharacterTextSplitter ซึ่งมีอยู่ในไลบรารี Langchain ไม่ได้คำนึงถึงลักษณะเฉพาะทั้งหมดที่เฉพาะเจาะจงสำหรับการแชท อย่างไรก็ตาม ในกรณีของ Telegram เราสามารถใช้ประโยชน์จาก threads ของ Telegram ที่ประกอบด้วยข้อความที่เกี่ยวข้องและการตอบกลับที่ส่งโดยผู้ใช้


ทุกครั้งที่มีข้อความชุดใหม่เข้ามาจากห้องแชท บอทของเราจะต้องดำเนินการตามขั้นตอนต่อไปนี้:


  • กรองข้อความสั้น ๆ ตามรายการคำหยุด (เช่น 'สวัสดี', 'ลาก่อน' เป็นต้น)
  • รวมข้อความจากผู้ใช้หนึ่งรายหากส่งติดต่อกันภายในระยะเวลาสั้นๆ
  • จัดกลุ่มข้อความทั้งหมดที่อยู่ในเธรดเดียวกัน
  • รวมกลุ่มข้อความที่ได้รับเป็นบล็อกข้อความที่ใหญ่ขึ้นและแบ่งบล็อกข้อความเหล่านี้ออกเป็นส่วนๆ โดยใช้ 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 - เราจำเป็นต้องตั้งค่ากระบวนการเพื่อประเมินคุณภาพการตอบสนองของบอทของเรา





L O A D I N G
. . . comments & more!

About Author

Iaroslav Sobolev HackerNoon profile picture
Iaroslav Sobolev@slavasobolev
i'm writing about software engineering and AI

แขวนแท็ก

บทความนี้ถูกนำเสนอใน...