Özgür Işık Damar
8 Min Lesezeit

Die Woche, in der BM25 meinen Cross-Encoder schlug — und warum ich den Reranker trotzdem behielt

Manchmal ist die klügste Engineering-Entscheidung, das zu löschen, was du vor drei Wochen gebaut hast. Manchmal ist es nur, es hinter ein if zu setzen.

hybrid-searchrerankingproductionmultilingual

Ich hatte drei Wochen damit verbracht, eine BGE-Reranker-Stufe ans Ende unserer Hybrid-Pipeline zu deployen. Der Cross-Encoder fügte 40 ms P95-Latenz hinzu. Er hob nDCG@10 um 6 Prozentpunkte. Dann begannen die Anfragen vom mazedonischen Marktplatz einzutrudeln. Für die Hälfte davon schlug BM25 allein — kein Rerank, keine Dense-Vektoren — die vollständige Pipeline.

Dieser Satz hat eine Woche ruiniert. Die Woche wiederum hat mir beigebracht, wofür der Cross-Encoder eigentlich da war.

Die Pipeline, wie sie war

Die hybride Suchmaschine hat drei Stufen: Retrieval, Fusion, Rerank. Die Retrieval-Stufe läuft drei Named-Vector-Queries parallel gegen Qdrant — BM25 Sparse, Dense Text, Dense Image. Die Fusion-Stufe läuft Reciprocal Rank Fusion über die drei. Die Rerank-Stufe nimmt die Top 50 der fusionierten Liste und läuft einen BGE-Reranker-Base Cross-Encoder über (Query, Dokument)-Paare, um eine finale Reihenfolge zu erzeugen.

Der Reranker ist die einzige Stufe, die sowohl die Query als auch das Dokument gleichzeitig lesen darf. Die Retrieval-Stufe embedet sie getrennt und vergleicht Abstände. Die Fusion-Stufe liest nichts — sie merged nur Rang-Listen. Der Cross-Encoder ist die einzige Stelle im System, an der das Modell sagen kann "diese exakte Query und dieser exakte Titel und diese exakte Beschreibung gehören zusammen."

Drei Wochen Arbeit, um ihn innerhalb unseres Latenzbudgets zu betreiben. Der Gewinn war sauber: nDCG@10 stieg von 0,74 auf 0,80 auf dem globalen Eval-Set. Das ist eine Zahl, die man im Roadmap-Review verteidigen kann.

Der mazedonische Freitag

Zwei Wochen nachdem der Cross-Encoder auf 100 Prozent Traffic stand, zog der Data-Lead Per-Locale-Metriken für ein Planungsmeeting. Die Schlagzeile war in Ordnung. Die Aufschlüsselung nicht.

locale   nDCG@10 vorher    nDCG@10 nachher    delta
-----   ---------------    ----------------    -------
tr-TR        0,71              0,81           +10
en-US        0,78              0,84            +6
mk-MK        0,69              0,65            -4

Mazedonische Anfragen hatten vier Punkte verloren. Der Reranker, das teure Ding, das ich gerade ausgeliefert hatte, machte sie schlechter.

Ich saß einen Tag damit. Die Retrieval-Stufe war derselbe Code-Pfad für jedes Locale. Die Fusion-Stufe war dieselbe. Das einzige Stück, das Locale-bedingt arbeitete, war der Cross-Encoder, denn Sprache ist etwas, das ein Cross-Encoder implizit durchs Training aufnimmt. BGE-Reranker-Base ist überwiegend auf Englisch und Chinesisch trainiert. Türkisch bekommt etwas zufällige Hilfe durch Transfer. Mazedonisch — Kyrillisch und Latein gemischt, oft Code-Switching mit Albanisch- oder Serbisch-Fragmenten — war eine Verteilung, die das Modell faktisch nie gesehen hatte.

Der Cross-Encoder, konfrontiert mit einer mazedonischen Anfrage und einer mazedonischen Produktbeschreibung, produzierte Scores, die nahezu zufällig in Bezug auf Relevanz waren. Er sortierte die Top-50 einer fusionierten Liste, die BM25 üblicherweise getroffen hatte, neu — und verwürfelte die Reihenfolge auf eine Weise, die korrekte Ergebnisse nach unten und verrauschte nach oben drückte.

Für die Hälfte der mazedonischen Anfragen schlug das einfachste mögliche System — nur BM25, keine Fusion, kein Rerank — die volle Pipeline. Ich ließ das Eval dreimal laufen, weil ich nicht wollte, dass es wahr ist.

Es war wahr.

Der Reflex zu löschen

Mein erster Instinkt war, den Cross-Encoder herauszureißen. Drei Wochen Arbeit, weg. Das Latenzbudget würde sich lockern. Die Infra-Kosten würden fallen. Das Team hätte ein bewegliches Teil weniger zu babysitten. Es gab einen leisen, ego-förmigen Zug zu "ich lag mit der ganzen Sache falsch."

