← 一覧へ

ベクトル検索だけでは足りない 第2回:BM25 / RRF / ハイブリッド検索

この記事の読み方
RAG を作ると、まずベクトル検索を入れたくなります。

RAG を作ると、まずベクトル検索を入れたくなります。

文章を embedding にして、近いものを探す。
意味が似ている文書を拾える。
キーワードが完全に一致しなくても、関連する断片を見つけられる。

これはたしかに強いです。

でも、ここで一つ大きな落とし穴があります。
ベクトル検索は「意味が近いもの」を探すのは得意ですが、「まさにその文字列が入っているもの」を探すのは、いつも得意とは限りません。

たとえば、次のような問いを考えます。

ERR_PAYMENT_GATEWAY_TIMEOUT が出たときの復旧手順はどれか。
v3.2 のロールバック手順はどこにあるか。
payment_v2_enforce という feature flag を無効化する条件は何か。

この場合、ほしいのは「だいたい似た話」ではありません。
エラーコード、バージョン番号、機能名が合っている文書です。

RAG の事故は、ここで起きます。

似ているが違う文書を拾う。
有効化の手順と無効化の手順を混ぜる。
v3.1v3.2 を近いものとして扱う。
エラーコードの前半が似ているだけで、別の障害対応を持ってくる。

こういう間違いは、モデルが賢いかどうかだけでは防げません。
そもそも、モデルに渡す前の検索で違うものを持ってきているからです。

第2回では、RAG の土台になる検索を見ます。
キーワード検索、ベクトル検索、そして RRF によるハイブリッド検索です。

まず分けるべきなのは「同じ」と「近い」

検索でいう「関連している」には、少なくとも 2 種類あります。

1 つは、同じものを探すことです。
エラーコード、関数名、商品名、バージョン、設定キー、文書 ID。
これは、文字列そのものに意味があります。

もう 1 つは、近い意味を探すことです。
「障害時の切り戻し」と書いてある文書を、「rollback」という言葉から見つける。
「請求が二重に走る」と書かれた報告を、「決済の重複」という問いから見つける。
これは、表現が違っても意味が近ければよい。

この 2 つは、似ているようで違います。

前者は identity です。
「これそのもの」を探しています。

後者は similarity です。
「これに近いもの」を探しています。

BM25 のようなキーワード検索は、identity に強い。
ベクトル検索は、similarity に強い。

ここを混ぜて考えると、RAG は急に不安定になります。

BM25 は古いが、古びていない

BM25 は、検索の世界ではかなり長く使われてきた手法です。
いまでも Elasticsearch などの検索基盤で重要な役割を持っています。

仕組みを大ざっぱに言うと、BM25 は「文書の中に出てくる言葉」を見ます。
ただし、単純に出現回数を数えるだけではありません。

大事なのは、珍しい言葉です。

どの文書にも出てくる言葉は、あまり手がかりになりません。
「システム」「対応」「確認」だけでは、どの文書を探しているのか分かりません。

逆に、ほとんど出てこない言葉は強い手がかりになります。
ERR_PAYMENT_GATEWAY_TIMEOUTpayment_v2_enforce のような文字列は、それが出てくるだけでかなり情報量があります。

BM25 は、こうした珍しい語を強く見ます。
これが IDF の感覚です。

もう一つ大事なのは、同じ言葉が何度も出てきたからといって、無限に評価を上げないことです。

ある文書に rollback が 1 回出てくる。
別の文書に rollback が 30 回出てくる。

30 回出ているほうが重要そうに見えます。
でも、30 倍重要とは言えません。

ある程度出ていれば、「この文書は rollback について語っている」という判断には十分です。
それ以上は、重みをゆっくり増やすだけでよい。

BM25 の term frequency saturation は、この感覚に近いです。
単語の出現回数をそのまま信じず、あるところから伸びを鈍らせます。

つまり BM25 は、雑なキーワード一致ではありません。
珍しい語を強く見て、長い文書が有利になりすぎないようにし、同じ語の連打も過大評価しない。

