モジュール 04 · 第 4 回

ハイブリッド検索とリランキング

BM25 のキーワード検索をゼロから書き、RRF でベクトル検索と融合し、中国語の質問を先に英語の検索語に書き換え、最後にリランキングモデルを加えます。どのステップも同じ 20 問で効果を数値化します。

  • 約 50 分
  • 難易度:中級
  • 検証:2026-09-14 deepseek-flash、multilingual-e5-small、bge-reranker-base

コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。

前の課のベクトル検索は、20 問のうち半分ちょっとしか正しく見つけられませんでした。問題は二つに集約されます。「Digest」や「NO_PROXY」のような正確な名前に敏感でないこと、そして中国語の質問を英語のドキュメントにマッチさせること自体が難しいことです。

この課では三つのものを順に加えます。キーワード検索、クエリの書き換え、リランキングです。一つ加えるたびに、前の課の evaluate で同じ 20 問を実行し、数字が実際にどれだけ変わるかを見ます。結論を先に言うと、最大の改善は思いもよらないところから来ます。

キーワード検索:BM25

ベクトル検索が広まる前、検索エンジンはずっとキーワード検索を使っていました。質問の語がドキュメントに多く、集中して現れるほど、そのドキュメントのスコアが高くなります。その中で最もよく使われるアルゴリズムが BM25 です。「出現回数を数える」よりも賢い点が二つあります。

  • まれな語ほど重要。「the」はほぼすべてのチャンクにあるので、現れても何の手がかりにもなりません。「DigestAuth」は一つか二つのチャンクにしか現れないので、マッチすれば強いシグナルです。この重みを IDF(逆文書頻度)と呼びます。
  • 語の頻度の効果には上限がある。ある語が一つのチャンクに 10 回現れても、2 回現れる場合の 5 倍関係があるわけではありません。BM25 では頻度が増えるほどスコアの伸びが鈍くなります。同時に、とても長いチャンクにはペナルティを課します。長いチャンクには、もともといろいろな語が現れやすいからです。

ゼロから書いてみます。

import math
import re
from collections import Counter


def tokenize(text):
    # 英文单词和代码标识符按单词切(转成小写),中文按单个汉字切
    return re.findall(r"[a-z0-9_]+|[一-鿿]", text.lower())


class BM25:
    def __init__(self, chunks, k1=1.5, b=0.75):
        self.chunks = chunks
        self.docs = [tokenize(text) for _, text in chunks]
        self.avg_len = sum(len(d) for d in self.docs) / len(self.docs)
        self.tf = [Counter(d) for d in self.docs]
        df = Counter(word for d in self.docs for word in set(d))
        n = len(self.docs)
        # 越少的块里出现的词,越能说明问题,权重越高
        self.idf = {w: math.log(1 + (n - c + 0.5) / (c + 0.5)) for w, c in df.items()}
        self.k1, self.b = k1, b

    def score(self, query_words, i):
        tf, length = self.tf[i], len(self.docs[i])
        s = 0.0
        for w in query_words:
            if w in tf:
                # 词频越高分越高,但增长越来越慢;块越长,同样的词频得分越低
                s += self.idf[w] * tf[w] * (self.k1 + 1) / (tf[w] + self.k1 * (1 - self.b + self.b * length / self.avg_len))
        return s

    def search(self, query, k=5):
        words = tokenize(query)
        scores = [(self.score(words, i), i) for i in range(len(self.docs))]
        scores.sort(reverse=True)
        return [(s, *self.chunks[i]) for s, i in scores[:k]]

k1 は頻度の効果がどれだけ早く頭打ちになるかを、b は長いチャンクへのペナルティの強さを制御し、1.5 と 0.75 がよく使われる既定値です。この実装は毎回すべてのチャンクのスコアを計算しますが、196 個ならまったく問題ありません。チャンクが数十万個になれば、転置インデックスを使ってクエリの語を含むチャンクだけを計算する必要があり、Elasticsearch のような検索エンジンがしているのはまさにそれです。既成の Python 実装もあり、たとえば rank_bm25 パッケージがあります。

中国語の質問でそのまま検索する:かなり悪い

