T-CREATOR

LangChain Retriever レシピ集:BM25/ハイブリッド/再ランキングの定石

LangChain Retriever レシピ集:BM25/ハイブリッド/再ランキングの定石

RAG(Retrieval-Augmented Generation)システムを構築する際、どの Retriever 手法を選ぶかは検索精度を左右する重要な判断です。シンプルなベクトル検索だけでは意味的な類似度は捉えられても、固有名詞や専門用語の完全一致を見逃してしまうことがあります。一方で、従来のキーワード検索(BM25)は高速ですが、文脈理解には弱い傾向があるのです。

そこで本記事では、LangChain を使ったBM25ハイブリッド検索再ランキングという 3 つの定石手法を、実装コード付きでご紹介します。それぞれの手法がどんな場面で威力を発揮するのか、具体例を交えながら段階的に解説していきます。

Retriever 手法早見表

#手法名特徴精度速度実装難易度推奨シーン
1ベクトル検索のみ意味的類似度で検索★★★☆☆★★★★☆★★☆☆☆一般的な質問応答
2BM25 のみキーワードマッチで検索★★☆☆☆★★★★★★★☆☆☆固有名詞・専門用語の検索
3ハイブリッド検索ベクトル + BM25 を組み合わせ★★★★☆★★★☆☆★★★☆☆バランス重視の検索
4再ランキング初期検索後に精度向上★★★★★★★☆☆☆★★★★☆高精度が必要な場面
5ハイブリッド + 再ランキング最高精度の組み合わせ★★★★★★☆☆☆☆★★★★★プロダクション環境

エラーコード早見表

#エラーコード例発生条件解決方法
1ModuleNotFoundError: No module named 'rank_bm25'BM25 パッケージ未インストールyarn add rank-bm25 実行
2TypeError: Cannot read property 'length' of undefinedドキュメントリストが空データロード処理を確認
3ValueError: weights must sum to 1.0ハイブリッド検索の重み設定エラー重み合計を 1.0 に調整
4RateLimitError: 429 Too Many Requests再ランキング API 呼び出し過多バッチサイズ削減またはレート制限実装

背景

RAG における検索の課題

RAG システムでは、ユーザーの質問に対して関連性の高いドキュメントを取得し、それを基に回答を生成します。しかし、検索手法によって取得できる情報の質が大きく変わるのです。

以下の図は、従来の単一検索手法における課題を示しています。

mermaidflowchart TB
  query["ユーザークエリ<br/>「Next.js 13のApp Routerとは?」"]

  subgraph vectorSearch["ベクトル検索のみ"]
    vec["埋め込みベクトル化"]
    vecResult["意味的に類似した文書<br/>★ 文脈理解: 強い<br/>★ 固有名詞: 弱い"]
  end

  subgraph keywordSearch["キーワード検索のみ"]
    bm["BM25アルゴリズム"]
    bmResult["キーワード一致文書<br/>★ 固有名詞: 強い<br/>★ 文脈理解: 弱い"]
  end

  query --> vectorSearch
  query --> keywordSearch

  vec --> vecResult
  bm --> bmResult

  vecResult -.->|課題| issue1["専門用語の<br/>完全一致を逃す"]
  bmResult -.->|課題| issue2["同義語や<br/>言い換えに弱い"]

上図から分かるように、ベクトル検索は意味理解に優れますが、「Next.js 13」のようなバージョン番号や固有名詞の完全一致が必要な場面では不十分です。逆に、BM25 は高速でキーワードマッチに強いものの、「App Router」と「アプリケーションルーター」のような表記揺れに対応できません。

検索精度を左右する要因

検索精度を決定する要因は以下の 3 つに集約されます。

1. 意味的理解の深さ

ベクトル検索では、文章を数値ベクトルに変換して類似度を計算します。これにより「料金」と「価格」のような同義語も適切に認識できるのです。

2. 語彙の完全一致性

専門用語、製品名、バージョン番号などは完全一致が求められます。BM25 のようなキーワードベースの手法が得意とする領域ですね。

3. 文書の再評価能力

初期検索で候補を絞り込んだ後、より高精度なモデルで再評価することで、最終的な検索品質を向上させられます。これが再ランキングの役割です。

これら 3 つの要因を理解することで、状況に応じた最適な手法選択が可能になります。

課題

単一手法の限界

実際の検索システムでは、単一の手法だけでは対応しきれない課題が頻出します。具体的な問題点を見ていきましょう。

課題 1:ベクトル検索の弱点

ベクトル検索は意味的な類似度計算には優れていますが、以下のような弱点があります。

  • 固有名詞の曖昧性:「GPT-4」と「GPT-3.5」を区別できないことがある
  • 数値情報の不正確さ:「2023 年」と「2024 年」の違いをベクトル空間で正確に表現できない
  • 短いクエリへの対応:数単語のキーワード検索では埋め込みベクトルの情報量が不足する

例えば、「TypeScript 5.0 の新機能」と検索したとき、ベクトル検索だけでは「TypeScript 4.9 の新機能」も高スコアで返してしまう可能性があるのです。

課題 2:BM25 の弱点

