Claude Media
Claude Codeでハイブリッド検索(RRF)をElasticsearchに実装する手順

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スコア
3BM25の順位2kNNの順位1RRFスコア1/(1+2) + 1/(1+1) = 0.8333
2BM25の順位3kNNの順位2RRFスコア1/(1+3) + 1/(1+2) = 0.5833
4BM25の順位1kNNの順位圏外RRFスコア1/(1+1) = 0.5000
1BM25の順位4kNNの順位3RRFスコア1/(1+4) + 1/(1+3) = 0.4500
5BM25の順位圏外kNNの順位4RRFスコア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段に分けます。

  1. judgments.jsonl の形式と、run_eval.py の骨格だけを作らせる(検索は未実装でよい)
  2. 上の search 関数を実装させ、BM25のみ・kNNのみ・RRFの3通りで評価を回させる
  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_constantBM25の重み0.5123
1BM25の重み0.50.83310.83320.66730.667
20BM25の重み0.51.00010.83320.83330.833
60BM25の重み0.51.00010.83320.83330.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 に切り詰められます
  • 使えない機能: rrf retrieverは scroll、sort、rescore に対応しておらず、使うと例外になります。集計、from、サジェスト、ハイライト、collapse、プロファイルは使えます
  • ポイントインタイム: RRFが内部で作って子の検索に共有するため、リクエストに自分で付けることは避けます
  • サブ検索: sub_searches を使うRRFは廃止されています。retriever APIで書き直します
  • 重みの数式: 重み付き形式の最終スコアは、各子のRRFスコアに重みを掛けて足したものです。重みを2倍にすると、その子が2倍に効きます

ElasticsearchのMCPサーバーをClaudeにつなぐ構成を検討しているなら、Elasticsearch MCPサーバーの非推奨化とAgent Builder移行も併せて確認してください。

実装の進め方をまとめる

RRFそのものは数行の式で、実装の手間はほとんどありません。差がつくのは評価セットを持っているかどうかです。Claude Codeに頼むときは、CLAUDE.mdに「パラメータは外出し、変更のたびに評価を回す」と書き、評価の骨格を検索の実装より先に作らせます。BM25単独・kNN単独の成績を基準線にして、weight と rank_constant のグリッドを回し、悪化したクエリまで見て採用値を決める流れです。

最終的な重みは、データと検索ログで変わります。玩具のデータで見えた傾向を、そのまま本番に持ち込まないでください。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fus…
  2. elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/rrf-retr…
  3. elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/linear-r…
  4. elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval
  5. elastic.co/docs/solutions/search/hybrid-search
  6. platform.claude.com/docs/en/build-with-claude/embeddings
この記事を共有:XはてブLinkedIn