0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ハイブリッド検索を実測したらBM25に負けた — RRFが順位を下げる仕組みと対策

0
Posted at

RAG(検索拡張生成)の検索精度を上げる手法として「ハイブリッド検索」がよく紹介されます。ベクトル検索とキーワード検索(BM25)を組み合わせ、RRF(Reciprocal Rank Fusion)で順位を統合する――という構成です。

ただ、実際に手を動かして測ってみると、「ハイブリッドにすれば常に精度が上がる」わけではないという結果になりました。本記事では、ローカルの埋め込みモデルを使って実際に測定した数字を出しながら、どこで何が起きているのかを整理します。

結論(先に数字)

8件の文書・6問のクエリという小規模な検証ですが、実測値はこうなりました。

  • 識別子型クエリ(4問): BM25 4/4(MRR 1.000) / ベクトル 3/4(0.750) / ハイブリッド 4/4(0.875)
  • 意味型クエリ(2問): BM25 2/2(MRR 1.000) / ベクトル 1/2(0.500) / ハイブリッド 1/2(0.500)
  • 全体(6問): BM25 6/6(MRR 1.000) / ベクトル 4/6(0.667) / ハイブリッド 5/6(0.750)

小規模コーパスでは、BM25単体が最も強く、ナイーブなRRF統合はBM25の正解をむしろ押し下げました。以下、なぜそうなるのかを機構から見ていきます。

検証環境

埋め込みはローカルのOllama(nomic-embed-text)を使用しました。外部APIキーなしで完結します。

ollama pull nomic-embed-text

文書集合は「隣接するエラーコード」を意図的に並べています。ベクトル検索が識別子に弱いという主張を検証するには、意味的に近いが答えが違う文書(デコイ)を置く必要があるためです。

DOCS = [
    "エラーコードE-4021の対処法は、認証トークンの再発行です。接続設定を確認してください",   # gold
    "エラーコードE-4020の対処法は、ネットワーク接続の確認と再接続です。タイムアウトが原因", # デコイ
    "エラーコードE-4022の対処法は、メモリ割り当ての増加です。リソース不足が原因",           # デコイ
    "エラーコードE-4012の対処法は、サーバー証明書の更新です。有効期限切れが原因",           # デコイ
    "認証に失敗したときはアカウントの資格情報を再設定する必要がある",                        # 意味的言い換え
]

実装: BM25と埋め込み検索

BM25はTF・IDF・文書長正規化を組み合わせたスコアリングです。

import math
from collections import Counter

class BM25:
    def __init__(self, corpus, k1=1.5, b=0.75):
        self.k1, self.b = k1, b
        self.docs = [Counter(d) for d in corpus]
        self.lengths = [len(d) for d in corpus]
        self.avgdl = sum(self.lengths) / len(self.lengths)
        df = Counter()
        for d in self.docs:
            df.update(d.keys())
        n = len(corpus)
        self.idf = {w: math.log((n - c + 0.5) / (c + 0.5) + 1.0)
                    for w, c in df.items()}

    def scores(self, query):
        out = []
        for d, dl in zip(self.docs, self.lengths):
            s = 0.0
            for w in query:
                tf = d.get(w, 0)
                if not tf:
                    continue
                s += self.idf.get(w, 0.0) * tf * (self.k1 + 1) / (
                    tf + self.k1 * (1 - self.b + self.b * dl / self.avgdl))
            out.append(s)
        return out

埋め込み検索はコサイン類似度です。正規化してから内積を取ります。

import json, urllib.request
import numpy as np

def embed(texts, model="nomic-embed-text"):
    out = []
    for t in texts:
        req = urllib.request.Request(
            "http://localhost:11434/api/embeddings",
            data=json.dumps({"model": model, "prompt": t}).encode(),
            headers={"Content-Type": "application/json"},
        )
        with urllib.request.urlopen(req, timeout=120) as r:
            out.append(json.loads(r.read())["embedding"])
    return np.array(out)

EMB = embed(DOCS)
EMB_N = EMB / np.linalg.norm(EMB, axis=1, keepdims=True)

RRFはスコアではなく「順位」を統合します。スケール差を吸収できるのが利点です。

