Search-rankingstack: BM25, Embeddings en Reranking

Automatische vertaling Dit artikel is automatisch vertaald vanuit de oorspronkelijke Engelse versie.

Search moet zowel aan exacte als aan semantische intent voldoen. Een query als “wireless headphones” moet die woorden matchen, maar de uiteindelijke volgorde kan ook afhangen van productkwaliteit, gebruikersvoorkeuren en beschikbaarheid. Geen enkele rankingmethode verwerkt al die signalen goed.

In deze post bouwen we de stack stap voor stap op: BM25 retrieval, dense embeddings, Reciprocal Rank Fusion, cross-encoder reranking en ten slotte LLM listwise ranking. Een begeleidende demo-repository bevat uitvoerbare code voor de verschillende stappen op een gesamplede subset van de Amazon ESCI product-searchdata.

Zie voor de korte handleiding voor stage-selectie BM25 vs Embeddings vs Rerankers.


Kies stages op basis van failure mode

De production stack is een funnel, maar de juiste funnel hangt af van de query en het zakelijke toepassingsgebied.

Use caseCandidate starting stackValidate
Product searchBM25 + dense retrieval + RRF + cross-encoderAttribute recall, substitutions, latency, business constraints
Documentation searchHybrid retrieval + cross-encoderExact identifiers, semantic questions, version filters
Support deflectionHybrid retrieval + citation checksRetrieval recall, grounding, abstention
Marketplace or listingsLexical filters + dense retrieval + business rerankerAvailability, freshness, policy, seller diversity
Small internal corpusBM25 baseline, then a rerankerWhether vocabulary mismatch justifies a dense index
High-stakes legal or medical searchRecall-focused retrieval plus expert reviewCoverage, provenance, calibrated abstention

Begin met BM25 als baseline. Voeg dense retrieval toe wanneer vocabulary mismatch de recall schaadt. Voeg een cross-encoder toe wanneer de eerste pagina de juiste candidates bevat, maar in de verkeerde volgorde. Voeg pas een LLM toe wanneer je de latency kunt dragen en de rankingbeslissingen kunt evalueren.

Hoe we hier zijn gekomen

De stack is het eenvoudigst te begrijpen als drie lagen. Lexical retrieval vindt exacte termen, dense retrieval overbrugt vocabulary gaps en rerankers vergelijken de sterkste candidates in detail.

BM25 en lexical retrieval

Decennialang was BM25 de default. Het is een probabilistisch model dat documenten scoort op basis van de frequentie van querytermen in het document, genormaliseerd voor documentlengte en inverse document frequency (IDF).

BM25 is sterk wanneer letterlijke termen de intent dragen: foutcodes, product-SKU’s, namen en API-identifiers. De belangrijkste beperking is vocabulary mismatch. Een query als “cheap laptop” kan een document over een “budget notebook computer” missen wanneer de geïndexeerde tekst geen brug tussen die uitdrukkingen biedt.

Dat neemt niet weg dat BM25 een solide baseline is. De BEIR-leaderboard rapporteert een gemiddelde nDCG@10 van 0.429 over 18 datasets voor de BM25 multifield-run. Pyserini’s reproductie gebruikt een Lucene multifield-index met contents=1.0 en title=1.0, doorzocht met --bm25. Dit is niet de plain-whitespace-tokenized rank_bm25-implementatie die in deze demo wordt gebruikt. BM25 verslaat ook sommige neural models op argumentative retrieval-taken zoals Touche-2020.

Dense retrieval en embeddings

BERT-achtige encoders maakten dense retrieval praktisch toepasbaar. Ze mappen queries en documenten naar een gedeelde vectorruimte en rangschikken candidates vervolgens met een similarity function zoals cosine similarity of dot product.

De bi-encoder- (of “two-tower”-)architectuur verwerkt de query en het document onafhankelijk via afzonderlijke encoder-towers en produceert embeddings met een vaste lengte. Documentvectors kunnen vooraf worden berekend en offline worden geïndexeerd, waarna ze snel kunnen worden opgehaald met Approximate Nearest Neighbor (ANN)-algoritmen. Zo komen “cheap laptop” en “budget notebook” dicht bij elkaar in de vectorruimte te liggen.

Sommige bi-encoders gebruiken een Siamese architecture, zoals in Sentence-BERT, waarbij beide zijden dezelfde weights delen. Andere gebruiken afzonderlijke query- en documenttowers. Pooling, vectorgrootte, similarity function en training objective zijn modelkeuzes, geen eigenschappen van elke dense retriever.

Deze models worden getraind met contrastive learning, meestal met de InfoNCE-loss. Gegeven een batch van paren van het type (query, positive_document) maximaliseert het objective sim(query, positive_doc) en minimaliseert het sim(query, negative_docs). Negatives komen uit de positives van andere queries in dezelfde batch (in-batch negatives). Een temperature-parameter τ\tau bepaalt hoe scherp het model de twee van elkaar moet scheiden.

Trainingsdata is vaak belangrijker dan de embeddingdimensie. Retrieval models leren van query-positive-paren en zorgvuldig geselecteerde hard negatives: plausibele maar niet-relevante documenten. In het latere trainingsgedeelte wordt getoond hoe SimANS zowel triviale negatives als vermoedelijke false negatives vermijdt.

De prijs is de representation bottleneck. Bi-encoders comprimeren alle semantische nuance in één vector met vaste grootte en missen daardoor vaak fijnmazige interacties tussen specifieke querytermen en specifieke documentinhoud.

Cross-encoders en LLMs

