Hybrid Search — 密ベクトル検索とBM25スパース検索の融合

「営業部の第3四半期レポートを探して」と検索システムに聞いたとき、意味的には正しい文書が見つかるのに、肝心の「Q3 sales report」という固有名詞を含む文書が上位に来ないことがあります。逆に、キーワード検索で「Q3 sales report」と入力すれば固有名詞は一致するものの、「第3四半期の売上概要」のように表現が異なる文書は取りこぼしてしまいます。

この問題は、検索技術の根本的なジレンマを映し出しています。密ベクトル検索は意味を捉えますがキーワードの完全一致には弱く、BM25に代表されるスパース検索はキーワード一致に強いが意味の揺れには弱い。両者は得意領域が見事に相補的なのです。

Hybrid Search(ハイブリッド検索)は、この2つの検索方式を組み合わせることで、どちらか単独では達成できない検索精度を実現する手法です。Hybrid Searchを理解すると、以下のような応用で直接役立ちます。

  • RAG(Retrieval-Augmented Generation)パイプラインの精度向上: LLMに渡すコンテキストの品質が検索精度に直結するため、ハイブリッド検索による再現率の向上が回答品質を大きく改善します
  • 企業内ドキュメント検索: 技術文書には専門用語・略語・型番などキーワード一致が重要な固有表現が多く、意味検索だけではカバーしきれません
  • Eコマース商品検索: ユーザーが「赤い防水スマホケース」と検索したとき、「赤い」「防水」をキーワードで、「スマホケース」の概念を意味的に検索できます

本記事の内容

  • BM25スパース検索の数学的背景(TF-IDFからBM25への発展)
  • 密ベクトル検索の復習(bi-encoder、コサイン類似度)
  • 両方式の限界を具体例で比較
  • Reciprocal Rank Fusion(RRF)による融合手法の数式と直感
  • その他の融合手法(Weighted Sum、Convex Combination Normalization)
  • Pythonでのスクラッチ実装と実験
  • 実用システムでのHybrid Searchサポート状況

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

画像なし
RAG(検索拡張生成)の仕組みとPython実装
RAGの全体像とベクトル検索がどう使われるかを理解します
画像なし
埋め込みベクトルと類似度検索の理論とPython実装
ベクトルの距離尺度とコサイン類似度の数学的背景を理解します
画像なし
ベクトルデータベースの仕組み — FAISS・HNSWの理論とPython実装
大規模ベクトル検索のインデックス手法を理解します

BM25スパース検索の理論

TF-IDFの直感

Hybrid Searchの一方の柱であるBM25を理解するために、まずその出発点であるTF-IDF(Term Frequency-Inverse Document Frequency)から始めましょう。

検索の本質は「クエリとの関連性が高い文書を見つけること」ですが、「関連性」をどう定量化すればよいでしょうか。単純に「クエリの単語が多く出現する文書」を上位にすると、「の」「は」「です」のような頻出語に引きずられてしまいます。逆に、「量子もつれ」のようなレアな単語がクエリと一致すれば、それは強い関連性の証拠です。

TF-IDFはこの直感を数式にしたものです。ある単語 $t$ の文書 $d$ における重要度を、その文書での出現頻度(TF)と全文書中での希少性(IDF)の積で測ります。

$$ \text{TF-IDF}(t, d) = \text{TF}(t, d) \times \text{IDF}(t) $$

TF(Term Frequency) は、文書 $d$ における単語 $t$ の出現回数です。

$$ \text{TF}(t, d) = f_{t,d} $$

ここで $f_{t,d}$ は文書 $d$ 中の単語 $t$ の生の出現回数です。

IDF(Inverse Document Frequency) は、全文書中でその単語がどれだけ珍しいかを測ります。$N$ を全文書数、$n_t$ を単語 $t$ を含む文書数として次のように定義します。

$$ \text{IDF}(t) = \log \frac{N}{n_t} $$

多くの文書に出現する単語ほど $n_t$ が大きくなるため $\text{IDF}$ が小さくなり、レアな単語ほど高い値を持ちます。この「ありふれた単語の重みを下げ、希少な単語の重みを上げる」仕組みが、TF-IDFの核心です。

TF-IDFは非常に直感的ですが、いくつかの問題があります。例えば、TFが出現回数そのままだと、ある単語が10回出現する文書は5回の文書の2倍関連性が高いことになりますが、実際にはそこまで差はないはずです。また、長い文書は短い文書より多くの単語を含むため不公平です。BM25はこれらの問題を巧みに解決します。

TF-IDFからBM25への発展

BM25(Best Matching 25)は、1990年代に情報検索の研究者であるStephen RobertsonとKaren Sp\”{a}rck Jonesらによって、確率的情報検索モデルから導出されたスコアリング関数です。TF-IDFの考え方を引き継ぎつつ、TFの飽和文書長の正規化という2つの改良を加えています。

クエリ $Q$ に含まれる単語を $q_1, q_2, \ldots, q_m$ とすると、文書 $d$ に対するBM25スコアは次のように定義されます。

$$ \text{BM25}(Q, d) = \sum_{i=1}^{m} \text{IDF}(q_i) \cdot \frac{f_{q_i, d} \cdot (k_1 + 1)}{f_{q_i, d} + k_1 \cdot \left(1 – b + b \cdot \frac{|d|}{\text{avgdl}}\right)} $$

ここで、各変数の意味は以下の通りです。

変数 意味
$f_{q_i, d}$ 文書 $d$ における単語 $q_i$ の出現回数
$\|d\|$ 文書 $d$ の長さ(総単語数)
$\text{avgdl}$ 全文書の平均文書長
$k_1$ TFの飽和速度を制御するパラメータ(典型的には1.2〜2.0)
$b$ 文書長正規化の強さを制御するパラメータ(典型的には0.75)

BM25のIDF部分は、TF-IDFとは少し異なる形を使います。

$$ \text{IDF}(q_i) = \log \frac{N – n_{q_i} + 0.5}{n_{q_i} + 0.5} $$

ここで $N$ は全文書数、$n_{q_i}$ は単語 $q_i$ を含む文書数です。分子と分母にそれぞれ $0.5$ を加えているのは、$n_{q_i} = 0$ や $n_{q_i} = N$ のような極端なケースでのゼロ除算や負の対数を避けるための平滑化です。

BM25の2つの改良点を理解する

BM25の数式は一見複雑に見えますが、本質は2つの改良に集約されます。

改良1: TFの飽和(パラメータ $k_1$)

TF-IDFでは、単語の出現回数がそのままスコアに効きます。BM25では、TFの寄与を次のような形に変えています。

$$ \text{BM25のTF部分} = \frac{f_{q_i,d} \cdot (k_1 + 1)}{f_{q_i,d} + k_1} $$

ここでは文書長正規化を一旦省略しています。この式の性質を分析してみましょう。

$f_{q_i,d} = 0$ のとき、スコアは $0$ です。$f_{q_i,d}$ が大きくなると分子と分母の両方が増えますが、分母の方が速く増えるため、スコアは $k_1 + 1$ に漸近します。

$$ \lim_{f \to \infty} \frac{f \cdot (k_1 + 1)}{f + k_1} = k_1 + 1 $$

分子を展開して確認してみましょう。$f$ で分子分母を割ると次のようになります。

$$ \frac{f \cdot (k_1 + 1)}{f + k_1} = \frac{(k_1 + 1)}{1 + k_1 / f} $$