def rrf(rank_lists, k=60):
    fused = {}
    for ranks in rank_lists:
        for rank, idx in enumerate(ranks, start=1):
            fused[idx] = fused.get(idx, 0.0) + 1.0 / (k + rank)
    return sorted(fused.items(), key=lambda x: -x[1])

測定結果で見えたこと

1. ベクトル検索は識別子で順位を落とした

E-4020が出たときの対応は というクエリの結果です。

  • BM25: 正解を 1位
  • ベクトル検索: 正解を 5位(上位は 認証に失敗したときは… など意味的に近い文書)
  • ハイブリッド: 正解を 2位

E-4020とE-4021は埋め込み空間で近くに配置されるため、ベクトル検索は「エラーコードの話」という意味的なまとまりを捉えつつ、どのコードかを取り違えます。識別子の正確な一致が要る場面では、この性質がそのまま誤検索になります。

2. ナイーブなRRFは強いシグナルを薄めた

ここが今回いちばんの収穫でした。ログインできないときはどうすればいい というクエリです。

  • BM25: 正解を 1位
  • ベクトル検索: 正解を 8位
  • ハイブリッド: 正解を 4位

BM25が1位に挙げた文書が、ベクトル検索で8位だったため、RRFで足し合わせた結果として順位が下がりました。RRFは 1/(k+rank) を加算する仕組みなので、片方の検索器が正解を下位に置くと、もう片方の1位という強いシグナルが平均化されてしまうのです。

つまり「両方の検索器が同意した文書が上位に来る」というRRFの長所は、裏返すと「片方しか正解を出せないクエリでは順位が沈む」という短所になります。

3. 小規模コーパスではBM25が強い

8件という規模では、語彙の重なりが検索の主要な手がかりになります。BM25のIDFは「珍しい語ほど重要」を正しく評価するため、識別子を含むクエリで非常に強い。一方で埋め込み検索は、文書数が少ないと意味空間の分解能が足りず、隣接する識別子を分離しきれません。

では何をすべきか

測定結果を踏まえた実務的な判断です。

第一に、RRFに渡す前に候補数を絞る。 各検索器の上位5件だけをRRFに渡すと、片方が下位に置いた文書が統合に混入しにくくなります。全件を渡すと不要な順位情報が混ざります。

第二に、重みを付ける。 RRFの加算に重みを掛ければ、信頼できる検索器のシグナルを優先できます。

def rrf_weighted(rank_lists, weights, k=60):
    fused = {}
    for ranks, w in zip(rank_lists, weights):
        for rank, idx in enumerate(ranks, start=1):
            fused[idx] = fused.get(idx, 0.0) + w * (1.0 / (k + rank))
    return sorted(fused.items(), key=lambda x: -x[1])

第三に、クエリ種別で切り替える。 識別子(型番、エラーコード、条文番号、SKU)を含むクエリはBM25寄り、自然文の言い換えクエリはベクトル寄りに重みを倒すのが素直です。クエリ側に英数字の識別子パターンがあるかどうかは正規表現で判定できます。

第四に、リランキングを挟む。 上位20件をCross-Encoderで再評価する構成が、RRFの重み調整より効くことが多いです。ただし計算コストは増えます。

検証コードを書くときの注意

今回、最初に作ったベンチマークは全手法が5/5・MRR 1.000でした。これは「識別力のないベンチマーク」で、差が出ないので何も示せません。デコイ(意味的に近いが答えが違う文書)を入れ、識別子型と意味型の両方のクエリを用意して初めて、手法ごとの差が数字に出ます。

精度検証の設計そのものが結果を左右する、というのが今回の実測で得た教訓です。

まとめ

  • ハイブリッド検索は万能ではなく、小規模コーパスではBM25単体に負けることがある
  • RRFは「順位の合意」を取る手法なので、片方しか正解を出せないクエリでは順位が下がる
  • 候補数の制限・重み付け・クエリ種別での切替・リランキングの順に検討する
  • 検証用ベンチマークにはデコイが必須。全手法が満点になるベンチマークは何も示さない

RAGの検索精度は「どの検索手法を使うか」より「クエリの性質に合わせてどう組み合わせるか」で決まります。まず自分の文書集合で測ってみることが、どのブログの主張よりも信頼できる判断材料になります。


ハイブリッド検索やリランキングを含むRAG構築の全体像は、書籍『RAG構築入門』で第5章から順に扱っています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?