Open6

「GitHubレポジトリ仕様回答Bot」におけるRAG設計について考える

KazuKazu

やること

LangChainでLangChainのGitHubレポジトリを読み込んで仕様についての質問に回答してくれるSlack Botを作る

解決したい課題

・社内で開発チームへの問い合わせの際に「仕様確認します」といったコミュニケーションを減らしたい(任意のレポジトリをデータソースにできる想定)
・新しい開発を行う際に特定のレポジトリの仕様を簡単に確認して開発速度を加速させたい

得られる体験

Slackでレポジトリの仕様について質問をしたら、
・機能の仕様について簡潔に回答してくれる

Slackでレポジトリの機能について質問をしたら、
・レポジトリにその機能があるか回答してくれる
・対象のディレクトリやファイルをGithubリンクで提示してくてる
・簡単な使い方をサンプルコードと共に提示してくれる
・ないなら無いと回答してくれる

このスクラップで考えたい・決定したいこと

RAG実装における設計を示す以下の図における
・Query Construction
・Query Translation
・Routing
・Indexing
・Retrieval
・Generation
について、今回の要件では何が最適かを検討しながら決定してきたい


https://twitter.com/LangChainAI/status/1771582684130849053/photo/1

補足

GitHubレポジトリの読み込みはLangChainのGithubRepoLoaderを使う

KazuKazu

今回のベクトルDBのデータ構造について

コードをベクトル化の対象とし、メタデータは、GithubRepoLoaderメソッドから取得できるデータを踏まえて、ファイル名、ファイルパス、ブランチ、GitHub URLとする。

source: file.metadata.source,
repository: file.metadata.repository,
branch: file.metadata.branch,
fileUrl: githubFileUrl
KazuKazu

レポジトリからデータを取得する実装

GithubRepoLoaderを使った

async function loadAndStoreGithubRepoData(githubUrl: string) {
  const loader = new GithubRepoLoader(githubUrl, {
    branch: "main",
    recursive: true,
    unknown: "warn",
    maxConcurrency: 3,
    accessToken: githubToken,
    ignoreFiles: ignoreFiles,
    ignorePaths: ignorePaths,
  });

  const docs: Document<Record<string, unknown>>[] = [];

  for await (const file of loader.loadAsStream()) {
    console.log("Loaded metadata from GitHub:", file.metadata);
    // GitHubのファイルURL
    const githubFileUrl = `${file.metadata.repository}/blob/${file.metadata.branch}/${file.metadata.source}`;
    // ドキュメント
    const doc: Document<Record<string, unknown>> = {
      pageContent: file.pageContent,
      metadata: {
        source: file.metadata.source,
        repository: file.metadata.repository,
        branch: file.metadata.branch,
        fileUrl: githubFileUrl,
      },
    };
    console.log("Loaded doc from GitHub:", doc);
    docs.push(doc);
  }

  console.log("Loaded docs.length from GitHub:", docs.length);

  // docsをPineconeに保存
}
KazuKazu

レポジトリから取得したデータをPinecone にぶっ込んだ

とりあえずlangchainとlangchain/coreだけにしたけど、最大8192トークンのチャンクに分けて1318ベクトルで収まった。

LLMへのプロンプトでトークン数オーバー

類似検索結果とシステムプロンプトをマージしてLLMに投げるところでトークンがデカくなりすぎた。Pineconeに保存するトークンをMAXの8192トークンにしてたので、そのサイズにシステムプロンプトをマージしたら発生する。それはそう。
なので今回の問題は、IndexingのタイミングではなくRetrievalとGeneraionの間で発生している。

handleLLMError BadRequestError: 400 This model's maximum context length is 8192 tokens. However, your messages resulted in 8799 tokens (8506 in the messages, 293 in the functions). Please reduce the length of the messages or functions.

トークン数オーバーの問題をどう解決するか考える

以下の三つの方法がありそう。

  • Map Reduce
  • Map Rerank
  • Refine

Map Reduce

ユーザープロンプト+システムプロンプト+検索結果分割その1→回答結果1
ユーザープロンプト+システムプロンプト+検索結果分割その2→回答結果2
ユーザープロンプト+システムプロンプト+検索結果分割その3→回答結果3
その後、
ユーザープロンプト+システムプロンプト+(回答結果1+回答結果2+回答結果3)→最終回答

Map Rerank

ユーザープロンプト+システムプロンプト+検索結果分割その1→回答結果1
ユーザープロンプト+システムプロンプト+検索結果分割その2→回答結果2
ユーザープロンプト+システムプロンプト+検索結果分割その3→回答結果3
その後、
回答結果1 or 回答結果2 or 回答結果3 でLLMに比較させて一番自信があると判断されたものが最終回答

Refine

ユーザープロンプト+システムプロンプト+検索結果分割その1→回答結果1
その後、ユーザープロンプト+システムプロンプト+検索結果分割その2+回答結果1→回答結果2
その後、ユーザープロンプト+システムプロンプト+検索結果分割その3+回答結果2→最終回答

今回参考にしたのがこちらの記事。
https://zenn.dev/umi_mori/articles/langchain-chains-long-text-algorithm#3-2.-分割されたデータの塊に対してそれぞれ処理する方法

その他の拡張RAGの手法については、この記事が参考になるので後で試してみる。
https://qiita.com/jw-automation/items/045917be7b558509fdf2#ragの拡張手法

