AI

ハイブリッド検索とは?RAGでBM25とベクトル検索を統合する仕組み・RRF・実装コード

ハイブリッド検索は、キーワード検索(BM25)とベクトル検索を同時に走らせ、2つのランキングを1つに束ねる検索方式です。RAGの回答品質は、LLMに何を渡せたかでほぼ決まります。型番や社内用語のような表記が一致する語はキーワード検索が、言い換えられた質問はベクトル検索が拾う。その取りこぼしを互いに埋めるのがハイブリッド検索の狙いです。ただし、束ね方(スコア統合)を間違えると単独検索より精度が落ちます。本記事は統合方式の違い、主要プラットフォームの実装コード、そして日本語環境で逆効果になる条件までを扱います。

まとめ

  • ハイブリッド検索=BM25とベクトル検索の並列実行+ランキング統合。片方が苦手なクエリをもう片方が補う。
  • 統合方式は順位ベース(RRF)と値ベース(スコア正規化)の2択。BM25スコアは上限なし、コサイン類似度は概ね0〜1と尺度が違うため、素の足し算は成立しない。
  • RRFの計算式は 1/(k + rank)、定数kは60が事実上の標準。ただし調整できるのはElasticsearchとOpenSearchだけで、Azure AI Searchは60固定。順位しか見ないので「僅差の1位」と「圧倒的な1位」を区別できない。スコア差を活かしたいなら正規化+加重平均を選ぶ。
  • 日本語では、BM25側を作り込まない限りハイブリッド化で精度が下がる。公開実験では、ベクトル単独のHit Rate@10 0.8355に対し、形態素解析だけ入れた既定設定のBM25を足すと0.7819へ低下した。上回ったのは、アナライザ・正規化・リランカーまで作り込んだ構成(0.8954)だけだった。
  • 導入判断は「BM25側を作り込めるか」で決まる。全文検索の品質が低いまま束ねると、弱い方のランキングがノイズとして混ざる。

ハイブリッド検索の定義とRAGで必要になる理由

キーワード検索とベクトル検索が取りこぼすもの

キーワード検索は転置インデックスとBM25(Okapi BM25)のスコアリングで、クエリ語と文書語の表記一致を評価します。「ERR-4021」「MFC-J6983CDW」のような型番・エラーコード・固有名詞に強く、その語が本文に無ければヒットしない、という素直な挙動を持ちます。この素直さは説明可能性という利点でもあります。

ベクトル検索は文をEmbeddingモデルで密ベクトル化し、コサイン類似度などの距離で近い文書を返します。「印刷が途中で止まる」と「用紙が詰まって停止する」を近いものとして扱えるかわりに、学習データに無い社内固有の型番は他の型番と潰し合い、上位に出てこないことがあります。仕組みの詳細はベクトル検索とセマンティック検索の違いで整理しています。

この2つの弱点は綺麗に相補的です。片方が0点のクエリで、もう片方が高得点を出す。ハイブリッド検索はその性質を利用します。

RAGの回答品質が検索段階で決まる理由

RAG(Retrieval-Augmented Generation、検索拡張生成)は、質問に関連する文書を検索し、その本文をプロンプトに詰めてLLMに答えさせる構成です。検索が誤った文書を上位に返せば、LLMはその誤りを流暢な日本語で述べます。生成側のプロンプトをどれだけ磨いても、渡していない情報は出てきません。

だからRAGの改善は、まず検索の再現率(Recall)を上げる作業になります。上位k件(k=5〜10が一般的)に正解文書が含まれていなければ、そこで負けが確定する。ハイブリッド検索が効くのは、この「上位k件に正解を入れる」確率を、2系統の検索で二重化できるからです。

スコア統合の2方式 ― 順位で束ねるRRFと、値で束ねる正規化

BM25スコアとコサイン類似度を素で足してはいけない理由

2つの検索結果を統合する際、最初に直面するのが尺度の不一致です。Microsoftの公式ドキュメントは、Azure AI Searchが返すスコアの範囲を次のように明示しています。

検索方式 スコアリング 値域
全文検索 BM25 上限なし
ベクトル検索 HNSW+類似度指標 0.333〜1.00(コサイン)
ハイブリッド RRF 融合するクエリ数×約1/kが上限
セマンティックランカー 再ランキング 0.00〜4.00

BM25は文書長やクエリ語数によって3にも30にもなり、上限がありません。一方コサイン類似度は1を超えません。これを単純加算すれば、常にBM25の側だけで順位が決まります。統合するには、順位に変換するか(RRF)、値域を揃えるか(正規化)のどちらかが必要です。

RRF:順位だけを見る統合