Zwei Dinge hielten mich auf.

Erstens, die türkischen und englischen Zahlen. Der Reranker war ein +10-Punkte-Gewinn auf Türkisch und ein +6-Punkte-Gewinn auf Englisch. Das sind die beiden größten Traffic-Segmente. Ihn wegzuwerfen, um Mazedonisch zu fixen, wäre der teuerste locale-spezifische Bugfix in der Geschichte der Suchinfrastruktur gewesen.

Zweitens hatte das Merchant-CRM still einen kleinen Test laufen lassen, bei dem sie echte türkische kaufintensive Queries gegen das System mit und ohne Reranker replayten. Die Click-Through-Rate auf die Top-3-Ergebnisse war mit eingeschaltetem Reranker 8 Prozent höher. Diese Zahl ist nicht nDCG. Sie ist Euro.

Die richtige Antwort war nicht "lösche das Ding, das du vor drei Wochen gebaut hast." Die richtige Antwort war "setz das Ding, das du vor drei Wochen gebaut hast, hinter ein if."

Das konditionale Rerank

Die Form des Fixes war einfacher als der Bug.

// Reranke nur für Locales, bei denen sich gezeigt hat, dass der Cross-Encoder hilft.
// Die Liste ist datengetrieben aus dem Nightly-Eval — wenn mk-MK je über
// die Baseline steigt, wandert es automatisch rein. Wir hardcoden keine Sprachmeinungen.
func shouldRerank(query Query) bool {
    cfg := rerankerConfig.Current()
    if cfg.GlobalEnabled == false {
        return false
    }
    locale := query.Locale.Normalize()
    return cfg.LocalesWithLift[locale]
}
 
func RunPipeline(ctx context.Context, q Query) ([]Result, error) {
    fused, err := retrieveAndFuse(ctx, q)
    if err != nil {
        return nil, err
    }
    if shouldRerank(q) {
        return rerankTop50(ctx, q, fused)
    }
    return fused[:min(20, len(fused))], nil
}

LocalesWithLift ist keine Konstante. Es ist eine Map, die vom Nightly-Evaluator gepflegt wird. Jede Nacht lässt der Eval-Harness die Queries des Vortags durch beide Pipelines — mit und ohne Rerank — für jedes Locale laufen und aktualisiert die Map. Wenn ein Locale mit Rerank an einen nDCG@10-Lift von mindestens 1,5 Punkten zeigt, wird es aufgenommen. Sonst nicht.

Das war der Teil, der mich beim Ausliefern beruhigt hat. Die Entscheidung war nicht mehr ich, an einem Schreibtisch sitzend, der entscheidet, welche Sprachen den Cross-Encoder bekommen. Die Entscheidung waren die Daten. Wenn Mazedonisch morgen einen neuen Reranker bekäme, der für es funktioniert, würde die Map den Lift einfangen und das Bit automatisch umlegen. Wenn Türkisch je aufhören würde, vom Reranker zu profitieren, würde es aus der Map entfernt, ohne dass jemand Code anfassen müsste.

Der locale-bewusste Eval-Harness

Der Harness war die Engineering-Arbeit, die die Policy verteidigbar machte. Er läuft nächtlich auf einer eingefrorenen Stichprobe von 35.000 Queries, gezogen aus den Produktions-Logs der vergangenen Woche, locale-stratifiziert, sodass Locales mit kleinem Traffic wie Mazedonisch genug Sample bekommen, um eine stabile Zahl zu produzieren.

async def evaluate_rerank_lift(
    queries: list[Query],
    locales: list[str],
) -> dict[str, float]:
    # Lass jede Query durch beide Pipelines laufen, berechne nDCG@10-Delta pro Locale.
    # Stratifiziertes Sampling garantiert, dass mk-MK ehrliches Volumen bekommt.
    lifts: dict[str, list[float]] = defaultdict(list)
    for q in queries:
        baseline = await pipeline_without_rerank(q)
        candidate = await pipeline_with_rerank(q)
        b = ndcg_at_k(baseline, q.relevance_labels, k=10)
        c = ndcg_at_k(candidate, q.relevance_labels, k=10)
        lifts[q.locale].append(c - b)
    return {loc: statistics.mean(vals) for loc, vals in lifts.items()}

Der Output dieser Funktion ist die gesamte LocalesWithLift-Policy. Ein Locale schafft es in den Rerank, wenn sein mittlerer Lift 1,5 übersteigt und das Bootstrap-90-Prozent-Konfidenzintervall positiv ist. Die Schwelle von 1,5 ist keine magische Zahl; sie ist kalibriert gegen die Latenzkosten — etwa 40 ms — bei denen wir den Rerank für lohnend halten.

Auf was du zurückfällst, wenn Rerank aus ist

