RAG & Retrieval Systems Series — Presentation 03

Hybrid Search & Reranking

BM25 + dense + RRF, cross-encoder rerankers, ColBERT-as-reranker, multi-stage cascades, and latency budgets under 500 ms.

BM25 RRF Fusion BGE-Reranker Cohere Rerank ColBERT PLAID NDCG Multi-Stage
BM25 → Dense ANN → RRF Fusion → Top-100 → Cross-Encoder → Top-10 → LLM Context
00

Topics We'll Cover

01

Why Dense Alone Falls Short

Dense retrieval encodes global semantics but loses lexical specificity. A query for "CVE-2024-1234" or "errno ENOSPC" or a product SKU will fail completely against a dense index if those exact strings were not seen in the training distribution — the model has no way to generalise a random identifier it has never encountered.

Where dense fails

  • Rare named entities (CVE IDs, part numbers, chemical formulae)
  • Exact keyword search (“the exact phrase” contract language)
  • New vocabulary added after training cutoff
  • Code identifiers (NullPointerException, API endpoint paths)
  • Highly technical jargon in specialised domains

Where BM25 fails

  • Paraphrase and synonym queries (“car” vs “automobile”)
  • Multilingual queries over an English corpus
  • Long-context semantic matching
  • Abstractive questions (“What causes X?”)
  • Queries shorter than 3–4 words with common terms
Empirical evidence

On the BEIR benchmark, BM25 achieves NDCG@10 of 31.7 averaged across 18 datasets. Top dense bi-encoders (BGE-M3, E5-mistral) achieve ~44–48. But on the Signal-1M (news tweet retrieval), FEVER (fact verification), and DBPedia tasks, BM25 still beats many bi-encoders. Hybrid consistently achieves 48–52 — a 5–10% relative gain over either alone.

02

BM25 — Mechanics, IDF, Length Normalisation

BM25 (Best Match 25, Robertson & Walker 1994) is still the default sparse baseline in 2025. Understanding its parameters prevents misuse.

BM25 scoring formula

For query Q with terms q𝑖 and document D:

score(Q,D) = ∑ IDF(q𝑖) × [tf(q𝑖,D) × (k₁+1)] / [tf(q𝑖,D) + k₁ × (1 − b + b × |D|/avgdl)]

where tf is term frequency in D, |D| is document length in tokens, avgdl is average document length, k₁ controls term-frequency saturation (typical: 1.2–2.0), and b controls length normalisation (typical: 0.75).

ParameterEffect of increasingProduction default
k1Higher k1 → less saturation → frequent terms get more weight. For technical corpora with rare keywords, lower k1 (1.0–1.5) is better.1.2
bb=1.0: full length normalisation. b=0: no normalisation. For short documents (FAQ, product titles), lower b (0.5) avoids penalising dense long docs.0.75
IDF floorStandard IDF can go negative for very common terms. Elasticsearch uses a floor at 0. Lucene 9+ uses a smoothed IDF.log(1 + (N−df+0.5)/(df+0.5))
Tokenise
→
Lowercase + Stem/Lemmatise
→
Remove stopwords
→
Build inverted index
→
BM25 score per doc
→
Rank list
03

Hybrid Fusion — RRF, Score Normalisation, Weighted Sum

Once you have two (or more) ranked lists — one from BM25, one from dense — you need a fusion strategy. The three main approaches differ in robustness and tunability.

Reciprocal Rank Fusion (RRF)

Score = ∑ 1/(k + rank𝑖) where k=60 is the smoothing constant. No score calibration needed — only rank positions. Extremely robust to score-scale differences between retrievers. Default choice for most hybrid RAG systems. Implemented natively in Elasticsearch 8.9+ and Qdrant 1.7+.

Score normalisation + weighted sum

Normalise each retriever's scores to [0,1] (min-max or softmax), then combine: α × dense_score + (1−α) × sparse_score. Allows tuning the weight α for your domain — technical corpora often need α=0.3 (more BM25 weight). Sensitive to score distribution shifts between queries.

Learned fusion

Train a small MLP or LambdaMART model to weight the retrievers based on query features. Used in Bing, Google, and enterprise search systems. Requires labelled query–document pairs. Rarely justified for a single-domain RAG system — RRF is within 1–2 NDCG points of a tuned learned fusion at zero training cost.

