Modul 04 · Lektion 4

Hybride Suche und Reranking

Eine BM25-Stichwortsuche von Grund auf schreiben, sie per RRF mit der Vektorsuche fusionieren, chinesische Fragen vorher in englische Suchbegriffe umformulieren und zum Schluss ein Reranking-Modell hinzufügen. Jeder Schritt wird mit denselben 20 Fragen gemessen.

  • Etwa 50 Minuten
  • Niveau: Fortgeschritten
  • Getestet: 2026-09-14 deepseek-flash, multilingual-e5-small, bge-reranker-base

Code und Programmausgaben stehen genau so da, wie sie gelaufen sind – Kommentare und Ausgaben sind daher auf Chinesisch.

Die Vektorsuche der letzten Lektion fand bei den 20 Fragen nur gut die Hälfte richtig. Die Probleme lassen sich auf zwei zurückführen: Sie reagiert kaum auf exakte Namen wie „Digest“ oder „NO_PROXY“, und chinesische Fragen zu englischen Dokumenten passen zu lassen, ist an sich schwer.

Diese Lektion fügt nacheinander drei Dinge hinzu: Stichwortsuche, Umformulieren der Anfrage und Reranking. Nach jedem Schritt laufen dieselben 20 Fragen durch evaluate aus der letzten Lektion, und wir sehen, wie sehr sich die Zahlen tatsächlich ändern. Das Ergebnis vorweg: Die größte Verbesserung kommt von einer Stelle, mit der du vielleicht nicht rechnest.

Stichwortsuche: BM25

Bevor die Vektorsuche populär wurde, nutzten Suchmaschinen immer Stichwortsuche: Je öfter und konzentrierter die Wörter der Frage in einem Dokument vorkommen, desto höher dessen Punktzahl. Der gebräuchlichste Algorithmus heißt BM25. Er ist in zwei Punkten klüger als bloßes „Zählen, wie oft etwas vorkommt“:

  • Seltene Wörter zählen mehr. „the“ steht in fast jedem Chunk, sein Vorkommen sagt nichts; „DigestAuth“ steht nur in ein, zwei Chunks, ein Treffer ist ein starkes Signal. Dieses Gewicht heißt IDF (inverse Dokumenthäufigkeit).
  • Die Wirkung der Worthäufigkeit ist begrenzt. Kommt ein Wort zehnmal in einem Chunk vor, ist er nicht fünfmal relevanter als bei zweimal. BM25 lässt die Punktzahl mit der Häufigkeit immer langsamer steigen. Außerdem bestraft es sehr lange Chunks, weil darin ohnehin mehr Wörter vorkommen.

Von Grund auf geschrieben:

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 steuert, wie schnell die Wirkung der Worthäufigkeit sättigt, b, wie stark lange Chunks bestraft werden; 1,5 und 0,75 sind übliche Standardwerte. Diese Implementierung berechnet jedes Mal alle Chunks, was bei 196 völlig in Ordnung ist; bei Hunderttausenden Chunks braucht man einen invertierten Index, der nur Chunks mit den Suchwörtern berechnet, und genau das tun Suchmaschinen wie Elasticsearch. Es gibt auch fertige Python-Implementierungen, etwa das Paket rank_bm25.

Direkt mit der chinesischen Frage suchen: schlecht

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

Noch viel schlechter als die Vektorsuche (Top 5: 65 %). Der Grund ist einfach: Die Frage ist chinesisch, die Dokumente englisch, die Wörter passen überhaupt nicht zusammen. In „服务器要求 Digest 认证怎么办?“ (was tun, wenn der Server Digest-Authentifizierung verlangt?) kann nur das eine englische Wort „digest“ treffen; keines der chinesischen Zeichen kommt in der englischen Dokumentation vor.

Die Anfrage umformulieren

Passen die Wörter nicht, lässt man sie passend machen: Zuerst formuliert das Sprachmodell die chinesische Frage in englische Suchbegriffe um, dann wird damit gesucht.

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]

Der Prompt verlangt außer „ins Englische übersetzen“ auch „Parameternamen, Klassennamen und Begriffe, die in der Dokumentation vorkommen könnten“. Ein Beispiel für eine Umformulierung:

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

Das Modell hat nicht nur übersetzt, sondern auch den passenden httpx-Klassennamen DigestAuth erraten. Genau das kann ein Modell gut: Es weiß ungefähr, wie httpx aussieht, und übersetzt die umgangssprachliche Frage des Nutzers in das Fachvokabular der Dokumentation.

Die Umformulierungen werden in rewrites.json zwischengespeichert, sodass jede Frage nur einmal Geld kostet. In einer echten Anwendung braucht jede Nutzerfrage einen zusätzlichen Modellaufruf, etwa einige hundert Millisekunden mehr und weniger als 0,0001 Dollar.