Cross-encoders (Nogueira & Cho, 2019) voeren de query en het document samen aan een Transformer als één aaneengeschakelde sequence ([CLS] Query [SEP] Document), zodat elke querytoken via full self-attention aandacht kan besteden aan elke documenttoken. Die diepe interactie detecteert nuance die independent encoding mist.

LLM reranking gebruikt een prompted model om meerdere candidates tegelijk te vergelijken. RankGPT liet sterke zero-shot listwise-resultaten met GPT-4 zien op de geëvalueerde benchmarks, maar output stability, cost en domain fit moeten nog steeds afzonderlijk worden getest.

Deze scores kunnen niet vooraf worden berekend voor willekeurige queries, dus vindt reranking plaats na retrieval. Die kostenasymmetrie motiveert de multi-stage funnel.


De multi-stage funnel

Een dure cross-encoder of LLM uitvoeren over miljoenen documenten is niet haalbaar. Daarom gebruiken moderne search stacks een funnel. Elke stage verkleint de candidate pool, terwijl de modelcomplexiteit toeneemt.

Een goedkope retriever heeft bovendien onvoldoende final precision. De funnel gebruikt elk model daarom alleen waar de kosten redelijk zijn.

Multi-Stage Ranking FunnelMulti-Stage Ranking Funnel

StageInput scalePrimary objectiveTypical methodsExit measurement
RetrievalCorpus or indexCandidate recallBM25, bi-encodersRecall at candidate cutoff
Pre-rankingLarge candidate setCheap filteringLightweight models, rulesRecall retained per millisecond
Full rankingShortlistTop-rank qualityCross-encoders, LLMsNDCG/MRR, latency, cost
BlendingFinal ranked lists or slotsConstraints and mixRules, multi-objective rankPolicy, diversity, business guardrails

Retrieval bepaalt het plafond en reranking optimaliseert daarbinnen. Als een relevant document retrieval niet overleeft, kan geen downstream model het terughalen.


De demo: een pipeline met vijf stages

Om dit concreet te maken, heb ik een search-ranking-stack-demo gebouwd die een pipeline met vijf stages uitvoert op de Amazon ESCI product-searchbenchmark. Elke stage wordt afzonderlijk gemeten, zodat je kunt zien waar de verbeteringen daadwerkelijk vandaan komen.

Demo Pipeline ArchitectureDemo Pipeline Architecture

De pipeline:

  1. BM25 sparse retrieval — lexical baseline (rank_bm25)
  2. Dense bi-encoder retrieval — semantic candidate generation (all-MiniLM-L6-v2)
  3. Hybrid RRF fusion — rank-based fusion van sparse en dense resultaten
  4. Cross-encoder reranking — pairwise relevance scores (ms-marco-MiniLM-L-12-v2)
  5. LLM listwise reranking — prompted comparison van de final shortlist (Ollama, API of local model)

Stappen 1—3 vormen de retrieval-stage van de funnel (recall maximaliseren); stappen 4—5 vormen de full ranking-stage (precision maximaliseren). De demo slaat pre-ranking en blending over. Met ongeveer 8.500 documenten kun je het je veroorloven alle hybrid results rechtstreeks naar reranking te sturen.

Snel starten

git clone https://github.com/slavadubrov/search-ranking-stack.git
cd search-ranking-stack
uv sync

# Download and sample ESCI dataset (~2.5GB download, ~5MB sample)
uv run download-data

# Run the full pipeline (without LLM reranking)
uv run run-all

# Run with LLM reranking via Ollama
uv run run-all --llm-mode ollama

Dataset en sampling: Amazon ESCI

De demo gebruikt de Amazon Shopping Queries Dataset (ESCI) van de KDD Cup 2022 — een realistische product-searchbenchmark met relevance labels op vier niveaus:

LabelGainMeaningExample (Query: “wireless headphones”)
Exact (E)3Satisfies all query requirementsSony WH-1000XM5 Wireless Headphones
Substitute (S)2Functional alternativeWired headphones with Bluetooth adapter
Complement (C)1Related useful itemHeadphone carrying case
Irrelevant (I)0No meaningful relationshipUSB charging cable

Graded relevance is belangrijk omdat je daarmee NDCG (Normalized Discounted Cumulative Gain) kunt gebruiken, dat een “perfecte” ranking onderscheidt van een “slechts adequate”. Binaire metrics scoren beide hetzelfde.

Ik gebruikte de small_version-sample van de demo: ongeveer 500 queries, 8.500 producten en 12.000 judgments. De downloader leest de enige train-split van tasksource/esci, filtert op de Engelse us-locale en small_version == 1, en gebruikt seed 42 om maximaal 500 unieke query-ID’s zonder replacement te samplen. Het corpus bevat de unieke producten uit de geselecteerde judgment-rows. Dit is geen held-out split en geen onafhankelijk corpus. De sample is klein genoeg om op een laptop te draaien, maar te klein en te domeinspecifiek om een production ranking te onderbouwen. Gebruik hem om de stages te reproduceren en failure modes te inspecteren; gebruik held-out, representatieve queries voor deploymentbeslissingen.


De taak van de retrieval layer is recall maximaliseren: zoveel mogelijk relevante documenten in de candidate set krijgen.

BM25: de lexical baseline

BM25 scoort documenten op term-overlap met de query, met verzadiging van de termfrequentie en normalisatie van de documentlengte:

BM25(q,d)=tqIDF(t)tf(t,d)(k1+1)tf(t,d)+k1(1b+bd/avgdl)\text{BM25}(q, d) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{tf(t,d) \cdot (k_1 + 1)}{tf(t,d) + k_1 \cdot (1 - b + b \cdot |d|/\text{avgdl})}

