🤖

ベクトルDB無しでのRAG開発話(ITルール回答Bot)

に公開

ベクトルDB無しでのRAG開発話(ITルール回答Bot)

今回はベクトルDBを用意しないRAGを開発した話をしたいと思います!

RAG開発では、ナレッジをクレンジングしてベクトル化する作業が特に面倒ですよね。
なので今回はforrep さんの「RAGにベクトルDBは必要ない!DBも不要で運用めちゃ楽な RAG Chatbot を作った話」を参考にしました。
https://speakerdeck.com/forrep/rag-does-not-need-a-vector-db

開発したのはITルール回答Botです。
社内のITルールやソフトウェア利用申請など、社員からの質問に対して、GitHubリポジトリに格納されたITルール文書や過去のSlackでのやり取りを検索し、AIが適切な回答を生成します。

この記事では、開発で利用した技術要素やアーキテクチャ、特に工夫した点についてご紹介します。

このBotが解決する課題:

  • 情報検索の手間: ITルールが書かれたドキュメントや過去のSlackでの回答を探す手間。
  • 回答の属人化: 担当者によって回答内容が異なる可能性。
  • 問い合わせ対応の負荷: 定型的な質問への対応に追われる情シス担当者の負担。

使用技術とアーキテクチャ

技術スタックの概要

処理のフロー

    1. 質問受付
    • アプリにメンションで質問を受け付ける
    • 質問に対して、検索用の複数のワードを生成する
    1. GitHub検索
    • 生成したワードでリポジトリに対してコード検索をする
    • GitHub検索で見つかったMarkdownファイルをスコアリングし、そのスコアでフィルタリングする
    • フィルタリングした結果残ったファイルをコンテキストとして質問に対する回答を用意する
    1. Slack検索
    • GitHub検索結果のフィルタリングの結果ファイルの個数が0の場合、Slackの会話履歴を2で生成したワードで検索する
    • Slack検索の結果をコンテキストとして質問に対する回答を用意する
    1. 回答生成
    • GitHub情報またはSlack情報から生成した回答が質問に適切かどうかをチェックする
    • チェックした結果、適切であればそのまま回答とする、適切でなければ「情報システム部門に詳しく回答を求めるように促す」メッセージを回答とする

このうち5箇所でLLMを使用しています。

  • 質問に対して、検索するためのワードを複数個生成する
  • GitHubでコード検索して引っかかったMarkdownファイルをそれぞれスコアリングして、スコアでフィルタリングする
  • GitHub検索結果のフィルタリングした結果残ったファイルをコンテキストとして質問に対する回答を用意する
  • Slack検索の結果をコンテキストとして質問に対する回答を用意する
  • GitHub情報から生成した回答またはSlack情報から生成した回答の回答が質問に対する回答として適切かチェックする

実装のポイント

Slack連携 (@slack/bolt)

@slack/bolt を利用し、Socket ModeでSlack APIと接続します。

import { App, LogLevel } from "@slack/bolt";

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  appToken: process.env.SLACK_APP_TOKEN,
  socketMode: true,
  signingSecret: process.env.SIGNING_SECRET,
  // ...
});

// メンション時のイベントハンドラ
app.event("app_mention", async ({ event, client, say }) => {
  // ... 処理 ...
  const userQuery = event.text.replace(/<@.*?>\s*/, "");
  const { userName, locale, question } = await getUserSettings(client, event.user || "", userQuery);

  // 最初にGitHubリポジトリを検索 (RAG)
  const githubAnswer = <GitHub検索>

  if (githubAnswer.found) {
    // GitHubで見つかった場合
    await say({ /* ... 回答送信 ... */ });
    return;
  } else {
    // GitHubで見つからない場合はSlackメッセージを検索
    const slackAnswer = <Slack検索>
    await say({ /* ... 回答送信 ... */ });
  }

  // GitHubでもSlackでも見つからない場合  
  await say({ /* ... 回答送信 ... */ });
});

// ユーザー設定(言語など)を取得
async function getUserSettings(client: any, userId: string, userQuery: string) { /* ... */ }

(async () => {
  await app.start();
  console.log("⚡️ IT Rule Bot app is running!");
})();
  • app_mention イベントをリッスンし、ボットへのメンションをトリガーに処理を開始します。
  • ユーザーの言語設定を取得し、多言語対応を考慮しています (getUserSettings)。
  • まずはGitHubリポジトリを検索し(後述のRAG)、情報が見つかればそれを回答します。
  • GitHubに情報がない場合は、過去のSlackメッセージを検索し、それをもとに回答を生成するフォールバック戦略をとっています。

