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