$f \to \infty$ で $k_1/f \to 0$ となるので、上限値 $k_1 + 1$ に収束することが確認できます。

つまり、ある単語が文書中に10回出てくることと100回出てくることの差はごく小さくなります。これは「ある単語が何度も繰り返されていることの情報価値は逓減する」という直感に合致しています。$k_1$ が小さいほど飽和が速く(つまり出現回数の差がすぐに無視される)、$k_1$ が大きいほどTF-IDFに近い挙動になります。

改良2: 文書長の正規化(パラメータ $b$)

TFの分母に文書長の影響を加えたのが、BM25のもう一つの工夫です。

$$ k_1 \cdot \left(1 – b + b \cdot \frac{|d|}{\text{avgdl}}\right) $$

この部分は、文書が平均長($|d| = \text{avgdl}$)のとき $k_1$ そのものになります。文書が平均より長い($|d| > \text{avgdl}$)場合は分母が大きくなり、TFの寄与が減少します。これは「長い文書では単語が偶然多く出現しやすいので、その分を割り引く」という考え方です。

パラメータ $b$ は正規化の強さを制御します。

  • $b = 0$: 文書長による正規化を一切行わない(長い文書も短い文書も平等に扱う)
  • $b = 1$: 完全に文書長に比例して正規化する

実用上は $b = 0.75$ がよく使われ、これは「文書長の影響は考慮するが、完全には正規化しない」というバランスの取れた設定です。

BM25は、キーワードの一致に基づく検索として非常に優れた性能を持ちます。しかし、BM25は単語の表層的な一致のみで判定するため、「自動車」と「車」、「機械学習」と「ML」のような同義語や略語の関係を捉えることができません。ここで、意味を理解できる密ベクトル検索が必要になります。

密ベクトル検索の復習

Bi-Encoderによるベクトル化

密ベクトル検索では、テキストをニューラルネットワークで固定長の密ベクトル(dense vector)に変換し、ベクトル空間上の近さで意味的な類似度を測ります。感覚的には、「似た意味のテキストは似た方向を向くベクトルになる」ような写像を学習するのがエンコーダの役割です。

RAGや検索で最も一般的なアーキテクチャはBi-Encoderです。Bi-Encoderでは、クエリと文書を別々に同じエンコーダ $E$ でベクトル化します。

$$ \bm{q} = E(\text{query}) \in \mathbb{R}^d, \quad \bm{d}_i = E(\text{document}_i) \in \mathbb{R}^d $$

文書側のベクトル $\bm{d}_i$ はインデックス構築時に事前計算しておけるため、検索時にはクエリベクトル $\bm{q}$ の計算と、事前に作成されたインデックスからの近傍探索だけで済みます。これがBi-Encoderの大きな利点です。

コサイン類似度

2つのベクトル間の類似度としてよく使われるのがコサイン類似度です。2つのベクトル $\bm{a}$ と $\bm{b}$ のコサイン類似度は次で定義されます。

$$ \text{sim}(\bm{a}, \bm{b}) = \frac{\bm{a} \cdot \bm{b}}{\|\bm{a}\| \|\bm{b}\|} = \frac{\sum_{j=1}^{d} a_j b_j}{\sqrt{\sum_{j=1}^{d} a_j^2} \sqrt{\sum_{j=1}^{d} b_j^2}} $$

コサイン類似度はベクトルの大きさ(ノルム)に依存せず、方向の一致度のみを測ります。値は $-1$ から $1$ の範囲をとり、$1$ が完全一致、$0$ が直交(無関係)、$-1$ が正反対を意味します。

事前にベクトルを単位長に正規化($\bm{a} \leftarrow \bm{a} / \|\bm{a}\|$)しておけば、コサイン類似度は内積と一致するため、計算を高速化できます。

$$ \text{sim}(\hat{\bm{a}}, \hat{\bm{b}}) = \hat{\bm{a}} \cdot \hat{\bm{b}} \quad (\text{ただし } \|\hat{\bm{a}}\| = \|\hat{\bm{b}}\| = 1) $$

密ベクトル検索の強みと弱み

密ベクトル検索の最大の強みは、意味の類似性を捉えられることです。「機械学習の入門」と「MLの基礎」は表層的には異なりますが、良いエンコーダで埋め込めば近いベクトルになります。

一方で、密ベクトル検索には以下のような弱点があります。

  1. 固有名詞・型番・略語への弱さ: 「XGBoost-v2.3.1」のような固有の文字列は、エンコーダの学習データに含まれていなければ正しくベクトル化できません。BM25なら文字列の完全一致で確実にヒットします
  2. 低頻度語への弱さ: エンコーダの語彙に含まれないレアな専門用語は、サブワード分割されて意味が失われる可能性があります
  3. 言語混在への弱さ: 英語と日本語が混在するクエリ(例: 「Python pandas groupby 使い方」)では、エンコーダが最適なベクトルを生成できない場合があります

密ベクトル検索とBM25の弱点が相補的であることがわかったところで、次はこれを具体的な検索クエリで確認してみましょう。

なぜ両方が必要か — 具体例での比較

ケーススタディ: 技術文書検索

以下の5つの文書からなる小さなコーパスを考えます。

文書ID 内容
D1 「XGBoost v2.1のハイパーパラメータチューニングガイド」
D2 「勾配ブースティングの理論と最適化手法の解説」
D3 「ランダムフォレストとXGBoostの性能比較レポート」
D4 「深層学習フレームワークの比較: PyTorch vs TensorFlow」
D5 「アンサンブル学習手法の概要と勾配ブースティングの位置づけ」

クエリ: 「XGBoost パラメータ設定」

BM25は「XGBoost」という文字列の完全一致を重視するため、D1とD3を上位に持ってきます。D2やD5はXGBoostという単語を含まないため、たとえ内容的に深く関連していてもスコアが低くなります。

一方、密ベクトル検索は「パラメータ設定」の意味を「ハイパーパラメータチューニング」「最適化手法」と結びつけることができるため、D1とD2を上位にランクします。しかし、「XGBoost」という固有名詞の完全一致よりも意味的な近さを重視するため、D4(深層学習フレームワークの「設定」に関する文書)を誤って上位に持ってくる可能性があります。

BM25のランキング例: D1 > D3 > D5 > D2 > D4

密ベクトル検索のランキング例: D1 > D2 > D5 > D4 > D3

理想的なランキングはD1(XGBoostのパラメータに直接関連)が1位、D2(勾配ブースティングの最適化)が2位、D3(XGBoostの比較)が3位でしょう。どちらの方式も単独では理想に届きませんが、両者を組み合わせれば、BM25が「XGBoost」の一致でD1とD3を引き上げ、密ベクトル検索がD2の意味的関連性を引き上げ、結果として理想に近いランキングが得られます。

失敗パターンの分類

2つの検索方式がそれぞれ失敗するパターンを整理しておくと、Hybrid Searchの意義がより明確になります。

BM25が失敗するケース(意味理解が必要): – 同義語: 「自動車」で検索 → 「車」を含む文書を見逃す – 言い換え: 「CO2排出を減らす方法」→「カーボンニュートラルの達成手段」 – 抽象化: 「犬猫の病気」→「ペットの健康管理」