GitHubコード検索(@octokit/rest)

GitHubのAPI利用はoctokit(@octokit/rest)を使っています。
axiosfetchを使っても可能ですが、octokitであればよりシンプルに実装が可能なので今回採用しました。

コード検索をしたい場合は、octokit.search.code()を使えば可能です。
今回は、コード検索をするqueryには以下の内容を入れています。

  • リポジトリの指定
  • 検索ワードの設定
import { Octokit } from "@octokit/rest";
import { createAppAuth } from "@octokit/auth-app";

export class GitHubClient {
  private octokit: any;
  // ...
  async initialize() {
    this.octokit = new Octokit({
      authStrategy: createAppAuth,
      auth: {
        appId: this.config.appId,
        privateKey: this.config.privateKey,
        installationId: this.config.installationId,
      },
    });
  }

  async searchCode(keyword: string): Promise<GitHubDocument[]> {
    const cacheKey = this.getCacheKey("gh", keyword);
    const cachedResult = this.getCacheWithStats(cacheKey);
    if (cachedResult) return cachedResult; // キャッシュヒット

    // ... Octokitで search.code と repos.getContent を呼び出す ...
    const response = await this.octokit!.search.code({
      q: `${keyword} repo:${this.config.owner}/${this.config.repo}`,
      per_page: 10, // 取得件数を制限
    });
    
    return documents;
  }
}

Slack検索(@slack/web-api)

src/clients/slack.ts では @slack/web-api を使用して、メッセージ検索 (search.messages) と投稿 (chat.postMessage) を行います。検索時にはチャンネルや期間を指定して効率的に検索しています。

import { WebClient } from "@slack/web-api";

export class SlackClient {
  private client: WebClient;
  // ...
  async searchMessages(query: string): Promise<SlackSearchResult[]> {
    const searchQuery = `in:${config.slack.searchChannels.join(' in:')} ${query} after:${config.slack.searchTimeRange}`;
    const response = await this.client.search.messages({ query: searchQuery, count: 50 });
    // ...
  }
  async sendMessage(params: SlackMessageResponse): Promise<void> { /* ... */ }
}

AIによる応答生成 (LangChain, OpenAI)

AI部分は src/services/ai/openaiService.ts です。
ここではLangChainの ChatOpenAI を使用してOpenAI API (GPT-4o miniなど)を利用しています。

プロンプトを4種類用意したのと、langfuseでのトレースを行う関係でservice自体をラッピングしています

import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { CallbackHandler } from "langfuse-langchain"; // Langfuse連携

// Langfuseハンドラの設定
export const langfuseHandler = new CallbackHandler({ /* ... */ });

export class OpenAIService {
  private model: ChatOpenAI;
  // ...
  private constructor() {
    this.model = new ChatOpenAI({
      openAIApiKey: process.env.OPENAI_API_KEY,
      model: process.env.OPENAI_MODEL,
      temperature: process.env.OPENAI_TEMPERATURE,
      maxTokens: process.env.OPENAI_MAX_TOKENS,
      callbacks: [langfuseHandler], // Langfuseコールバックを登録
    });
  }

  async generateResponse(params: AIGenerationParams & { /* ... */ }): Promise<AIResponse> {
    const promptTemplate = ChatPromptTemplate.fromMessages([
      { role: "system", content: params.templateConfig.system },
      { role: "user", content: params.templateConfig.human },
    ]);
    const chain = promptTemplate.pipe(this.model);
    const response = await chain.invoke(
      { /* ... プロンプト変数 ... */ },
      { callbacks: [langfuseHandler], runId: params.traceId, /* ... */ } // Langfuse連携
    );
    // ...
  }
}
  • LangChainの ChatPromptTemplate を使って、システムプロンプトとユーザープロンプトを組み立てます。プロンプトの内容は一元管理されていて、LLMの利用目的に応じたプロンプトを引っ張って使っています。
  • Langfuse と連携し、API呼び出しのトレース、プロンプト、応答、コストなどを記録・可視化しています。これにより、デバッグやパフォーマンス分析が格段に容易になります。

RAG (Retrieval-Augmented Generation) の実装

今回の最も重要な機能の一つが、GitHubリポジトリやSlackの会話履歴を知識源としたRAGです。
ベクトルDBを使わずナレッジを毎回生成することになるので、トークン利用量のこともありコンテキストが長くなりすぎないように、コンテキストのフィルタリングを行っています。

