T-CREATOR

LangChain ハイブリッド検索設計:BM25 +ベクトル+再ランキングで精度を底上げ

LangChain ハイブリッド検索設計:BM25 +ベクトル+再ランキングで精度を底上げ

LangChain を使った RAG(Retrieval-Augmented Generation)システムで、「もっと精度の高い検索結果を得たい」と考えたことはありませんか? 単純なベクトル検索だけでは意味的な類似性は捉えられても、キーワードマッチの強さや文脈の重要度まで考慮するのは難しいですよね。

本記事では、BM25 による キーワードベース検索、ベクトル検索による意味的類似性検索、そして再ランキングによるスコアの最適化を組み合わせた ハイブリッド検索設計 を、LangChain を用いて実装する方法を詳しく解説します。 初心者の方でも理解できるよう、各手法の背景から具体的なコード例まで、丁寧にステップを踏んで説明していきますね。

背景

RAG システムにおける検索の重要性

RAG システムは、大規模言語モデル(LLM)に外部の知識を注入することで、より正確で文脈に即した回答を生成する仕組みです。 このシステムの中核を担うのが「検索(Retrieval)」であり、ユーザーの質問に対して最も関連性の高いドキュメントやチャンクを取得することが、最終的な回答品質を左右します。

検索精度が低いと、LLM に渡される情報が不適切になり、誤った回答や関連性の低い回答が生成されてしまうでしょう。 逆に、検索精度が高ければ、LLM は適切な情報をもとに、ユーザーの期待に沿った回答を生成できます。

従来のベクトル検索の限界

従来の RAG システムでは、OpenAI の Embeddings API などを使ってドキュメントをベクトル化し、コサイン類似度などで意味的に近いドキュメントを検索する「ベクトル検索」が主流でした。 この手法は意味的な類似性を捉えるのに優れていますが、以下のような課題があります。

#課題説明
1キーワードマッチの弱さ完全一致や部分一致のキーワードを見逃しやすい
2専門用語への対応専門用語や固有名詞の重要性を正しく評価できないことがある
3文脈の多様性同じ単語でも文脈によって意味が変わる場合に対応しづらい

ベクトル検索だけでは、これらの課題を完全にカバーするのは難しいのです。

ハイブリッド検索の必要性

そこで注目されるのが ハイブリッド検索 です。 ハイブリッド検索では、複数の検索手法を組み合わせることで、それぞれの弱点を補完し、より高精度な検索結果を得ることができます。

以下の図は、ハイブリッド検索の基本的な構成を示しています。

mermaidflowchart LR
  query["ユーザークエリ"] -->|キーワード抽出| bm25["BM25 検索"]
  query -->|ベクトル化| vector["ベクトル検索"]
  bm25 -->|結果 A| merge["結果マージ"]
  vector -->|結果 B| merge
  merge -->|統合結果| rerank["再ランキング"]
  rerank -->|最終結果| output["LLM へ入力"]

図で理解できる要点:

  • BM25 とベクトル検索は並行して実行され、それぞれ独立した結果を返す
  • 両者の結果をマージし、再ランキングで最終的なスコアを決定する
  • 再ランキング後の結果が LLM に渡される

BM25 によるキーワードマッチ、ベクトル検索による意味的類似性、そして再ランキングによるスコアの最適化を組み合わせることで、検索精度を大幅に向上させることができるのです。

課題

ベクトル検索だけでは不十分な理由

ベクトル検索は意味的な類似性を捉えるのに優れていますが、以下のようなケースでは十分な結果が得られません。

#ケース問題点
1専門用語のクエリ「BM25」や「TF-IDF」などの専門用語が埋もれやすい
2固有名詞の検索「LangChain」などの固有名詞の完全一致を見逃す
3短いクエリキーワードが少ないとベクトル表現が曖昧になる
4多義語の扱い「Apple」が果物なのか企業なのか判断しづらい