Hierbij is IDF(t)\text{IDF}(t) de inverse document frequency van term tt, tf(t,d)tf(t,d) de term frequency in document dd, d|d| de documentlengte en avgdl\text{avgdl} de gemiddelde documentlengte over het corpus. Twee parameters zijn belangrijk: k1k_1 (doorgaans 1.2—2.0) bepaalt de TF-saturatie — hoe snel herhaalde termen geen extra waarde meer toevoegen — en bb (doorgaans 0.75) bepaalt de normalisatie van de documentlengte.

De implementatie is kort. Plain-whitespace-tokenization met rank_bm25:

# src/search_ranking_stack/stages/s01_bm25.py

from rank_bm25 import BM25Okapi

def run_bm25(data: ESCIData, top_k: int = 100):
    doc_ids = list(data.corpus.keys())
    tokenized_corpus = [text.lower().split() for text in data.corpus.values()]

    bm25 = BM25Okapi(tokenized_corpus)

    results = {}
    for query_id, query_text in data.queries.items():
        scores = bm25.get_scores(query_text.lower().split())
        top_indices = np.argsort(scores)[::-1][:top_k]
        results[query_id] = {doc_ids[idx]: float(scores[idx]) for idx in top_indices}

    return results

BM25 komt uit op een Recall@100 van 0.741 — 74% van de relevante producten verschijnt ergens in de top 100. Niet slecht voor een puur lexical method, maar 26% van de relevante items is onzichtbaar voor elke downstream stage.

Dense bi-encoder retrieval

De bi-encoder mapt queries en documenten onafhankelijk naar een gedeelde embedding space:

# src/search_ranking_stack/stages/s02_dense.py

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")

# Encode corpus once, cache to disk
corpus_embeddings = model.encode(
    doc_texts,
    batch_size=128,
    normalize_embeddings=True,  # Cosine sim = dot product
    convert_to_numpy=True,
)

# At query time: encode query, compute dot product
query_embeddings = model.encode(query_texts, normalize_embeddings=True)
similarity_matrix = np.dot(query_embeddings, corpus_embeddings.T)

Met genormaliseerde embeddings reduceert cosine similarity tot een dot product. De demo berekent de volledige query-by-corpus-matrix omdat 8.500 documenten gemakkelijk in memory passen; een production corpus zou normaal gesproken een approximate-nearest-neighbor-index gebruiken. In deze sample verhoogt all-MiniLM-L6-v2 Recall@100 van 0.741 naar 0.825.

Hoe bi-encoders goede representations leren

Bi-encoder training verloopt meestal in twee phases. Eerst wordt het model pre-trained op Natural Language Inference (NLI)- en Semantic Textual Similarity (STS)-datasets, die general-purpose semantic understanding aanleren. Daar leert het model dat “a cat sits on a mat” en “a feline rests on a rug” vergelijkbare embeddings moeten hebben. Vervolgens wordt het fine-tuned op retrieval-specifieke data zoals MS MARCO, waar het leert dat een search query en de relevante passage dichter bij elkaar moeten liggen dan de query en irrelevante passages.

De tweede phase hangt af van hard negative mining. Random negatives, zoals een document over koken gekoppeld aan een query over headphones, zijn triviaal van elkaar te onderscheiden. Het model leert er dus weinig van. In plaats daarvan gebruik je het huidige model zelf om documenten te vinden die het hoog rangschikt, maar die in werkelijkheid niet relevant zijn.

De SimANS-aanpak (Simple Ambiguous Negatives Sampling) formaliseert dit. Rangschik alle documenten met de huidige bi-encoder, sluit vervolgens easy negatives uit — die staan te laag om het model iets te leren — en mogelijke false negatives, die zo hoog staan dat ze misschien wel relevant zijn maar geen label hebben. Wat in het midden overblijft bevat het meeste trainingssignaal.

# What a training triplet looks like after hard negative mining
training_triplet = {
    "query": "wireless noise canceling headphones",
    "positive": "Sony WH-1000XM5 Wireless Noise Cancelling Headphones",
    "negative": "Sony headphone replacement ear pads",  # Hard negative: same brand, related product, but wrong intent
}
# The bi-encoder must learn that "ear pads" is NOT what the user wants,
# even though it shares many tokens with the positive document.

De contrastive loss function (InfoNCE) brengt dit samen. Voor elke query qq met positief document d+d^+ en een set negatieve documenten {d1,,dn}\{d^-_1, \ldots, d^-_n\}:

L=logesim(q,d+)/τesim(q,d+)/τ+i=1nesim(q,di)/τ\mathcal{L} = -\log \frac{e^{\text{sim}(q, d^+) / \tau}}{e^{\text{sim}(q, d^+) / \tau} + \sum_{i=1}^{n} e^{\text{sim}(q, d^-_i) / \tau}}

Hierbij is sim(q,d)\text{sim}(q, d) de cosine similarity tussen query- en documentembeddings en τ\tau de temperature parameter (doorgaans 0.05—0.1). Lagere waarden maken de loss gevoeliger voor hard negatives. In essentie is dit een softmax cross-entropy: verhoog de similarity van het positieve paar ten opzichte van alle negatives. Wanneer τ\tau klein is, leiden zelfs kleine verschillen in similarity tot grote gradients, waardoor het model fijnmaziger onderscheid moet leren maken.

Bi-Encoder Training PipelineBi-Encoder Training Pipeline

Bi-encoder embeddings op schaal serveren