Reciprocal Rank Fusion(RRF)は、各ランキングでの順位の逆数を足し合わせます。文書dのスコアは各リストについて 1/(k + rank(d)) を計算し、合算した値です。定数kはAzure AI Searchのドキュメントで「実験上、60のような小さい値が最も良く機能する」と説明され、Elasticsearchの rank_constant、OpenSearchの score-ranker-processor、Weaviateのranked fusionも同じ60を既定にしています。

ただし、kを触れるかは製品によって違います。Elasticsearchの rank_constant とOpenSearchの combination.rank_constant は変更できますが、Azure AI Searchのkはアルゴリズム内部の定数で公開パラメータがなく、60から動かせません(ベクトル検索の近傍数kとは別物である点も公式が注意喚起しています)。Weaviateの60はranked fusionを明示的に選んだときの定数で、既定のrelative score fusionでは使われません。

kが大きいほどスコアは平坦になり、上位の重みが薄れます。小さくすると1位と2位の差が開き、トップの結果が支配的になります。アルゴリズムの導出やRAG-Fusionでの使い方はRRF(Reciprocal Rank Fusion)の仕組みとスコア計算で扱っています。

RRFの弱点は、元のスコアを捨てることです。BM25で圧勝した文書も、僅差で1位になった文書も、同じ「1位」として 1/61 になる。確信度の情報が消えます。

スコア正規化:値の差を残す統合

もう一方の流儀が、各ランキングのスコアをmin-max正規化などで0〜1に写像してから加重平均する方式です。OpenSearchの normalization-processor は正規化に min_max(既定)・l2z_score、結合に arithmetic_mean(既定)・geometric_meanharmonic_mean を提供し、サブクエリごとの重みを合計1.0で指定できます。Weaviateがv1.24から既定にしたrelative score fusionも同じ発想です。

使い分けの判断基準は、スコアの差に意味があるかどうかです。BM25側のスコア分布が安定している社内文書検索なら正規化+加重平均で重み調整の余地が生まれます。コーパスやクエリの性質が変わりやすく、スコア分布が読めないならRRFの方が壊れにくい。迷ったらRRFで始めるのが安全です。パラメータ1つ(k)で済み、片側のスコアが暴れても引きずられません。

プラットフォーム別のハイブリッド検索実装

Elasticsearch:rrf retriever

Elasticsearchはretriever構文でBM25とkNNを束ねます。注意点は rank_window_size です。rrf retrieverのリファレンスでは既定10とされており、各サブクエリの上位10件しかRRFの対象になりません。実運用では50〜100へ広げます。子retrieverは2本以上必要で、num_candidatesk より大きい値にします。

POST my-index/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": { "match": { "content": "プリンタ 給紙 停止" } }
          }
        },
        {
          "knn": {
            "field": "content_vector",
            "query_vector": [0.023, -0.117, 0.451],
            "k": 50,
            "num_candidates": 200
          }
        }
      ],
      "rank_constant": 60,
      "rank_window_size": 50
    }
  },
  "size": 5
}

OpenSearch:hybridクエリ+検索パイプライン

OpenSearchは統合ロジックを検索パイプライン側に置きます。hybrid クエリはサブクエリを最大5本まで受け取り、正規化と結合はパイプラインのプロセッサが担当します。RRFを使うなら score-ranker-processor(2.19以降)、値ベースの正規化なら normalization-processor を選びます。

PUT /_search/pipeline/hybrid-pipeline
{
  "phase_results_processors": [
    {
      "normalization-processor": {
        "normalization": { "technique": "min_max" },
        "combination": {
          "technique": "arithmetic_mean",
          "parameters": { "weights": [0.3, 0.7] }
        }
      }
    }
  ]
}

POST /my-index/_search?search_pipeline=hybrid-pipeline
{
  "query": {
    "hybrid": {
      "queries": [
        { "match": { "content": "プリンタ 給紙 停止" } },
        {
          "neural": {
            "content_vector": {
              "query_text": "プリンタ 給紙 停止",
              "model_id": "<your-model-id>",
              "k": 50
            }
          }
        }
      ]
    }
  }
}

重みの配列は合計1.0でなければエラーになります。上の例はキーワード側3割・ベクトル側7割の配分です。model_id は、インデックスに既定モデルを設定していれば省略できます。正規化に z_score を選んだ場合、結合は arithmetic_mean しか使えません。

Azure AI Search:RRF+セマンティックランカー

Azure AI Searchは search(全文)と vectorQueries を同一リクエストに入れるだけでRRF統合が走ります。特徴は、RRFで融合した後段にセマンティックランカー(0.00〜4.00の @search.rerankerScore)を挟める点です。

