BM25 + dense + RRF, cross-encoder rerankers, ColBERT-as-reranker, multi-stage cascades, and latency budgets under 500 ms.
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.
NullPointerException, API endpoint paths)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.
BM25 (Best Match 25, Robertson & Walker 1994) is still the default sparse baseline in 2025. Understanding its parameters prevents misuse.
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).
| Parameter | Effect of increasing | Production default |
|---|---|---|
k1 | Higher k1 → less saturation → frequent terms get more weight. For technical corpora with rare keywords, lower k1 (1.0–1.5) is better. | 1.2 |
b | b=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 floor | Standard 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)) |
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.
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+.
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.
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.
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))
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.
| Model | Params | Latency (k=100, A100) | BEIR avg NDCG@10 | Notes |
|---|---|---|---|---|
| BGE-reranker-v2-m3 | 568 M | ~150 ms | ~57 | Best open-weights; multilingual; pair with BGE-M3 bi-encoder |
| BGE-reranker-large | 335 M | ~80 ms | ~55 | English-only; lower latency than v2-m3 |
| Cohere Rerank 3.5 | — | API ~200 ms | ~60 | Best precision on proprietary benchmarks; >100 languages; $0.002/1k chunks |
| mxbai-rerank-large-v1 | 435 M | ~110 ms | ~56 | Open weights (Apache 2.0); competitive with BGE-reranker-large |
| ms-marco-MiniLM-L-6-v2 | 22 M | ~15 ms | ~48 | Extremely fast; good for low-latency pipelines; quality trade-off |
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]
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.
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.
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.
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}
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.
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.
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.
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.
| Stage | Component | P50 latency | P99 latency | Optimisation lever |
|---|---|---|---|---|
| Query encode | BGE-base bi-encoder, single query, GPU | ~5 ms | ~12 ms | Batch queries; use fp16 or int8 |
| BM25 search | Elasticsearch / OpenSearch, 1 M docs | ~15 ms | ~40 ms | Tune shard count; avoid deep pagination |
| Dense ANN | Qdrant HNSW, 1 M docs, ef=80 | ~8 ms | ~20 ms | Lower ef_search; binary quant with rescore |
| RRF fusion | Python, 2 lists of 100 | <1 ms | <1 ms | No optimisation needed |
| Reranker | BGE-reranker-large, k=100, A100 | ~80 ms | ~120 ms | Reduce k to 50; use MiniLM for <20 ms |
| LLM generation | Claude Haiku / GPT-4o-mini, 5 docs | ~300 ms | ~900 ms | Reduce context; stream response |
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.
Retrieval evaluation is distinct from generation evaluation. You need a labelled test set with known relevant documents per query.
| Metric | Formula | Interpretation | When 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| / k | Fraction 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@k | DCG@k / IDCG@k | Discounted cumulative gain; rewards relevant results at higher positions. Requires graded relevance. | Multi-document retrieval; BEIR standard |
| Answer faithfulness | LLM-as-judge | Does the generated answer cite the retrieved context? Requires generation eval (RAGAS/ARES). | End-to-end pipeline eval |
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.
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.