古い技術に見えますが、RAG ではむしろここが効きます。

なぜなら、RAG の入力には、バージョン、エラーコード、設定名、関数名、製品名のような「意味で丸めてはいけないもの」が多いからです。

ベクトル検索は意味を丸める

ベクトル検索は、文章を固定長の数値に変換します。
その数値どうしの近さを見て、意味が近い文書を探します。

これはとても便利です。

ユーザーが使った言葉と、文書に書かれている言葉が違っても拾える。
表現のゆれに強い。
自然文の質問に対して、候補を広く集められる。

ただし、ここには避けられない性質があります。

長い文章を固定長のベクトルにするということは、情報を圧縮するということです。
圧縮する以上、細部はどこかで丸まります。

たとえば、有効化手順と無効化手順の文書があるとします。
どちらも同じ機能名、同じ画面名、同じ設定項目、同じ注意事項を含んでいる。
違うのは、enable なのか disable なのかです。

人間にとっては、ここが決定的です。
でも、文書全体として見ると、2 つはかなり似ています。

ベクトル空間で近くなるのは、ある意味では自然です。
それは embedding の失敗というより、圧縮の性質です。

だから、ベクトル検索だけで RAG を作ると、こういう事故が起きます。

似ているが、答えとしては違う。
話題は近いが、条件が違う。
文脈は同じだが、操作が逆。

LLM に渡す前にここで混ざると、生成側はかなり苦しくなります。
モデルは「それらしい根拠」を見てしまうからです。

どちらが正しいかではない

ここで大事なのは、BM25 とベクトル検索のどちらが上か、という話ではありません。

見ているものが違います。

BM25 は、珍しい文字列に敏感です。
その代わり、言い換えには弱い。

ベクトル検索は、意味の近さに強い。
その代わり、細かい識別子を丸めることがあります。

たとえば、問いがこうだったとします。

v3.2 のデプロイを切り戻す手順を探したい。

この問いには、2 つの成分があります。

切り戻す は意味の問題です。
文書側では rollback と書かれているかもしれないし、復旧revert と書かれているかもしれません。

一方で、v3.2 は identity の問題です。
v3.1 でも v3.3 でもありません。

つまり、本当の検索クエリはだいたい混ざっています。
一部は意味で探したい。
一部は文字列で外したくない。

だからハイブリッド検索が必要になります。

スコアは混ぜにくい

では、BM25 とベクトル検索の結果をどう混ぜるか。

最初に思いつくのは、点数を足すことです。

BM25 のスコアを 0.5 倍して、ベクトル類似度を 0.5 倍して足す。
あるいは、0.7 対 0.3 のように重みを決める。

でも、これは簡単ではありません。

BM25 のスコアと、cosine similarity のスコアは、そもそも同じ物差しではありません。
片方はキーワード一致から出た検索スコアです。
もう片方はベクトル空間上の近さです。

数字として大きく見えても、意味は違います。
BM25 の検索スコアと cosine similarity を、そのまま足してよいとは言えません。

さらに、重みはクエリによって変わります。

エラーコードを探すなら BM25 を強くしたい。
「この障害に近い過去事例」を探すならベクトル検索を強くしたい。
毎回それを正しく調整するのは、現実のシステムではかなり難しいです。

そこで出てくるのが RRF です。

RRF は「点数」ではなく「順位」を混ぜる

RRF は Reciprocal Rank Fusion の略です。
複数の検索結果を、点数ではなく順位で融合します。

考え方は単純です。

BM25 が上位に出した文書。
ベクトル検索も上位に出した文書。
このように複数の検索方法で上に来る文書は、信頼してよさそうです。

逆に、片方だけで突然 1 位に来た文書は、少し慎重に見たい。
それが本当に強い根拠なのか、片方の検索方法の癖なのか分からないからです。

RRF の基本形は、次のような式です。

score(d) = Σ 1 / (k + rank(d))