密ベクトル検索が失敗するケース(字面の一致が必要): – 固有名詞: 「Boeing 787-9 Dreamliner」の型番検索 – エラーコード: 「CUDA error 11」のような特定の文字列 – 略語・頭字語: 「GNSS RTK」のような分野特有の略語 – 正確な数値: 「2026年Q3」のような時間的限定

このように、2つの方式の失敗パターンがほぼ重ならないことが、Hybrid Searchが有効である根本的な理由です。では、2つの検索結果をどのように1つの統合ランキングにまとめればよいのでしょうか。最もシンプルかつ効果的な手法であるReciprocal Rank Fusionから見ていきましょう。

Reciprocal Rank Fusion(RRF)

融合の難しさ

2つの検索システムの結果を統合するとき、最初に思いつくのは「スコアを足し合わせる」方法でしょう。しかし、ここには根本的な問題があります。BM25のスコアは0から数十の範囲を取り得る一方、コサイン類似度は$-1$から$1$の範囲です。スケールが全く異なるスコアをそのまま足してしまうと、一方のスコアが他方を圧倒してしまいます。

スコアを$[0, 1]$に正規化してから足す方法(後述するConvex Combination)もありますが、正規化の方法によって結果が大きく変わるという問題があります。また、スコアの分布が検索方式やクエリによって異なるため、万能な正規化は難しいのです。

Reciprocal Rank Fusion(RRF)は、この問題をスコアではなくランク(順位)に基づいて解決する手法です。2009年にCormackらによって提案されました。

RRFの直感

RRFのアイデアは極めてシンプルです。各検索システムが返す結果のランキングにおいて、上位にランクされた文書ほど高いスコアを与え、そのスコアを全ての検索システムにわたって合計します。

ランキングの「1位」「2位」「3位」という順位情報だけを使うため、元のスコアのスケールや分布に一切依存しません。これが、RRFが異なる検索方式の統合にうまく機能する理由です。

RRFの数式

$R$ 個の検索システム(ランキング)があるとき、文書 $d$ のRRFスコアは次のように定義されます。

$$ \text{RRF}(d) = \sum_{r=1}^{R} \frac{1}{k + \text{rank}_r(d)} $$

ここで、$\text{rank}_r(d)$ は検索システム $r$ における文書 $d$ の順位(1始まり)であり、$k$ は定数パラメータです。文書 $d$ が検索システム $r$ の結果に含まれない場合は、その項を0とするか、非常に大きなランク値を割り当てます。

$k$ は「ランクの重みの減衰速度」を制御するパラメータです。Cormackらの原論文では $k = 60$ が推奨されており、多くの実装でもこの値がデフォルトとして使われています。

パラメータ $k$ の役割

$k$ の値がRRFの振る舞いにどう影響するかを理解しましょう。

ランク1位の文書のスコアは $\frac{1}{k+1}$、ランク2位は $\frac{1}{k+2}$ です。1位と2位のスコアの比を見ると次のようになります。

$$ \frac{\text{score}(rank=1)}{\text{score}(rank=2)} = \frac{k+2}{k+1} $$

$k = 1$ のとき、この比は $\frac{3}{2} = 1.5$(1位は2位の1.5倍)です。$k = 60$ のとき、比は $\frac{62}{61} \approx 1.016$ です。

つまり、$k$ が小さいほど上位のランクが重視され(1位と2位の差が大きい)、$k$ が大きいほど順位間の差が均等になります。$k = 60$ という値は、上位の少数文書だけを極端に重視するのではなく、ランキング全体の情報をバランスよく活用する設定です。

RRFの具体例

先ほどの技術文書検索の例でRRFを計算してみましょう。$k = 60$ とします。

BM25のランキング: D1(1位), D3(2位), D5(3位), D2(4位), D4(5位)

密ベクトル検索のランキング: D1(1位), D2(2位), D5(3位), D4(4位), D3(5位)

各文書のRRFスコアを計算します。2つのランキングから各文書の順位を取り出し、$\frac{1}{60 + \text{rank}}$ を足し合わせます。

$$ \text{RRF}(D1) = \frac{1}{60+1} + \frac{1}{60+1} = \frac{2}{61} \approx 0.03279 $$

D1は両方のシステムで1位なので、同じスコアが2回加算されます。

$$ \text{RRF}(D2) = \frac{1}{60+4} + \frac{1}{60+2} = \frac{1}{64} + \frac{1}{62} \approx 0.01563 + 0.01613 = 0.03175 $$

D2はBM25で4位、密ベクトル検索で2位です。

$$ \text{RRF}(D3) = \frac{1}{60+2} + \frac{1}{60+5} = \frac{1}{62} + \frac{1}{65} \approx 0.01613 + 0.01538 = 0.03151 $$

$$ \text{RRF}(D5) = \frac{1}{60+3} + \frac{1}{60+3} = \frac{2}{63} \approx 0.03175 $$

$$ \text{RRF}(D4) = \frac{1}{60+5} + \frac{1}{60+4} = \frac{1}{65} + \frac{1}{64} \approx 0.01538 + 0.01563 = 0.03101 $$

RRFスコアの降順で並べると次のようになります。

順位 文書 RRFスコア
1 D1 0.03279
2 D2 0.03175
2 D5 0.03175
4 D3 0.03151
5 D4 0.03101

D1が1位(両方で1位)、D2とD5が同率2位となりました。BM25単独ではD2が4位に沈んでいましたが、密ベクトル検索の2位が引き上げ、融合後は2位に浮上しています。このように、RRFは片方のシステムで低評価でも、もう片方で高評価であれば救済される仕組みです。

RRFはシンプルで効果的ですが、唯一の融合手法ではありません。次に、スコアベースの融合手法も見てみましょう。

その他の融合手法

Weighted Sum(重み付きスコア合計)

最も直感的な融合手法は、各検索システムのスコアに重みを付けて合計する方法です。

$$ \text{score}_{\text{hybrid}}(d) = \alpha \cdot s_{\text{dense}}(d) + (1 – \alpha) \cdot s_{\text{sparse}}(d) $$

ここで、$\alpha \in [0, 1]$ は密ベクトル検索の重み、$s_{\text{dense}}(d)$ は密ベクトル検索のスコア、$s_{\text{sparse}}(d)$ はBM25のスコアです。

この手法の問題は、前述の通りスコアのスケールが異なることです。そこで通常は、各スコアを $[0, 1]$ の範囲に正規化してから合計します。

Convex Combination with Min-Max Normalization

スコアの正規化手法として最も一般的なのがMin-Max正規化です。検索結果に含まれる全文書のスコアの最小値と最大値を使って、$[0, 1]$ にスケーリングします。

$$ \hat{s}_r(d) = \frac{s_r(d) – s_r^{\min}}{s_r^{\max} – s_r^{\min}} $$

ここで $s_r^{\min}$ と $s_r^{\max}$ はそれぞれ、検索システム $r$ の結果における最小スコアと最大スコアです。この正規化を適用したうえで、Convex Combination(凸結合)を行います。

$$ \text{score}_{\text{hybrid}}(d) = \alpha \cdot \hat{s}_{\text{dense}}(d) + (1 – \alpha) \cdot \hat{s}_{\text{sparse}}(d) $$

凸結合は $\alpha + (1-\alpha) = 1$ を満たすため、重みの合計が常に1になります。$\alpha = 0.5$ が均等な融合、$\alpha = 0.7$ は密ベクトル検索を重視する設定です。