Het architecturale voordeel van een bi-encoder is de offline/online-split. Documentembeddings worden tijdens index time berekend en in een vector index opgeslagen. Tijdens query time encodeert het systeem de query en doorzoekt het de opgeslagen vectors. Latency hangt af van de encoder, hardware, index, filters en recall target; profileer daarom beide stappen afzonderlijk.

In de demo is de berekening bescheiden: 8.500 documenten ×\times 384 dimensies ×\times 4 bytes per float = ongeveer 13 MB aan embeddings. Op production scale zijn de getallen niet meer bescheiden: 1 miljard documenten met 768-dimensionale embeddings vereisen ongeveer 3 TiB opslag. Daar komen quantization (het comprimeren van 32-bit floats naar 8-bit integers), product quantization (het opsplitsen van vectors in subspaces) en SSD-backed indexes zoals DiskANN in beeld. De dense-vector indexing-sectie behandelt de indexalgoritmen.

Bi-Encoder Serving PipelineBi-Encoder Serving Pipeline

Waarom hybrid retrieval testen

De twee methoden falen vaak op verschillende manieren. BM25 is geschikt voor eigennamen, product-SKU’s en foutcodes. Dense retrieval kan vocabulary mismatch herstellen, zoals “cheap laptop” versus “budget notebook computer”. Of fusion helpt, hangt af van hoe vaak zulke complementaire gevallen voorkomen in de target query set.

Een gebruikelijk volgend experiment is hybrid search: voer beide retrievalmethoden uit en fuseer daarna hun ranked lists.

Reciprocal rank fusion (RRF)

BM25 en dense retrieval produceren scores met verschillende betekenissen en schalen. Een lineaire combinatie vereist daarom calibration en validation wanneer de retrievers of het corpus veranderen.

Hybrid Search with RRFHybrid Search with RRF

Reciprocal Rank Fusion (Cormack et al., 2009) laat raw scores volledig buiten beschouwing en gebruikt alleen de rankpositie:

RRF(d)=rRankings1k+rank(d,r)\text{RRF}(d) = \sum_{r \in \text{Rankings}} \frac{1}{k + \text{rank}(d, r)}

Hierbij is kk een smoothing constant; 60 is een gebruikelijk startpunt. RRF beloont items die in de input lists dicht bij de top staan, zonder hun raw scores te vergelijken. Het vermijdt score-scale calibration, maar de retrieval cutoffs, weights en kk moeten nog steeds worden geëvalueerd.

De implementatie:

# src/search_ranking_stack/stages/s03_hybrid_rrf.py

def reciprocal_rank_fusion(ranked_lists, k=60, top_k=100):
    fused_results = {}

    for query_id in all_query_ids:
        rrf_scores = defaultdict(float)

        for results in ranked_lists:
            sorted_docs = sorted(results[query_id].items(),
                                 key=lambda x: x[1], reverse=True)

            for rank, (doc_id, _score) in enumerate(sorted_docs, start=1):
                rrf_scores[doc_id] += 1.0 / (k + rank)

        sorted_rrf = sorted(rrf_scores.items(),
                            key=lambda x: x[1], reverse=True)[:top_k]
        fused_results[query_id] = dict(sorted_rrf)

    return fused_results

Hybrid RRF bereikt een Recall@100 van 0.842 en een NDCG@10 van 0.628 — beter dan BM25 (0.585) en Dense (0.611) afzonderlijk. Documenten hoeven maar in één methode goed te ranken om fusion te overleven.


Cross-encoder reranking

Met 100 hybrid candidates per query kun je een duurder model betalen. De cross-encoder verwerkt de query en het document samen via één Transformer, met full cross-attention tussen alle tokens.

Bi-Encoder vs. Cross-EncoderBi-Encoder vs. Cross-Encoder

Interactie op tokenniveau

Het verschil zit in de attention matrix. In een bi-encoder is attention block-diagonal: querytokens besteden alleen aandacht aan andere querytokens en documenttokens alleen aan andere documenttokens. De twee representations ontmoeten elkaar nooit op tokenniveau — ze kruisen elkaar alleen aan het einde via een dot product. Een cross-encoder berekent de full attention matrix, waarin elke querytoken aandacht besteedt aan elke documenttoken en omgekeerd. Die cross-attention maakt diepe interactie op tokenniveau mogelijk.

Cross-Encoder Attention ArchitectureCross-Encoder Attention Architecture

In een bi-encoder wordt de query “apple” gecodeerd voordat er een document is gezien. Een cross-encoder ziet de query en candidate samen en kan daardoor hun relatie op tokenniveau gebruiken. Dit kan helpen bij gevallen zoals:

  • Negatie: “headphones die niet wireless zijn.” Een pooled embedding kan de negatie onderwaarderen, terwijl joint encoding het model een directe query-documentinteractie geeft. Dit is een hypothese die je met een gerichte slice moet verifiëren, geen garantie.
  • Beperking: “laptop onder $500.” Joint encoding kan de constraint relateren aan een prijs in de producttekst, hoewel structured price filters veiliger zijn wanneer het veld beschikbaar is.

De input van de cross-encoder wordt geformatteerd als [CLS] query tokens [SEP] document tokens [SEP]. [CLS] is een classification token waarvan de final hidden state via een linear head wordt gebruikt om één relevance score te produceren. Segment embeddings onderscheiden querytokens van documenttokens en [SEP] markeert de grens tussen de segments.

Hoe cross-encoders worden getraind

Cross-encoders kunnen leren van (query, document, relevance_label)-examples met pointwise, pairwise of listwise objectives. Het pointwise-example hieronder gebruikt één relevance label; dit is niet het enige mogelijke training design.

