المجتمعات والدردشات والمنتديات هي مصدر لا ينضب للمعلومات حول العديد من الموضوعات. غالبًا ما يحل Slack محل الوثائق الفنية، وتساعد مجتمعات Telegram وDiscord في التعامل مع أسئلة الألعاب والشركات الناشئة والعملات المشفرة والسفر. وعلى الرغم من أهمية المعلومات المباشرة، إلا أنها غالبًا ما تكون غير منظمة إلى حد كبير، مما يجعل البحث فيها أمرًا صعبًا. في هذه المقالة، سنستكشف تعقيدات تنفيذ روبوت Telegram الذي سيجد إجابات للأسئلة من خلال استخراج المعلومات من سجل رسائل الدردشة.
وهنا التحديات التي تنتظرنا:
تجاهل المواضيع التي لا تتعلق بالموضوع . هناك الكثير من الرسائل غير المرغوب فيها والموضوعات التي لا تتعلق بالموضوع، والتي يجب أن نتعلم كيفية التعرف عليها وتصفيتها
تحديد الأولويات . تصبح المعلومات قديمة. كيف تعرف الإجابة الصحيحة حتى الآن؟
سير عمل مستخدم برنامج المحادثة الآلي الأساسي الذي سنقوم بتنفيذه
دعونا نتناول المراحل الرئيسية لتدفق المستخدم هذا ونلقي الضوء على التحديات الرئيسية التي سنواجهها.
لإعداد سجل الرسائل للبحث، نحتاج إلى إنشاء تضمينات لهذه الرسائل - تمثيلات نصية متجهية. أثناء التعامل مع مقالة wiki أو مستند PDF، سنقوم بتقسيم النص إلى فقرات وحساب تضمين الجملة لكل فقرة.
ومع ذلك، يجب علينا أن نأخذ في الاعتبار الخصائص المميزة للدردشات والتي لا تتميز بها النصوص المنظمة بشكل جيد:
بعد ذلك، يجب علينا اختيار نموذج التضمين. هناك العديد من النماذج المختلفة لبناء التضمينات، ويجب مراعاة العديد من العوامل عند اختيار النموذج المناسب.
لتحسين جودة نتائج البحث، يمكننا تصنيف الرسائل حسب الموضوع. على سبيل المثال، في الدردشة المخصصة لتطوير الواجهة الأمامية، يمكن للمستخدمين مناقشة مواضيع مثل: CSS، والأدوات، وReact، وVue، وما إلى ذلك. يمكنك استخدام أساليب LLM (الأكثر تكلفة) أو أساليب النمذجة الموضوعية الكلاسيكية من مكتبات مثل BERTopic لتصنيف الرسائل حسب المواضيع.
سنحتاج أيضًا إلى قاعدة بيانات متجهة لتخزين التضمينات والمعلومات الوصفية (روابط إلى المنشورات الأصلية والفئات والتاريخ). توجد العديد من وحدات تخزين المتجهات، مثل FAISS أو Milvus أو Pinecone ، لهذا الغرض. ستعمل أيضًا قاعدة بيانات PostgreSQL العادية مع امتداد pgvector .
للإجابة على سؤال المستخدم، نحتاج إلى تحويل السؤال إلى نموذج قابل للبحث، وبالتالي حساب تضمين السؤال، بالإضافة إلى تحديد هدفه.
قد تكون نتيجة البحث الدلالي حول سؤال ما عبارة عن أسئلة مشابهة من سجل الدردشة ولكن ليست الإجابات عليها.
لتحسين ذلك، يمكننا استخدام إحدى تقنيات تحسين HyDE (التضمينات الافتراضية للمستندات). والفكرة هي توليد إجابة افتراضية لسؤال باستخدام LLM ثم حساب تضمين الإجابة. يسمح هذا النهج في بعض الحالات بالبحث بشكل أكثر دقة وكفاءة عن الرسائل ذات الصلة بين الإجابات بدلاً من الأسئلة.
بمجرد تضمين السؤال، يمكننا البحث عن أقرب الرسائل في قاعدة البيانات. يحتوي LLM على نافذة سياق محدودة، لذا قد لا نتمكن من إضافة جميع نتائج البحث إذا كان عدد النتائج كبيرًا جدًا. ينشأ السؤال حول كيفية تحديد أولوية الإجابات. هناك عدة طرق لذلك:
درجة الحداثة . بمرور الوقت، تصبح المعلومات قديمة، ولإعطاء الأولوية للرسائل الجديدة، يمكنك حساب درجة الحداثة باستخدام الصيغة البسيطة 1 / (today - date_of_message + 1)
تصفية البيانات الوصفية (يجب عليك تحديد موضوع السؤال والمشاركات). يساعد هذا في تضييق نطاق البحث، وترك المشاركات ذات الصلة بالموضوع الذي تبحث عنه فقط
بعد البحث والفرز في الخطوة السابقة، يمكننا الاحتفاظ بـ 50-100 من المنشورات الأكثر صلة والتي ستناسب سياق LLM.
الخطوة التالية هي إنشاء موجه واضح وموجز لـ LLM باستخدام استعلام المستخدم الأصلي ونتائج البحث. يجب أن يوضح لـ LLM كيفية الإجابة على السؤال واستعلام المستخدم والسياق - الرسائل ذات الصلة التي وجدناها. لهذا الغرض، من الضروري مراعاة الجوانب التالية:
تُعد موجهات النظام عبارة عن تعليمات للنموذج توضح كيفية معالجة المعلومات. على سبيل المثال، يمكنك إخبار LLM بالبحث عن إجابة في البيانات المقدمة فقط.
طول السياق - الحد الأقصى لطول الرسائل التي يمكننا استخدامها كمدخلات. يمكننا حساب عدد الرموز باستخدام أداة التجزئة المقابلة للنموذج الذي نستخدمه. على سبيل المثال، تستخدم OpenAI Tiktoken.
المعلمات الفائقة للنموذج - على سبيل المثال، تكون درجة الحرارة مسؤولة عن مدى إبداع النموذج في استجاباته.
اختيار النموذج . ليس من المجدي دائمًا دفع مبالغ زائدة مقابل النموذج الأكبر والأقوى. من المنطقي إجراء عدة اختبارات باستخدام نماذج مختلفة ومقارنة نتائجها. في بعض الحالات، ستؤدي النماذج الأقل استهلاكًا للموارد المهمة إذا لم تتطلب دقة عالية.
الآن دعنا نحاول تنفيذ هذه الخطوات باستخدام NodeJS. إليك مجموعة الأدوات التقنية التي سأستخدمها:
دعنا نتخطى الخطوات الأساسية لتثبيت التبعيات وإعداد بوت تيليجرام وننتقل مباشرة إلى أهم الميزات. مخطط قاعدة البيانات، والذي سيكون مطلوبًا لاحقًا:
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; }
بعد ذلك، اطلب من خبير القانون الإجابة على سؤال المستخدم من خلال تلخيص نتائج البحث. ستبدو النسخة المبسطة من معالجة استعلام البحث على النحو التالي:
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 - نحتاج إلى إعداد خط أنابيب لتقييم جودة استجابات الروبوت الخاص بنا