RAG 上級講座 #3 ハイブリッド検索 — ベクトルとキーワードの組み合わせ

読了 5分

第2回でチャンクを整えたので、次は探し方の番です。これまでの検索は埋め込みの類似度、つまり意味検索の一本だけでした。意味検索には「返金」と「決済キャンセル」を同じ意味として結びつける強みがありますが、弱点もはっきりしています。ORD-2026-0001 のようなコード、製品名、人名のように、正確にその文字列を見つける必要がある質問で外します。第1回の診断で固有名詞の質問だけが目立って失敗していたなら、今回がその処方箋です。

二つの検索の性格 #

ベクトル(意味)検索キーワード検索
強み同義語、別の言い回し(「返金」=「決済キャンセル」)正確な一致(コード、固有名詞、略語)
弱み希少なトークンが埋め込みに埋もれる言い回しが違うと見つけられない
代表的なアルゴリズム埋め込み + コサイン類似度BM25

ハイブリッド検索(hybrid search)は、この二つを同時に走らせて結果を統合する方式です。どちらかが取りこぼしてももう一方が拾ってくれるので、質問のタイプによってばらついていた検索が安定します。

BM25 キーワード検索を作る #

BM25 はキーワード検索の標準アルゴリズムです。単語が文書にどれだけ頻繁に現れるか(頻度)と、その単語がどれだけ希少か(逆文書頻度)を合わせて反映し、スコアを付けます。rank_bm25 パッケージですぐに使えます。

terminal
pip install rank_bm25
bm25_search.py
from rank_bm25 import BM25Okapi

# トークン化: この例は空白区切り。日本語は形態素解析器(MeCab など)でのトークン化が前提になる。
tokenized = [chunk["text"].split() for chunk in chunks]
bm25 = BM25Okapi(tokenized)

def keyword_search(question: str, top_k: int = 20) -> list:
    scores = bm25.get_scores(question.split())
    ranked = sorted(range(len(chunks)), key=lambda i: scores[i], reverse=True)
    return ranked[:top_k]   # チャンクのインデックスのリスト

この例の空白区切りは、単語が空白で区切られる言語を想定したものです。日本語はそもそも単語の間に空白がないため、空白区切りではまともにトークン化できません。実際のサービスでは、MeCab などの形態素解析器でトークン化することが BM25 の品質に大きな差を生みます。

RRF で結果を統合する #

ベクトル検索と BM25 はスコアの単位が違うため(コサイン類似度は0〜1、BM25 は上限なし)、スコアを直接足すことはできません。そこでスコアの代わりに順位で統合します。標準的な方法が RRF(Reciprocal Rank Fusion、相互順位融合)です。各検索での順位を逆数に変えて足し合わせる、単純ですが実績のある方式です。

rrf_fusion.py
def rrf(rankings: list, k: int = 60, top_k: int = 5) -> list:
    """rankings: 各検索器が返したチャンクのインデックスのリスト。"""
    scores = {}
    for ranked in rankings:
        for rank, idx in enumerate(ranked):
            scores[idx] = scores.get(idx, 0) + 1 / (k + rank + 1)
    fused = sorted(scores, key=scores.get, reverse=True)
    return fused[:top_k]

def hybrid_search(question: str, top_k: int = 5) -> list:
    vec = vector_search_ids(question, top_k=20)     # ベクトル検索の上位20
    kw = keyword_search(question, top_k=20)         # BM25 の上位20
    return [chunks[i] for i in rrf([vec, kw], top_k=top_k)]

定数 k=60 は慣例的なデフォルト値で、上位の順位差をなだらかに反映する役割を持ちます。ほとんどの場合そのままで構いません。注目すべきは、各検索から最終件数よりずっと多い20件ずつを取ってきてから融合している点です。両方の検索で中位のチャンクが、融合後に上位へ浮かび上がることがあるからです。

いつ効果があり、いつないのか #

ハイブリッド検索の効果は、質問の分布にかかっています。

  • 効果が大きい場合 — 製品コード、エラーコード、人名・製品名、社内略語が入った質問が多いとき。こうしたトークンは埋め込み空間での弁別力が弱く、ベクトル検索がよく取りこぼします。
  • 効果が小さい場合 — 質問がほとんど叙述型で、文書と語彙が異なるとき。このときキーワード検索が補えるものは少なく、むしろ第4回のクエリ変換のほうが大きな処方箋になります。

そのため導入の判断も、第1回のゴールデンセットで行います。ゴールデンセットを「固有名詞型の質問」と「叙述型の質問」に分けて測定すれば、ハイブリッドがどの質問群をどれだけ引き上げたかがそのまま見えます。

ベクトルデータベースを使っているなら #

上の実装は原理を示すコードですが、実際には自分で作る必要がない場合も多いです。LLM アプリ開発 第7回で紹介したベクトルデータベースの多く(Qdrant、pgvector の組み合わせなど)が、キーワード検索やハイブリッド検索を機能として提供しています。原理を知っていれば、それらの機能のオプションが何を調整しているのか読み取れますし、自前実装と組み込み機能のどちらを使うかも判断できます。

よくつまずくところ #

  • スコアをそのまま足す — コサイン類似度と BM25 のスコアは単位が違うため、直接足すと片方が支配します。順位ベース(RRF)で統合するか、正規化を挟みます。
  • 融合前の候補を狭く取る — 各検索から最終件数のぶんだけ取って融合すると、融合の意味が薄れます。候補は多めに(3〜4倍)取ってから統合します。
  • トークン化を忘れる — BM25 の品質はトークン化が半分を占めます。日本語は空白だけでは切れないため、形態素解析器を検討します。

まとめ #

今回はベクトル検索とキーワード検索を組み合わせました。

  • 意味検索は同義語に強く固有名詞に弱く、BM25 はその逆です。ハイブリッドが互いの穴を埋めます。
  • スコアの単位が異なる二つの結果は、RRF で順位ベースに融合します。候補は多めに取ってから統合します。
  • 効果は質問の分布にかかっているため、ゴールデンセットを質問タイプ別に分けて測定し、導入を判断します。

検索が持ってくる候補の質は上がりました。次は質問そのものと候補の順序を磨く番です。次回の「RAG 上級講座 #4 クエリ変換とリランキング」で、検索の前段と後段を仕上げます。

X