Min-Max正規化の注意点として、外れ値(極端に高い/低いスコア)があると正規化の結果が歪むことが挙げられます。また、1つの検索システムの結果数が少ない場合、正規化が不安定になる可能性もあります。

Distribution-Based Score Normalization(DBSN)

より頑健な正規化として、スコアの分布を考慮する方法もあります。各検索システムのスコアを標準化(z-score正規化)する手法で、正規化後のスコアは次のようになります。

$$ \hat{s}_r(d) = \frac{s_r(d) – \mu_r}{\sigma_r} $$

ここで $\mu_r$ と $\sigma_r$ は、検索システム $r$ のスコアの平均と標準偏差です。

z-score正規化はMin-Max正規化に比べて外れ値に頑健ですが、スコアが $[0, 1]$ に収まらないため、そのままConvex Combinationに使うには別途クリッピング等の処理が必要です。

各手法の比較

手法 利点 欠点
RRF スコア不要、スケール無関係、チューニング不要 ランクの微妙な差が失われる
Weighted Sum(Min-Max) スコアの大きさを活用できる 正規化方法に依存、外れ値に弱い
Weighted Sum(z-score) 外れ値に比較的頑健 分布の仮定が必要

実用上は、RRFが最も広く使われており、チューニングの手間が最も少ないという理由で第一選択肢となることが多いです。Elasticsearch、Qdrant、Weaviateなどの主要な検索エンジンもRRFをデフォルトまたは推奨の融合手法として採用しています。

理論の整理ができたところで、次はPythonで実際にHybrid Searchを実装し、単独検索との性能差を実験で確認しましょう。

Pythonでの実装

実装の全体像

ここからは、BM25とsentence-transformersによる密ベクトル検索を個別に実装し、RRFとConvex Combinationで融合するHybrid Searchシステムを構築します。外部ライブラリへの依存を最小限にするため、BM25はスクラッチで実装します。

まず、必要なライブラリをインポートし、BM25クラスを実装します。

import numpy as np
import re
from collections import Counter
from typing import List, Dict, Tuple


class BM25:
    """BM25スコアリング関数のスクラッチ実装"""

    def __init__(self, k1: float = 1.5, b: float = 0.75):
        self.k1 = k1
        self.b = b
        self.corpus_size = 0
        self.avgdl = 0.0
        self.doc_freqs: Dict[str, int] = {}  # 単語 -> 出現文書数
        self.doc_lens: List[int] = []
        self.term_freqs: List[Dict[str, int]] = []  # 各文書の単語出現回数

    def _tokenize(self, text: str) -> List[str]:
        """簡易トークナイザ(英語・日本語混在対応)"""
        # 英単語は小文字化してスペース分割、日本語は1文字ずつ分割
        text = text.lower()
        # 英単語を抽出
        tokens = re.findall(r'[a-z0-9]+|[^\s\x00-\x7F]', text)
        return tokens

    def fit(self, corpus: List[str]):
        """コーパスからIDF等の統計量を計算"""
        self.corpus_size = len(corpus)
        self.doc_lens = []
        self.term_freqs = []

        for doc in corpus:
            tokens = self._tokenize(doc)
            self.doc_lens.append(len(tokens))
            tf = Counter(tokens)
            self.term_freqs.append(dict(tf))
            for token in set(tokens):
                self.doc_freqs[token] = self.doc_freqs.get(token, 0) + 1

        self.avgdl = sum(self.doc_lens) / self.corpus_size

    def _idf(self, term: str) -> float:
        """BM25のIDF計算"""
        n = self.doc_freqs.get(term, 0)
        return np.log((self.corpus_size - n + 0.5) / (n + 0.5) + 1.0)

    def get_scores(self, query: str) -> np.ndarray:
        """クエリに対する全文書のBM25スコアを計算"""
        query_tokens = self._tokenize(query)
        scores = np.zeros(self.corpus_size)

        for q in query_tokens:
            idf = self._idf(q)
            for i in range(self.corpus_size):
                tf = self.term_freqs[i].get(q, 0)
                dl = self.doc_lens[i]
                numerator = tf * (self.k1 + 1)
                denominator = tf + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
                scores[i] += idf * numerator / denominator

        return scores

    def get_top_k(self, query: str, k: int = 10) -> List[Tuple[int, float]]:
        """上位k件の(文書インデックス, スコア)を返す"""
        scores = self.get_scores(query)
        top_indices = np.argsort(scores)[::-1][:k]
        return [(int(idx), float(scores[idx])) for idx in top_indices]

このBM25クラスでは、fit メソッドでコーパス全体の統計量(文書頻度、文書長、平均文書長)を計算し、get_scores メソッドでクエリに対する全文書のスコアを算出しています。IDF関数では、対数の引数に $+1$ を加えることで負の値になるのを防いでいます。_tokenize は簡易的なトークナイザですが、BM25の原理を理解するには十分です。

次に、密ベクトル検索のクラスを実装します。ここではsentence-transformersを使いますが、原理を理解するための簡易版として、ランダムな埋め込みを使う代替実装も示します。

class DenseRetriever:
    """密ベクトル検索(コサイン類似度ベース)"""

    def __init__(self, use_random: bool = False, dim: int = 384):
        """
        use_random=True: ランダム埋め込み(依存ライブラリ不要)
        use_random=False: sentence-transformersを使用
        """
        self.use_random = use_random
        self.dim = dim
        self.doc_embeddings = None

        if not use_random:
            from sentence_transformers import SentenceTransformer
            self.model = SentenceTransformer('all-MiniLM-L6-v2')

    def _embed(self, texts: List[str]) -> np.ndarray:
        """テキストをベクトルに変換"""
        if self.use_random:
            np.random.seed(42)
            embeddings = np.random.randn(len(texts), self.dim)
        else:
            embeddings = self.model.encode(texts, convert_to_numpy=True)
        # L2正規化
        norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
        return embeddings / norms

    def fit(self, corpus: List[str]):
        """コーパスの埋め込みベクトルを事前計算"""
        self.doc_embeddings = self._embed(corpus)

    def get_scores(self, query: str) -> np.ndarray:
        """クエリに対する全文書のコサイン類似度を計算"""
        query_emb = self._embed([query])  # (1, dim)
        # 正規化済みなので内積 = コサイン類似度
        scores = (self.doc_embeddings @ query_emb.T).flatten()
        return scores

    def get_top_k(self, query: str, k: int = 10) -> List[Tuple[int, float]]:
        """上位k件の(文書インデックス, スコア)を返す"""
        scores = self.get_scores(query)
        top_indices = np.argsort(scores)[::-1][:k]
        return [(int(idx), float(scores[idx])) for idx in top_indices]

DenseRetriever は、内部で全てのベクトルをL2正規化しているため、内積の計算がそのままコサイン類似度になります。use_random=True を指定すれば、sentence-transformers のインストール不要でコードの構造を確認できます。

融合アルゴリズムの実装

BM25と密ベクトル検索の結果を融合するRRFとConvex Combinationを実装します。

def reciprocal_rank_fusion(
    rankings: List[List[Tuple[int, float]]],
    k: int = 60
) -> List[Tuple[int, float]]:
    """
    Reciprocal Rank Fusion

    Parameters
    ----------
    rankings : list of list of (doc_id, score)
        各検索システムのランキング結果
    k : int
        RRFの定数パラメータ(デフォルト: 60)

    Returns
    -------
    list of (doc_id, rrf_score)
        RRFスコアの降順でソートされた結果
    """
    rrf_scores: Dict[int, float] = {}

    for ranking in rankings:
        for rank, (doc_id, _score) in enumerate(ranking, start=1):
            if doc_id not in rrf_scores:
                rrf_scores[doc_id] = 0.0
            rrf_scores[doc_id] += 1.0 / (k + rank)

    # スコア降順でソート
    sorted_results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
    return sorted_results


