Claude Codeでハイブリッド検索(RRF)をElasticsearchに実装する手順
BM25とkNNの結果をrrf retrieverで統合するクエリと、weightやrank_constantを評価セットで決める流れを、Claude Codeに書かせる前提でまとめます。
ElasticsearchのRRF(Reciprocal Rank Fusion、逆順位融合)は、BM25の検索結果とkNN(ベクトル近傍)の検索結果を、スコアではなく順位だけで1つの並びにまとめる方法です。キーワード検索は固有名詞やエラー文言に強く、ベクトル検索は言い換えに強いので、両方を1回のリクエストで走らせて統合すると、片方だけでは落ちる検索語を拾えます。
この記事では、rrf retrieverのクエリを組み立て、weight と rank_constant を評価セットで決めるまでを、Claude Codeに書かせる前提で順に示します。ベクトルの作成にはVoyage AIを使います。Anthropicは自前の埋め込みモデルを提供しておらず、埋め込みガイドもVoyage AIを紹介しているためです。
RRFは順位の逆数を足すだけ — 公式の例を手計算で再現する
RRFは、複数の検索結果それぞれで文書の順位を取り、1 / (k + 順位) を全検索について足したものを最終スコアにします。k が rank_constant で、既定値は60です。スコアの単位が違うBM25とkNNでも、順位にさえ直せば同じ土俵で足せます。
Elasticsearchのドキュメントにある完全な例(rank_constant は1、rank_window_size は5)で、手計算を確かめます。BM25側の順位は文書4、3、2、1、kNN側は3、2、1、5です。
| 文書 | BM25の順位 | kNNの順位 | RRFスコア |
|---|---|---|---|
| 3 | BM25の順位2 | kNNの順位1 | RRFスコア1/(1+2) + 1/(1+1) = 0.8333 |
| 2 | BM25の順位3 | kNNの順位2 | RRFスコア1/(1+3) + 1/(1+2) = 0.5833 |
| 4 | BM25の順位1 | kNNの順位圏外 | RRFスコア1/(1+1) = 0.5000 |
| 1 | BM25の順位4 | kNNの順位3 | RRFスコア1/(1+4) + 1/(1+3) = 0.4500 |
| 5 | BM25の順位圏外 | kNNの順位4 | RRFスコア1/(1+4) = 0.2000 |
同じ計算をPythonの関数にして手元で走らせても、ドキュメントの数値と一致しました。この関数は後段の重み調整でも使い回します。
def rrf(lists, weights, k):
score = {}
for lst, w in zip(lists, weights):
for rank, d in enumerate(lst, start=1):
score[d] = score.get(d, 0.0) + w / (k + rank)
return sorted(score.items(), key=lambda x: -x[1])文書4はkNN側に載っていませんが、BM25で1位なので3位に残ります。片方で上位なら、もう片方に無くても沈みません。ハイブリッド検索ガイドが「どちらかでスコアの高い文書に重みが寄る」と説明している挙動です。
インデックスとrrfクエリを組み立てる
マッピングには、本文の text フィールドとベクトルの dense_vector フィールドを置きます。dims は使う埋め込みモデルの次元に合わせます。Voyage AIの voyage-4 は既定が1024次元で、256、512、2048も選べます。
PUT docs
{
"mappings": {
"properties": {
"text": { "type": "text" },
"vector": {
"type": "dense_vector",
"dims": 1024,
"index": true,
"similarity": "cosine",
"index_options": { "type": "hnsw" }
}
}
}
}Voyage AIの埋め込みは長さ1に正規化されているため、内積とコサイン類似度は同じ並びになります。投入時の埋め込みは input_type="document"、検索時は input_type="query" で作ります。両者を分ける理由はVoyage embeddingのinput_typeの解説にあります。
検索クエリは、standard(BM25)と knn を子にした rrf retrieverです。重みを付けるときは、子を retriever と weight の組で包む形式にします。
GET docs/_search
{
"retriever": {
"rrf": {
"retrievers": [
{
"retriever": { "standard": { "query": { "match": { "text": "接続がタイムアウトする" } } } },
"weight": 1.0
},
{
"retriever": {
"knn": {
"field": "vector",
"query_vector": [0.01, -0.02, 0.03],
"k": 50,
"num_candidates": 100
}
},
"weight": 1.0
}
],
"rank_window_size": 50,
"rank_constant": 60
}
},
"size": 10
}query_vector の3要素はプレースホルダです。実際には1024個の値が入ります。重みを省いた直接形式の子は、重み1.0として扱われます。重み付き形式と直接形式は、同じクエリの中で混ぜられます。
アプリ側から呼ぶ関数
ベクトル化とクエリ送信を1つの関数にまとめると、あとで重みを振る評価ループから呼べます。
import os
import requests
import voyageai
vo = voyageai.Client()
ES = os.environ["ES_URL"]
HEADERS = {"Authorization": f"ApiKey {os.environ['ES_API_KEY']}"}
def search(text, bm25_w=1.0, knn_w=1.0, k=60, window=50, size=10):
qv = vo.embed([text], model="voyage-4", input_type="query").embeddings[0]
body = {
"retriever": {"rrf": {
"retrievers": [
{"retriever": {"standard": {"query": {"match": {"text": text}}}},
"weight": bm25_w},
{"retriever": {"knn": {"field": "vector", "query_vector": qv,
"k": window, "num_candidates": window * 2}},
"weight": knn_w},
],
"rank_window_size": window,
"rank_constant": k,
}},
"size": size,
}
r = requests.post(f"{ES}/docs/_search", json=body, headers=HEADERS, timeout=30)
r.raise_for_status()
return [h["_id"] for h in r.json()["hits"]["hits"]]Claude Codeには評価セットを先に作らせる
「ハイブリッド検索を実装して」とだけ頼むと、重みも rank_constant も根拠のない値で埋まった実装が返ってきがちです。RRFは「チューニング不要」と説明されますが、これは重みの正解を探さなくても動く、という意味です。自分のデータで最適かどうかは別問題で、評価セットがなければ比べられません。
そこで依頼の順番を決め、CLAUDE.mdに書いておきます。
## 検索の実装ルール
- rank_constant・weight・rank_window_size は params.json から読む。コードに直書きしない
- 検索の変更では、先に eval/judgments.jsonl(クエリと正解ドキュメントID)を用意する
- パラメータを変えたら eval/run_eval.py を実行し、MRR を表で出力して結果を報告する
- 評価セットのクエリは実際の検索ログから拾う。Claudeが作文したクエリだけで評価しない最後の1行が効きます。Claudeが書いたクエリは、ドキュメントの言い回しに寄りやすく、BM25に有利に出ます。利用者が実際に打った語を混ぜないと、重みの判断が歪みます。
依頼は次の3段に分けます。
judgments.jsonlの形式と、run_eval.pyの骨格だけを作らせる(検索は未実装でよい)- 上の
search関数を実装させ、BM25のみ・kNNのみ・RRFの3通りで評価を回させる bm25_wとrank_constantを振ったグリッドの結果表を出させ、採用値をparams.jsonに書かせる
2段目で「BM25のみ」「kNNのみ」の単独成績を先に取るのが要点です。RRFが単独より良くならないなら、重みを振る前にフィールドの選び方か埋め込みを疑う局面です。
重みとrank_constantを評価セットで決める
Elasticsearchには評価用の _rank_eval エンドポイントがあります。クエリごとに文書へ整数の評価値(rating)を付けた正解データを渡すと、平均逆順位(MRR)、precision、recall、DCGなどの指標を返します。
GET docs/_rank_eval
{
"requests": [
{
"id": "timeout_query",
"request": { "query": { "match": { "text": "接続がタイムアウトする" } } },
"ratings": [
{ "_index": "docs", "_id": "doc2", "rating": 3 },
{ "_index": "docs", "_id": "doc7", "rating": 1 }
]
}
],
"metric": { "mean_reciprocal_rank": {} }
}_rank_evalのページの例は、request に通常の query を渡す形です。rrf retrieverを request に渡せるかどうかは、このページに記載がありません。そこで、RRFの比較は上の search 関数とPythonの評価ループで行い、_rank_eval はBM25単独の足場づくりに使う、と分けるのが安全です。
評価ループは、重みと rank_constant の組を総当たりにして、MRRを出すだけです。
import itertools, json
judged = [json.loads(l) for l in open("eval/judgments.jsonl")] # {"q": ..., "rel": [...]}
def mrr(ids, rel):
for i, d in enumerate(ids, start=1):
if d in rel:
return 1 / i
return 0
for k, bm25_w in itertools.product([1, 20, 60], [0.5, 1, 2, 3]):
score = sum(mrr(search(j["q"], bm25_w=bm25_w, k=k), set(j["rel"])) for j in judged)
print(f"k={k:<3} bm25_weight={bm25_w:<3} MRR={score / len(judged):.3f}")仕組みを確かめるため、上の rrf 関数と、BM25・kNNの順位をあらかじめ決めた3クエリで同じグリッドを回しました。Elasticsearchは使わない玩具のデータで、実際の検索品質を示す数値ではありません。
| rank_constant | BM25の重み0.5 | 1 | 2 | 3 |
|---|---|---|---|---|
| 1 | BM25の重み0.50.833 | 10.833 | 20.667 | 30.667 |
| 20 | BM25の重み0.51.000 | 10.833 | 20.833 | 30.833 |
| 60 | BM25の重み0.51.000 | 10.833 | 20.833 | 30.833 |
読み取れるのは2点です。重みは、BM25とkNNの順位が食い違うクエリでだけ結果を動かします。そして rank_constant が小さいと、1位同士の差が大きく出て、重みの影響が強まります。この玩具では、BM25を軽くしたほうが良く出ましたが、これはkNN側に正解が上位で並ぶよう作ったデータの都合です。自分のデータでは逆になることも十分あります。
結果表を見るときは、全体の平均だけでなく、悪化したクエリを個別に確認します。Claude Codeには「前回の設定と比べて順位が下がったクエリを列挙して」と頼むと、平均に埋もれた退行を拾ってくれます。
順位が思った通りにならないときはexplainで寄与を見る
評価で悪化したクエリが見つかったら、その文書がなぜその順位になったかを、explain=true を付けて調べます。レスポンスの _explanation には、最終スコアが各子の順位からどう計算されたかが出ます。ドキュメントの例では、文書3について「初期順位[2, 1]、rankConstant 1で、各クエリの1 / (順位 + rankConstant)の和」と説明されています。
子のretrieverに _name を付けると、どの検索の寄与なのかが説明文に名前で出て、読みやすくなります。悪化したクエリのレスポンスをClaude Codeに貼り、「BM25側の順位とkNN側の順位を表にして、重みを変えたらどう動くか」を計算させると、数式を追う手間が省けます。
埋め込みの作成までElasticsearchに任せる選択肢
ここまでは、ベクトルをアプリ側でVoyage AIから取得する構成でした。ハイブリッド検索ガイドは、Elastic Stackでの推奨手順として semantic_text を使う流れを挙げています。この場合、埋め込みの作成と保存をElasticsearch側が担います。
一方、埋め込みモデルを自分で選び、input_type や次元数を細かく制御したいなら、この記事の構成のほうが向いています。どちらでも、統合にRRFを勧めている点は共通です。
RRFで足りないときの選択肢
RRFは順位しか見ないため、「BM25のスコアが突出して高い」という情報は捨てられます。スコアの差を活かしたいときは、別の統合方法を使います。
| 方式 | 統合に使う情報 | 重みの付け方 | 注意点 |
|---|---|---|---|
rrf retriever | 統合に使う情報各結果の順位 | 重みの付け方子ごとの weight | 注意点スコア差は使われない |
linear retriever | 統合に使う情報各結果のスコア | 重みの付け方子ごとの weight と normalizer | 注意点正規化の選択が結果を左右する |
linear retrieverは、スコアを正規化したうえで重み付き和を取ります。normalizer には none、minmax、l2_norm を指定できます。linear retrieverの例は、kNNの重みを5、BM25の重みを1.5にして minmax を掛けるものです。子ごとに normalizer を上書きでき、最上位の normalizer を既定値にする指定は9.2以降と書かれています。
統合後の並びをさらに精度よく整えたいなら、リランカーを重ねる手もあります。Voyage AIは、クエリと文書の一覧を渡して関連度順に並べ替える rerank() を提供しています。埋め込みガイドでは rerank-3 が「ほとんどの用途に推奨」、rerank-3-lite が低遅延・低コスト向けと説明されています。
ベクトルDBをElasticsearch以外にする場合も考え方は同じです。Zilliz Cloudでは zilliz vector hybrid-search にRRFを指定できます。手順はClaude CodeにZilliz Cloudプラグインを入れてMilvusを操作するにあり、Qdrantを使うならQdrant MCPサーバーの手順が参考になります。
RRFを実装するときのつまずき
各ページの記述から、実装前に押さえておく点を挙げます。
rank_window_sizeの既定値: RRFの解説ページは「sizeと同じ」、rrf retrieverのリファレンスは「10」と書いています。ページ間で食い違うため、既定値に頼らず明示します- kがウィンドウより大きいとき: kNNの
kがrank_window_sizeより大きいと、結果はrank_window_sizeに切り詰められます - 使えない機能:
rrfretrieverはscroll、sort、rescoreに対応しておらず、使うと例外になります。集計、from、サジェスト、ハイライト、collapse、プロファイルは使えます - ポイントインタイム: RRFが内部で作って子の検索に共有するため、リクエストに自分で付けることは避けます
- サブ検索:
sub_searchesを使うRRFは廃止されています。retrieverAPIで書き直します - 重みの数式: 重み付き形式の最終スコアは、各子のRRFスコアに重みを掛けて足したものです。重みを2倍にすると、その子が2倍に効きます
ElasticsearchのMCPサーバーをClaudeにつなぐ構成を検討しているなら、Elasticsearch MCPサーバーの非推奨化とAgent Builder移行も併せて確認してください。
実装の進め方をまとめる
RRFそのものは数行の式で、実装の手間はほとんどありません。差がつくのは評価セットを持っているかどうかです。Claude Codeに頼むときは、CLAUDE.mdに「パラメータは外出し、変更のたびに評価を回す」と書き、評価の骨格を検索の実装より先に作らせます。BM25単独・kNN単独の成績を基準線にして、weight と rank_constant のグリッドを回し、悪化したクエリまで見て採用値を決める流れです。
最終的な重みは、データと検索ログで変わります。玩具のデータで見えた傾向を、そのまま本番に持ち込まないでください。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fus…
- elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/rrf-retr…
- elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/linear-r…
- elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval
- elastic.co/docs/solutions/search/hybrid-search
- platform.claude.com/docs/en/build-with-claude/embeddings