RAGパイプライン

  • キーワード生成: ユーザーの質問から、GitHub検索に適した日本語と英語のキーワードをAIに生成してもらってます。日本語と英語の双方の回答が必要なため英語も作成しています。

    const response = await this.openAI.generateResponse({ /* ... */ });
    const keywords = response.content.split('\n')...; // AIの応答からキーワードを抽出
    
    • キーワード生成用プロンプト
    keywordGeneration: {
      system: `あなたはキーワード生成専用のアシスタントです。以下の指示に厳密に従ってください。
    
      - ユーザーが質問をした場合、その質問内容に関連する日本語と英語のキーワードを生成してください。
      - 回答では、追加の説明や提案は一切行わず、以下のフォーマットでのみ出力してください:
    
      日本語: キーワード1, キーワード2, キーワード3, キーワード4, キーワード5  
      英語: keyword1, keyword2, keyword3, keyword4, keyword5
    
      - 回答には、他の形式や情報は含めないでください。
      - 質問に関連するキーワードがない場合は、以下のように出力してください:
    
      日本語: 該当なし  
      英語: None`,
      human: `質問: {question}\n\n出力形式:\n日本語: キーワード1, キーワード2, キーワード3, キーワード4, キーワード5\n英語: keyword1, keyword2, keyword3, keyword4, keyword5`,
    },
    
  • 文書検索: 生成されたキーワードを使ってGitHubリポジトリ内のファイルを検索します。検索ヒット数を考慮し、最大検索ドキュメント数 (MAX_DOCS_TO_SEARCH) やキーワード数を制限しています。
    詳細は前述のGitHubコード検索

  • 関連性チェック: 検索で見つかった各ドキュメントが、本当にユーザーの質問と関連性が高いかをAIが評価(スコアリング)します。

    async checkDocumentRelevance(doc: any, query: string, traceId: string): Promise<boolean> {
      const response = await this.openAI.generateResponse({
        /* ... 関連性チェック用プロンプト ... */
        templateConfig: templates.checkDocumentRelevance,
      });
      const result = JSON.parse(response.content);
      return result.score >= 50; // スコアが閾値以上か
    }
    
  • コンテキスト構築: 関連性が高いと判断されたドキュメントのみを、OpenAI APIのトークン制限 (MAX_TOTAL_TOKENS, MAX_TOKENS_PER_DOC) を超えないように最終的なコンテキスト(AIに与える情報)を作成します。トークン数をtiktokenライブラリで正確に計算 (TokenCounter.ts) し、超過する場合はドキュメントを省略したり、内容を切り詰めたりします。

    private buildTokenLimitedContext(/* ... */): string {
      let totalTokens = 0;
      const contextParts: string[] = [];
      const availableTokens = maxTokens - /* ... template/query tokens ... */;
    
      for (const doc of documents) {
        let content = TokenCounter.truncateToTokenLimit(doc.pageContent, MAX_TOKENS_PER_DOC); // トークン数で切り詰め
        const docEntry = /* ... ファイルパス、URL、内容を整形 ... */;
        const docTokens = TokenCounter.countTokens(docEntry);
    
        if (totalTokens + docTokens > availableTokens) {
          continue; // トークン制限を超える場合はスキップ
        }
        contextParts.push(docEntry);
        totalTokens += docTokens;
      }
      return contextParts.join("\n\n");
    }
    
  • 回答生成: 構築されたコンテキストと元の質問をAIに渡し、最終的な回答を生成させます。プロンプトで、コンテキストを引用し、出典リンクを含めるように指示しています。

    • 回答生成用プロンプト
    githubResponse: {
      system: `あなたは優秀なアシスタントです。
      以下のコンテキストを引用し、質問に簡潔に答えてください。
      回答には、最も関連するファイル名やリンクを含めてください。
    
      ### 質問内容
      {question}
    
      ### ドキュメント情報
      以下に関連するドキュメントを提供します。それぞれの内容を考慮し、{question}に対する回答を記載してください。
      {context}
    
      ### 回答の手順
      1. 各ドキュメントの要点を要約する。  
      2. 要約した内容を基に、{question}に対する回答を記載する。
      3. 回答には、参照したドキュメント名またはパスを明記する。  
      4. 不足している情報がある場合は指摘する。  
    
      ## 従うべきルール
      1. 回答の冒頭に「回答: 」(ただし{language}に翻訳すること)と付けて、質問への直接的な回答を記載
      2. 必要に応じて手順や説明を箇条書きで記載
      3. 最後に「注意: 」で補足情報を記載(必要な場合のみ)
      4. 回答は簡潔にしてください
      5. 出典リンクは必ず最後に含めてください
      6. 回答のために参照したファイルの名前やリンクを含めてください
      7. 回答は{language}に翻訳してください
      8. 出典リンクは{language}版(〜.{language}.md)がある場合は出典リンクを変更してください
      9. コンテキスト内に質問に回答できる情報がない場合は「回答できません」と回答してください
      `,
    
      human: `
      コンテキスト: {context}
      質問: {question}
    
      **注意**:
        - 回答は簡潔にまとめてください。
        - 出典リンクは必ず含めてください。`,
    },
    
  • 品質チェック (qualityService.checkAnswerQuality): 生成された回答が、質問の意図に沿っているか、矛盾がないかなどをAIが再度チェックします。品質が低いと判断された場合は、定型文を返すようにします。

    // src/services/ai/qualityService.ts より抜粋
    async checkAnswerQuality(/* ... */): Promise<QualityCheckResult> {
        const response = await this.openAI.generateResponse({
            /* ... 品質チェック用プロンプト ... */
            templateConfig: templates.qualityCheck,
        });
        const result = JSON.parse(response.content); // JSON形式で評価結果を取得
        return { isAppropriate: result.isAppropriate, reason: result.reason };
    }
    

