Komuniti, sembang dan forum ialah sumber maklumat yang tidak berkesudahan mengenai pelbagai topik. Slack sering menggantikan dokumentasi teknikal, dan komuniti Telegram dan Discord membantu dengan soalan permainan, permulaan, crypto dan perjalanan. Walaupun terdapat kaitan maklumat secara langsung, ia selalunya sangat tidak tersusun, menjadikannya sukar untuk mencari. Dalam artikel ini, kami akan meneroka kerumitan melaksanakan bot Telegram yang akan mencari jawapan kepada soalan dengan mengekstrak maklumat daripada sejarah mesej sembang.
Berikut adalah cabaran yang menanti kita:
Mengabaikan offtopic . Terdapat banyak spam dan luar topik, yang harus kita pelajari untuk mengenal pasti dan menapis
Pengutamaan . Maklumat menjadi ketinggalan zaman. Bagaimana anda tahu jawapan yang betul sehingga kini?
Aliran pengguna bot sembang asas yang akan kami laksanakan
Mari kita berjalan melalui peringkat utama aliran pengguna ini dan menyerlahkan cabaran utama yang akan kita hadapi.
Untuk menyediakan sejarah mesej untuk carian, kita perlu mencipta pembenaman mesej ini - perwakilan teks bervektor. Semasa berurusan dengan artikel wiki atau dokumen PDF, kami akan membahagikan teks kepada perenggan dan mengira Pembenaman Ayat untuk setiap satu.
Walau bagaimanapun, kita harus mengambil kira keistimewaan yang biasa untuk sembang dan bukan untuk teks yang tersusun dengan baik:
Seterusnya, kita harus memilih model benam. Terdapat banyak model yang berbeza untuk membina benam, dan beberapa faktor mesti dipertimbangkan semasa memilih model yang betul.
Untuk meningkatkan kualiti hasil carian, kami boleh mengkategorikan mesej mengikut topik. Contohnya, dalam sembang yang didedikasikan untuk pembangunan bahagian hadapan, pengguna boleh membincangkan topik seperti: CSS, perkakas, React, Vue, dll. Anda boleh menggunakan kaedah pemodelan topik LLM (lebih mahal) atau klasik daripada perpustakaan seperti BERTopic untuk mengklasifikasikan mesej mengikut topik.
Kami juga memerlukan pangkalan data vektor untuk menyimpan embeddings dan meta-maklumat (pautan ke siaran asal, kategori, tarikh). Banyak storan vektor, seperti FAISS , Milvus atau Pinecone , wujud untuk tujuan ini. PostgreSQL biasa dengan sambungan pgvector juga akan berfungsi.
Untuk menjawab soalan pengguna, kami perlu menukar soalan kepada bentuk yang boleh dicari, dan dengan itu mengira pembenaman soalan itu, serta menentukan niatnya.
Hasil carian semantik pada soalan boleh menjadi soalan yang serupa daripada sejarah sembang tetapi bukan jawapan kepada soalan tersebut.
Untuk imporve ini, kita boleh menggunakan salah satu teknik pengoptimuman HyDE (hypothetical document embeddings) yang popular. Ideanya adalah untuk menjana jawapan hipotesis kepada soalan menggunakan LLM dan kemudian mengira pembenaman jawapan. Pendekatan ini dalam beberapa kes membolehkan carian yang lebih tepat dan cekap untuk mesej yang berkaitan antara jawapan dan bukannya soalan.
Sebaik sahaja kami mempunyai soalan membenamkan, kami boleh mencari mesej terdekat dalam pangkalan data. LLM mempunyai tetingkap konteks yang terhad, jadi kami mungkin tidak dapat menambah semua hasil carian jika terdapat terlalu banyak. Timbul persoalan bagaimana untuk mengutamakan jawapan. Terdapat beberapa pendekatan untuk ini:
Skor terkini . Lama kelamaan, maklumat menjadi lapuk, dan untuk mengutamakan mesej baharu, anda boleh mengira skor kekinian menggunakan formula mudah 1 / (today - date_of_message + 1)
Penapisan metadata. (anda perlu mengenal pasti topik soalan dan siaran). Ini membantu untuk mengecilkan carian anda, meninggalkan hanya siaran yang berkaitan dengan topik yang anda cari
Selepas mencari dan mengisih dalam langkah sebelumnya, kami boleh menyimpan 50-100 siaran paling berkaitan yang sesuai dengan konteks LLM.
Langkah seterusnya ialah membuat gesaan yang jelas dan ringkas untuk LLM menggunakan pertanyaan asal pengguna dan hasil carian. Ia harus menyatakan kepada LLM cara menjawab soalan, pertanyaan pengguna dan konteks - mesej berkaitan yang kami temui. Untuk tujuan ini, adalah penting untuk mempertimbangkan aspek berikut:
System Prompt ialah arahan kepada model yang menerangkan cara ia harus memproses maklumat. Sebagai contoh, anda boleh memberitahu LLM untuk mencari jawapan hanya dalam data yang disediakan.
Panjang konteks - panjang maksimum mesej yang boleh kami gunakan sebagai input. Kita boleh mengira bilangan token menggunakan tokenizer yang sepadan dengan model yang kita gunakan. Sebagai contoh, OpenAI menggunakan Tiktoken.
Hiperparameter model - sebagai contoh, suhu bertanggungjawab untuk sejauh mana kreatif model itu dalam tindak balasnya.
Pilihan model . Ia tidak selalu bernilai membayar lebih untuk model yang paling besar dan berkuasa. Masuk akal untuk menjalankan beberapa ujian dengan model yang berbeza dan membandingkan keputusannya. Dalam sesetengah kes, model kurang intensif sumber akan melakukan kerja jika mereka tidak memerlukan ketepatan yang tinggi.
Sekarang mari cuba laksanakan langkah-langkah ini dengan NodeJS. Berikut ialah timbunan teknologi yang akan saya gunakan:
Mari langkau langkah asas memasang kebergantungan dan persediaan bot telegram dan teruskan ke ciri yang paling penting. Skema pangkalan data, yang akan diperlukan kemudian:
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; }
Membahagikan dialog panjang antara berbilang pengguna kepada beberapa bahagian bukanlah tugas yang paling remeh.
Malangnya, pendekatan lalai seperti RecursiveCharacterTextSplitter , tersedia dalam pustaka Langchain, tidak mengambil kira semua keanehan khusus untuk berbual. Walau bagaimanapun, dalam kes Telegram, kami boleh memanfaatkan threads
Telegram yang mengandungi mesej berkaitan dan balasan yang dihantar oleh pengguna.
Setiap kali kumpulan mesej baharu tiba dari bilik sembang, bot kami perlu melakukan beberapa langkah:
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; } // .... }
Seterusnya, kita perlu mengira benam untuk setiap ketulan. Untuk ini kita boleh menggunakan model 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(); }
Untuk menjawab soalan pengguna, kami mula-mula mengira pembenaman soalan dan kemudian mencari mesej yang paling berkaitan dalam sejarah sembang
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); }
Kemudian kami menyusun semula hasil carian dengan bantuan model penyusunan semula 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; }
Seterusnya, minta LLM menjawab soalan pengguna dengan meringkaskan hasil carian. Versi ringkas pemprosesan pertanyaan carian akan kelihatan seperti ini:
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.. `; }
Walaupun selepas semua pengoptimuman, kami mungkin merasakan jawapan bot yang dikuasakan LLM adalah tidak ideal dan tidak lengkap. Apa lagi yang boleh diperbaiki?
Untuk siaran pengguna yang menyertakan pautan, kami juga boleh menghuraikan kandungan halaman web dan dokumen pdf.
Penghalaan Pertanyaan — mengarahkan pertanyaan pengguna ke sumber data, model atau indeks yang paling sesuai berdasarkan niat dan konteks pertanyaan untuk mengoptimumkan ketepatan, kecekapan dan kos.
Kami boleh memasukkan sumber yang berkaitan dengan topik bilik sembang kepada indeks carian — di tempat kerja, ia boleh berupa dokumentasi daripada Confluence, untuk sembang visa, tapak web konsulat dengan peraturan, dsb.
RAG-Evaluation - Kami perlu menyediakan saluran paip untuk menilai kualiti respons bot kami