def convex_combination(
    results_list: List[List[Tuple[int, float]]],
    alpha: float = 0.5
) -> List[Tuple[int, float]]:
    """
    Convex Combination(Min-Max正規化 + 重み付き合計)

    Parameters
    ----------
    results_list : list of list of (doc_id, score)
        [密ベクトル検索結果, BM25結果] の順
    alpha : float
        密ベクトル検索の重み(0〜1)

    Returns
    -------
    list of (doc_id, combined_score)
    """
    weights = [alpha, 1 - alpha]
    normalized_scores: Dict[int, float] = {}

    for w, results in zip(weights, results_list):
        if not results:
            continue
        scores = [s for _, s in results]
        s_min, s_max = min(scores), max(scores)
        denom = s_max - s_min if s_max != s_min else 1.0

        for doc_id, score in results:
            norm_score = (score - s_min) / denom
            if doc_id not in normalized_scores:
                normalized_scores[doc_id] = 0.0
            normalized_scores[doc_id] += w * norm_score

    sorted_results = sorted(normalized_scores.items(), key=lambda x: x[1], reverse=True)
    return sorted_results

reciprocal_rank_fusion 関数は、複数のランキングを受け取り、各文書のRRFスコアを計算して降順に返します。スコアそのものは使わず、ランキングの順位のみを使っている点がポイントです。convex_combination 関数は、Min-Max正規化を適用した後に重み付き合計を行います。

HybridSearcherクラス

上記のコンポーネントを統合するクラスを作成します。

class HybridSearcher:
    """BM25 + 密ベクトル検索のハイブリッド検索"""

    def __init__(self, use_random_embeddings: bool = False):
        self.bm25 = BM25(k1=1.5, b=0.75)
        self.dense = DenseRetriever(use_random=use_random_embeddings)
        self.corpus: List[str] = []

    def fit(self, corpus: List[str]):
        """コーパスのインデックスを構築"""
        self.corpus = corpus
        self.bm25.fit(corpus)
        self.dense.fit(corpus)

    def search(
        self,
        query: str,
        top_k: int = 10,
        method: str = "rrf",
        alpha: float = 0.5,
        rrf_k: int = 60
    ) -> List[Tuple[int, float, str]]:
        """
        ハイブリッド検索を実行

        Returns
        -------
        list of (doc_id, score, document_text)
        """
        bm25_results = self.bm25.get_top_k(query, k=top_k)
        dense_results = self.dense.get_top_k(query, k=top_k)

        if method == "rrf":
            fused = reciprocal_rank_fusion(
                [bm25_results, dense_results], k=rrf_k
            )
        elif method == "convex":
            fused = convex_combination(
                [dense_results, bm25_results], alpha=alpha
            )
        else:
            raise ValueError(f"Unknown method: {method}")

        results = [
            (doc_id, score, self.corpus[doc_id])
            for doc_id, score in fused[:top_k]
        ]
        return results

HybridSearcher はBM25と密ベクトル検索を内部に持ち、search メソッドで融合手法を切り替えられる設計です。method="rrf" ならRRF、method="convex" ならConvex Combinationを使います。

これでHybrid Searchの基本的な実装が揃いました。次は、合成データを使って単独検索とハイブリッド検索の精度差を実験で確認しましょう。

実験: 単独検索 vs ハイブリッド検索の精度比較

実験設計

Hybrid Searchの効果を定量的に検証するため、合成データセットを作成して実験します。BM25が得意なケース、密ベクトル検索が得意なケース、両方が必要なケースを意図的に含めることで、Hybrid Searchの優位性を明確にします。

評価指標にはMRR(Mean Reciprocal Rank)を使用します。MRRは、正解文書が検索結果の何位に現れるかを $\frac{1}{\text{rank}}$ で測り、全クエリで平均を取ったものです。

$$ \text{MRR} = \frac{1}{|Q|} \sum_{i=1}^{|Q|} \frac{1}{\text{rank}_i} $$

正解文書が1位なら $1.0$、2位なら $0.5$、3位なら $0.333\ldots$ です。MRRが$1.0$に近いほど、正解文書が常に上位に来ていることを意味します。

import numpy as np
from typing import List, Tuple, Dict
import re
from collections import Counter


# ---- BM25 クラス(上と同一) ----
class BM25:
    def __init__(self, k1=1.5, b=0.75):
        self.k1, self.b = k1, b
        self.corpus_size = 0
        self.avgdl = 0.0
        self.doc_freqs, self.doc_lens, self.term_freqs = {}, [], []

    def _tokenize(self, text):
        return re.findall(r'[a-z0-9]+|[^\s\x00-\x7F]', text.lower())

    def fit(self, corpus):
        self.corpus_size = len(corpus)
        self.doc_lens, self.term_freqs = [], []
        for doc in corpus:
            tokens = self._tokenize(doc)
            self.doc_lens.append(len(tokens))
            tf = Counter(tokens)
            self.term_freqs.append(dict(tf))
            for t in set(tokens):
                self.doc_freqs[t] = self.doc_freqs.get(t, 0) + 1
        self.avgdl = sum(self.doc_lens) / self.corpus_size

    def _idf(self, term):
        n = self.doc_freqs.get(term, 0)
        return np.log((self.corpus_size - n + 0.5) / (n + 0.5) + 1.0)

    def get_scores(self, query):
        scores = np.zeros(self.corpus_size)
        for q in self._tokenize(query):
            idf = self._idf(q)
            for i in range(self.corpus_size):
                tf = self.term_freqs[i].get(q, 0)
                dl = self.doc_lens[i]
                num = tf * (self.k1 + 1)
                den = tf + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
                scores[i] += idf * num / den
        return scores

    def get_top_k(self, query, k=10):
        scores = self.get_scores(query)
        idx = np.argsort(scores)[::-1][:k]
        return [(int(i), float(scores[i])) for i in idx]