この多段階のRAGパイプラインにより、単にキーワード検索するだけでなく、より質問内容に即した、信頼性の高い回答を生成できるよう工夫しました。特に関連性チェック品質チェックをAI自身に行わせることで、ノイズの多い検索結果から適切な情報を選び出し、不確かな回答をユーザーに提示してしまうリスクを低減しています。

トレースとモニタリング (Langfuse)

LLMアプリケーション開発では、プロンプトの調整や予期せぬAIの応答に対するデバッグが不可欠なため、Langfuse を導入し、リクエストごとの処理フローを詳細にトレースしています。

import { Langfuse } from "langfuse";

export class TracingService {
  private client: Langfuse;
  // ...
  async createTrace(params: { /* ... */ }) {
    return this.client.trace({ /* ... */ });
  }
  async createSpan(traceId: string, params: { /* ... */ }) { /* ... */ }
  async createGeneration(traceId: string, params: { /* ... */ }) { /* ... */ }
  async shutdown() { /* ... */ }
}
  • 1つの質問につき一意なtraceIdを生成し、Langfuseのtraceオブジェクトを作成します。
  • キーワード生成、GitHub検索、関連性チェック、回答生成、品質チェックといった各ステップをspangenerationとして記録します。
  • LangChainのコールバック (langfuse-langchain) と連携し、OpenAI API呼び出しの詳細(プロンプト、応答、トークン数、レイテンシ、コストなど)も自動的に記録されます。

これにより、「どのキーワードで検索されたか」「どのドキュメントが関連性が高いと判断されたか」「最終的なプロンプトは何か」「AIの応答は適切だったか」などをLangfuseのUI上で簡単に追跡・分析でき、開発効率と運用保守性が大幅に向上しました。

工夫した点・苦労した点

  • Langchainの使い方: 普段は手軽さからDify(セルフホスト)を使うことが多いのですが、今回は外部ソースへのアクセス等から初めてLangChainを使ったためLangfuseとの統合やプロンプトの取り回しに苦労しました。
  • RAGパイプラインの最適化: 今回のようにLong Contextになる場合トークン数制限があるため、どのようにトークン数制限内に収めるのかに試行錯誤しました。検索の結果を全て渡すのは無理な部分が多いので必要な箇所のみに絞るという処理で時間が取られるのでここは改善ポイントだと思っています。

まとめと今後の展望

今回の開発を通じて、Slack Bolt、GitHub API、LangChain、OpenAI、そしてLangfuseといった技術を組み合わせることで、社内問い合わせ対応の効率化に貢献できるAIボットを構築できました。特に、GitHubリポジトリを活用したRAGパイプラインとLangfuseによるトレーサビリティ確保は、このボットの信頼性と保守性を高める上で重要な要素となりました。

今後は、

  • GitHub、Slackの情報取得方法の改善
  • 回答精度のさらなる向上(ファインチューニングやプロンプト改善)
  • ユーザーからのフィードバック機能の実装

などを検討していきたいと考えています。

この記事が、Slackボット開発やLLMアプリケーション開発、RAG実装に興味のある方々の参考になれば幸いです。


Axelspace

Discussion