BM25 は高速でシンプルな実装が可能ですが、以下の制約があります。

  • 同義語の認識不可:「エラー」と「例外」を別の単語として扱う
  • 文脈理解の欠如:単語の出現頻度だけで判断するため、文章全体の意図を捉えられない
  • 言語間の壁:日本語と英語が混在する文書では精度が落ちる

特に技術文書では「Exception」「エラー」「例外」など複数の表現が混在するため、キーワードマッチだけでは不十分なのです。

課題 3:初期検索結果の精度不足

初期検索で取得した上位 N 件の文書が必ずしも最適な順序とは限りません。検索システムには以下の課題があります。

#課題影響対処法
1スコアの絶対値が信頼できない無関係な文書が上位に入る再ランキングで再評価
2検索速度と精度のトレードオフ高精度モデルは遅い2 段階検索の採用
3複数シグナルの統合困難ベクトルとキーワードの融合ハイブリッド検索

これらの課題を解決するために、複数の手法を組み合わせたアプローチが必要になります。

解決策

アプローチ 1:BM25 Retriever

BM25(Best Matching 25)は、TF-IDF(Term Frequency-Inverse Document Frequency)を改良したランキング関数です。キーワードの出現頻度と文書の長さを考慮して、関連度スコアを計算します。

以下の図は、BM25 の基本的な処理フローを示しています。

mermaidflowchart LR
  input["クエリ<br/>「Next.js API Routes」"]

  subgraph bm25Process["BM25処理"]
    tokenize["トークン化<br/>['Next.js', 'API', 'Routes']"]
    calcTF["TF計算<br/>(単語出現頻度)"]
    calcIDF["IDF計算<br/>(文書内の希少性)"]
    score["BM25スコア算出"]
  end

  docs[("ドキュメント<br/>コレクション")]
  result["スコア順の<br/>検索結果"]

  input --> tokenize
  tokenize --> calcTF
  calcTF --> calcIDF
  calcIDF --> score
  docs --> calcIDF
  score --> result

BM25 は高速で実装も容易なため、まず最初に試すべき手法です。特にキーワードが明確な検索クエリに対して高い効果を発揮します。

BM25 の実装手順

LangChain で BM25 Retriever を実装する手順を段階的に見ていきましょう。

ステップ 1:必要なパッケージのインストール

まず、BM25 を使用するために必要なパッケージをインストールします。

bashyarn add langchain @langchain/community

LangChain のコミュニティパッケージには、BM25 Retriever が含まれています。

ステップ 2:ドキュメントの準備

検索対象となるドキュメントを準備します。ここでは、技術文書を例にしています。

typescriptimport { Document } from 'langchain/document';

// 検索対象のドキュメントを作成
const documents = [
  new Document({
    pageContent:
      'Next.js 13ではApp Routerが導入され、サーバーコンポーネントがデフォルトになりました。',
    metadata: { source: 'nextjs-docs', version: '13' },
  }),
  new Document({
    pageContent:
      'API RoutesはNext.jsでサーバーレス関数を簡単に作成できる機能です。',
    metadata: { source: 'nextjs-docs', version: '12' },
  }),
  new Document({
    pageContent:
      'TypeScript 5.0ではDecoratorのサポートが標準化されました。',
    metadata: { source: 'typescript-docs', version: '5.0' },
  }),
];

このコードでは、Documentクラスを使って検索対象の文書を定義しています。pageContentに本文、metadataにメタ情報を格納するのが基本パターンです。

ステップ 3:BM25 Retriever の初期化

BM25 Retriever を初期化して、検索可能な状態にします。

typescriptimport { BM25Retriever } from '@langchain/community/retrievers/bm25';

// BM25 Retrieverの作成
const bm25Retriever = BM25Retriever.fromDocuments(
  documents,
  {
    k: 2, // 取得する文書数
  }
);

kパラメータで取得する上位 N 件の文書数を指定します。通常は 2〜5 件程度が適切でしょう。

ステップ 4:検索の実行

実際にクエリを投げて検索を実行してみます。

typescript// クエリで検索を実行
const query = 'Next.js API Routes';
const results = await bm25Retriever.getRelevantDocuments(
  query
);

// 結果を表示
console.log('検索結果:', results.length, '件');
results.forEach((doc, index) => {
  console.log(`\n${index + 1}. ${doc.pageContent}`);
  console.log(`   メタデータ:`, doc.metadata);
});

このコードは、「Next.js API Routes」というクエリで BM25 検索を実行し、結果を表示します。キーワード「Next.js」と「API」「Routes」に一致する文書が上位に返されるはずです。

BM25 の特徴として、完全一致するキーワードが多いほどスコアが高くなります。そのため、固有名詞や専門用語を含むクエリに対して効果的なのです。

アプローチ 2:ハイブリッド検索

ハイブリッド検索は、ベクトル検索と BM25 を組み合わせることで、両方の長所を活かす手法です。意味的な類似度とキーワードマッチの両方を考慮することで、より高精度な検索が実現できます。

以下の図は、ハイブリッド検索の処理フローを示しています。

mermaidflowchart TB
  query["クエリ入力"]

  subgraph parallel["並列処理"]
    direction LR
    vector["ベクトル検索<br/>(意味的類似度)"]
    bm25["BM25検索<br/>(キーワード一致)"]
  end

  subgraph merge["スコア統合"]
    normalize["スコア正規化"]
    weighted["重み付け加算<br/>α×ベクトル + (1-α)×BM25"]
  end

  rerank["再ランキング"]
  final["最終結果"]

  query --> parallel
  vector --> normalize
  bm25 --> normalize
  normalize --> weighted
  weighted --> rerank
  rerank --> final