RRF fusion in Python (retriever results as lists of doc IDs)
from collections import defaultdict
from typing import List, Dict

def rrf_fusion(
    ranked_lists: List[List[str]],
    k: int = 60,
) -> Dict[str, float]:
    scores: Dict[str, float] = defaultdict(float)
    for ranked in ranked_lists:
        for rank, doc_id in enumerate(ranked, start=1):
            scores[doc_id] += 1.0 / (k + rank)
    return dict(sorted(scores.items(), key=lambda x: x[1], reverse=True))
04

Cross-Encoder Rerankers — BGE-Reranker, Cohere Rerank, mxbai

Cross-encoders achieve higher relevance precision than any bi-encoder because the query and document tokens attend to each other inside the encoder. The cost is O(k) forward passes per query where k is the candidate set size.

ModelParamsLatency (k=100, A100)BEIR avg NDCG@10Notes
BGE-reranker-v2-m3568 M~150 ms~57Best open-weights; multilingual; pair with BGE-M3 bi-encoder
BGE-reranker-large335 M~80 ms~55English-only; lower latency than v2-m3
Cohere Rerank 3.5—API ~200 ms~60Best precision on proprietary benchmarks; >100 languages; $0.002/1k chunks
mxbai-rerank-large-v1435 M~110 ms~56Open weights (Apache 2.0); competitive with BGE-reranker-large
ms-marco-MiniLM-L-6-v222 M~15 ms~48Extremely fast; good for low-latency pipelines; quality trade-off
BGE-reranker-v2-m3 via sentence-transformers CrossEncoder
from sentence_transformers import CrossEncoder

reranker = CrossEncoder(
    "BAAI/bge-reranker-v2-m3",
    max_length=512,
    device="cuda",
)

pairs = [(query, doc) for doc in candidate_docs]
scores = reranker.predict(pairs, batch_size=64)

reranked = sorted(
    zip(candidate_docs, scores),
    key=lambda x: x[1],
    reverse=True,
)[:top_k]
Truncation is a silent quality killer

Cross-encoders have a token budget (typically 512 tokens for BERT-based models). If query + document exceeds this, the document is truncated. For long passages (>300 tokens), use a chunk-and-max strategy: split the document into overlapping windows, score each window independently, take the max score. This adds latency but recovers 3–5 NDCG points on long-document corpora.

05

ColBERT as a Reranker — PLAID and Late Interaction at Scale

ColBERT (covered in Deck 01) can serve dual purpose: as the first-stage retriever (requires token-level index) or as a late-interaction reranker over a short-list of bi-encoder candidates. Used as a reranker, it has cross-encoder-level quality with 3–5× lower latency for k=100.

Bi-encoder
Top-1000
→
ColBERT encode
all 1000 docs
(token vectors)
→
MaxSim
per query token
(batched on GPU)
→
Sum scores
→ Top-20

PLAID (Performance-optimised Late Interaction Driver) extends ColBERTv2 with centroid-based candidate pruning. For reranking over a pre-filtered short-list, you can skip the PLAID indexing step and run MaxSim directly on GPU in <50 ms for k=200.

RAGatouille ColBERT reranker (pip install ragatouille)
from ragatouille import RAGPretrainedModel

reranker = RAGPretrainedModel.from_pretrained("colbert-ir/colbertv2.0")

# Rerank a pre-retrieved list (query, list of passage strings)
results = reranker.rerank(
    query="transformer attention mechanism",
    documents=candidate_passages,   # list[str], len <= 1000
    k=10,
)
# Returns list of {"content": str, "score": float, "rank": int}
When to choose ColBERT vs cross-encoder reranker

ColBERT reranker wins when: (1) candidate list is large (k>200), (2) you have GPU available, (3) you need to stay open-source. Cross-encoder wins when: (1) you need the absolute best MRR on a small k (≤50), (2) you can tolerate higher latency, (3) you want a managed API (Cohere). For most production pipelines: bi-encoder top-100 → ColBERT reranker top-20 → LLM with top-5 is a strong default cascade.

06