# ---- 簡易密ベクトル検索(TF-IDFベースの疑似意味検索) ----
class SimpleDenseRetriever:
    """
    sentence-transformers不要の疑似密ベクトル検索
    TF-IDFベクトルにランダム射影を加えて「意味検索的な」振る舞いを模擬
    """
    def __init__(self, dim=64, seed=42):
        self.dim = dim
        self.seed = seed
        self.doc_embeddings = None

    def _build_vocab(self, corpus):
        vocab = set()
        for doc in corpus:
            vocab.update(re.findall(r'[a-z0-9]+|[^\s\x00-\x7F]', doc.lower()))
        self.vocab = sorted(vocab)
        self.word2idx = {w: i for i, w in enumerate(self.vocab)}

    def _tfidf_vector(self, text, doc_freqs, n_docs):
        tokens = re.findall(r'[a-z0-9]+|[^\s\x00-\x7F]', text.lower())
        tf = Counter(tokens)
        vec = np.zeros(len(self.vocab))
        for t, c in tf.items():
            if t in self.word2idx:
                idf = np.log((n_docs + 1) / (doc_freqs.get(t, 0) + 1))
                vec[self.word2idx[t]] = c * idf
        return vec

    def fit(self, corpus):
        self._build_vocab(corpus)
        doc_freqs = {}
        for doc in corpus:
            for t in set(re.findall(r'[a-z0-9]+|[^\s\x00-\x7F]', doc.lower())):
                doc_freqs[t] = doc_freqs.get(t, 0) + 1
        self.doc_freqs = doc_freqs
        self.n_docs = len(corpus)

        # TF-IDFベクトルを計算
        tfidf_matrix = np.array([
            self._tfidf_vector(doc, doc_freqs, len(corpus)) for doc in corpus
        ])
        # ランダム射影で次元削減(意味空間の模擬)
        np.random.seed(self.seed)
        self.proj = np.random.randn(len(self.vocab), self.dim) / np.sqrt(self.dim)
        self.doc_embeddings = tfidf_matrix @ self.proj
        # L2正規化
        norms = np.linalg.norm(self.doc_embeddings, axis=1, keepdims=True)
        norms[norms == 0] = 1.0
        self.doc_embeddings /= norms

    def get_scores(self, query):
        vec = self._tfidf_vector(query, self.doc_freqs, self.n_docs)
        q_emb = vec @ self.proj
        q_norm = np.linalg.norm(q_emb)
        if q_norm > 0:
            q_emb /= q_norm
        return (self.doc_embeddings @ q_emb).flatten()

    def get_top_k(self, query, k=10):
        scores = self.get_scores(query)
        idx = np.argsort(scores)[::-1][:k]
        return [(int(i), float(scores[i])) for i in idx]


# ---- 融合関数 ----
def reciprocal_rank_fusion(rankings, k=60):
    rrf_scores = {}
    for ranking in rankings:
        for rank, (doc_id, _) in enumerate(ranking, start=1):
            rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)


def convex_combination(results_list, alpha=0.5):
    weights = [alpha, 1 - alpha]
    combined = {}
    for w, results in zip(weights, results_list):
        if not results:
            continue
        scores = [s for _, s in results]
        s_min, s_max = min(scores), max(scores)
        denom = s_max - s_min if s_max != s_min else 1.0
        for doc_id, score in results:
            norm = (score - s_min) / denom
            combined[doc_id] = combined.get(doc_id, 0.0) + w * norm
    return sorted(combined.items(), key=lambda x: x[1], reverse=True)


# ---- 合成データセット ----
corpus = [
    # 0-4: XGBoost関連
    "XGBoost v2.1 hyperparameter tuning guide for gradient boosting",
    "Gradient boosting theory and optimization techniques overview",
    "Random forest vs XGBoost performance comparison benchmark",
    "XGBoost feature importance and SHAP value interpretation",
    "Ensemble learning methods including bagging and boosting",
    # 5-9: 深層学習関連
    "PyTorch deep learning framework tutorial for beginners",
    "Convolutional neural network CNN image classification",
    "Transfer learning with pretrained models in computer vision",
    "Recurrent neural network RNN for sequence modeling",
    "Transformer architecture and self-attention mechanism",
    # 10-14: NLP関連
    "BERT fine-tuning for text classification NLP task",
    "Word embedding Word2Vec and GloVe comparison",
    "Sentiment analysis using natural language processing",
    "Named entity recognition NER with spaCy tutorial",
    "Text preprocessing tokenization and lemmatization guide",
    # 15-19: データ分析関連
    "Pandas DataFrame operations for data analysis",
    "Exploratory data analysis EDA visualization techniques",
    "Feature engineering for machine learning pipelines",
    "Data cleaning and handling missing values in datasets",
    "Statistical hypothesis testing t-test and chi-square",
]

# クエリと正解文書のペア
# (query, relevant_doc_ids)
queries_and_relevance = [
    # BM25が得意: キーワード完全一致
    ("XGBoost hyperparameter", [0, 3]),
    ("CNN image classification", [6, 7]),
    ("Pandas DataFrame", [15, 16]),
    ("spaCy NER tutorial", [13, 14]),
    # 密ベクトル検索が得意: 意味的一致
    ("gradient tree model tuning", [0, 1]),
    ("deep neural network for pictures", [6, 7]),
    ("analyzing text sentiment", [12, 10]),
    ("preparing data for ML", [17, 18]),
    # 両方が必要: キーワード + 意味
    ("XGBoost optimization best practices", [0, 1, 3]),
    ("NLP text feature extraction", [10, 14, 11]),
    ("ensemble model comparison benchmark", [4, 2, 1]),
    ("missing data preprocessing pipeline", [18, 17, 14]),
]


def compute_mrr(rankings, relevant_ids, top_k=10):
    """MRR(Mean Reciprocal Rank)を計算"""
    rrs = []
    for ranking, rel_ids in zip(rankings, relevant_ids):
        ranked_ids = [doc_id for doc_id, _ in ranking[:top_k]]
        rr = 0.0
        for rank, doc_id in enumerate(ranked_ids, start=1):
            if doc_id in rel_ids:
                rr = 1.0 / rank
                break
        rrs.append(rr)
    return np.mean(rrs)


# ---- 実験実行 ----
bm25 = BM25(k1=1.5, b=0.75)
bm25.fit(corpus)

dense = SimpleDenseRetriever(dim=64, seed=42)
dense.fit(corpus)

queries = [q for q, _ in queries_and_relevance]
relevance = [r for _, r in queries_and_relevance]
top_k = 10

# 各手法のランキングを取得
bm25_rankings = [bm25.get_top_k(q, k=top_k) for q in queries]
dense_rankings = [dense.get_top_k(q, k=top_k) for q in queries]
rrf_rankings = [
    reciprocal_rank_fusion([b, d], k=60)
    for b, d in zip(bm25_rankings, dense_rankings)
]
convex_rankings = [
    convex_combination([d, b], alpha=0.5)
    for b, d in zip(bm25_rankings, dense_rankings)
]

# MRR計算
mrr_bm25 = compute_mrr(bm25_rankings, relevance, top_k)
mrr_dense = compute_mrr(dense_rankings, relevance, top_k)
mrr_rrf = compute_mrr(rrf_rankings, relevance, top_k)
mrr_convex = compute_mrr(convex_rankings, relevance, top_k)

print("=== MRR(Mean Reciprocal Rank) ===")
print(f"BM25 単独:           {mrr_bm25:.4f}")
print(f"Dense 単独:          {mrr_dense:.4f}")
print(f"Hybrid (RRF):        {mrr_rrf:.4f}")
print(f"Hybrid (Convex 0.5): {mrr_convex:.4f}")

この実験コードは、sentence-transformersをインストールしなくても実行できるように、TF-IDFベクトルのランダム射影で密ベクトル検索を模擬しています。実際のプロダクション環境では SentenceTransformer 等の事前学習済みモデルを使うべきですが、Hybrid Searchの原理を検証するにはこの簡易版で十分です。

実行すると、以下のような出力が得られます(簡易密ベクトル検索のため、値は実行環境で若干異なる可能性があります)。

=== MRR(Mean Reciprocal Rank) ===
BM25 単独:           0.8750
Dense 単独:          0.5417
Hybrid (RRF):        0.8750
Hybrid (Convex 0.5): 0.7917

BM25が高いMRRを示しているのは、合成データのクエリの多くがキーワード一致で正解を特定できるように設計されているためです。簡易密ベクトル検索(ランダム射影ベース)は本物のsentence-transformersに比べて意味理解の能力が低いため、MRRが低くなっています。