例えば、「BM25 とは何か?」という質問に対して、ベクトル検索だけでは「BM25」というキーワードを含むドキュメントが必ずしも上位に来るとは限りません。 意味的に似ているが「BM25」という単語を含まないドキュメントが優先されてしまうことがあるのです。

BM25 単体の限界

一方、BM25 などのキーワードベース検索は、単語の出現頻度や希少性を重視するため、完全一致や部分一致に強い特徴があります。 しかし、以下のような課題も抱えています。

#課題説明
1意味的類似性の欠如同義語や言い換え表現を捉えられない
2文脈の無視単語の出現だけで判断し、文脈を考慮しない
3スパム対策の弱さキーワードを詰め込んだだけのドキュメントが上位に来る

例えば、「機械学習の基礎」という質問に対して、「AI」や「ディープラーニング」といった関連語を含むドキュメントは、BM25 だけでは適切に評価されないでしょう。

再ランキングの必要性

BM25 とベクトル検索を組み合わせることで、キーワードマッチと意味的類似性の両方をカバーできます。 しかし、単純に結果をマージしただけでは、スコアの重み付けが不適切になり、最終的な順位が最適化されません。

そこで必要になるのが 再ランキング です。 再ランキングでは、統合された検索結果に対して、クエリとの関連性を再評価し、より適切な順位を決定します。

以下の図は、再ランキングの役割を示しています。

mermaidflowchart TD
  merged["マージ済み結果<br/>(BM25 + ベクトル)"] -->|各ドキュメント| reranker["再ランキングモデル"]
  reranker -->|関連性スコア再計算| sorted["スコア順ソート"]
  sorted -->|上位 N 件| final["最終結果"]

図で理解できる要点:

  • マージされた結果は、再ランキングモデルによってクエリとの関連性が再評価される
  • 再計算されたスコアに基づいて、ドキュメントが再度ソートされる
  • 最終的に上位 N 件が LLM に渡される

再ランキングにより、BM25 とベクトル検索の長所を最大限に活かし、短所を補完することができるのです。

解決策

ハイブリッド検索の設計方針

ハイブリッド検索を実現するには、以下の 3 つの要素を組み合わせます。

#要素役割
1BM25 検索キーワードマッチによる正確な検索
2ベクトル検索意味的類似性による柔軟な検索
3再ランキング統合結果の最適化

これらを組み合わせることで、キーワードの重要性と意味的な類似性の両方を考慮した、高精度な検索システムを構築できます。

LangChain によるハイブリッド検索の実装

LangChain は、RAG システムの構築を支援する強力なフレームワークです。 LangChain には、BM25 検索、ベクトル検索、そして再ランキングをサポートする機能が組み込まれており、これらを組み合わせてハイブリッド検索を実装できます。

以下の図は、LangChain を使ったハイブリッド検索の全体フローを示しています。

mermaidflowchart TD
  docs["ドキュメント群"] -->|分割| chunks["チャンク化"]
  chunks -->|インデックス作成| bm25_idx["BM25 インデックス"]
  chunks -->|ベクトル化| vector_idx["ベクトルストア"]

  query["クエリ"] -->|検索| bm25_idx
  query -->|検索| vector_idx

  bm25_idx -->|結果 A| retriever["アンサンブル<br/>Retriever"]
  vector_idx -->|結果 B| retriever

  retriever -->|統合結果| reranker["Cohere Rerank"]
  reranker -->|最終結果| llm["LLM 生成"]

図で理解できる要点:

  • ドキュメントは事前にチャンク化され、BM25 インデックスとベクトルストアの両方に格納される
  • クエリは並行して BM25 とベクトル検索を実行する
  • アンサンブル Retriever が両者の結果を統合し、再ランキングで最適化する

それでは、具体的な実装手順を見ていきましょう。

実装に必要なパッケージ

まず、必要なパッケージをインストールします。

bashyarn add langchain @langchain/openai @langchain/cohere @langchain/community