{
  "search": "プリンタが給紙途中で停止する",
  "vectorQueries": [
    {
      "kind": "vector",
      "vector": [0.023, -0.117, 0.451],
      "fields": "contentVector",
      "k": 50
    }
  ],
  "queryType": "semantic",
  "semanticConfiguration": "default",
  "top": 5
}

全文検索側の候補数は maxTextRecallSize(既定1,000)で制限されます。これはBM25側の候補数だけを制御するもので、ベクトル側の k は増えません。件数の多いコーパスで再現率が伸び悩むときに疑う値ですが、執筆時点ではプレビュー機能で、プレビュー版APIかつ hybridSearch オブジェクト内での指定が必要です。

Weaviate:alpha 1つで比率を決める

Weaviateは統合比率を alpha という単一パラメータに集約しています。1.0がベクトル検索のみ、0.0がキーワード検索のみ。融合方式はv1.24以降、relative score fusionが既定です。

ここで既定値に罠があります。サーバー側の既定alphaは0.75ですが、Python v4クライアントは省略時に0.7を送ります。ドキュメント間でも0.5と記載されたページが残っています。alphaは常に明示指定するのが安全です。

collection = client.collections.use("Document")   # get() は将来非推奨

response = collection.query.hybrid(
    query="プリンタが給紙途中で停止する",
    alpha=0.5,          # 0=BM25のみ / 1=ベクトルのみ。既定に頼らず明示する
    limit=5,
)

PostgreSQL+pgvector:RRFを自前で書く

専用の検索エンジンを増やしたくない場合、PostgreSQLの全文検索(tsvector)とpgvectorによるベクトル検索を1本のSQLでRRF統合できます。要点は、順位付けを50件に絞った後で行うことです。RANK() をCTEの中で LIMIT と同居させると、ウィンドウ関数がWHERE通過後の全行に対して評価されるため、pgvectorのHNSWインデックスが効かなくなり全件ソートに落ちます。

WITH kw AS (
  SELECT id, ts_rank_cd(tsv, q) AS s
  FROM docs, plainto_tsquery('simple', :query) q
  WHERE tsv @@ q
  ORDER BY s DESC
  LIMIT 50
),
kw_r AS (SELECT id, ROW_NUMBER() OVER (ORDER BY s DESC) AS r FROM kw),
vec AS (
  SELECT id, embedding <=> :qvec AS d      -- <=> はコサイン距離
  FROM docs
  ORDER BY d
  LIMIT 50                                  -- ウィンドウ関数を挟まないのでHNSWが効く
),
vec_r AS (SELECT id, ROW_NUMBER() OVER (ORDER BY d) AS r FROM vec)
SELECT doc.id, doc.title,
       COALESCE(1.0 / (60 + k.r), 0) + COALESCE(1.0 / (60 + v.r), 0) AS rrf
FROM kw_r k
FULL OUTER JOIN vec_r v ON k.id = v.id
JOIN docs doc ON doc.id = COALESCE(k.id, v.id)
ORDER BY rrf DESC
LIMIT 5;

LIMIT の前に必ず ORDER BY を置く点も外せません。PostgreSQLは順序を指定しない LIMIT について「予測不能な部分集合が返る」と明記しています。ウィンドウ関数と LIMIT を同じCTEに書くと、たまたま正しく見えて本番で崩れます。

日本語では plainto_tsquery('simple', ...) のままでは語が切れません。PGroongaやpg_bigmを導入するか、アプリ側で形態素解析してから空白区切りで渡す必要があります。この問題は次章の本題そのものです。

ハイブリッド検索が逆効果になる条件(日本語環境の落とし穴)

BM25側のトークナイズが既定のままだと精度は下がる

ハイブリッド検索は常に単独検索より強い、という前提は誤りです。ランク融合は弱いランキングもそのまま混ぜるため、片方の品質が低ければ全体を引き下げます。

この点を実測した公開実験があります(Ahogrammer「ハイブリッド検索で必ずしも検索性能が上がるわけではない」2023年12月)。尼崎市のQAデータ784問に対し、ベクトル検索単独(text-embedding-ada-002)とハイブリッド構成を比較したものです。

構成 Hit Rate@10 MRR@10
ベクトル単独(ada-002) 0.8355 0.6440
BM25単独(MeCab) 0.5918 0.3955
ベクトル+BM25(MeCab・他は既定) 0.7819 0.5855
ベクトル+BM25(ja.microsoft) 0.7997 0.5859
ベクトル+BM25(ja.lucene) 0.8252 0.6185
ベクトル+改善版全文検索+リランカー 0.8954 0.6616

