paint-brush
AI チャットボットが Telegram コミュニティをプロのように管理@slavasobolev
新しい歴史

AI チャットボットが Telegram コミュニティをプロのように管理

Iaroslav Sobolev12m2025/01/09
Read on Terminal Reader

長すぎる; 読むには

Telegram チャットボットは、チャット メッセージの履歴から情報を抽出して質問に対する回答を見つけます。履歴から最も近い回答を見つけることで、関連する回答を検索します。ボットは LLM の助けを借りて検索結果を要約し、関連するメッセージへのリンクを含む最終的な回答をユーザーに返します。
featured image - AI チャットボットが Telegram コミュニティをプロのように管理
Iaroslav Sobolev HackerNoon profile picture
0-item
1-item


コミュニティ、チャット、フォーラムは、さまざまなトピックに関する情報の無限のソースです。Slack は技術文書の代わりとなることが多く、Telegram や Discord のコミュニティはゲーム、スタートアップ、暗号通貨、旅行に関する質問に回答します。直接入手した情報は関連性が高いにもかかわらず、構造化されていないことが多く、検索が困難です。この記事では、チャット メッセージの履歴から情報を抽出して質問の回答を見つける Telegram ボットの実装の複雑さについて説明します。


私たちを待ち受ける課題は次のとおりです。

  • 関連するメッセージを見つけます。回答は、複数の人の対話に散らばっていたり、外部リソースへのリンクに含まれていたりする場合があります。

  • オフトピックを無視する。スパムやオフトピックがたくさんあるので、それを識別してフィルタリングすることを学ぶ必要があります。

  • 優先順位付け。情報は古くなります。これまでの正しい答えをどうやって知るのでしょうか?


実装する基本的なチャットボットのユーザーフロー

  1. ユーザーがボットに質問する
  2. ボットはメッセージの履歴から最も近い回答を見つけます
  3. ボットはLLMの助けを借りて検索結果を要約します
  4. 関連メッセージへのリンクを含む最終的な回答をユーザーに返します


このユーザー フローの主な段階を順に見ていき、直面する主な課題を明らかにしていきます。

データ準備

検索用にメッセージ履歴を準備するには、これらのメッセージの埋め込み、つまりベクトル化されたテキスト表現を作成する必要があります。Wiki 記事または PDF ドキュメントを処理する場合は、テキストを段落に分割し、それぞれに対して文の埋め込みを計算します。


ただし、適切に構造化されたテキストではなく、チャットに特有の特性を考慮する必要があります。


  • 1人のユーザーからの複数の短いメッセージ。このような場合は、メッセージをより大きなテキストブロックにまとめる価値があります。
  • メッセージの中には非常に長く、複数の異なるトピックをカバーしているものもあります。
  • 意味のないメッセージやスパムはフィルタリングすべき
  • ユーザーは元のメッセージにタグを付けずに返信することができます。質問と回答は、チャット履歴で他の多くのメッセージによって分離される可能性があります。
  • ユーザーは外部リソース(記事や文書など)へのリンクで応答することができます。


次に、埋め込みモデルを選択する必要があります。埋め込みを構築するためのさまざまなモデルがあり、適切なモデルを選択する際にはいくつかの要素を考慮する必要があります。


  • 埋め込み次元。この値が高いほど、モデルがデータから学習できるニュアンスが多くなります。検索はより正確になりますが、より多くのメモリと計算リソースが必要になります。
  • 埋め込みモデルがトレーニングされたデータセット。これにより、たとえば、必要な言語がどの程度サポートされているかが決まります。


検索結果の品質を向上させるために、メッセージをトピック別に分類することができます。たとえば、フロントエンド開発専用のチャットでは、ユーザーは CSS、ツール、React、Vue などのトピックについて話し合うことができます。トピック別に分類するには、LLM (より高価) またはBERTopicなどのライブラリの従来のトピック モデリング メソッドを使用できます。


また、埋め込みとメタ情報 (元の投稿へのリンク、カテゴリ、日付) を保存するためのベクター データベースも必要になります。この目的のために、 FAISSMilvusPineconeなど、多くのベクター ストレージが存在します。 pgvector拡張機能を備えた通常の PostgreSQL でも動作します。

ユーザーの質問の処理