上図のように、ハイブリッド検索では 2 つの検索手法を並列実行し、それぞれのスコアを統合します。重みパラメータ(α)で両者のバランスを調整できるのが特徴です。

ハイブリッド検索の実装手順

LangChain でハイブリッド検索を実装していきます。

ステップ 1:ベクトルストアの準備

まず、ベクトル検索用のベクトルストアを準備します。ここでは MemoryVectorStore を使用しますが、本番環境では Pinecone や Chroma などを使うことが多いでしょう。

typescriptimport { OpenAIEmbeddings } from '@langchain/openai';
import { MemoryVectorStore } from 'langchain/vectorstores/memory';

// OpenAIの埋め込みモデルを初期化
const embeddings = new OpenAIEmbeddings({
  modelName: 'text-embedding-3-small',
});

// ベクトルストアを作成
const vectorStore = await MemoryVectorStore.fromDocuments(
  documents,
  embeddings
);

text-embedding-3-smallは、OpenAI の最新の埋め込みモデルです。コストと性能のバランスが良く、多くのケースで推奨されます。

ステップ 2:各 Retriever の作成

ベクトル検索用と BM25 用、それぞれの Retriever を作成します。

typescript// ベクトル検索用Retriever
const vectorRetriever = vectorStore.asRetriever({
  k: 5, // 初期検索では多めに取得
});

// BM25用Retriever
const bm25Retriever = BM25Retriever.fromDocuments(
  documents,
  {
    k: 5, // 同じく多めに取得
  }
);

ハイブリッド検索では、初期段階で多めの候補を取得しておき、後で絞り込むアプローチが効果的です。

ステップ 3:EnsembleRetriever の構築

2 つの Retriever を統合する EnsembleRetriever を作成します。

typescriptimport { EnsembleRetriever } from 'langchain/retrievers/ensemble';

// ハイブリッドRetrieverを作成
const ensembleRetriever = new EnsembleRetriever({
  retrievers: [vectorRetriever, bm25Retriever],
  weights: [0.5, 0.5], // 重みを均等に設定
  k: 3, // 最終的に返す文書数
});

weightsパラメータで各 Retriever の重みを設定します。ここでは 50:50 の均等配分にしていますが、用途に応じて調整が可能です。

ステップ 4:検索の実行と結果確認

ハイブリッド検索を実行してみましょう。

typescript// ハイブリッド検索を実行
const hybridQuery = 'Next.js 13のApp Router';
const hybridResults =
  await ensembleRetriever.getRelevantDocuments(hybridQuery);

console.log(
  'ハイブリッド検索結果:',
  hybridResults.length,
  '件'
);
hybridResults.forEach((doc, index) => {
  console.log(`\n${index + 1}. ${doc.pageContent}`);
});

このクエリでは、「Next.js 13」という固有名詞のキーワードマッチと、「App Router」という意味的な理解の両方が必要です。ハイブリッド検索により、両方の要素を持つ文書が上位に来るはずです。

重みパラメータの調整指針

重みパラメータ(weights)の調整は、検索精度に大きく影響します。以下の指針を参考にしてください。

#ベクトル推奨シーン特徴
10.7:0.3意味的検索重視同義語・言い換えに強い
20.5:0.5バランス型汎用的な検索
30.3:0.7キーワード重視固有名詞・専門用語の検索
40.8:0.2FAQ・チャット自然言語クエリ対応

実際の運用では、A/B テストを通じて最適な重みを見つけることが重要です。

アプローチ 3:再ランキング

再ランキング(Reranking)は、初期検索で取得した候補文書を、より高精度なモデルで再評価する手法です。2 段階検索アプローチとも呼ばれ、速度と精度のバランスを取ることができます。

以下の図は、再ランキングの処理フローを示しています。

mermaidflowchart TB
  query["クエリ"]

  subgraph stage1["第1段階:初期検索"]
    retriever["高速Retriever<br/>(ベクトル or ハイブリッド)"]
    candidates["候補文書<br/>top 20〜50件"]
  end

  subgraph stage2["第2段階:再ランキング"]
    reranker["高精度モデル<br/>(Cross-Encoder等)"]
    rescore["詳細スコア計算"]
    resort["再ソート"]
  end

  final["最終結果<br/>top 3〜5件"]

  query --> retriever
  retriever --> candidates
  candidates --> reranker
  reranker --> rescore
  rescore --> resort
  resort --> final

上図のように、まず高速な Retriever で候補を絞り込み、その後に高精度なモデルで詳細評価を行います。この 2 段階アプローチにより、全文書を高精度モデルで評価する必要がなくなり、実用的な速度を保ちながら精度を向上できるのです。

再ランキングの実装手順

LangChain で再ランキングを実装していきます。ここでは、Cohere の再ランキング API を使用します。

ステップ 1:Cohere パッケージのインストール

Cohere の再ランキング API を使用するため、必要なパッケージをインストールします。

bashyarn add cohere-ai