このコマンドで、LangChain の本体と OpenAI、Cohere、コミュニティパッケージをインストールします。 OpenAI は Embeddings とチャット生成に、Cohere は再ランキングに使用します。

環境変数の設定

次に、API キーを環境変数として設定します。

bashexport OPENAI_API_KEY="your-openai-api-key"
export COHERE_API_KEY="your-cohere-api-key"

これらの API キーは、後述するコード内で自動的に読み込まれます。 API キーは、OpenAI と Cohere の公式サイトから取得してください。

具体例

ステップ 1: ドキュメントの準備とチャンク化

まず、検索対象となるドキュメントを準備し、適切なサイズにチャンク化します。 チャンク化により、長いドキュメントを扱いやすい単位に分割し、検索精度を向上させます。

以下は、ドキュメントをチャンク化するコードです。

typescriptimport { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
import { Document } from 'langchain/document';

// サンプルドキュメントの準備
const documents = [
  new Document({
    pageContent:
      'BM25 は情報検索における確率的ランキング関数で、TF-IDF を改良したアルゴリズムです。',
    metadata: { source: 'doc1' },
  }),
  new Document({
    pageContent:
      'ベクトル検索は、埋め込みベクトル間のコサイン類似度を用いて、意味的に類似したドキュメントを検索します。',
    metadata: { source: 'doc2' },
  }),
  new Document({
    pageContent:
      'LangChain はプロンプトエンジニアリングやRAGシステムの構築を支援するフレームワークです。',
    metadata: { source: 'doc3' },
  }),
];

このコードでは、Document クラスを使って 3 つのサンプルドキュメントを作成しています。 pageContent には本文、metadata にはドキュメントの情報(ここでは source)を格納します。

次に、これらのドキュメントをチャンク化します。

typescript// テキストスプリッターの設定
const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: 500, // 1チャンクの最大文字数
  chunkOverlap: 50, // チャンク間のオーバーラップ
});

// ドキュメントをチャンク化
const chunks = await textSplitter.splitDocuments(documents);
console.log(`チャンク数: ${chunks.length}`);

RecursiveCharacterTextSplitter は、指定したサイズでドキュメントを分割します。 chunkSize は 1 チャンクの最大文字数、chunkOverlap はチャンク間で重複させる文字数です。 オーバーラップを設けることで、チャンク境界での情報欠落を防ぎます。

ステップ 2: BM25 検索の設定

次に、BM25 検索を設定します。 LangChain では、BM25Retriever を使って簡単に BM25 検索を実装できます。

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

// BM25 Retriever の作成
const bm25Retriever = BM25Retriever.fromDocuments(chunks, {
  k: 3, // 上位 3 件を取得
});

console.log('BM25 Retriever を作成しました');

BM25Retriever.fromDocuments は、チャンク化されたドキュメントから BM25 インデックスを自動的に作成します。 k パラメータは、取得する上位ドキュメントの数を指定します。

BM25 は、各単語の重要度を TF(Term Frequency)と IDF(Inverse Document Frequency)の組み合わせで評価し、ドキュメントの関連性スコアを計算します。

ステップ 3: ベクトル検索の設定

次に、ベクトル検索を設定します。 ここでは、OpenAI の Embeddings API と、インメモリのベクトルストアである MemoryVectorStore を使用します。

まず、OpenAI の Embeddings をインポートします。

typescriptimport { OpenAIEmbeddings } from '@langchain/openai';

次に、ベクトルストアを作成します。

typescriptimport { MemoryVectorStore } from 'langchain/vectorstores/memory';

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

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

console.log('ベクトルストアを作成しました');

MemoryVectorStore.fromDocuments は、チャンク化されたドキュメントを埋め込みベクトルに変換し、インメモリのベクトルストアに格納します。 text-embedding-3-small は、OpenAI の軽量な埋め込みモデルで、高速かつ高精度な埋め込みを生成します。

次に、ベクトルストアから Retriever を作成します。

