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

Sechs Monate RRF in Produktion: was dir k=60 nicht sagt

Die 60 in '1/(60+rank)' stammt aus einem Paper von 2009. Die meisten Teams, die sie kopieren, halten sie für einen Qdrant-Default.

hybrid-searchqdrantproduction

Recall@10 sah im Benchmark-Notebook großartig aus. Drei Wochen nach dem Produktivgang leitete mir das Merchant-CRM-Team Screenshots weiter: "warum liefert die Suche nach mavi gömlek eine marineblaue Decke?" Die Antwort lag in k.

Das ist der ganze Beitrag in einem Absatz. Die nächsten zehn Minuten sind die Details, die ich vor sechs Monaten gerne gelesen hätte — bevor ich RRF mit dem Default-Konstanten ausgerollt habe, das der Rest des Internets per Copy-Paste übernimmt.

Wie wir hierher kamen

Wir betreiben eine hybride Suchmaschine über einen grenzüberschreitenden Katalog zwischen der Türkei und Nordmazedonien — etwa 7 Millionen aktive Artikel, drei Retrieval-Modalitäten, eine Fusion-Stufe. Die Modalitäten sind die üblichen Verdächtigen:

  • ein mehrsprachiger Dense-Text-Encoder
  • ein BM25-Sparse-Index mit IDF-Reweighting
  • ein Bild-Embedding-Tower (CLIP/SigLIP) für cross-modales Retrieval

Als Qdrant native Hybrid-Suche ausgeliefert hat, dauerte die Umstellung einen Nachmittag. Das API ist sauber, die Fusion eingebaut, die Default-Fusionsstrategie ist RRF — Reciprocal Rank Fusion. Die Formel ist der Teil, den niemand hinterfragt:

score(d) = Summe über Modalitäten von 1 / (k + rank_modalität(d))

Dieses k ist eine Konstante. Die Qdrant-Docs verwenden 60. Die Elastic-Docs verwenden 60. Die HuggingFace-Beispiele verwenden 60. Eine Seniorin in meinem Team, die seit zehn Jahren Suche baut, hat 60 verwendet. Also habe ich 60 verwendet.

Der naive Benchmark vs. echte Nutzer

Unser Offline-Eval-Set bestand aus 4.200 gelabelten Anfragen. nDCG@10 mit k=60 lag bei 0,61. Recall@10 bei 0,78. Die Zahlen waren im Notebook in Ordnung. Ich rollte hinter einem Feature-Flag mit 5 Prozent aus, beobachtete die Dashboards eine Woche, zog auf 100 Prozent. Latenz hielt. Fehlerrate flach. Alles grün.

Dann begannen die Screenshots.

Ein Merchant-Ops-Lead aus dem Skopje-Büro schickte den mit der Marinedecke. Dann schickte eine Customer-Service-Supervisorin einen, in dem eine Suche nach Adidas Samba beyaz ein Paar weiße Socken zurückgab — Samba-gebrandet, technisch ein Match, aber Socken. Dann baute ein Junior aus dem Data-Team das Eval-Set aus einer Woche echter Produktions-Logs neu auf, und die Offline-Zahlen wurden schlechter: nDCG@10 fiel von 0,61 auf 0,49.

Das Eval-Set war sauber. Produktion war es nicht. Die interessanten Fehler waren alle der Schwanz einer Modalität, der oben im Fusion-Ergebnis auftauchte.

Warum k=60 dir konkret schadet

Schau dir die Formel nochmal an. Mit k=60 trägt ein Ergebnis, das in einer einzelnen Modalität auf Rang 200 steht, immer noch 1/(60+200) = 0,0038 zum fused Score bei. Das ist klein. Aber wenn eine Anfrage eine Modalität über viele Kandidaten sehr schwach trifft, stapeln sich diese winzigen Beiträge. Schlimmer: Der Dense-Text-Tower hatte die Angewohnheit, auf den Rängen 50 bis 200 minderwertige, semantisch benachbarte Nachbarn zu produzieren — Dinge wie marineblaue Decke, wenn man nach marineblauem Hemd fragte. Das Modell lag nicht falsch. Das Modell sagte "die sind irgendwie ähnlich." RRF nahm diese laue Meinung und gab ihr genug Gewicht, um in die Top 10 zu landen, sobald die anderen beiden Modalitäten nichts zu sagen hatten.

