コミュニティ、チャット、フォーラムは、さまざまなトピックに関する情報の無限のソースです。Slack は技術文書の代わりとなることが多く、Telegram や Discord のコミュニティはゲーム、スタートアップ、暗号通貨、旅行に関する質問に回答します。直接入手した情報は関連性が高いにもかかわらず、構造化されていないことが多く、検索が困難です。この記事では、チャット メッセージの履歴から情報を抽出して質問の回答を見つける Telegram ボットの実装の複雑さについて説明します。
私たちを待ち受ける課題は次のとおりです。
オフトピックを無視する。スパムやオフトピックがたくさんあるので、それを識別してフィルタリングすることを学ぶ必要があります。
優先順位付け。情報は古くなります。これまでの正しい答えをどうやって知るのでしょうか?
実装する基本的なチャットボットのユーザーフロー
このユーザー フローの主な段階を順に見ていき、直面する主な課題を明らかにしていきます。
検索用にメッセージ履歴を準備するには、これらのメッセージの埋め込み、つまりベクトル化されたテキスト表現を作成する必要があります。Wiki 記事または PDF ドキュメントを処理する場合は、テキストを段落に分割し、それぞれに対して文の埋め込みを計算します。
ただし、適切に構造化されたテキストではなく、チャットに特有の特性を考慮する必要があります。
次に、埋め込みモデルを選択する必要があります。埋め込みを構築するためのさまざまなモデルがあり、適切なモデルを選択する際にはいくつかの要素を考慮する必要があります。
検索結果の品質を向上させるために、メッセージをトピック別に分類することができます。たとえば、フロントエンド開発専用のチャットでは、ユーザーは CSS、ツール、React、Vue などのトピックについて話し合うことができます。トピック別に分類するには、LLM (より高価) またはBERTopicなどのライブラリの従来のトピック モデリング メソッドを使用できます。
また、埋め込みとメタ情報 (元の投稿へのリンク、カテゴリ、日付) を保存するためのベクター データベースも必要になります。この目的のために、 FAISS 、 Milvus 、 Pineconeなど、多くのベクター ストレージが存在します。 pgvector拡張機能を備えた通常の PostgreSQL でも動作します。
ユーザーの質問に答えるには、質問を検索可能な形式に変換し、質問の埋め込みを計算して、質問の意図を判断する必要があります。
質問に対するセマンティック検索の結果には、チャット履歴からの類似の質問が表示されることがありますが、その質問に対する回答は表示されません。
これを改善するには、人気のHyDE (仮想ドキュメント埋め込み) 最適化手法の 1 つを使用できます。アイデアは、LLM を使用して質問に対する仮想的な回答を生成し、回答の埋め込みを計算することです。このアプローチにより、場合によっては、質問ではなく回答の中から関連するメッセージをより正確かつ効率的に検索できます。
質問を埋め込んだら、データベース内で最も近いメッセージを検索できます。LLM のコンテキスト ウィンドウは限られているため、検索結果が多すぎるとすべての検索結果を追加できない場合があります。回答に優先順位を付ける方法という疑問が生じます。これにはいくつかの方法があります。
最新スコア。時間が経つと情報は古くなります。新しいメッセージを優先するには、簡単な式1 / (today - date_of_message + 1)
を使用して最新スコアを計算できます。
メタデータフィルタリング。 (質問と投稿のトピックを特定する必要があります)。これにより、検索を絞り込み、探しているトピックに関連する投稿のみを残すことができます。
前のステップで検索と並べ替えを行った後、LLM コンテキストに適合する最も関連性の高い 50 ~ 100 件の投稿を保持できます。
次のステップは、ユーザーの元のクエリと検索結果を使用して、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; }
複数のユーザー間の長いダイアログをチャンクに分割することは、決して簡単な作業ではありません。
残念ながら、Langchain ライブラリで利用できるRecursiveCharacterTextSplitterなどのデフォルトのアプローチでは、チャット特有のすべての特殊性は考慮されていません。ただし、Telegram の場合は、関連メッセージとユーザーが送信した返信を含む Telegram 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 を利用したボットの回答は理想的ではなく不完全であると感じるかもしれません。他に改善できる点はありますか?
リンクを含むユーザー投稿の場合、Web ページや PDF ドキュメントのコンテンツも解析できます。
クエリ ルーティング- クエリの意図とコンテキストに基づいてユーザー クエリを最も適切なデータ ソース、モデル、またはインデックスに送信し、精度、効率、コストを最適化します。
チャットルームのトピックに関連するリソースを検索インデックスに含めることができます。職場では、Confluence のドキュメント、ビザチャット、規則が記載された領事館の Web サイトなどが含まれます。
RAG評価- ボットの応答の品質を評価するためのパイプラインを設定する必要があります