# Cross-encoder training data format
training_example = {
    "query": "wireless headphones",
    "document": "Sony WH-1000XM5 Wireless Headphones",
    "label": 1.0,  # Relevant
}
# Forward pass: [CLS] hidden state → Linear layer → sigmoid → score
# Loss: binary cross-entropy between predicted score and label

Een gebruikelijke classifier mapt de laatste [CLS]-representation naar een score. Binaire labels kunnen binary cross-entropy gebruiken; graded relevance kan regression, ordinal, pairwise of listwise losses gebruiken. Kies op basis van held-out ranking metrics in plaats van aan te nemen dat één objective universeel beter is.

Hard negative mining is nog belangrijker voor cross-encoders dan voor bi-encoders. Cross-encoders zijn duur om te trainen — elk training example vereist een volledige forward pass door de aaneengeschakelde sequence — dus je kunt het je niet veroorloven compute te verspillen aan triviaal eenvoudige negatives. Het praktische recept: gebruik een bi-encoder om voor elke training query de top-K candidates op te halen en haal vervolgens hard negatives uit specifieke rank ranges (bijvoorbeeld ranks 10–100). Zo krijgt de cross-encoder voorbeelden waarin diepe tokeninteractie nodig is om relevant van irrelevant te onderscheiden.

# src/search_ranking_stack/stages/s04_cross_encoder.py

from sentence_transformers import CrossEncoder

model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-12-v2")

def run_cross_encoder(data, hybrid_results, top_k_rerank=50):
    reranked_results = {}

    for query_id, query_text in data.queries.items():
        candidates = list(hybrid_results[query_id].items())[:top_k_rerank]

        # Form (query, document) pairs for joint encoding
        pairs = []
        doc_ids = []
        for doc_id, _ in candidates:
            doc_text = data.corpus.get(doc_id, "")[:2048]
            pairs.append([query_text, doc_text])
            doc_ids.append(doc_id)

        # Score all pairs with full cross-attention
        scores = model.predict(pairs, batch_size=64)

        # Rerank by cross-encoder score
        scored_docs = sorted(zip(doc_ids, scores),
                             key=lambda x: x[1], reverse=True)
        reranked = {
            doc_id: float(score) for doc_id, score in scored_docs
        }

        # Keep original hybrid candidates at ranks 51--100 for Recall@100.
        for doc_id, score in list(hybrid_results[query_id].items())[top_k_rerank:100]:
            if doc_id not in reranked:
                reranked[doc_id] = float(score) * 0.01

        reranked_results[query_id] = reranked

    return reranked_results

In de vastgelegde demo-run rerankt ms-marco-MiniLM-L-12-v2 50 candidates per query en verhoogt het NDCG@10 van 0.628 naar 0.645. Meet de latency op de deploymenthardware; model, sequence length, batch size en runtime beïnvloeden allemaal het resultaat.

De speed-quality trade-off

Waarom gebruiken we cross-encoders niet overal? Omdat je de scores niet vooraf kunt berekenen. Bi-encoder documentembeddings zijn query-onafhankelijk, dus je berekent ze één keer en slaat ze op. De output van een cross-encoder hangt af van de query én het document samen. De relevance score voor “wireless headphones” gekoppeld aan een Sony-product komt voort uit de volledige cross-attention tussen die specifieke tokens. Je kunt deze score niet cachen of hergebruiken voor een andere query.

Een bi-encoder heeft één query encoding nodig plus een vector search over vooraf berekende documentembeddings. Een cross-encoder scoort elk query-documentpaar in de shortlist, waarbij de kosten toenemen met zowel het aantal candidates als de sequence length. Batching helpt, maar 100.000 candidates scoren is nog steeds het verkeerde operating point; doe eerst retrieval en benchmark de grootste shortlist die aan de quality- en latencydoelen voldoet.

In de demo blijft Recall@100 vlak op 0.842 gedurende de cross-encoder-stage. Reranking kan resultaten herschikken, maar geen documenten toevoegen. Retrieval bepaalt het plafond.


LLM listwise reranking

De laatste demo-stage gebruikt een LLM voor listwise reranking. In plaats van elk document afzonderlijk te scoren, ziet het model de top 10 en retourneert het een ordering. Geïnspireerd door RankGPT maakt de prompt relative comparison expliciet, maar hij introduceert ook context limits, position bias, parsing failures en variantie tussen runs.

LLM Reranking ApproachesLLM Reranking Approaches

De listwise prompt

De prompt template vraagt de LLM rekening te houden met de ESCI relevance hierarchy:

# src/search_ranking_stack/stages/s05_llm_rerank.py

def _create_listwise_prompt(query, documents, max_words=200):
    n = len(documents)

    doc_texts = []
    for i, (doc_id, doc_text) in enumerate(documents, start=1):
        words = doc_text.split()[:max_words]
        doc_texts.append(f"[{i}] {' '.join(words)}")

    return (
        f"I will provide you with {n} product listings, each indicated by "
        f"a numerical identifier [1] to [{n}]. Rank the products based on "
        f'their relevance to the search query: "{query}"\n\n'
        "Consider:\n"
        "- Exact matches should rank highest\n"
        "- Substitutes should rank above complements\n"
        "- Irrelevant products should rank lowest\n\n"
        f"{chr(10).join(doc_texts)}\n\n"
        "Output ONLY a comma-separated list of identifiers: [3], [1], [2], ...\n"
        "Do not explain your reasoning."
    )

Drie execution modes

De demo ondersteunt drie backends voor LLM reranking:

ModeModelHow it runs
ollamallama3.2:3b (configurable)Local via Ollama API
apiclaude-haiku-4-5-20251001Anthropic API
localQwen/Qwen2.5-1.5B-InstructHuggingFace Transformers

Parsing en fallback

LLM-outputs volgen niet gegarandeerd het gevraagde schema, dus parsing en een fallback path zijn belangrijk:

def _parse_ranking(output: str, n: int) -> list[int] | None:
    """Parse LLM output to extract ranking order."""
    matches = re.findall(r"\[(\d+)\]", output)

    if not matches:
        return None

    positions = [int(m) - 1 for m in matches]

    # Pad with remaining positions if LLM returned partial output
    if len(positions) < n:
        seen = set(positions)
        for i in range(n):
            if i not in seen:
                positions.append(i)

    return positions[:n]

Als parsing volledig faalt, valt de demo terug op de cross-encoder-ordering. Een production parser moet ook identifiers buiten het toegestane bereik en dubbele identifiers afwijzen, weggelaten candidates in hun eerdere volgorde toevoegen, de failure loggen en het fallbackpercentage vergelijken met een launch threshold.


Resultaten van deze ESCI-sample

Deze vastgelegde resultaten vóór de LLM-stap gebruiken de small_version van de demo: de Engelse us locale na het small_version == 1-filter, met maximaal 500 query-ID’s die zonder replacement met seed 42 zijn gesampled en een corpus dat is opgebouwd uit hun beoordeelde producten. De waarden van MRR gebruiken de binary rule relevance > 0 van de demo-evaluator: een Complement-, Substitute- of Exact-resultaat telt als relevant, terwijl een Irrelevant-resultaat dat niet doet. recip_rank evalueert elke geretourneerde candidate list, die maximaal 100 candidates bevat; dit is geen MRR@10.

StageNDCG@10MRRRecall@100NDCG Delta
BM250.5850.8120.741
Dense Bi-Encoder0.6110.8080.825+0.026
Hybrid (RRF)0.6280.8340.842+0.017
+ Cross-Encoder0.6450.8600.842+0.017

Belangrijkste observaties

Hybrid search verslaat beide afzonderlijke methoden. RRF NDCG (0.628) is hoger dan zowel BM25 (0.585) als Dense (0.611). De twee methoden kunnen op verschillende queries falen; door ze te combineren haal je documenten terug die één van beide afzonderlijk zou missen.

Recall wordt tijdens retrieval bepaald. Recall@100 blijft gedurende de cross-encoder-stage vlak op 0.842. Rerankers herschikken resultaten, maar voegen geen documenten toe. Als je een hogere recall wilt, moet je de retrieval layer verbeteren. De bovenstaande MRR-values gebruiken dezelfde demo-brede regel: Complement of hoger is relevant.

Het LLM-resultaat wordt niet gerapporteerd. De parser accepteert dubbele identifiers en identifiers buiten het toegestane bereik, waarna het padding path de candidate list stilzwijgend kan wijzigen. De eerdere LLM-vergelijking is daarom illustratief en geen auditable result. Voer de vergelijking opnieuw uit met strikte identifier validation, gelogd fallbackgedrag, herhaalde runs, latency- en costmetingen en een held-out domain set voordat je enige verbetering aan de reranker toeschrijft.

De begeleidende repository is nuttig voor setup en code, maar de README rapporteert nog steeds de ongeldige + LLM Reranker NDCG@10 van 0.717. Beschouw de pre-LLM-tabel in dit artikel en de bovenstaande LLM-caveat als het authoritative record totdat die repository een auditable LLM-evaluation bevat.

Dense retrieval verslaat BM25 op deze sample. Inspecteer query slices voordat je de oorzaak vastlegt. Vocabulary mismatch is één plausibele factor, maar ook de sampleconstructie, tokenizer, trainingsdomain van het model en corpusvelden beïnvloeden de vergelijking.


Evaluation: meten wat ertoe doet

De demo gebruikt drie metrics, die elk vanuit een andere invalshoek naar de ranking kijken:

NDCG@10 (primary metric)

Normalized Discounted Cumulative Gain meet de kwaliteit van de top-10-ranking met graded relevance. De metric beloont het hoog plaatsen van sterk relevante documenten met een logarithmische discount:

DCG@k=i=1k2reli1log2(i+1)NDCG@k=DCG@kIDCG@k\text{DCG@k} = \sum_{i=1}^{k} \frac{2^{rel_i} - 1}{\log_2(i + 1)} \qquad \text{NDCG@k} = \frac{\text{DCG@k}}{\text{IDCG@k}}

Van de drie metrics gebruikt alleen NDCG ESCI’s graded relevance met vier niveaus volledig. Een systeem dat een Exact-match op positie 1 plaatst, scoort hoger dan een systeem dat daar een Substitute plaatst. Daarom is dit hier de primary metric.

MRR (eerste Complement-or-higher-resultaat)

Mean Reciprocal Rank gebruikt de positie van het eerste resultaat dat als relevant telt. In deze demo geeft de evaluator ESCI’s graded qrels door aan pytrec_eval en gebruikt hij recip_rank over elke geretourneerde list met maximaal 100 candidates. relevance > 0 is dus de binaire threshold: Complement (1), Substitute (2) en Exact (3) tellen als relevant. Irrelevant (0) niet. Een relevant resultaat op positie 1 geeft een reciprocal rank van 1.0; op positie 3 is dat 0.333. Dit is MRR over de geretourneerde candidate lists, niet MRR@10.