今回はどの手法を採用するか

今回は、Refineが最も適していると考えられる。
GitHubリポジトリの各ファイルは、コードやドキュメントなど関連する情報を含んでいる可能性が高く、それぞれのファイルから得られる情報が次のファイルの理解に役立つため、情報の連鎖を考慮した処理が重要になると考えられる。Refineはこのように連続した情報の流れを最もよく考慮できるアプローチ。反面、精度を最優先するので処理速度が遅くなるデメリットを受け入れる必要はある。

ちなみに、Map Reduce、Refineは機能として提供されていた。
https://js.langchain.com/docs/modules/chains/popular/summarize

KazuKazu

トークンサイズ確認のためtiktoken-nodeを使用する

リクエスト前にトークン数を確認するためにtiktoken-nodeを使用します。GPT4はcl100k_baseでエンコーディングされているとのことだったのでcl100k_baseを指定します。

import { getEncoding } from "tiktoken-node";

// 対象のモデルに応じたエンコーディングを取得
const encoding = getEncoding("cl100k_base");

// トークン数を計算する関数
export function calculateTokenCount(text: string): number {
  const tokens = encoding.encode(text);
  return tokens.length;
}

参考:
https://github.com/ceifa/tiktoken-node
https://zenn.dev/microsoft/articles/3438cf410cc0b5

KazuKazu

期待する返答が得られなかった失敗ケースとその原因

類似検索で適切なデータが取得できていなかった+トークンの分割方法も悪かった

先ほどここで書いたように、まずは Refineという方法で類似検索とLLMへのリクエストを行いましたが結果としては期待される回答が得られませんでした。原因としては以下の二つがありました。

  • 類似検索の際に適切なファイルのコードを取得できていなかったこと
  • Refine手法に則り、検索結果を5つに分割した時に、1つのチャンクだけに期待される内容が入っていたが、残りの4つに負けて最終回答が期待する内容にならなかったこと

このように、まず最初に試した、レポジトリ全体を一定のトークンサイズでベクトルDBに埋め込み、類似検索+Refine手法でLLMリクエストをして回答を得る、ではうまくいきませんでしたので対応策として別の方法をとってみます。

ローカルベクトルDB+質問の内容によって読み込むディレクトリを動的に変更させるToolを使って改善してみた

改善する中で都度Pineconeにデータを突っ込んでいくのが面倒だったのでローカルでベクトルDBが使えるHNSWLibを使用することにした。また、類似検索の精度を上げるためのツールを作成して、質問によってその内容のテーマとなる機能やモジュールをあらかじめて特定して、そのディレクトリのみをGithubRepoLoaderを使って取得するようにした。結果、先ほどよりは期待される内容に近づいた。

使用したプロンプト

const customPromptTemplate = PromptTemplate.fromTemplate(`
    Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer. Additionally, if possible, provide a sample code snippet that could help in understanding or solving the question. Also, include a brief explanation of what the module or feature does, what inputs it takes, what outputs it produces, and for what purpose it is used.
    ----------------
    CONTEXT: {context}
    ----------------
    CHAT HISTORY: {chatHistory}
    ----------------
    QUESTION: {question}
    ----------------
    Helpful Answer:
    ----------------
    Module or Feature Explanation:
    - What does it do?
    - What inputs does it take?
    - What outputs does it produce?
    - For what purpose is it used?
    ----------------
    Sample Code (if applicable):
    `);

質問:

LangChainでプロンプトはどうやって作成しますか?

回答:

LangChainでは、ユーザが予め定義したテンプレートを使用してプロンプトを作成します。これは、ユーザの要求に対して特定の応答を取得するためのものです。

具体的には、PromptTemplateChatPromptTemplateStructuredPromptBasePromptTemplateBaseStringPromptTemplatePipelinePromptTemplateなどのクラスが使用されます。これらのクラスはBasePromptTemplateから派生し、異なる型のプロンプトを作成するために使用されます。また、プロンプトの作成に必要な入力変数を指定し、それらを使用してプロンプトをフォーマットできます。

大まかなハンドリングの流れとしては、BasePromptTemplateクラスが基本的なプロンプトのテンプレートを定義し、inputVariablesoutputParserなどの主要な属性やメソッドを提供します。他のプロンプトテンプレートクラスは、この基本クラスを拡張し、それぞれの特定のケースで使用する特定の方法でプロンプトをフォーマットします。例えば、BaseStringPromptTemplateはプロンプトが文字列であることを保証します。

サンプルコードとその説明:

import { PromptTemplate } from "langchain_core/prompts/prompt.js";
// プロンプトテンプレートを作成
const promptTemplate = PromptTemplate.fromTemplate("こんにちは、{name}さん。今日は何をしたいですか?");
// 入力値を作成
const userInput = {
 name: "Alice", 
};
// プロンプトをフォーマット
const formattedPrompt = await promptTemplate.format(userInput);
// 出力: "こんにちは、Aliceさん。今日は何をしたいですか?"
console.log(formattedPrompt);

このサンプルコードでは、fromTemplateメソッドを使用してプロンプトテンプレート (PromptTemplate)を作成し、formatメソッドを使用して入力値(userInput)を用いてプロンプトをフォーマットしています。出力はフォーマットされたプロンプト文字列になります。

(WIP) メタデータでフィルタリングするToolを使って改善してみた