Cohere は OpenAI と並ぶ LLM プロバイダーで、特に再ランキングに特化したモデルを提供しています。

ステップ 2:初期 Retriever の準備

まず、第 1 段階の検索用 Retriever を準備します。ここではハイブリッド検索を使用します。

typescript// 先ほど作成したハイブリッドRetrieverを使用
// 初期段階では多めの候補を取得
const initialRetriever = new EnsembleRetriever({
  retrievers: [vectorRetriever, bm25Retriever],
  weights: [0.5, 0.5],
  k: 20, // 再ランキング用に多めに取得
});

再ランキングを前提とする場合、初期検索では 20〜50 件程度の候補を取得しておくのが一般的です。

ステップ 3:Cohere Reranker の設定

Cohere の再ランキングモデルを設定します。

typescriptimport { CohereRerank } from '@langchain/cohere';

// Cohere Rerankerを初期化
const reranker = new CohereRerank({
  apiKey: process.env.COHERE_API_KEY,
  model: 'rerank-english-v3.0', // または rerank-multilingual-v3.0
  topN: 3, // 最終的に返す文書数
});

rerank-multilingual-v3.0は日本語にも対応しているため、日本語文書を扱う場合はこちらを選択します。

ステップ 4:再ランキング処理の実装

初期検索と再ランキングを組み合わせた処理を実装します。

typescript// 再ランキングを含む検索処理
async function searchWithReranking(query: string) {
  // 第1段階:初期検索で候補を取得
  const initialDocs =
    await initialRetriever.getRelevantDocuments(query);

  console.log(`初期検索: ${initialDocs.length}件取得`);

  // 第2段階:再ランキングで精度向上
  const rerankedDocs = await reranker.compressDocuments(
    initialDocs,
    query
  );

  console.log(
    `再ランキング後: ${rerankedDocs.length}件に絞り込み`
  );

  return rerankedDocs;
}

このコードは、まず初期 Retriever で候補を取得し、その後 Cohere の再ランキングモデルで精度の高い上位 N 件に絞り込みます。

ステップ 5:実行と結果確認

実際に再ランキングを実行して、結果を確認します。

typescript// 再ランキング検索を実行
const rerankQuery = 'Next.js 13のApp Routerの使い方';
const rerankResults = await searchWithReranking(
  rerankQuery
);

console.log('\n再ランキング検索結果:');
rerankResults.forEach((doc, index) => {
  console.log(`\n${index + 1}. ${doc.pageContent}`);
  // Cohereは関連度スコアも返す
  if (doc.metadata.relevanceScore) {
    console.log(
      `   関連度スコア: ${doc.metadata.relevanceScore}`
    );
  }
});

再ランキング後の結果には、Cohere が算出した関連度スコアが付与されます。このスコアは 0〜1 の範囲で、クエリとの関連性を示すため、結果の信頼性判断に活用できるのです。

アプローチ 4:ハイブリッド検索 + 再ランキング(最高精度)

最高精度を求める場合は、ハイブリッド検索と再ランキングを組み合わせます。この構成は、プロダクション環境で推奨される定石パターンです。

以下の図は、完全な処理フローを示しています。

mermaidflowchart TB
  query["ユーザークエリ"]

  subgraph stage1["第1段階:ハイブリッド検索"]
    direction LR
    vec["ベクトル検索<br/>k=30"]
    bm["BM25検索<br/>k=30"]
    ensemble["統合<br/>weights: [0.5, 0.5]"]
  end

  candidates["候補文書<br/>30件"]

  subgraph stage2["第2段階:再ランキング"]
    cross["Cross-Encoder<br/>(Cohere Rerank)"]
    score["詳細スコアリング"]
  end

  final["最終結果<br/>top 3件<br/>(高精度・高信頼)"]

  query --> stage1
  vec --> ensemble
  bm --> ensemble
  ensemble --> candidates
  candidates --> stage2
  cross --> score
  score --> final

この構成により、以下の 3 つの強みを持つ検索システムが実現できます。

  1. 意味的理解:ベクトル検索による文脈把握
  2. キーワード精度:BM25 による固有名詞の完全一致
  3. 最終精度向上:再ランキングによる詳細評価

完全実装例

これまでの手法を統合した完全な実装例を見ていきましょう。

ステップ 1:完全な設定ファイル

まず、環境変数などの設定を準備します。

typescript// config.ts
export const config = {
  // OpenAI設定
  openai: {
    apiKey: process.env.OPENAI_API_KEY,
    embeddingModel: 'text-embedding-3-small',
  },

  // Cohere設定
  cohere: {
    apiKey: process.env.COHERE_API_KEY,
    rerankModel: 'rerank-multilingual-v3.0',
  },

  // Retriever設定
  retriever: {
    initialK: 30, // 初期検索の取得数
    finalK: 3, // 最終結果の件数
    vectorWeight: 0.5, // ベクトル検索の重み
    bm25Weight: 0.5, // BM25の重み
  },
};

設定を外部化することで、パラメータチューニングが容易になります。

ステップ 2:ドキュメントローダーの実装

実際のアプリケーションでは、ファイルやデータベースからドキュメントを読み込みます。

typescript// documentLoader.ts
import { TextLoader } from 'langchain/document_loaders/fs/text';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';