Recall@100 (retrieval coverage)

Recall meet welk aandeel van de beoordeelde relevante documenten in de top 100 verschijnt. Het is een candidate-ceiling-metric voor de geëvalueerde judgments: een reranker kan geen document toevoegen dat retrieval heeft weggelaten, terwijl incomplete judgments het schijnbare plafond onzeker kunnen maken.


Dense vectors indexeren buiten de demo

Dense embeddings worden pas op schaal nuttig wanneer je een Approximate Nearest Neighbor (ANN)-index hebt. De demo gebruikt brute-force cosine similarity (wat prima is voor ongeveer 8.500 documenten), maar production systems hebben gespecialiseerde indexes nodig.

HNSW (hierarchical navigable small world)

HNSW bouwt een multi-layer graph: sparse upper layers navigeren breed en dichtere lower layers verfijnen de neighborhood. M bepaalt de graph connectivity, terwijl efSearch query work inruilt voor recall. Nuttige waarden hangen af van dimension, distance distribution, filters, implementation en target recall.

Updates en deletions zijn een operationele overweging omdat graph indexes mogelijk background repair nodig hebben. Het gedrag verschilt per database. Een Qdrant issue rapporteert bijvoorbeeld verminderde filtered-searchkwaliteit in een multi-tenant HNSW-configuratie. Het rapport mat een mean precision@100 van 0.597 ± 0.0541 met een library_id-filter, terwijl exact search in de diepere analyse 100% recall bereikte. Het wijzigen van payload_m forceerde een rebuild die de filtered-searchkwaliteit herstelde. Het rapport bespreekt tenant filters, HNSW-configuratie en een geforceerde HNSW-indexrebuild, niet een deletion-heavy workload. Reproduceer het beoogde churn pattern en neem compaction- of rebuildgedrag op in de evaluation.

IVF (inverted file)

IVF-indexes partitioneren de vector space in clusters en scannen vervolgens de nprobe-clusters die het dichtst bij de query liggen. Ze kunnen een nuttige trade-off bieden tussen memory, build time en recall, vooral in combinatie met compression. Update semantics en performance hangen af van de implementation, niet alleen van de index family.

Voor extreme scale comprimeert IVF_RaBitQ (Gao & Long, SIGMOD 2024) floating-point vectors naar single-bit representations. In een high-dimensional space bevat het teken (+/-) van een coordinate voldoende angular information voor similarity computation.

DimensionHNSW graphIVF clusters
Query controlefSearchnprobe
Build controlConnectivity and construction beamCluster count and training sample
Memory profileGraph edges plus vectorsCentroids, lists, and stored vectors
Update behaviorDatabase-specific repair/cleanupDatabase-specific list maintenance
Evaluate withRecall-latency-memory-churn curveRecall-latency-memory-churn curve

In één Uber delivery-search case study verlaagde het reduceren van een search parameter op shardniveau van 1.200 naar 200 de gerapporteerde latency met 34% en CPU-gebruik met 17%, met weinig gemeten recallverlies. De herbruikbare les is dat je de recall-cost-curve afstemt op production-like traffic, niet dat je de waarde 200 kopieert.


Optionele uitbreidingen na de core pipeline

Zodra retrieval en reranking afzonderlijk worden gemeten, zijn verschillende uitbreidingen eenvoudiger te evalueren zonder de core pipeline te vertroebelen.

Query understanding

Query expansion en rewriting kunnen vocabulary mismatch vóór retrieval aanpakken. Query2doc genereerde pseudo-documents en rapporteerde BM25-gains op zijn MS MARCO-experimenten. Expansion kan ook de verkeerde intent introduceren, dus vergelijk recall en precision op slices met ambiguous, navigational en exact-identifier queries.

Praktische patronen zijn abbreviation expansion, entity enrichment, sub-query decomposition voor multi-hop reasoning en RAG-Fusion — meerdere queryvarianten genereren en de resultaten combineren via RRF.

LLM-assisted relevance labeling

LLMs kunnen relevance labels opstellen wanneer human judgments schaars zijn. TALEC en Pinterest’s werk aan relevance labeling bieden twee geëvalueerde designs. Een LLM-label is nog steeds model output: kalibreer het tegen geblindeerde human judgments, inspecteer disagreement slices en houd een human gold set bij voor regression tests.

Nuttige controls zijn:

  • een rubric met concrete grenzen voor relevance en voorbeelden;
  • geblindeerde human calibration en periodieke hercontroles;
  • order randomization en herhaalde judgments voor instabiele gevallen;
  • model panels wanneer de extra cost de agreement verbetert; en
  • expliciete checks op position bias en central-tendency bias.

Knowledge distillation

Wanneer een LLM-teacher waarde toevoegt maar niet aan de serving constraints kan voldoen, is distillation een optie:

  1. Gebruik een krachtige LLM (de teacher) om duizenden training queries te reranken
  2. Train een kleine, snelle cross-encoder (de student, ongeveer 100M–200M parameters) om de ranking distribution van de LLM na te bootsen
  3. Vergelijk de student met zowel teacher als baseline op quality, calibration en serving cost

InRanker distilleert MonoT5-3B naar models met 60M en 220M parameters — een reductie van 50x in grootte met competitieve performance. De aanpak Rank-Without-GPT produceert 7B open-source listwise rerankers die met QLoRA fine-tuning 97% van de effectiviteit van GPT-4 behalen.

Gepubliceerde compression-resultaten zijn startpunten, geen verwachte production ratios. Distillation kan de biases van de teacher overnemen en kwaliteit verliezen op zeldzame query slices. Houd daarom de oorspronkelijke relevance judgments in de evaluation loop.