読むべきは差分の向きです。形態素解析器(MeCab)を入れてなお、他を既定のままにしたBM25を足すと、ベクトル単独の0.8355から0.7819へ5ポイント以上落ちています。アナライザをja.microsoft、ja.luceneと替えても、まだベクトル単独に届きません。BM25単独が0.5918しかない以上、その弱いランキングを融合すれば全体が引きずられる、という素直な帰結です。

ベクトル単独を超えたのは最後の行だけで、その中身は全文検索側の全面的な作り込みです。原典によれば、トークナイザをSudachi(mode A)に替え、基本形化・品詞フィルタ・正規化・nGramを施し、BM25RetrieverをTF-IDFベースに変更したうえ、bge-reranker-largeによる再ランキングを追加し、RRFの定数kを60から10へ下げています。つまり「BM25を足したら精度が上がる」のではなく、BM25側を検索エンジンとして作り込めた場合にだけ上がる。この差を認識せずに導入すると、工数をかけて精度を下げます。

実装上の起点は形態素解析です。Elasticsearch/OpenSearchならkuromojiやSudachiのアナライザ、Azure AI Searchなら ja.lucene を指定します。LangChainの BM25Retrieverpreprocess_func の既定が text.split()、つまり空白区切りなので、日本語では形態素解析器を渡さない限りそもそも語が切れません(上の実験もMeCabを渡したうえでの数値です)。

採用すべきでない場面

次の条件に当てはまるなら、ハイブリッド化は見送るべきです。

  • 検索対象が意味の言い換えばかりで、固有名詞や型番をほとんど含まない:BM25の出番が無く、運用コストだけが増える。
  • 全文検索側を設定する体制が無い:アナライザ・シノニム辞書・ストップワードを誰も保守しないなら、弱いランキングを混ぜるだけになる。
  • レイテンシ要件が厳しい:2系統の検索を並列実行して融合する分、単独検索よりレスポンスは遅い。上位k件を広げるほど顕著になる。
  • そもそも文書がチャンク分割に馴染まない構造をしている:見出し階層で辿るべき長大な仕様書などは、PageIndexのような推論型RAGGraphRAG的な構造化の方が筋が良い。

評価セットによる効果測定

ハイブリッド化の是非は、体感ではなく評価セットで決めます。用意するのは「質問」と「正解文書ID」の対を数十〜数百件並べたCSV1枚です。指標はHit Rate@k(正解が上位k件に入った割合)とMRR@k(正解の順位の逆数の平均)で足ります。前掲の実験も784問でこの2指標を測っています。

比較すべき構成は最低4つ。BM25単独、ベクトル単独、RRF統合、正規化+加重平均です。ハイブリッドが単独を下回るケースは実在するため、単独構成をベースラインとして必ず残します。BM25単独のスコアは、そのままBM25側の作り込み度合いを示す健康診断になる。ここが極端に低ければ、統合方式をいじる前にアナライザを直す番です。改善が0.01ポイント未満なら、2系統を運用する保守コストに見合いません。

よくある質問

ハイブリッド検索とRRFは同じものですか?

違います。ハイブリッド検索は「複数の検索方式を併用する」という構成の名前で、RRFはその結果を1つに束ねる融合アルゴリズムの1つです。RRFの代わりにスコア正規化+加重平均を使ってもハイブリッド検索です。

RRFの定数kは60から変えるべきですか?

まずは60のままで構いません。そもそも変更できるのはElasticsearch(rank_constant)とOpenSearch(combination.rank_constant)で、Azure AI Searchのkは内部定数のため60から動かせません。上位の結果をより強く効かせたい場合だけ小さくし(前掲の実験は10に下げています)、評価セットで効果を確認してから採用します。

キーワード検索とベクトル検索の重みはどう決めますか?

Weaviateなら alpha、OpenSearchなら weights で調整します。0.5から始め、評価セットのHit Rateを見ながら0.1刻みで振るのが実務的です。日本語で重み調整が効かないときは、重みではなくBM25側のトークナイズを疑ってください。

ベクトルデータベースを新たに導入しないとハイブリッド検索はできませんか?

できます。PostgreSQL+pgvectorなら既存のRDBだけでBM25相当の全文検索とベクトル検索をRRF統合できます。データ量が数十万件規模までなら現実的な選択肢です。専用エンジンとの違いはベクトルデータベースの仕組みを参照してください。

ハイブリッド検索を入れればハルシネーションは無くなりますか?

減りますが、無くなりません。正しい文書を渡せる確率が上がるだけで、LLMが渡された文書を読み違える余地は残ります。検索側の改善と、生成側の指示(引用の強制、根拠が無ければ答えない指示)は別の対策として両方必要です。

関連記事

資料請求

RELATED POSTS 関連記事