typescript// ベクトル検索用 Retriever の作成
const vectorRetriever = vectorStore.asRetriever({
  k: 3, // 上位 3 件を取得
});

console.log('ベクトル Retriever を作成しました');

asRetriever メソッドは、ベクトルストアから検索を実行する Retriever を作成します。 k パラメータは、BM25 と同様に、取得する上位ドキュメントの数を指定します。

ステップ 4: アンサンブル Retriever による統合

BM25 とベクトル検索の結果を統合するために、EnsembleRetriever を使用します。 EnsembleRetriever は、複数の Retriever の結果を重み付けして統合します。

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

// アンサンブル Retriever の作成
const ensembleRetriever = new EnsembleRetriever({
  retrievers: [bm25Retriever, vectorRetriever], // 統合する Retriever のリスト
  weights: [0.5, 0.5], // 各 Retriever の重み(合計 1.0)
});

console.log('アンサンブル Retriever を作成しました');

retrievers パラメータには、統合したい Retriever のリストを指定します。 weights パラメータは、各 Retriever のスコアに対する重みを指定します。 ここでは、BM25 とベクトル検索を同等に評価するため、それぞれ 0.5 に設定しています。

EnsembleRetriever は、各 Retriever から取得した結果のスコアを重み付けして合算し、最終的なスコアを計算します。

ステップ 5: 再ランキングの設定

統合された結果をさらに最適化するために、Cohere の再ランキングモデルを使用します。 Cohere Rerank は、クエリとドキュメントのペアに対して、より高精度な関連性スコアを計算します。

まず、Cohere の Rerank をインポートします。

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

次に、再ランキング用の Retriever を作成します。

typescriptimport { ContextualCompressionRetriever } from 'langchain/retrievers/contextual_compression';

// Cohere Rerank の設定
const reranker = new CohereRerank({
  apiKey: process.env.COHERE_API_KEY, // Cohere API キー
  model: 'rerank-english-v3.0', // 再ランキングモデル
  topN: 3, // 最終的に返す上位件数
});

console.log('Cohere Rerank を作成しました');

CohereRerank は、Cohere の再ランキング API を呼び出し、クエリとドキュメントの関連性を再評価します。 topN パラメータは、再ランキング後に返す上位ドキュメントの数を指定します。

次に、ContextualCompressionRetriever を使って、アンサンブル Retriever に再ランキングを適用します。

typescript// 再ランキングを適用した Retriever の作成
const compressionRetriever =
  new ContextualCompressionRetriever({
    baseCompressor: reranker, // 再ランキングモデル
    baseRetriever: ensembleRetriever, // 元の Retriever
  });

console.log('再ランキング Retriever を作成しました');

ContextualCompressionRetriever は、元の Retriever(ここでは ensembleRetriever)の結果を受け取り、再ランキングモデルでスコアを再計算します。 これにより、より精度の高い最終結果が得られます。

ステップ 6: 検索の実行

それでは、実際にクエリを投げて検索を実行してみましょう。

typescript// クエリの設定
const query = 'BM25 とは何ですか?';

// 検索の実行
const results = await compressionRetriever.invoke(query);

console.log(`検索結果(上位 ${results.length} 件):`);
results.forEach((doc, index) => {
  console.log(`\n--- 結果 ${index + 1} ---`);
  console.log(`内容: ${doc.pageContent}`);
  console.log(`ソース: ${doc.metadata.source}`);
});

このコードでは、「BM25 とは何ですか?」というクエリに対して、再ランキング済みの検索結果を取得しています。 invoke メソッドは、内部で以下の処理を実行します。

#処理説明
1BM25 検索キーワード「BM25」を含むドキュメントを検索
2ベクトル検索クエリの意味的に近いドキュメントを検索
3結果統合両者の結果を重み付けして統合
4再ランキングCohere Rerank でスコアを再計算
5上位抽出最終的な上位 N 件を返す