Fusion: RRF

Vektorsuche und Stichwortsuche haben jeweils ihre Stärken: die eine erfasst den Grundsinn, die andere exakte Wörter. Kann man beide zusammen nutzen?

Die Schwierigkeit: Die Punktzahlen lassen sich nicht einfach addieren. Die Vektorsuche liefert Ähnlichkeiten zwischen 0 und 1, BM25 Punktzahlen von zehn oder mehreren Dutzend. Üblich ist, die Punktzahlen zu ignorieren und nur auf die Ränge zu schauen; das heißt RRF (Reciprocal Rank Fusion, Fusion der reziproken Ränge):

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)]

Ein Chunk, der in beiden Listen weit vorn steht, bekommt eine hohe Punktzahl; einer, der nur in einer Liste auftaucht, eine niedrigere. c=60 ist der übliche Wert aus dem Originalartikel; er verhindert, dass der Abstand zwischen Platz 1 und 2 zu groß wird. In der Anwendung nimmt man aus jeder Suchmethode die Top 20 und nach der Fusion die Top 5.

Ergebnisse

code/04-rag/hybrid.py lässt alle diese Kombinationen laufen; die Vektorsuche nutzt das in der letzten Lektion bessere 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

Das Umformulieren der Anfrage ist der Hauptgewinn. Bei BM25 springt die Top-5-Trefferquote von 35 % auf 100 %, bei der Vektorsuche steigt sie von 65 % auf 95 %. Ein billiger Modellaufruf bringt mehr als jeder Wechsel des Suchalgorithmus. In unserem Szenario „chinesische Frage, englische Dokumentation“ ist dieser Schritt praktisch Pflicht. Auch wenn Frage und Dokumente in derselben Sprache sind, hilft es meist, eine umgangssprachliche Frage in das Vokabular der Dokumentation umzuformulieren.

Nach dem Umformulieren ist BM25 besser als die Vektorsuche. Technische Dokumentation steckt voller Parameter- und Klassennamen, die umformulierten Suchbegriffe enthalten genau diese Wörter, und die Stichwortsuche trifft sofort. Das widerspricht dem Eindruck vieler, die „Vektorsuche sei fortschrittlicher“.

Die hybride Suche war bei diesen Fragen nicht besser. Nach der RRF-Fusion liegt die Top-5-Trefferquote bei 100 %, gleich wie bei BM25, aber der MRR sinkt sogar von 0,804 auf 0,772. Schlecht platzierte Ergebnisse der Vektorsuche haben einige richtige Antworten, die BM25 auf Platz 1 hatte, nach hinten gedrängt.

Das heißt nicht, dass hybride Suche nutzlos ist. Bei anderen Daten, etwa wenn Nutzerfragen sehr umgangssprachlich sind und die Dokumentation die passenden Stichwörter nicht enthält, trägt die Vektorsuche viel mehr bei. Worauf ich hinauswill: Nimm eine Methode nicht, weil sie fortschrittlicher klingt, sondern entscheide mit Evaluationsdaten. 20 Fragen sind zu wenig, der Unterschied kann Zufall sein; mit deinen eigenen Daten kann das Ergebnis ganz anders ausfallen.

Reranking

Die Vektorsuche verwandelt Frage und Dokumente getrennt in Vektoren und vergleicht dann die Vektoren. Beim Berechnen „sehen“ sich Frage und Dokument nicht. Das ist schnell, weil man die Vektoren aller Dokumente vorab berechnen kann, aber grob.

Ein Reranking-Modell (Reranker) geht anders vor: Es fügt Frage und einen Chunk zusammen, gibt beides dem Modell, das beides gleichzeitig liest und direkt eine Relevanzpunktzahl ausgibt. Das urteilt viel genauer, aber jedes Paar muss einzeln berechnet werden, nichts lässt sich vorab berechnen, es ist viel langsamer.

Daher meist zwei Stufen: erst mit einer schnellen Methode (Vektor, BM25, hybrid) grob 20 Chunks auswählen, dann diese 20 vom Reranking-Modell genau bewerten, neu sortieren und die Top 5 nehmen.

Ich nutze bge-reranker-base von BAAI (Open Source), das Chinesisch und Englisch kann, 1,1 GB groß ist und auf der CPU läuft:

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 ist die „Umformulierung + hybride RRF“ von oben mit den Top 20. Ergebnis (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 秒)

Mit der umformulierten englischen Frage steigt die Trefferquote auf Platz 1 durch Reranking von 60 % auf 85 % und der MRR von 0,772 auf 0,912, der beste Wert aller Kombinationen. Für RAG ist wichtig, ob Platz 1 stimmt: Modelle gewichten das zuerst gezeigte Material oft am stärksten, und man kann dadurch weniger Chunks mitgeben, was Geld und Ablenkung spart.