文書 d が、それぞれの検索結果で何位にいるかを見る。
順位が高いほど大きな点をもらう。
複数の検索結果で上位にいれば、点が足される。

ここでよく使われる k=60 は、理論上の魔法の数字ではありません。
実験上、広い範囲で安定しやすい値として使われてきたものです。

大事なのは、RRF が絶対スコアを信じすぎないことです。
BM25 の点数とベクトル類似度を無理に同じ物差しにしない。
それぞれの検索方法が出した順位を見て、上位の一致を拾う。

この割り切りが、RAG ではかなり実用的です。

典型的な流れ

実際の RAG では、検索はだいたい次のような流れになります。

  1. BM25 で候補を拾う
  2. ベクトル検索で候補を拾う
  3. RRF で候補をまとめる
  4. 必要なら reranker で並べ直す
  5. 上位だけを LLM に渡す

BM25 は、エラーコードや固有名詞を拾う。
ベクトル検索は、言い換えや意味の近い文書を拾う。
RRF は、両方の結果を順位としてまとめる。

そのあとに reranker を置くこともあります。
reranker は、クエリと文書を一緒に読ませて、より細かく関係を判定します。

ただし、reranker は重いです。
最初から全ドキュメントにかけるものではありません。
BM25 とベクトル検索で候補を絞り、RRF でまとめ、その後の少数候補にだけ使う。

この順番には理由があります。

安い方法で広く拾い、順位を整え、最後に重い方法で細かく見る。
RAG の検索部分は、この段階設計がかなり重要です。

ハイブリッド検索にも失敗はある

もちろん、ハイブリッド検索にすればそれで終わり、ではありません。

たとえば、完全にエラーコードだけを探している場合。
このときは、BM25 だけのほうがきれいに当たることがあります。

ベクトル検索を混ぜると、似た障害名や近い説明文が候補に入ってくる。
人間が読むならまだよくても、LLM に渡すと雑音になります。

また、BM25 とベクトル検索がほぼ同じ順位を返す場合もあります。
そのとき RRF だけでは差がつきにくい。
実装側で、同点のときは BM25 を優先する、日付を見る、文書の信頼度を見る、といったルールが必要になることもあります。

つまり、RRF は便利な部品ですが、判断を全部肩代わりしてくれるわけではありません。

検索で最初に見るべきなのは、クエリの性質です。

識別子を探しているのか。
意味の近い事例を探しているのか。
その両方なのか。

ここを見ないまま「とりあえずベクトル検索」や「とりあえずハイブリッド」にすると、原因の分からない失敗が増えます。

RAG の検索は、答えの品質そのものになる

昔の検索結果は、人間が読みました。
多少順位が悪くても、人間がスクロールして選び直せます。

RAG では、検索結果がそのまま LLM の入力になります。
上位に入った文書が、答えの根拠になります。

つまり、検索の失敗は、そのまま回答の失敗になります。

ここが、普通の検索エンジンと RAG の大きな違いです。

RAG で「モデルが間違えた」と見える問題の一部は、実は検索の問題です。
必要な文書が入っていない。
似ているが違う文書が入っている。
重要な識別子を落としている。
雑音が多すぎる。

生成を直す前に、まず検索を見るべき場面は多いです。

そして検索を見るときは、BM25 とベクトル検索を競わせるのではなく、役割を分けて考える必要があります。

BM25 は、文字列の鋭さを持っている。
ベクトル検索は、意味の広がりを持っている。
RRF は、その 2 つを無理に同じ点数へ押し込まず、順位の合意として扱う。

この 3 つを分けて考えるだけで、RAG の設計はかなり見通しがよくなります。

次回は、ここから少し逆方向に進みます。
本当にベクトル検索や索引が必要なのか。
Agent が何度も探し直せるなら、grep やファイル検索のような低いレベルの道具のほうが強い場面もあります。

第3回では、grep とベクトル検索の使い分けを扱います。
検索アルゴリズムだけでなく、Agent にどう結果を見せるかまで含めて考えます。

―― AI未来編集室「AIウォッチ」

← 一覧へ