// ドキュメントを読み込んでチャンク分割
export async function loadDocuments(filePath: string) {
  // テキストファイルを読み込み
  const loader = new TextLoader(filePath);
  const docs = await loader.load();

  // 適切なサイズにチャンク分割
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500, // 1チャンクの文字数
    chunkOverlap: 50, // チャンク間の重複
  });

  const splitDocs = await splitter.splitDocuments(docs);

  console.log(
    `${splitDocs.length}個のチャンクに分割しました`
  );
  return splitDocs;
}

RecursiveCharacterTextSplitterは、文章の意味を保ちながら適切な長さに分割してくれます。chunkOverlapを設定することで、チャンク境界での情報欠落を防げるのです。

ステップ 3:統合 Retriever クラスの実装

すべての機能を統合した Retriever クラスを作成します。

typescript// hybridReranker.ts
import { OpenAIEmbeddings } from '@langchain/openai';
import { MemoryVectorStore } from 'langchain/vectorstores/memory';
import { BM25Retriever } from '@langchain/community/retrievers/bm25';
import { EnsembleRetriever } from 'langchain/retrievers/ensemble';
import { CohereRerank } from '@langchain/cohere';
import { Document } from 'langchain/document';
import { config } from './config';

export class HybridReranker {
  private ensembleRetriever: EnsembleRetriever;
  private reranker: CohereRerank;

  // コンストラクタでは初期化しない(非同期処理が必要なため)
  constructor() {}

  // 初期化メソッド
  async initialize(documents: Document[]) {
    // ベクトルストアの作成
    const embeddings = new OpenAIEmbeddings({
      modelName: config.openai.embeddingModel,
    });

    const vectorStore =
      await MemoryVectorStore.fromDocuments(
        documents,
        embeddings
      );

    // 各Retrieverの作成
    const vectorRetriever = vectorStore.asRetriever({
      k: config.retriever.initialK,
    });

    const bm25Retriever = BM25Retriever.fromDocuments(
      documents,
      {
        k: config.retriever.initialK,
      }
    );

    // ハイブリッドRetrieverの作成
    this.ensembleRetriever = new EnsembleRetriever({
      retrievers: [vectorRetriever, bm25Retriever],
      weights: [
        config.retriever.vectorWeight,
        config.retriever.bm25Weight,
      ],
    });

    // Rerankerの作成
    this.reranker = new CohereRerank({
      apiKey: config.cohere.apiKey,
      model: config.cohere.rerankModel,
      topN: config.retriever.finalK,
    });

    console.log('HybridReranker初期化完了');
  }

  // 検索メソッド
  async search(query: string): Promise<Document[]> {
    // 第1段階:ハイブリッド検索
    const initialDocs =
      await this.ensembleRetriever.getRelevantDocuments(
        query
      );
    console.log(`初期検索: ${initialDocs.length}件`);

    // 第2段階:再ランキング
    const rerankedDocs =
      await this.reranker.compressDocuments(
        initialDocs,
        query
      );
    console.log(`再ランキング後: ${rerankedDocs.length}件`);

    return rerankedDocs;
  }
}

このクラスは、ハイブリッド検索と再ランキングのすべての処理をカプセル化しています。使う側はシンプルにsearch()メソッドを呼ぶだけで、高精度な検索が実行できるのです。

ステップ 4:実行スクリプトの作成

実際に使用するためのスクリプトを作成します。

typescript// main.ts
import { HybridReranker } from './hybridReranker';
import { loadDocuments } from './documentLoader';

async function main() {
  // ドキュメントの読み込み
  const documents = await loadDocuments(
    './data/technical-docs.txt'
  );

  // HybridRerankerの初期化
  const retriever = new HybridReranker();
  await retriever.initialize(documents);

  // 検索クエリリスト
  const queries = [
    'Next.js 13のApp Routerの基本的な使い方',
    'TypeScript 5.0のDecorator機能',
    'API Routesでのエラーハンドリング方法',
  ];

  // 各クエリで検索を実行
  for (const query of queries) {
    console.log(`\n${'='.repeat(60)}`);
    console.log(`クエリ: ${query}`);
    console.log('='.repeat(60));

    const results = await retriever.search(query);

    results.forEach((doc, index) => {
      console.log(
        `\n${index + 1}. ${doc.pageContent.substring(
          0,
          150
        )}...`
      );
      console.log(
        `   関連度: ${doc.metadata.relevanceScore?.toFixed(
          3
        )}`
      );
    });
  }
}

main().catch(console.error);

このスクリプトは、複数のクエリで検索を実行し、結果を見やすく表示します。

ステップ 5:パフォーマンス計測の追加

検索速度を計測する機能を追加しましょう。

typescript// パフォーマンス計測機能を追加
export class HybridReranker {
  // ... 既存のコード ...

  async searchWithMetrics(query: string) {
    const startTime = Date.now();

    // 初期検索の計測
    const stage1Start = Date.now();
    const initialDocs =
      await this.ensembleRetriever.getRelevantDocuments(
        query
      );
    const stage1Time = Date.now() - stage1Start;

    // 再ランキングの計測
    const stage2Start = Date.now();
    const rerankedDocs =
      await this.reranker.compressDocuments(
        initialDocs,
        query
      );
    const stage2Time = Date.now() - stage2Start;

    const totalTime = Date.now() - startTime;

    return {
      documents: rerankedDocs,
      metrics: {
        totalTime,
        stage1Time,
        stage2Time,
        initialCount: initialDocs.length,
        finalCount: rerankedDocs.length,
      },
    };
  }
}