Die Rerank-Stufe zu überspringen ist auch nicht kostenlos. Die fusionierte Liste wird weiterhin von RRF über drei Modalitäten produziert, und der Kopf der fusionierten Liste ist meist gut — aber die Reihenfolge innerhalb des Kopfes ist ohne die Neusortierung des Cross-Encoders gröber. Wir mussten dafür sorgen, dass die fusionierte Reihenfolge bedachter ist, wenn der Rerank-Fallback greift.

def confidence_aware_fallback(fused: list[ScoredDoc], q: Query) -> list[Result]:
    # Wenn der Cross-Encoder übersprungen wird, falle auf eine konfidenz-gewichtete
    # Mischung aus Fusion-Score und einer winzigen logistischen Regression zurück,
    # trainiert auf historischen Klickdaten pro Locale. Billig, deterministisch, kein Modell-Load.
    blend = []
    for doc in fused[:20]:
        ctr_prior = locale_ctr_prior(q.locale, doc.category)
        blended = 0.6 * doc.fusion_score + 0.4 * ctr_prior
        blend.append(Result(doc=doc.id, score=blended))
    blend.sort(key=lambda r: r.score, reverse=True)
    return blend

Die logistische Regression ist eines dieser Systemteile, an die du dich selbst erinnern musst. Sie läuft in Mikrosekunden. Sie wurde auf sechs Monaten historischer Klickdaten trainiert, aufgeschlüsselt nach Locale und Kategorie. Sie ist kein Gehirn. Sie ist ein kleines, dummes, nützliches Stück Statistik, das aufgreift "in diesem Locale, in dieser Kategorie, werden Produkte dieser groben Form häufiger geklickt." Wenn der Cross-Encoder in der Pipeline ist, übertönt er dieses Signal vollständig. Wenn der Cross-Encoder fort ist, hört das kleine Signal auf, übertönt zu werden, und wird der Unterschied zwischen einem okayen Fallback und einem merklich schlechteren.

Die Kostenseite

Die Zahlen, die das beim Engineering-Manager zu einer einfachen Auslieferung machten, waren die Kostenzahlen. Mazedonischer Traffic liegt bei etwa 12 Prozent der gesamten Anfragen. Den Rerank darauf zu überspringen sparte 22 Prozent der gesamten Reranker-Inferenz, weil mazedonische Anfragen auch überproportional lang waren — mehr Tokens, größerer Cross-Encoder-Kontext, längere Compute pro Paar. Diese Last loszuwerden gab GPU-Kapazität frei, die wir vorher zu erweitern geplant hatten. Das Infra-Ticket für die Erweiterung wurde die Woche nach dem Ausliefern des konditionalen Rerank gestrichen.

Das Latenzbudget, das durch das Überspringen des Rerank für Mazedonisch gewonnen wurde — etwa 38 ms bei P95 — wurde in ein etwas größeres Retrieval-limit speziell für mazedonische Anfragen reinvestiert. Größerer Kandidatenpool, mehr BM25-Kopf zum Arbeiten, bessere Chancen, dass der richtige Artikel überhaupt in den Top 20 ist. Das Locale-Chart des Data-Leads, drei Wochen später, hatte Mazedonisch wieder auf +2 Punkte relativ zur Pre-Rerank-Baseline. Nicht weil wir ihm einen Reranker gebaut haben. Weil wir aufgehört haben, das zu zerbrechen, was schon funktionierte.

Was ich in jenem Quartal ins Planungsdokument schrieb

Die Zeile am Ende meiner Quartalszusammenfassung lautete: der Reranker ist ein Werkzeug, das man manchmal benutzt, kein Pipeline-Default. Dieser Satz war ein Jahr Suchearbeit in elf Wörter komprimiert.

Die Hybrid-Search-Literatur neigt dazu, Pipelines monolithisch zu präsentieren — retrieve, fuse, rerank, ship. In Produktion ist jede Stufe dieser Pipeline auf etwas konditional. Die Rerank-Stufe ist konditional darauf, ob das Modell die Sprache kennt. Die Bildstufe ist konditional darauf, ob die Anfrage visuelle Intention hat. Die Dense-Text-Stufe ist konditional darauf, ob das Modell katalog-artige Daten gesehen hat. Die BM25-Stufe ist das Einzige, das unbedingt läuft, weil BM25 keine Meinungen hat, mit denen man widersprechen müsste.

Manchmal ist die klügste Engineering-Entscheidung, das zu löschen, was du vor drei Wochen gebaut hast. Manchmal ist es nur, es hinter ein if zu setzen. Zu wissen, welches deine Situation braucht, ist die Senior-Version des Jobs. Meine in jenem Monat brauchte das if.

Der Reranker ist immer noch in Produktion. Er gibt Türkisch und Englisch weiterhin ihre +10 und +6. Mazedonisch bekommt weiterhin BM25 plus die kleine logistische Regression und einen erweiterten Kandidatenpool. Die Pipeline wurde nicht durch Hinzufügen klüger, sondern indem sie lernte, wann sie aus dem Weg gehen muss.

// wenn du schon hier bist