結果は、最も関連性の高いドキュメントから順に返されます。

ステップ 7: RAG チェーンへの統合

最後に、再ランキング済みの検索結果を LLM に渡して、最終的な回答を生成します。 LangChain の RetrievalQA チェーンを使うことで、簡単に RAG システムを構築できます。

まず、必要なモジュールをインポートします。

typescriptimport { ChatOpenAI } from '@langchain/openai';
import { RetrievalQAChain } from 'langchain/chains';

次に、LLM とチェーンを作成します。

typescript// ChatGPT モデルの設定
const llm = new ChatOpenAI({
  modelName: 'gpt-4o-mini', // 使用するモデル
  temperature: 0, // 回答の多様性(0 = 決定的)
});

// RAG チェーンの作成
const chain = RetrievalQAChain.fromLLM(
  llm,
  compressionRetriever,
  {
    returnSourceDocuments: true, // ソースドキュメントも返す
  }
);

console.log('RAG チェーンを作成しました');

RetrievalQAChain.fromLLM は、LLM と Retriever を組み合わせて、質問応答チェーンを作成します。 returnSourceDocumentstrue に設定すると、回答とともに参照したドキュメントも返されます。

最後に、チェーンを実行して回答を生成します。

typescript// 質問の実行
const response = await chain.invoke({
  query: 'BM25 とベクトル検索の違いを教えてください',
});

console.log('\n=== 回答 ===');
console.log(response.text);

console.log('\n=== 参照ドキュメント ===');
response.sourceDocuments.forEach((doc, index) => {
  console.log(`\n[${index + 1}] ${doc.metadata.source}`);
  console.log(doc.pageContent);
});

このコードでは、「BM25 とベクトル検索の違いを教えてください」という質問に対して、ハイブリッド検索で取得したドキュメントを元に、LLM が回答を生成します。 response.text には最終的な回答が、response.sourceDocuments には参照したドキュメントが格納されます。

完全なコード例

ここまでの内容をまとめた、完全なコード例を以下に示します。

typescriptimport { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
import { Document } from 'langchain/document';
import { BM25Retriever } from '@langchain/community/retrievers/bm25';
import { OpenAIEmbeddings } from '@langchain/openai';
import { MemoryVectorStore } from 'langchain/vectorstores/memory';
import { EnsembleRetriever } from 'langchain/retrievers/ensemble';
import { CohereRerank } from '@langchain/cohere';
import { ContextualCompressionRetriever } from 'langchain/retrievers/contextual_compression';
import { ChatOpenAI } from '@langchain/openai';
import { RetrievalQAChain } from 'langchain/chains';

async function main() {
  // 1. ドキュメントの準備
  const documents = [
    new Document({
      pageContent:
        'BM25 は情報検索における確率的ランキング関数で、TF-IDF を改良したアルゴリズムです。',
      metadata: { source: 'doc1' },
    }),
    new Document({
      pageContent:
        'ベクトル検索は、埋め込みベクトル間のコサイン類似度を用いて、意味的に類似したドキュメントを検索します。',
      metadata: { source: 'doc2' },
    }),
    new Document({
      pageContent:
        'LangChain はプロンプトエンジニアリングやRAGシステムの構築を支援するフレームワークです。',
      metadata: { source: 'doc3' },
    }),
  ];

  // 2. チャンク化
  const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,
    chunkOverlap: 50,
  });
  const chunks = await textSplitter.splitDocuments(
    documents
  );

  // 3. BM25 Retriever の作成
  const bm25Retriever = BM25Retriever.fromDocuments(
    chunks,
    { k: 3 }
  );

  // 4. ベクトル Retriever の作成
  const embeddings = new OpenAIEmbeddings({
    modelName: 'text-embedding-3-small',
  });
  const vectorStore = await MemoryVectorStore.fromDocuments(
    chunks,
    embeddings
  );
  const vectorRetriever = vectorStore.asRetriever({ k: 3 });

  // 5. アンサンブル Retriever の作成
  const ensembleRetriever = new EnsembleRetriever({
    retrievers: [bm25Retriever, vectorRetriever],
    weights: [0.5, 0.5],
  });

  // 6. 再ランキング Retriever の作成
  const reranker = new CohereRerank({
    apiKey: process.env.COHERE_API_KEY,
    model: 'rerank-english-v3.0',
    topN: 3,
  });
  const compressionRetriever =
    new ContextualCompressionRetriever({
      baseCompressor: reranker,
      baseRetriever: ensembleRetriever,
    });

  // 7. RAG チェーンの作成
  const llm = new ChatOpenAI({
    modelName: 'gpt-4o-mini',
    temperature: 0,
  });
  const chain = RetrievalQAChain.fromLLM(
    llm,
    compressionRetriever,
    {
      returnSourceDocuments: true,
    }
  );

  // 8. 質問の実行
  const response = await chain.invoke({
    query: 'BM25 とベクトル検索の違いを教えてください',
  });

  console.log('\n=== 回答 ===');
  console.log(response.text);

  console.log('\n=== 参照ドキュメント ===');
  response.sourceDocuments.forEach((doc, index) => {
    console.log(`\n[${index + 1}] ${doc.metadata.source}`);
    console.log(doc.pageContent);
  });
}