パフォーマンス計測により、各段階の処理時間を把握できます。これはボトルネック特定やパラメータチューニングに役立つでしょう。

具体例

ケーススタディ 1:技術ドキュメント検索システム

実際の技術ドキュメント検索システムを構築する例を見ていきます。ここでは、Next.js、TypeScript、React の公式ドキュメントから情報を検索するシステムを想定します。

データ準備と前処理

まず、複数の情報源からドキュメントを収集し、適切に前処理します。

typescript// dataPreparation.ts
import { Document } from 'langchain/document';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';

interface RawDocument {
  content: string;
  source: string;
  url: string;
  version: string;
}

// 生のドキュメントデータ
const rawDocs: RawDocument[] = [
  {
    content:
      'Next.js 13では、App Routerが新しいルーティングシステムとして導入されました。これにより、Reactのサーバーコンポーネントがデフォルトで使用できるようになり、パフォーマンスが大幅に向上します。',
    source: 'nextjs',
    url: 'https://nextjs.org/docs/app',
    version: '13.0',
  },
  // ... 他のドキュメント
];

export async function prepareDocuments(): Promise<
  Document[]
> {
  // Documentオブジェクトに変換
  const documents = rawDocs.map(
    (doc) =>
      new Document({
        pageContent: doc.content,
        metadata: {
          source: doc.source,
          url: doc.url,
          version: doc.version,
        },
      })
  );

  // チャンク分割(長い文書の場合)
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 400,
    chunkOverlap: 50,
    separators: ['\n\n', '\n', '。', '、', ' '],
  });

  const splitDocs = await splitter.splitDocuments(
    documents
  );
  return splitDocs;
}

separatorsに日本語の句読点を含めることで、日本語文書を自然な区切りで分割できます。

エラーハンドリングの実装

実際の運用では、API エラーやタイムアウトへの対処が必要です。

typescript// errorHandling.ts
export class SearchError extends Error {
  constructor(
    message: string,
    public code: string,
    public originalError?: Error
  ) {
    super(message);
    this.name = 'SearchError';
  }
}

export async function safeSearch(
  retriever: HybridReranker,
  query: string,
  maxRetries: number = 3
): Promise<Document[]> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await retriever.search(query);
    } catch (error) {
      // エラーコードの判定
      if (error.message?.includes('429')) {
        // レート制限エラー
        const waitTime = Math.pow(2, attempt) * 1000; // 指数バックオフ
        console.log(
          `Rate limit hit. Waiting ${waitTime}ms before retry ${attempt}/${maxRetries}`
        );
        await new Promise((resolve) =>
          setTimeout(resolve, waitTime)
        );
        continue;
      } else if (error.message?.includes('timeout')) {
        // タイムアウトエラー
        throw new SearchError(
          'Search timeout. Please try a more specific query.',
          'SEARCH_TIMEOUT',
          error
        );
      } else {
        // その他のエラー
        throw new SearchError(
          'Search failed. Please try again later.',
          'SEARCH_FAILED',
          error
        );
      }
    }
  }

  throw new SearchError(
    'Max retries exceeded',
    'MAX_RETRIES_EXCEEDED'
  );
}

このコードは、レート制限エラー(Error 429: Too Many Requests)に対して指数バックオフでリトライを行い、タイムアウトやその他のエラーには適切なエラーメッセージを返します。

実行例と結果分析

実際に検索を実行して、各手法の違いを確認しましょう。

typescript// comparison.ts
async function compareSearchMethods() {
  const documents = await prepareDocuments();
  const query =
    'Next.js 13のApp Routerでサーバーコンポーネントを使う方法';

  // 方法1:ベクトル検索のみ
  console.log('\n【ベクトル検索のみ】');
  const vectorResults =
    await vectorRetriever.getRelevantDocuments(query);
  displayResults(vectorResults);

  // 方法2:BM25のみ
  console.log('\n【BM25のみ】');
  const bm25Results =
    await bm25Retriever.getRelevantDocuments(query);
  displayResults(bm25Results);

  // 方法3:ハイブリッド検索
  console.log('\n【ハイブリッド検索】');
  const hybridResults =
    await ensembleRetriever.getRelevantDocuments(query);
  displayResults(hybridResults);

  // 方法4:ハイブリッド + 再ランキング
  console.log('\n【ハイブリッド + 再ランキング】');
  const retriever = new HybridReranker();
  await retriever.initialize(documents);
  const finalResults = await retriever.search(query);
  displayResults(finalResults);
}

function displayResults(docs: Document[]) {
  docs.slice(0, 3).forEach((doc, i) => {
    console.log(
      `${i + 1}. ${doc.pageContent.substring(0, 100)}...`
    );
    console.log(
      `   [${doc.metadata.source} v${doc.metadata.version}]`
    );
  });
}

このコードを実行すると、各手法の検索結果の違いが明確に分かります。特に、固有名詞(Next.js 13、App Router)と意味的内容(サーバーコンポーネントを使う方法)の両方を含むクエリでは、ハイブリッド検索と再ランキングの組み合わせが最も適切な結果を返すはずです。