重要なのは、RRFによるハイブリッド検索がBM25単独と同等以上のMRRを維持している点です。これは「良い結果を壊さない」というHybrid Searchの重要な性質です。片方の検索システムが強いケースでは、もう片方が足を引っ張ることなく、強い方の結果が保たれます。

クエリごとの詳細分析

各クエリの検索結果をより詳しく見るために、各手法のランキングを可視化します。

import matplotlib.pyplot as plt
import matplotlib
matplotlib.rcParams['font.size'] = 10

fig, axes = plt.subplots(3, 4, figsize=(18, 12))
fig.suptitle('Search Rankings by Query (Top-5)', fontsize=14, fontweight='bold')

query_labels = [q for q, _ in queries_and_relevance]
methods = {
    'BM25': bm25_rankings,
    'Dense': dense_rankings,
    'RRF': rrf_rankings,
}

for idx, (query, rel_ids) in enumerate(queries_and_relevance):
    ax = axes[idx // 4][idx % 4]
    method_names = list(methods.keys())
    top5_per_method = []

    for method_name in method_names:
        ranking = methods[method_name][idx]
        top5_ids = [doc_id for doc_id, _ in ranking[:5]]
        top5_per_method.append(top5_ids)

    # 各手法のTop-5をテーブル形式で表示
    cell_colors = []
    cell_text = []
    for m_idx, (m_name, top5) in enumerate(zip(method_names, top5_per_method)):
        row = []
        colors_row = []
        for rank_pos, doc_id in enumerate(top5):
            row.append(f"D{doc_id}")
            if doc_id in rel_ids:
                colors_row.append('#2ecc71')  # 正解: 緑
            else:
                colors_row.append('#ecf0f1')  # 不正解: 灰色
        cell_text.append(row)
        cell_colors.append(colors_row)

    ax.axis('off')
    table = ax.table(
        cellText=cell_text,
        rowLabels=method_names,
        colLabels=['1st', '2nd', '3rd', '4th', '5th'],
        cellColours=cell_colors,
        loc='center',
        cellLoc='center'
    )
    table.auto_set_font_size(False)
    table.set_fontsize(8)
    table.scale(1.0, 1.5)

    # クエリ名を短縮して表示
    short_query = query[:30] + "..." if len(query) > 30 else query
    ax.set_title(short_query, fontsize=9, pad=20)

plt.tight_layout(rect=[0, 0, 1, 0.95])
plt.savefig('hybrid_search_comparison.png', dpi=150, bbox_inches='tight')
plt.show()

このテーブル可視化では、各クエリについてBM25、Dense、RRFの上位5件を表示し、正解文書を緑色でハイライトしています。RRFの列を見ると、BM25とDenseのどちらかで正解文書が上位に来ていれば、RRFでも上位に残っていることがわかります。これがRRFの「良い結果を引き上げ、悪い結果を相殺する」という特性です。

パラメータ感度分析

RRFの $k$ パラメータとConvex Combinationの $\alpha$ パラメータが検索精度にどう影響するかを調べます。

import matplotlib.pyplot as plt
import numpy as np

# RRFのkパラメータの感度分析
k_values = [1, 5, 10, 20, 40, 60, 80, 100, 200]
mrr_by_k = []

for k_val in k_values:
    rrf_k_rankings = [
        reciprocal_rank_fusion([b, d], k=k_val)
        for b, d in zip(bm25_rankings, dense_rankings)
    ]
    mrr_val = compute_mrr(rrf_k_rankings, relevance, top_k)
    mrr_by_k.append(mrr_val)

# Convex Combinationのalphaパラメータの感度分析
alpha_values = np.linspace(0, 1, 21)
mrr_by_alpha = []

for alpha_val in alpha_values:
    convex_a_rankings = [
        convex_combination([d, b], alpha=alpha_val)
        for b, d in zip(bm25_rankings, dense_rankings)
    ]
    mrr_val = compute_mrr(convex_a_rankings, relevance, top_k)
    mrr_by_alpha.append(mrr_val)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# RRF k パラメータ
ax1.plot(k_values, mrr_by_k, 'o-', color='#3498db', linewidth=2, markersize=8)
ax1.axhline(y=mrr_bm25, color='#e74c3c', linestyle='--', label=f'BM25 only ({mrr_bm25:.3f})')
ax1.axhline(y=mrr_dense, color='#2ecc71', linestyle='--', label=f'Dense only ({mrr_dense:.3f})')
ax1.set_xlabel('RRF parameter k')
ax1.set_ylabel('MRR')
ax1.set_title('RRF: Sensitivity to k')
ax1.set_xscale('log')
ax1.legend()
ax1.grid(True, alpha=0.3)

# Convex Combination alpha パラメータ
ax2.plot(alpha_values, mrr_by_alpha, 'o-', color='#9b59b6', linewidth=2, markersize=6)
ax2.axhline(y=mrr_bm25, color='#e74c3c', linestyle='--', label=f'BM25 only ({mrr_bm25:.3f})')
ax2.axhline(y=mrr_dense, color='#2ecc71', linestyle='--', label=f'Dense only ({mrr_dense:.3f})')
ax2.set_xlabel('alpha (weight for dense retriever)')
ax2.set_ylabel('MRR')
ax2.set_title('Convex Combination: Sensitivity to alpha')
ax2.legend()
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('hybrid_search_sensitivity.png', dpi=150, bbox_inches='tight')
plt.show()

左のグラフ(RRFの $k$ 感度)からは、$k$ の値が広い範囲(10〜200程度)にわたってMRRが安定していることが読み取れます。これはRRFの大きな利点で、$k = 60$ という推奨値から多少ずれても性能が大きく劣化しません。つまり、RRFはパラメータチューニングがほとんど不要な手法と言えます。

右のグラフ(Convex Combinationの $\alpha$ 感度)からは、$\alpha$ の値によってMRRが大きく変動することがわかります。$\alpha = 0$(BM25のみ)付近でMRRが高く、$\alpha = 1$(Dense検索のみ)に近づくとMRRが低下する傾向が見られます。これは合成データにおいてBM25が強いためですが、実データでは $\alpha = 0.3 \sim 0.7$ の範囲が最適になることが多いです。Convex CombinationはRRFに比べて $\alpha$ の選択に敏感であり、データセットに応じたチューニングが必要です。

BM25のTF飽和を可視化する

理論で説明したBM25のTF飽和挙動を、実際にプロットして確認しましょう。

import numpy as np
import matplotlib.pyplot as plt

# TF飽和の可視化
tf_values = np.linspace(0, 20, 200)
k1_values = [0.5, 1.2, 1.5, 2.0, 5.0]

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# 左: k1による飽和の違い
for k1 in k1_values:
    bm25_tf = tf_values * (k1 + 1) / (tf_values + k1)
    ax1.plot(tf_values, bm25_tf, label=f'k1={k1}', linewidth=2)

# TF-IDF(線形)を比較用にプロット
ax1.plot(tf_values, tf_values, '--', color='gray', alpha=0.5, label='TF-IDF (linear)')
ax1.set_xlabel('Term Frequency (tf)')
ax1.set_ylabel('TF Component Score')
ax1.set_title('BM25 TF Saturation for Different k1')
ax1.legend()
ax1.set_xlim(0, 20)
ax1.set_ylim(0, 7)
ax1.grid(True, alpha=0.3)

# 右: 文書長正規化の効果
dl_ratios = np.linspace(0.2, 3.0, 200)  # |d| / avgdl
b_values = [0, 0.25, 0.5, 0.75, 1.0]
k1_fixed = 1.5
tf_fixed = 3

for b in b_values:
    denominator = tf_fixed + k1_fixed * (1 - b + b * dl_ratios)
    score = tf_fixed * (k1_fixed + 1) / denominator
    ax2.plot(dl_ratios, score, label=f'b={b}', linewidth=2)

ax2.axvline(x=1.0, color='gray', linestyle=':', alpha=0.5, label='avg doc length')
ax2.set_xlabel('Document Length / Average Document Length')
ax2.set_ylabel('TF Component Score (tf=3)')
ax2.set_title('BM25 Document Length Normalization')
ax2.legend()
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('bm25_parameters.png', dpi=150, bbox_inches='tight')
plt.show()

左のグラフからは、BM25のTF成分がTF-IDF(灰色の破線)のように線形に増加するのではなく、$k_1 + 1$ に向かって飽和することが明確に見て取れます。$k_1 = 0.5$ では非常に速く飽和し、tf=3程度でほぼ上限に達します。$k_1 = 5.0$ でも tf=20 の時点でスコアは6.0(上限の $5.0 + 1 = 6.0$)にかなり近づいています。TF-IDFの線形成長と比較すると、BM25の「出現回数の情報価値は逓減する」という設計思想が視覚的に理解できます。

右のグラフは文書長正規化の効果を示しています。$b = 0$(正規化なし)では文書長に関わらずスコアが一定です。$b = 1.0$(完全正規化)では、平均の2倍の長さの文書は、平均長の文書に比べてスコアが大きく低下しています。$b = 0.75$(BM25の標準値)はその中間で、長い文書のスコアを適度に割り引きつつ、極端な補正は避けるバランスの取れた設定であることがわかります。

この可視化から、BM25の2つのパラメータが検索の公平性にどう寄与しているかが実感できたかと思います。次に、これらの手法が実用の検索システムでどのように実装されているかを見てみましょう。

実用システムでのHybrid Searchサポート

主要な検索エンジン・ベクトルデータベースの対応状況

Hybrid Searchは理論的に優れているだけでなく、主要な検索エンジンやベクトルデータベースで既に実装が提供されています。

Elasticsearch / OpenSearch

Elasticsearchは伝統的にBM25ベースの全文検索エンジンですが、バージョン8.0以降でベクトル検索(kNN search)をネイティブにサポートしました。Hybrid Searchは、BM25クエリとkNNクエリを組み合わせた bool クエリで実現できます。さらにOpenSearchでは、search_pipeline 機能を通じてRRFや正規化ベースの融合を宣言的に設定できます。

Qdrant

Qdrantはベクトル検索に特化したデータベースですが、バージョン1.7以降でSparse Vectorをネイティブにサポートし、密ベクトルとスパースベクトルの両方を同一コレクションに格納できます。Hybrid Searchは prefetchfusion パラメータを使って、RRFによる融合を1回のAPIコールで実行できます。

Weaviate

Weaviateは最も早い段階からHybrid Searchを組み込んだベクトルデータベースの一つです。hybrid 検索クエリを発行するだけで、内部的にBM25と密ベクトル検索を実行し、指定した $\alpha$ パラメータで融合した結果を返します。Weaviate v1.25以降ではRRFとRelative Score Fusion(相対スコア融合)の2つの融合アルゴリズムが選択可能です。

Pinecone

Pineconeもスパースベクトルと密ベクトルの両方を格納できる「Sparse-Dense」インデックスを提供しています。クエリ時に密ベクトルとスパースベクトルの両方を送信し、$\alpha$ パラメータで融合の重みを制御できます。

各システムの比較

システム 融合手法 Sparse Vector 設定の容易さ
Elasticsearch / OpenSearch RRF, Weighted BM25内蔵 やや複雑(パイプライン設定が必要)
Qdrant RRF ネイティブ対応 シンプル(prefetch + fusion)
Weaviate RRF, RSF BM25内蔵 非常にシンプル(hybrid クエリ)
Pinecone Weighted ネイティブ対応 シンプル(alpha パラメータ)

プロダクション環境での推奨構成

実際にHybrid Searchをプロダクション環境に導入する際の一般的な推奨構成を整理しておきます。

  1. スパース検索の選択: BM25が最も一般的です。日本語の場合はMeCabやSudachiでの形態素解析を前処理に加えます。英語ではPorter Stemmerやlemmatizationを適用します
  2. 密ベクトル検索のモデル選択: 多言語対応なら multilingual-e5-largebge-m3 が定番です。日本語特化なら intfloat/multilingual-e5-base もよく使われます
  3. 融合手法の選択: 特別な理由がなければRRF($k = 60$)を第一選択肢とします。データセットに応じた最適化が可能な場合は、Convex Combination の $\alpha$ をバリデーションセットで調整します
  4. リランキング(Re-ranking): Hybrid Searchの後段にCross-Encoderベースのリランカーを配置すると、さらに精度が向上します。Hybrid Searchで広く候補を集め(高再現率)、リランカーで精度を高める(高適合率)という二段構成が効果的です

Hybrid Searchの実用面を押さえたところで、本記事の内容を振り返りましょう。

まとめ

本記事では、密ベクトル検索とBM25スパース検索を融合するHybrid Searchについて解説しました。

  • BM25はキーワードの完全一致に基づく検索で、TFの飽和(パラメータ $k_1$)と文書長正規化(パラメータ $b$)によりTF-IDFを改良した手法です。固有名詞、型番、略語の検索に強い一方、同義語や意味の揺れに対応できません
  • 密ベクトル検索はニューラルエンコーダでテキストをベクトル化し、コサイン類似度で意味的な類似度を測る手法です。言い換えや概念的な類似性を捉えられますが、固有名詞やレアな文字列の完全一致には弱い面があります
  • Reciprocal Rank Fusion(RRF)は、スコアではなくランクに基づいて複数の検索結果を融合する手法で、$\text{RRF}(d) = \sum 1/(k + \text{rank}_i(d))$ というシンプルな式で定義されます。スコアのスケール差に依存せず、パラメータ $k = 60$ で広い範囲のタスクに対応できます
  • Convex Combinationはスコアを正規化してから重み付き合計する手法で、$\alpha$ の選択によって柔軟に調整できますが、チューニングが必要です
  • 実験では、Hybrid Search(特にRRF)が単独検索と同等以上のMRRを達成し、「良い結果を壊さない」性質を確認しました

Hybrid Searchは、RAGパイプラインの検索精度を向上させる最も実践的な手法の一つです。Elasticsearch、Qdrant、Weaviateなどの主要な検索エンジンで既にネイティブサポートされており、導入のハードルも低くなっています。

次のステップとして、以下の記事も参考にしてください。

画像なし
RAG(検索拡張生成)の仕組みとPython実装
Hybrid Searchを活用するRAGパイプラインの全体像を理解します
画像なし
ベクトルデータベースの仕組み — FAISS・HNSWの理論とPython実装
密ベクトル検索の高速化技術であるANNの理論を深く学びます
画像なし
埋め込みベクトルと類似度検索の理論とPython実装
密ベクトル検索の数学的基盤であるコサイン類似度と埋め込みの理論を学びます