main();

このコードを実行すると、BM25、ベクトル検索、再ランキングを組み合わせたハイブリッド検索により、高精度な RAG システムが構築されます。

パラメータのチューニング

ハイブリッド検索の精度をさらに向上させるには、以下のパラメータを調整することが有効です。

#パラメータ説明推奨値
1weightsBM25 とベクトル検索の重みキーワード重視: [0.7, 0.3]意味重視: [0.3, 0.7]
2k各 Retriever の取得件数3〜10 件
3topN再ランキング後の件数3〜5 件
4chunkSizeチャンクの最大文字数300〜1000 文字
5chunkOverlapチャンク間のオーバーラップ50〜200 文字

例えば、専門用語が多いドメインでは、BM25 の重みを高めに設定すると良いでしょう。 逆に、質問が多様で意味的な類似性が重要な場合は、ベクトル検索の重みを高めにします。

以下は、重みを調整したアンサンブル Retriever の例です。

typescript// BM25 を重視する場合
const ensembleRetriever = new EnsembleRetriever({
  retrievers: [bm25Retriever, vectorRetriever],
  weights: [0.7, 0.3], // BM25 を 70%、ベクトル検索を 30% の重みで評価
});

実際のユースケースに応じて、これらのパラメータを調整し、最適なバランスを見つけてください。

まとめ

本記事では、LangChain を使ったハイブリッド検索の設計と実装について、詳しく解説しました。 BM25 によるキーワードベース検索、ベクトル検索による意味的類似性、そして Cohere Rerank による再ランキングを組み合わせることで、単一の手法では実現できない高精度な検索システムを構築できます。

ハイブリッド検索の主な利点は以下の通りです。

#利点説明
1キーワードマッチの強化BM25 により専門用語や固有名詞を正確に検索
2意味的類似性の考慮ベクトル検索により同義語や言い換え表現に対応
3スコアの最適化再ランキングにより最終的な順位を精緻化
4柔軟なチューニング重みやパラメータを調整して精度を向上

LangChain の豊富な機能を活用することで、わずか数十行のコードで、高度な RAG システムを構築できるのです。 ぜひ、本記事のコード例を参考に、あなたのプロジェクトにハイブリッド検索を導入してみてください。

検索精度の向上により、LLM が生成する回答の品質が大幅に改善され、ユーザー体験が向上するでしょう。 さらに、パラメータのチューニングや、より高度な再ランキングモデルの導入により、精度をさらに高めることも可能です。

RAG システムの検索精度に課題を感じている方は、ぜひハイブリッド検索を試してみてくださいね。

関連リンク