ケーススタディ 2:FAQ チャットボット

カスタマーサポート用の FAQ チャットボットを構築する例です。ここでは、ユーザーの質問に対して最適な FAQ を返すシステムを実装します。

FAQ データの構造化

まず、FAQ データを構造化して管理します。

typescript// faqData.ts
interface FAQ {
  id: string;
  question: string;
  answer: string;
  category: string;
  tags: string[];
}

export const faqData: FAQ[] = [
  {
    id: 'faq-001',
    question:
      'Next.jsのバージョンアップデート方法を教えてください',
    answer:
      'package.jsonのnextパッケージのバージョンを更新し、yarn installを実行してください。その後、Breaking Changesを確認し、必要に応じてコードを修正します。',
    category: '環境構築',
    tags: ['Next.js', 'アップデート', 'バージョン管理'],
  },
  {
    id: 'faq-002',
    question:
      'API Routesでエラーハンドリングはどうすればいいですか',
    answer:
      'try-catchブロックでエラーをキャッチし、適切なHTTPステータスコード(400, 500など)と共にエラーメッセージを返します。',
    category: '開発',
    tags: ['API Routes', 'エラーハンドリング', 'Next.js'],
  },
  // ... 他のFAQ
];

// FAQをDocumentに変換
export function faqToDocuments(): Document[] {
  return faqData.map(
    (faq) =>
      new Document({
        pageContent: `質問: ${faq.question}\n回答: ${faq.answer}`,
        metadata: {
          id: faq.id,
          category: faq.category,
          tags: faq.tags,
          question: faq.question,
        },
      })
  );
}

質問と回答を 1 つのpageContentにまとめることで、検索時に両方の情報が考慮されます。

カテゴリフィルタリング機能の追加

ユーザーがカテゴリを指定できる機能を追加します。

typescript// faqRetriever.ts
export class FAQRetriever extends HybridReranker {
  private documents: Document[];

  async initialize(documents: Document[]) {
    this.documents = documents;
    await super.initialize(documents);
  }

  // カテゴリフィルタ付き検索
  async searchByCategory(
    query: string,
    category?: string
  ): Promise<Document[]> {
    // カテゴリ指定がある場合は事前フィルタリング
    let targetDocs = this.documents;

    if (category) {
      targetDocs = this.documents.filter(
        (doc) => doc.metadata.category === category
      );

      console.log(
        `カテゴリ「${category}」で絞り込み: ${targetDocs.length}件`
      );

      // フィルタ後のドキュメントで再初期化
      await super.initialize(targetDocs);
    }

    // 検索実行
    return await this.search(query);
  }
}

カテゴリフィルタリングにより、検索範囲を限定して精度を向上させられます。

類似質問の提案機能

ユーザーの質問に対して、類似する質問を提案する機能を実装します。

typescript// suggestionFeature.ts
export async function searchWithSuggestions(
  retriever: FAQRetriever,
  query: string,
  category?: string
) {
  // メイン検索
  const mainResults = await retriever.searchByCategory(
    query,
    category
  );

  // 類似質問の抽出(メタデータから)
  const suggestions = mainResults.map((doc) => ({
    question: doc.metadata.question,
    category: doc.metadata.category,
    relevance: doc.metadata.relevanceScore,
  }));

  return {
    mainAnswer: mainResults[0], // 最も関連度の高い回答
    similarQuestions: suggestions.slice(1, 4), // 類似質問3件
  };
}

ユーザーが求める情報が見つからない場合でも、類似質問を提示することで解決につながりやすくなります。

実行例

FAQ チャットボットの実行例です。

typescript// faqBot.ts
async function runFAQBot() {
  // FAQデータの準備
  const documents = faqToDocuments();

  // Retrieverの初期化
  const retriever = new FAQRetriever();
  await retriever.initialize(documents);

  // ユーザークエリ
  const userQuery = 'Next.jsのバージョンを更新したい';

  // 検索実行
  const result = await searchWithSuggestions(
    retriever,
    userQuery
  );

  // 結果表示
  console.log('【最適な回答】');
  console.log(result.mainAnswer.pageContent);
  console.log(
    `\n関連度: ${result.mainAnswer.metadata.relevanceScore?.toFixed(
      3
    )}`
  );

  console.log('\n【類似する質問】');
  result.similarQuestions.forEach((sq, i) => {
    console.log(
      `${i + 1}. ${sq.question} (${sq.category})`
    );
  });
}

この実装により、ユーザーの自然言語での質問に対して、最適な FAQ とその類似質問を提示できます。

ケーススタディ 3:多言語対応検索

日本語と英語が混在する技術文書を検索するシステムです。

多言語ドキュメントの準備

日本語と英語の両方を含むドキュメントを用意します。

typescript// multilingualData.ts
const multilingualDocs = [
  new Document({
    pageContent:
      'TypeScript 5.0では、Decoratorが正式にサポートされました。This feature enables metadata programming.',
    metadata: {
      lang: 'ja-en',
      topic: 'typescript-decorator',
    },
  }),
  new Document({
    pageContent:
      'The App Router in Next.js 13 introduces server components as the default. サーバーコンポーネントにより、クライアントサイドのJavaScriptが削減されます。',
    metadata: { lang: 'en-ja', topic: 'nextjs-app-router' },
  }),
];