ユーザーの質問に答えるには、質問を検索可能な形式に変換し、質問の埋め込みを計算して、質問の意図を判断する必要があります。


質問に対するセマンティック検索の結果には、チャット履歴からの類似の質問が表示されることがありますが、その質問に対する回答は表示されません。


これを改善するには、人気のHyDE (仮想ドキュメント埋め込み) 最適化手法の 1 つを使用できます。アイデアは、LLM を使用して質問に対する仮想的な回答を生成し、回答の埋め込みを計算することです。このアプローチにより、場合によっては、質問ではなく回答の中から関連するメッセージをより正確かつ効率的に検索できます。


最も関連性の高いメッセージを見つける

質問を埋め込んだら、データベース内で最も近いメッセージを検索できます。LLM のコンテキスト ウィンドウは限られているため、検索結果が多すぎるとすべての検索結果を追加できない場合があります。回答に優先順位を付ける方法という疑問が生じます。これにはいくつかの方法があります。


  • 最新スコア。時間が経つと情報は古くなります。新しいメッセージを優先するには、簡単な式1 / (today - date_of_message + 1)を使用して最新スコアを計算できます。


  • メタデータフィルタリング。 (質問と投稿のトピックを特定する必要があります)。これにより、検索を絞り込み、探しているトピックに関連する投稿のみを残すことができます。


  • 全文検索。すべての一般的なデータベースで十分にサポートされている従来の全文検索が役立つ場合があります。


  • 再ランキング。回答が見つかったら、質問への「近さ」の度合いで並べ替えて、最も関連性の高いものだけを残すことができます。再ランキングにはCrossEncoderモデルが必要ですが、たとえばCohereの再ランキング API を使用することもできます。


最終回答の生成

前のステップで検索と並べ替えを行った後、LLM コンテキストに適合する最も関連性の高い 50 ~ 100 件の投稿を保持できます。


次のステップは、ユーザーの元のクエリと検索結果を使用して、LLM 用の明確で簡潔なプロンプトを作成することです。質問、ユーザーのクエリ、コンテキスト (見つかった関連メッセージ) にどのように答えるかを LLM に指定する必要があります。この目的のために、次の側面を考慮することが重要です。


  • システム プロンプトは、モデルが情報をどのように処理するかを説明する指示です。たとえば、提供されたデータ内でのみ回答を探すように LLM に指示できます。


  • コンテキストの長さ- 入力として使用できるメッセージの最大長。使用するモデルに対応するトークナイザーを使用してトークンの数を計算できます。たとえば、OpenAI は Tiktoken を使用します。


  • モデルのハイパーパラメータ- たとえば、温度はモデルの応答がどれだけ創造的になるかに影響します。


  • モデルの選択。最も大きく強力なモデルに高額を支払うことは、必ずしも価値があるわけではありません。異なるモデルで複数のテストを実施し、その結果を比較することは理にかなっています。高い精度を必要としない場合は、リソースをあまり消費しないモデルでも十分な場合があります。


実装

それでは、これらの手順を NodeJS で実装してみましょう。使用する技術スタックは次のとおりです。


  • NodeJSTypeScript
  • Grammy - Telegram ボット フレームワーク
  • PostgreSQL - すべてのデータのプライマリストレージとして
  • pgvector - テキスト埋め込みとメッセージを保存するための PostgreSQL 拡張機能
  • OpenAI API - LLM と埋め込みモデル
  • 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 ライブラリで利用できるRecursiveCharacterTextSplitterなどのデフォルトのアプローチでは、チャット特有のすべての特殊性は考慮されていません。ただし、Telegram の場合は、関連メッセージとユーザーが送信した返信を含む Telegram threads利用できます。


チャット ルームから新しいメッセージのバッチが届くたびに、ボットはいくつかの手順を実行する必要があります。


  • ストップワードのリスト(「hello」、「bye」など)で短いメッセージをフィルタリングします。
  • 短期間に連続して送信されたメッセージを 1 人のユーザーから結合する
  • 同じスレッドに属するすべてのメッセージをグループ化する
  • 受信したメッセージ グループをより大きなテキスト ブロックに結合し、 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評価- ボットの応答の品質を評価するためのパイプラインを設定する必要があります