BM25(原问题)      第 1 名  15%  前 3 名  30%  前 5 名  35%  MRR 0.229

ベクトル検索(上位 5 件で 65%)よりもずっと悪い結果です。理由は単純で、質問は中国語、ドキュメントは英語なので、語がまったくかみ合いません。「服务器要求 Digest 认证怎么办?」(サーバーが Digest 認証を要求したらどうする?)の中でマッチできるのは英単語の「digest」一つだけで、ほかの漢字は英語のドキュメントに一つもありません。

クエリの書き換え

語がかみ合わないなら、かみ合わせればいいのです。まず LLM に中国語の質問を英語の検索語に書き換えさせ、それで検索します。

def rewrite(question):
    """把中文问题改写成英文检索词。httpx 的文档是英文的,这样关键词才能对上。"""
    if question not in rewrites:
        response = client.chat.completions.create(
            model=MODEL,
            messages=[{"role": "user", "content": (
                "把下面这个关于 Python 库 httpx 的问题,改写成用于搜索 httpx 英文文档的检索词。"
                "输出一行英文,包含问题的英文翻译,以及文档里可能出现的参数名、类名、术语。不要解释。\n\n" + question)}],
            extra_body={"thinking": {"type": "disabled"}},
        )
        rewrites[question] = response.choices[0].message.content.strip()
        CACHE.write_text(json.dumps(rewrites, ensure_ascii=False, indent=2))
    return rewrites[question]

プロンプトでは「英語に翻訳する」だけでなく、「ドキュメントに出てきそうなパラメータ名、クラス名、用語」も求めています。書き換えの例です。

服务器要求 Digest 认证怎么办? → httpx Digest authentication server requires digest auth how to use DigestAuth parameter class terms

モデルは翻訳しただけでなく、httpx の対応するクラス名 DigestAuth まで推測しています。これこそモデルが得意なことです。httpx がおおよそどんなものかを知っているので、ユーザーの口語的な質問を、ドキュメントで使われる専門用語に翻訳できるのです。

書き換えの結果は rewrites.json にキャッシュされるので、同じ質問にお金がかかるのは 1 回だけです。実際のアプリでは、ユーザーの質問ごとにモデルの呼び出しが 1 回増え、数百ミリ秒と 0.0001 ドル未満が余分にかかります。

融合:RRF

ベクトル検索とキーワード検索にはそれぞれ得意なことがあります。一方は大意をとらえ、もう一方は正確な語をとらえます。両方を一緒に使えないでしょうか。

難しいのは、両者のスコアをそのまま足せないことです。ベクトル検索のスコアは 0 から 1 の類似度で、BM25 のスコアは十数や数十になることもあります。よく使われる方法は、スコアを見ずに順位だけを見るもので、RRF(Reciprocal Rank Fusion、逆順位融合)と呼ばれます。

def rrf(result_lists, k=5, c=60):
    """倒数排名融合:一个块在每个列表里排第 r 名,就得 1/(c+r) 分,把各列表的分数加起来。"""
    scores, items = Counter(), {}
    for results in result_lists:
        for rank, (_, file, text) in enumerate(results, 1):
            scores[(file, text)] += 1 / (c + rank)
            items[(file, text)] = (file, text)
    return [(s, *items[key]) for key, s in scores.most_common(k)]

あるチャンクが二つのリストの両方で上位にあればスコアが高く、一方のリストにしか出てこなければ少し低くなります。c=60 は元の論文でよく使われている値で、1 位と 2 位の差が大きくなりすぎないようにします。使うときは、それぞれの検索方法から上位 20 個を取り、融合して上位 5 個を取ります。

結果

code/04-rag/hybrid.py はこれらの組み合わせをすべて実行します。ベクトル検索には、前の課でよりよい結果だった multilingual-e5-small を使っています。

向量(原问题)        第 1 名  30%  前 3 名  50%  前 5 名  65%  MRR 0.418
BM25(原问题)      第 1 名  15%  前 3 名  30%  前 5 名  35%  MRR 0.229
向量(改写后)        第 1 名  55%  前 3 名  90%  前 5 名  95%  MRR 0.718
BM25(改写后)      第 1 名  70%  前 3 名  95%  前 5 名 100%  MRR 0.804
混合 RRF(改写后)    第 1 名  60%  前 3 名  90%  前 5 名 100%  MRR 0.772