Personalisatie en position bias

Generieke relevance brengt je maar tot op zekere hoogte. Een search naar “apple” moet iPhones teruggeven aan een techliefhebber en recepten met appels aan iemand die kookcontent heeft bekeken.

Een gebruikelijke retrieval architecture voor personalisatie gebruikt een two-tower embedding model: de query tower encodeert de query en user context, terwijl de item tower items en metadata encodeert. De offline/online-split ondersteunt approximate-nearest-neighbor retrieval; de latency hangt nog steeds af van encoder, index, filters en serving system.

Airbnb’s listing embeddings, Pinterest’s OmniSearchSage en Uber’s two-tower systems tonen verschillende production designs. Hun scale en gerapporteerde uplifts horen bij die systemen; het overdraagbare patroon is de offline item tower plus een online query/user tower.

Clickdata bevat position- en exposure bias. PAL is één debiasing approach: gebruik position tijdens training en houd die tijdens serving constant. Dit is geen universele oplossing; randomized interventions, inverse-propensity methods en counterfactual evaluation kunnen geschikter zijn voor een ander product.


Domain adaptation met synthetic queries

Een veelgemaakte fout in search strategy is aannemen dat een model dat op algemene webdata is getraind (zoals MS MARCO) goed zal werken in een gespecialiseerd domain. Dit is het out-of-domain (OOD)-problem.

LLMs kunnen de bottleneck van gelabelde data verkleinen, maar niet elimineren, via Generative Pseudo-Labeling (GPL, InPars):

  1. Neem je domain-specifieke document corpus
  2. Prompt een LLM met: “Generate a search query that this document would answer”
  3. Gebruik de synthetic (query, document)-pairs om je retriever en reranker te fine-tunen

Synthetic pairs kunnen helpen wanneer echte queries schaars zijn, maar ze weerspiegelen de generator en prompt. Dedupliceer ze, filter implausible queries en valideer op echte held-out traffic.

Een experiment sequence

Voeg alleen complexiteit toe wanneer de vorige stage een meetbare failure blootlegt:

Practical Maturity PathPractical Maturity Path

Stap 1 (baseline): implementeer BM25 of het huidige lexical system en bouw een judged query set. Leg recall, NDCG, latency en failure slices vast.

Stap 2 (candidate recall): test dense retrieval en fusion alleen wanneer de baseline relevante documenten mist. Stem de candidate cutoff af op recall en cost.

Stap 3 (ranking precision): voeg een cross-encoder toe wanneer de juiste candidates aanwezig zijn maar in de verkeerde volgorde staan. Kies de shortlist size op basis van een quality-latency-curve.

Stap 4 (domain fit): fine-tune of distill pas nadat generic models stabiele domain-specifieke failures laten zien. Houd echte held-out judgments gescheiden van synthetic training data.

Stap 5 (optionele dure layer): test listwise of reasoning-based reranking alleen wanneer de incrementele quality overeind blijft in herhaalde runs en latency, cost, privacy en fallback complexity rechtvaardigt.


Researchrichtingen die afzonderlijk moeten worden geëvalueerd

Reasoning rerankers en search-using agents zijn veelbelovend, maar beantwoorden andere vragen dan de vijf-stage product-searchdemo.

Reasoning-based rerankers

Rank1 traint rerankers met reasoning traces en rapporteert sterke resultaten op de BRIGHT-benchmark. Dit bewijs is relevant voor reasoning-heavy retrieval, niet voor een directe voorspelling van ESCI product search.

Vergelijk reasoning rerankers voor legal of scientific search met sterke cross-encoder- en listwise-baselines op expert judgments, citations, latency en failure consistency.

Search-o1 onderzoekt een model dat aanvullende searches uitvoert tijdens multi-hop question answering. Dit is een orchestration-probleem — query generation, stopping, evidence use en answer evaluation — geen extra reranking-stage. Evalueer dit met end-task correctness en citation support, niet alleen met retrieval metrics.


Belangrijkste conclusies

  1. Behandel de stack als een reeks experimenten. Stel een lexical baseline en een judged query set vast voordat je dense retrieval, fusion of reranking toevoegt.

  2. Meet candidate recall los van ranking precision. In deze ESCI-sample bereikt Recall@100 na fusion 0.842 en blijft deze waarde vlak gedurende beide reranking-stages.

  3. Gebruik hybrid retrieval wanneer errors complementair zijn. RRF verbeterde zowel Recall@100 als NDCG@10 in de demo, maar een ander corpus rechtvaardigt mogelijk geen twee indexes.

  4. Voeg een cross-encoder toe wanneer de shortlist juist is maar de volgorde niet. Kies het aantal candidates op basis van een gemeten quality-latency-curve.

  5. Behandel LLM reranking als een optioneel laatste experiment. De huidige LLM-vergelijking van de demo is illustratief, omdat de parser geen volledige permutation van candidate-ID’s valideert. Strikte parsing, gelogde fallbacks, herhaalde runs en cost horen bij de evaluation voordat je een verbetering rapporteert.

  6. Houd de verschillende bewijstypen gescheiden. Een paperresultaat, een vendor case study, deze laptopdemo en een production A/B-test beantwoorden verschillende vragen.

De volledige pipelinecode staat in de begeleidende repository. Clone de repository om de pre-LLM-stages te reproduceren of andere models en parameters te testen; beschouw de verouderde LLM-row niet als een resultaat.

References

Papers

Datasets and benchmarks

Models used in the demo

Tools and platforms

Industry references

Demo project