実際の技術文書では、日本語の説明の中に英語の技術用語が含まれることが多いため、このような混在ドキュメントへの対応が重要です。

多言語対応 Embeddings

多言語に対応した埋め込みモデルを使用します。

typescript// multilingualEmbeddings.ts
import { OpenAIEmbeddings } from '@langchain/openai';

// 多言語対応の埋め込みモデル
export const multilingualEmbeddings = new OpenAIEmbeddings({
  modelName: 'text-embedding-3-small', // 多言語対応
});

OpenAI のtext-embedding-3-smallは、日本語と英語の両方に対応しており、混在テキストでも適切にベクトル化できます。

BM25 の多言語対応

BM25 でも、日本語と英語の両方をトークン化する必要があります。

typescript// multilingualTokenizer.ts
// 簡易的な多言語トークナイザー
export function multilingualTokenize(
  text: string
): string[] {
  // 英単語と日本語を分離
  const tokens: string[] = [];

  // 英単語の抽出
  const englishWords = text.match(/[a-zA-Z]+/g) || [];
  tokens.push(...englishWords);

  // 日本語の形態素解析(簡易版:文字単位)
  // 本番環境ではkuromojiなどの形態素解析器を使用
  const japaneseChars =
    text.match(
      /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]+/g
    ) || [];
  japaneseChars.forEach((chunk) => {
    // 2文字ずつのbigramに分割
    for (let i = 0; i < chunk.length - 1; i++) {
      tokens.push(chunk.substring(i, i + 2));
    }
  });

  return tokens;
}

この簡易トークナイザーは、英単語はそのまま、日本語は bigram(2 文字組)に分割します。本格的な実装では、kuromoji などの形態素解析ライブラリを使用するとよいでしょう。

多言語検索の実行

多言語対応の検索システムを実行します。

typescript// multilingualSearch.ts
async function multilingualSearch() {
  // ベクトルストア作成
  const vectorStore = await MemoryVectorStore.fromDocuments(
    multilingualDocs,
    multilingualEmbeddings
  );

  // BM25(多言語トークナイザー使用)
  const bm25Retriever = BM25Retriever.fromDocuments(
    multilingualDocs,
    {
      k: 5,
    }
  );

  // 日英混在クエリ
  const query = 'TypeScript Decoratorの使い方';

  console.log(`クエリ: ${query}`);
  const results = await vectorStore
    .asRetriever()
    .getRelevantDocuments(query);

  results.forEach((doc, i) => {
    console.log(`\n${i + 1}. ${doc.pageContent}`);
  });
}

このシステムにより、日本語と英語が混在するクエリでも適切に検索できます。

まとめ

本記事では、LangChain を使った 3 つの主要な Retriever 手法を解説しました。

各手法の選択基準

実際のプロジェクトで手法を選択する際の基準を整理します。

#選択基準推奨手法理由
1検索精度が最優先ハイブリッド + 再ランキング意味理解とキーワード精度を両立
2レスポンス速度が重要BM25 のみ最速の検索手法
3コスト削減が必要ハイブリッド検索API 呼び出しなしで高精度
4固有名詞が多いBM25 またはハイブリッドキーワード完全一致に強い
5自然言語クエリ中心ベクトル検索またはハイブリッド意味的類似度に強い

実装のポイント

実装時に押さえておくべき重要なポイントです。

1. パラメータチューニングの重要性

ハイブリッド検索の重みパラメータ、初期検索の取得件数(k)、チャンクサイズなど、各パラメータは用途によって最適値が異なります。A/B テストを通じて、自分のユースケースに最適な値を見つけることが成功の鍵です。

2. エラーハンドリングの徹底

API 呼び出しを伴う検索では、レート制限(Error 429)やタイムアウト(TypeError: timeout)が発生する可能性があります。適切なリトライロジックとエラーメッセージの実装が、ユーザー体験の向上につながるでしょう。

3. パフォーマンスモニタリング

各検索段階の処理時間、取得文書数、関連度スコアなどをログに記録することで、ボトルネックの特定やチューニングの効果測定が可能になります。

今後の展開

LangChain の Retriever エコシステムは急速に進化しています。今後は以下のような発展が期待できます。

新しい再ランキングモデル

Cohere だけでなく、OpenAI や Anthropic、オープンソースの再ランキングモデルも登場しています。複数のモデルを比較検討することで、さらなる精度向上が見込めるでしょう。

ベクトルデータベースの活用

本記事では MemoryVectorStore を使用しましたが、本番環境では Pinecone、Weaviate、Qdrant などの専用ベクトルデータベースの利用が推奨されます。これにより、大規模データでも高速な検索が実現できます。

ハイブリッド検索の高度化

単純な重み付け加算だけでなく、クエリの特性に応じて動的に重みを調整する適応型ハイブリッド検索も研究されています。

本記事で紹介した手法を組み合わせることで、高精度な RAG システムを構築できます。まずはシンプルな BM25 から始めて、必要に応じてハイブリッド検索や再ランキングを導入していくアプローチがおすすめです。実際に手を動かして、各手法の特性を体感してみてください。

関連リンク