Multi-Stage Cascades — Recall vs Precision Per Stage

Every stage in a retrieval cascade has a different objective. Confusing recall (don't miss relevant docs) at stage 1 with precision (don't include irrelevant docs) at stage 3 is the most common architectural mistake in RAG pipelines.

Stage 1
BM25 (sparse)
Dense ANN bi-encoder
ColBERT full-index
→ goal: Recall@1000 > 95%
Fusion
RRF (k=60)
Weighted sum
→ Top-100 to reranker
Stage 2
Cross-encoder / ColBERT
Cohere Rerank API
→ Top-10 to LLM; goal: Precision@10 > 80%
Stage 3
LLM relevance filter
Contextual compression
→ Top-5 into context window
The recall ceiling problem

If stage 1 misses a relevant document, no downstream stage can recover it. Therefore: optimise stage 1 for recall, not precision. A Recall@100 of 92% means 8% of user queries will have the answer in positions 101+ — invisible to the reranker. Monitor Recall@100 on a labelled evaluation set before tuning any downstream stage.

07

Latency Budgets — Where Reranking Fits in < 500 ms

A user-facing RAG endpoint must complete end-to-end in <500 ms for interactive use. The retrieval pipeline typically needs to stay within 200 ms to leave budget for LLM generation.

StageComponentP50 latencyP99 latencyOptimisation lever
Query encodeBGE-base bi-encoder, single query, GPU~5 ms~12 msBatch queries; use fp16 or int8
BM25 searchElasticsearch / OpenSearch, 1 M docs~15 ms~40 msTune shard count; avoid deep pagination
Dense ANNQdrant HNSW, 1 M docs, ef=80~8 ms~20 msLower ef_search; binary quant with rescore
RRF fusionPython, 2 lists of 100<1 ms<1 msNo optimisation needed
RerankerBGE-reranker-large, k=100, A100~80 ms~120 msReduce k to 50; use MiniLM for <20 ms
LLM generationClaude Haiku / GPT-4o-mini, 5 docs~300 ms~900 msReduce context; stream response
Parallelise BM25 + dense retrieval

BM25 and dense ANN searches are independent — run them in parallel using asyncio.gather or a thread pool. This collapses two ~20 ms operations into one. Fusion then adds <1 ms. Total stage-1 time: ~25 ms instead of ~40 ms. If you use Weaviate or Elasticsearch's built-in hybrid, the parallelism happens server-side automatically.

08

Eval — Recall@k, MRR, NDCG, Manual Review

Retrieval evaluation is distinct from generation evaluation. You need a labelled test set with known relevant documents per query.

MetricFormulaInterpretationWhen to use
Recall@k|relevant ∩ top-k| / |relevant|Fraction of relevant docs found in top k. Critical for stage-1 quality.Always; k=100 for stage-1, k=10 for stage-2
Precision@k|relevant ∩ top-k| / kFraction of top-k that are relevant. Tells you noise seen by the LLM.Stage-2 and final context quality
MRR@k(1/|Q|) × ∑ 1/rank𝑖Mean reciprocal rank of first relevant result. Sensitive to position 1 quality.Single-answer factual RAG
NDCG@kDCG@k / IDCG@kDiscounted cumulative gain; rewards relevant results at higher positions. Requires graded relevance.Multi-document retrieval; BEIR standard
Answer faithfulnessLLM-as-judgeDoes the generated answer cite the retrieved context? Requires generation eval (RAGAS/ARES).End-to-end pipeline eval
Building a labelled test set without annotation teams

Use LLM-generated queries: for each document chunk, prompt a model to generate 3–5 questions that the chunk answers. The (question, chunk) pair is a positive label. Negative labels: pick chunks with BM25 rank 10–30 for the same query (hard negatives). 500–1000 pairs is sufficient for reliable Recall@10 and NDCG@10 estimates. Validated against human-labelled subsets in BEIR, this approach yields within 2–3% of human label quality.

09

What to Take Away

Where to next

Deck 04 covers the ingestion side: chunking strategies, layout-aware parsing with Unstructured / MinerU / LlamaParse / Docling, ColPali vision-based parsing, deduplication, and incremental ingestion pipeline design.