Mit der ursprünglichen chinesischen Frage wurde das Reranking sogar schlechter. bge-reranker-base kann zwar Chinesisch und Englisch, urteilt aber bei chinesischer Frage und englischem Dokument weniger genau. Interessant sind die zwei „nicht gefundenen“ Fragen: Platz 1 lag tatsächlich in der richtigen Datei, enthielt nur das Stichwort nicht; es hat also ungefähr die richtige Stelle gefunden, aber nicht den genauesten Chunk gewählt.

Der Preis ist Zeit: 20 Fragen brauchten 20 Sekunden, im Schnitt 1 Sekunde pro Frage, alles auf der CPU für die Bewertung der 20 Kandidaten. Mit GPU geht es viel schneller; man kann auch weniger Kandidaten nehmen oder einen Reranking-Dienst per API nutzen. Ob man Reranking hinzunimmt, hängt davon ab, ob die Anwendung diese zusätzliche Sekunde verkraftet.

Was jeder Schritt bringt

Vorgehen Top-5-Treffer MRR Zusätzliche Kosten
Vektorsuche 65 % 0,418 keine
plus Umformulieren der Anfrage 95 % 0,718 ein Modellaufruf pro Frage
Umformulieren + BM25 100 % 0,804 keine
Umformulieren + hybride RRF 100 % 0,772 keine
Umformulieren + hybrid + Reranking 100 % 0,912 etwa 1 Sekunde pro Frage (CPU)

In deinem Projekt empfiehlt sich diese Reihenfolge: zuerst die einfachste Suche und ein Evaluationsset; dann das Umformulieren der Anfrage; dann hybrid; zuletzt Reranking erwägen. Bei jedem Schritt auf die Zahlen schauen und nichts hinzufügen, was nichts bringt.

Übungen

  1. Setz k1 von BM25 auf 0,5 und 3, b auf 0 und 1, und schau, wie sich das Ergebnis von „BM25 (umformuliert)“ ändert.
  2. Ändere den Prompt von rewrite so, dass er nur übersetzen und keine Parameter- und Klassennamen ergänzen soll, und lass es erneut laufen (vorher rewrites.json löschen). Um wie viel sinkt die Top-5-Trefferquote?
  3. Gib den beiden Suchmethoden in rrf unterschiedliche Gewichte, etwa die Punktzahl von BM25 mal 2, und schau, ob die hybride Suche dann BM25 allein übertrifft.
  4. Senk die Zahl der Reranking-Kandidaten von 20 auf 10 und schau, wie sich Genauigkeit und Zeit ändern.

Selbsttest

1. Warum gibt BM25 seltenen Wörtern mehr Gewicht?

Wörter, die in fast jedem Chunk stehen (etwa „the“ oder „httpx“), können bei einem Treffer nicht unterscheiden, welcher Chunk relevanter ist; Wörter, die nur in wenigen Chunks vorkommen (etwa „DigestAuth“), zeigen bei einem Treffer stark an, dass dieser Chunk zur Frage passt. IDF misst, wie selten ein Wort ist.

2. Warum kann man die Punktzahlen von Vektorsuche und BM25 nicht einfach addieren? Wie löst RRF das?

Die Wertebereiche sind völlig verschieden: Vektorähnlichkeiten liegen zwischen 0 und 1, BM25-Punktzahlen können mehrere Dutzend betragen, und beim Addieren dominiert BM25 vollständig. RRF nutzt nicht die rohen Punktzahlen, sondern nur den Rang jedes Ergebnisses in seiner Liste, vergibt 1/(c+Rang) und summiert; so werden die Ränge beider Methoden fair kombiniert.

3. Ein Reranking-Modell ist genauer als die Vektorsuche. Warum sucht man nicht direkt mit dem Reranking-Modell über alle Chunks?

Das Reranking-Modell muss die Frage mit jedem einzelnen Chunk zusammen berechnen, nichts lässt sich vorab berechnen. Bei vielen Chunks ist das zu langsam: 196 Chunks sind 196 Berechnungen, Hunderttausende sind völlig unrealistisch. Daher wählt man zuerst mit einer schnellen Methode einige Dutzend Kandidaten aus und sortiert sie dann mit dem Reranking-Modell genau.

Fragen und Diskussion

Hängst du in dieser Lektion fest? Frag hier. Und wenn du die Frage von jemandem beantworten kannst, tu es gern.

Eine Frage bringt 3 Punkte, eine Antwort 6. Beiträge erscheinen nach der Prüfung.

Diskussion wird geladen…