Die Screenshots des Merchant-CRM-Teams hatten alle dieselbe Form: eine Modalität hat nichts Brauchbares in den Top 20, die anderen beiden sind verrauscht, RRF näht eine Fusion-Liste zusammen, in der der Schwanz der verrauschten Modalitäten den Kopf der stillen überflügelt. Die Konstante k=60 ist der Regler, der bestimmt, wie laut der Schwanz schreien darf. Sechzig ist laut.

Das naive RRF, zur Referenz

Das war die Version, die ich ausgeliefert habe. Die Version, die gerade auf der Titelseite der Hälfte aller Such-Tutorials steht.

# Das nutzt Qdrants natives RRF. k ist implizit und gleich 60.
# Sieht unschuldig aus. Verhält sich, als schulde es dir nichts.
results = await client.query_points(
    collection_name="products",
    prefetch=[
        models.Prefetch(query=text_vec,  using="text",  limit=200),
        models.Prefetch(query=sparse,    using="bm25",  limit=200),
        models.Prefetch(query=image_vec, using="image", limit=200),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=20,
)

Es gibt nirgendwo in diesem Snippet ein k-Argument. Das ist das ganze Problem. Du erbst 60 durch Schweigen.

Der Sweep

Ich lief dasselbe 4.200-Anfrage-Eval-Set plus das aus Produktion neu gebaute Set mit k in . Ergebnisse waren nicht subtil.

knDCG@10 (sauber)nDCG@10 (prod)Kommentar
200,690,71Kopf dominiert, Schwanz gedämpft
300,740,73Sweet Spot für unseren Mix
600,610,49der ausgelieferte Default
1200,550,44Schwanz ist laut
2000,480,38alle stimmen gleich ab, schlecht

Bei Long-Tail-Anfragen (vier oder mehr Tokens, oft eine Phrase) war k=20 sogar besser — 0,77 — weil der Kopf jeder Modalität meist vertrauenswürdig war und wir das Smoothing nicht brauchten.

Kleines k ist scharf. Großes k ist matschig. Der Default ist matschig.

Heterogenes k schlägt homogenes k

Das war der Teil, den ich nicht kommen sah. Wir haben drei Modalitäten mit sehr unterschiedlichen Rangverteilungen. BM25 hat einen steilen Kopf — die ersten zehn Ergebnisse sind meist die einzigen zehn, die zählen, dann fällt es vom Kliff. Der Dense-Text-Tower ist sanfter — die ersten vierzig sind alle plausibel verwandt. Der Bild-Tower ist der sanfteste von allen — die ersten hundert können nützlich sein, gerade für cross-modale Anfragen.

Wenn du ein k für alles verwendest, untergewichtest du BM25s sicheren Kopf und übergewichtest den verrauschten Schwanz des Bild-Towers. Der Fix war, jeder Modalität ihr eigenes k zu geben:

# Per-Modality-RRF: Wir schreiben die Fusion selbst, weil Qdrants
# native Fusion ein k für alle Prefetches nimmt.
def per_modality_rrf(hits_by_modality: dict[str, list[str]],
                     k_per_modality: dict[str, int]) -> dict[str, float]:
    scores: dict[str, float] = defaultdict(float)
    for modality, ids in hits_by_modality.items():
        k = k_per_modality[modality]
        for rank, doc_id in enumerate(ids, start=1):
            scores[doc_id] += 1.0 / (k + rank)
    return scores
 
# Was nach dem Sweep in Produktion landete.
K = {"bm25": 15, "text": 40, "image": 60}

BM25 mit k=15 bedeutet, dass der Kopf der Stichwortliste dominiert, wenn Stichwörter matchen. Der Text-Tower mit k=40 bedeutet, dass wir etwa seinen Top dreißig vertrauen, bevor es unscharf wird. Der Bild-Tower behält k=60, weil sein Rang 100 ehrlich gesagt manchmal für bildförmige Anfragen noch nützlich ist. nDCG@10 auf dem Produktions-Set stieg mit dieser einen Änderung von 0,49 auf 0,74.

In diesen Zahlen liegt keine Magie. Das ist, was aus dem Sweep gefallen ist. Der Punkt ist, dass es verschiedene Zahlen sind, und das Default-API erlaubt dir keine verschiedenen Zahlen.

Anfragelängen-bewusstes k

Das andere, was der Sweep enthüllt hat: Kurze und lange Anfragen wollen unterschiedliches Smoothing. Eine zweitokige Anfrage wie kırmızı elbise sollte fast vollständig durch den Kopf jeder Liste bedient werden — es gibt nicht viel zu disambiguieren, die Nutzerin weiß, was sie will. Eine sechstokige Anfrage wie yazlık keten erkek pantolon bej beden 32 ist eine lange dünne Nadel, die von einem leicht breiteren Netz profitiert.

// chooseK wählt k pro Modalität und pro Anfragelänge.
// Die Konstanten unten kamen aus einem Sweep über sechs Wochen Prod-Logs.
// Kopier sie nicht, sweep deine eigenen. Deine werden anders sein.
func chooseK(modality string, tokenCount int) int {
    base := map[string]int{"bm25": 15, "text": 40, "image": 60}[modality]
    switch {
    case tokenCount <= 2:
        return base / 2
    case tokenCount >= 5:
        return base + 20
    default:
        return base
    }
}

Ja, das ist mehr Code als Fusion.RRF. Ja, es produziert bessere Ergebnisse als Fusion.RRF. Der Handel ist der Handel.

Der Eval-Harness, kurz

Der Grund, warum irgendetwas davon verteidigbar ist, ist der Harness. Wir lassen jede Nacht Evals gegen ein eingefrorenes Produktions-Log-Sample mit dem aktuellsten Katalog-Snapshot laufen. Der Harness darf den Build brechen, wenn nDCG@10 in irgendeinem Locale um mehr als 1,5 Punkte fällt.

async def eval_run(queries: list[QueryRecord], k_config: dict) -> Report:
    # Lass dieselbe Anfrage-Batch unter Kandidaten-Config und aktueller Prod-Config laufen.
    # Vergleiche pro Locale, weil TR und MK sehr unterschiedliche Verteilungen haben.
    cand = await run_batch(queries, k_config)
    prod = await run_batch(queries, PROD_K_CONFIG)
    return Report(
        per_locale_ndcg=ndcg_at_k_per_locale(cand, prod, k=10),
        regression_threshold=1.5,
    )

Es ist die langweilige Infrastruktur, die das interessante Tuning möglich macht. Ohne Harness tunest du nach Bauchgefühl und Tickets, und Tickets sind ein langsamer Gradient.

Das, was dir niemand sagt

Die 60 in 1/(60+rank) stammt aus einem Paper von 2009 von Cormack, Clarke und Buettcher über die Kombination der Ergebnisse mehrerer Suchmaschinen auf TREC-Tracks. Sie probierten eine Handvoll Konstanten, und 60 schnitt zufällig auf den Korpora, die sie hatten, gut ab. Die meisten Teams, die das kopiert haben, halten es für einen Qdrant-Default. Ist es nicht. Es ist eine Zahl aus einem Paper, dimensioniert für einen Benchmark, der nicht dein Traffic ist.

Wenn ich dir eine Regel mitgeben müsste, wäre es diese: k ist kein Confidence-Parameter und auch kein "wie sehr vertraue ich dieser Modalität"-Parameter. Es ist ein Smoothing-Parameter, der entscheidet, wie viel der Schwanz jeder Liste zum fusionierten Kopf beiträgt. Wähle es pro Modalität. Wähle es für deinen Traffic. Wähle es neu, wenn sich dein Katalog oder Anfragenmix ändert. Der Default-Wert ist jemand anderes' Traffic.

Sechs Monate später ist unser k zweimal gewandert. Einmal, als wir mazedonisch-kyrillische Anfragen hinzufügten und der BM25-Kopf zuversichtlicher wurde. Einmal, als wir Bild-Encoder tauschten und der Bild-Schwanz viel weniger verrauscht wurde. Jede Bewegung war ein Sweep, ein Eval und ein leiser PR mit dem Titel "k tuning, Juni 2026." Keine im Team ist mehr romantisch zu der Zahl. Es ist ein Regler. Wir drehen ihn, wenn die Daten sich drehen.

// wenn du schon hier bist