クエリの書き換えが最大の功労者。同じ BM25 でも、上位 5 件のヒット率が 35% から 100% に跳ね上がりました。同じベクトル検索でも 65% から 95% に上がっています。安価なモデル呼び出し 1 回が、どんな検索アルゴリズムの変更よりも効きました。私たちの「中国語の質問、英語のドキュメント」という状況では、このステップはほぼ必須です。質問とドキュメントが同じ言語でも、口語的な質問をドキュメントの用語に書き換えることは、たいてい役に立ちます。

書き換えた後は、BM25 のほうがベクトル検索よりよい。技術文書はパラメータ名やクラス名であふれていて、書き換えた検索語にはちょうどそうした語が含まれるので、キーワード検索ならすぐにマッチします。これは「ベクトル検索のほうが進んでいる」という多くの人の印象とは違います。

ハイブリッド検索はこの問題セットではよくならなかった。RRF で融合すると上位 5 件のヒット率は 100% で、BM25 と同じですが、MRR はかえって 0.804 から 0.772 に下がりました。ベクトル検索の順位のよくない結果が、BM25 が 1 位にしていた正解のいくつかを後ろに押しやったのです。

ハイブリッド検索が役に立たないというわけではありません。別のデータ、たとえばユーザーの質問がとても口語的で、ドキュメントに対応するキーワードがない場合には、ベクトル検索の役割はずっと大きくなります。私が言いたいのは、ある方法が進んでいるように聞こえるからといって使うのではなく、評価データで決めようということです。20 問は少なすぎ、この差はただのランダムな揺らぎかもしれません。あなた自身のデータに替えれば、結論はまったく違うかもしれません。

リランキング

ベクトル検索は、質問とドキュメントを別々にベクトルに変えてから、ベクトルを比べます。計算のとき、質問とドキュメントは互いを「見て」いません。これは速く、すべてのドキュメントのベクトルを前もって計算しておけますが、粗いのです。

リランキングモデル(reranker)は別のやり方をします。質問とチャンクを一つにつなげてモデルに渡し、モデルに両方を同時に読ませて、関連度のスコアを直接出させます。こうすると判定はずっと正確になりますが、組ごとに毎回計算する必要があり、前もって計算しておけないので、ずっと遅くなります。

そこでふつうは二段階にします。まず速い方法(ベクトル、BM25、ハイブリッド)で 20 個のチャンクをざっと選び、次にリランキングモデルでその 20 個を正確に採点して並べ直し、上位 5 個を取ります。

私は BAAI がオープンソースで公開している bge-reranker-base を使いました。中国語と英語に対応し、ダウンロードは 1.1GB で、CPU で動きます。

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-base", max_length=512)


def reranked(question, k=5, use_rewrite=False):
    pool = candidates(question, 20)
    query = rewrite(question) if use_rewrite else question
    scores = reranker.predict([(query, text) for _, _, text in pool])
    order = sorted(range(len(pool)), key=lambda i: -scores[i])
    return [(float(scores[i]), pool[i][1], pool[i][2]) for i in order[:k]]

candidates は上の「書き換え + ハイブリッド RRF」で、上位 20 個のチャンクを取ります。結果です(code/04-rag/rerank.py)。

混合 RRF(改写后)        第 1 名  60%  前 3 名  90%  前 5 名 100%  MRR 0.772  (20 题用时 0.2 秒)
混合 + 重排(用原中文问题)    第 1 名  45%  前 3 名  85%  前 5 名  90%  MRR 0.643  (20 题用时 20.1 秒)
    没找到:怎么让请求走 HTTP 代理?(应在 advanced/proxies.md)→ 第 1 名 advanced/proxies.md
    没找到:有些域名不想走代理,环境变量怎么设置?(应在 environment_variables.md)→ 第 1 名 environment_variables.md
混合 + 重排(用改写后的问题)   第 1 名  85%  前 3 名  95%  前 5 名 100%  MRR 0.912  (20 题用时 20.0 秒)

