6. ハイブリッド検索とEnsembleRetriever
ハイブリッド検索の仕組み
ハイブリッド検索とは、密ベクトル検索(意味の近さ)と疎検索(BM25による単語一致)を同時に実行し、両方の結果を統合して最適な順位で文書を返す仕組みです。
LangChain では、複数のリトリーバーを統合するための標準コンポーネントとして EnsembleRetriever が提供されています。
これを利用することで、ベクトル検索用のリトリーバーと BM25Retriever を簡単に1つのリトリーバーとして束ねることができます。
Reciprocal Rank Fusion(RRF)による順位統合
異なるアルゴリズムの検索スコアをそのまま単純に足し算することはできません。 ベクトル検索の類似度スコア(例:0.0〜1.0)と BM25 のスコア(例:0.0〜数十)では、数値の尺度や分布が全く異なるためです。
EnsembleRetriever では、スコアの絶対値ではなく「検索順位(ランク)」を用いたアルゴリズムである「Reciprocal Rank Fusion(RRF: 逆順位融合法)」を採用しています。
各文書の順位 $r$ に対して、次の式で統合スコアを計算します。
RRF_Score(d) = Σ [ w_i / (c + r_i(d)) ]
ここで、$w_i$ は各検索エンジンの重み、$r_i(d)$ はエンジン $i$ における文書 $d$ の順位(1位なら 1)、$c$ は順位の影響度を滑らかにする定数(通常は 60)です。 この仕組みにより、どちらかの検索で1位になった文書や、両方の検索で上位に入った文書が自然と最上位に浮上します。
ハイブリッド検索の実装コード
Chroma によるベクトル検索と BM25Retriever を組み合わせたハイブリッド検索の実装コードは、次の通りです。
埋め込みモデルには Ollama でローカル実行する bge-m3 を使用します。
事前に Ollama でモデルを取得しておきます。
ollama pull bge-m3
必要なパッケージをインストールします。
pip install langchain-ollama chromadb
from janome.tokenizer import Tokenizer
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import Chroma
from langchain_core.documents import Document
from langchain_ollama import OllamaEmbeddings
# 1. 形態素解析器とトークナイズ関数
tokenizer = Tokenizer()
def japanese_tokenizer(text: str) -> list[str]:
return [t.base_form for t in tokenizer.tokenize(text)]
# 2. ドキュメント群の準備
docs = [
Document(
page_content="有給休暇の申請は、取得日の3日前までに勤怠システムから提出してください。",
metadata={"id": 1, "category": "HR"},
),
Document(
page_content="夏季特別休暇は、7月から9月の間に最大3日間取得できます。",
metadata={"id": 2, "category": "HR"},
),
Document(
page_content="経費精算のエラー ERR-502 は、通信タイムアウト時に発生します。",
metadata={"id": 3, "category": "IT"},
),
Document(
page_content="リモートワーク時のセキュリティ対策としてVPN接続を推奨します。",
metadata={"id": 4, "category": "SEC"},
),
]
# 3. BM25リトリーバーの作成 (疎検索)
bm25_retriever = BM25Retriever.from_documents(
documents=docs,
preprocess_func=japanese_tokenizer,
k=3,
)
# 4. Chromaベクトルストアとリトリーバーの作成 (密検索)
# 埋め込みモデルにはOllamaのbge-m3を使用
embeddings = OllamaEmbeddings(model="bge-m3")
vectorstore = Chroma.from_documents(
documents=docs,
embedding=embeddings,
)
vector_retriever = vectorstore.as_retriever(
search_kwargs={"k": 3},
)
# 5. EnsembleRetrieverによる統合 (ハイブリッド検索)
# weightsで各リトリーバーの比率を指定(合計1.0)
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.5, 0.5],
)
# 6. ハイブリッド検索の実行
query = "夏休みの取得可能日数について"
results = ensemble_retriever.invoke(query)
print(f"クエリ: {query}\n")
for i, doc in enumerate(results, start=1):
print(f"[{i}] {doc.page_content} (カテゴリ: {doc.metadata['category']})")
重みパラメータ(weights)の調整方針
EnsembleRetriever の weights パラメータは、各検索手法の重要度を [BM25の重み, ベクトル検索の重み] のようにリストで指定します。
▸ キーワードの一致を重視したい場合(weights=[0.6, 0.4] など)
製品マニュアル、仕様書、法令文書、エラーログなど、専門用語や記号、厳密な単語一致が重視されるデータセットでは、BM25 の重みを高めに設定します。
▸ 意味の理解や自然文検索を重視したい場合(weights=[0.3, 0.7] など)
Q&A サイトのログ、カスタマーサポートの問い合わせ、自由入力された相談テキストなど、ユーザーの語彙が定まっていない場合は、ベクトル検索の重みを高くします。
▸ 均等配分からのスタート(weights=[0.5, 0.5])
最初は [0.5, 0.5] の均等バランスからスタートし、実際のテストクエリ群を用いて検索精度(ヒット率など)を測定しながら微調整していくのがベストプラクティスです。
次章では、さらに検索精度を極限まで高める「リランキング」の導入と、LLM と連携した完全な RAG パイプラインを完成させます。