書き換えた英語の質問でリランキングすると、1 位のヒット率は 60% から 85% に、MRR は 0.772 から 0.912 に上がり、すべての組み合わせの中で最もよい結果です。RAG にとって、1 位が正しいかどうかは重要です。モデルは一番前に置かれた資料を最も重視しがちですし、そのぶん入れるチャンクを減らせるので、お金も惑わしも減らせます。

元の中国語の質問でリランキングすると、かえって悪くなりました。bge-reranker-base は中国語と英語に対応していますが、中国語の質問と英語のドキュメントの組み合わせでは、判定がそれほど正確ではありません。面白いのは「見つからなかった」2 問で、1 位は実は正しいファイルにありながら、キーワードを含んでいませんでした。おおよそ正しい場所は見つけたのに、最も正確なチャンクを選べなかったということです。

代償は時間です。20 問に 20 秒、1 問あたり平均 1 秒かかり、すべて CPU で 20 個の候補チャンクを採点するのに使われています。GPU があればずっと速くなりますし、候補のチャンク数を減らしたり、リランキングの API サービスを使ったりもできます。リランキングを加えるかどうかは、あなたのアプリがこの余分な 1 秒を許容できるかどうか次第です。

各ステップの効果のまとめ

やり方 上位 5 件のヒット MRR 追加の代償
ベクトル検索 65% 0.418 なし
クエリの書き換えを加える 95% 0.718 質問ごとにモデル呼び出しが 1 回増える
書き換え + BM25 100% 0.804 なし
書き換え + ハイブリッド RRF 100% 0.772 なし
書き換え + ハイブリッド + リランキング 100% 0.912 質問ごとに約 1 秒増える(CPU)

あなたのプロジェクトでは、次の順番で試すことをおすすめします。まず最も単純な検索方法を一つ作り、評価セットを用意する。次にクエリの書き換えを試し、それからハイブリッドを試し、最後にリランキングを検討する。どのステップでも数字を見て、改善しなければ加えないことです。

練習問題

  1. BM25 の k1 を 0.5 と 3 に、b を 0 と 1 に変えて、「BM25(書き換え後)」の結果がどう変わるか見てください。
  2. rewrite のプロンプトを、翻訳だけを求め、パラメータ名やクラス名の補足は求めないように変えて、実行し直してください(先に rewrites.json を削除するのを忘れずに)。上位 5 件のヒット率はどれだけ下がりましたか。
  3. rrf で二つの検索方法に異なる重みを付けてください。たとえば BM25 のスコアを 2 倍にして、ハイブリッド検索が BM25 単独を上回れるか見てください。
  4. リランキングの候補数を 20 から 10 に変えて、正確さと所要時間がどう変わるか見てください。

確認テスト

1. BM25 がまれな語に高い重みを与えるのはなぜですか?

ほぼすべてのチャンクにある語(「the」や「httpx」など)はマッチしても、どのチャンクがより関係しているかを区別できません。少数のチャンクにしか現れない語(「DigestAuth」など)は、マッチすればそのチャンクが質問と関係していることを強く示します。IDF は、ある語がどれだけまれかを測るものです。

2. ベクトル検索と BM25 のスコアをそのまま足せないのはなぜですか?RRF はどう解決しているのですか?

両者のスコアの範囲がまったく違うからです。ベクトルの類似度は 0 から 1 の間で、BM25 のスコアは数十にもなるので、そのまま足すと BM25 が圧倒的に支配します。RRF は元のスコアを使わず、各結果がそれぞれのリストで何位かだけを見て、1/(c+順位) で点数をつけて足し合わせるので、二つの方法の順位を公平に合わせられます。

3. リランキングモデルのほうがベクトル検索より正確なのに、なぜリランキングモデルですべてのチャンクを直接検索しないのですか?

リランキングモデルは質問と各チャンクを一つにつなげて毎回計算する必要があり、前もって計算しておけません。チャンクが多いと遅すぎ、196 個なら 196 回の計算が必要で、数十万個ではまったく現実的ではありません。ですから、まず速い方法で数十個の候補をざっと選び、それからリランキングモデルで正確に並べ直すのです。

質問と議論

このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。

質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。

議論を読み込んでいます…