Search Tech Journey

Find topics, journeys and posts

6-month learning plan118 / 130
back to blog
llmadvanced 50m read

S118 · RAG II — Retrieval, Hybrid Search, Reranking

The other 80% of RAG performance — dense vs sparse vs hybrid retrieval, cross-encoder reranking, query rewriting, and the failure modes each one fixes.

LLMsM14 · LLMs & Applications· Session 118 of 130 90 min

🎯 Design a production retrieval pipeline (dense + BM25 + rerank + query rewrite) that beats vector-only search by 10–30 recall points.

Why this session exists

Vector search alone is not enough. Every serious RAG system in 2026 layers three retrievers (dense, sparse, and sometimes a knowledge-graph lookup), fuses their results, and reranks the top candidates with a cross-encoder before passing anything to an LLM. Get this stack right and your bot answers correctly with 3 chunks. Get it wrong and 20 chunks won't save you. This session teaches you each layer, when it earns its cost, and how to measure whether it's actually helping.

You will be able to
  • Explain dense (embedding), sparse (BM25), and hybrid retrieval — and pick one for a new problem.
  • Add a cross-encoder reranker and measure the recall@K vs precision@K trade-off.
  • Implement query rewriting (multi-query, HyDE) and know when each pays off.
  • Use Reciprocal Rank Fusion (RRF) to combine multiple retrievers cleanly.
  • Diagnose 'the right chunk exists but the retriever missed it' bugs and know which layer to add.

Prerequisites

  • S117 · RAG I — Chunking & Indexing. You need chunks + an index to retrieve from.
  • S116 · Prompting — for query rewriting.
  • Rough sense of TF-IDF / BM25 (any classical IR video).


(a) Intuition · 5 min

Two different librarians, one arbiter
🌍 Real world

You walk into a library with a question. One librarian (dense/embedding) understands meaning — ask for 'books about how AI thinks' and they know you mean 'cognition + LLMs'. Another librarian (sparse/BM25) is a keyword pedant — ask for 'RFC 6749 OAuth 2.0 refresh token grant' and they find the exact page in seconds where the semantic librarian is stumped.

You ask both. Each gives you a stack of ~20 candidates. Then an arbiter (the reranker) reads each candidate carefully and returns the top 3 that actually answer your question.

💻 Code world

This is the modern RAG retriever. Dense = embeddings (semantic recall). Sparse = BM25 or SPLADE (lexical exactness). Reranker = a small cross-encoder that scores query+chunk pairs one at a time — 10-100× slower per pair than vector search, but only over 20 candidates instead of 10 million.

Add query rewriting on the front (LLM turns 'how do i refresh?' into 'OAuth 2.0 refresh token flow') and you have every production RAG stack in the industry.

The four retrieval layers, ranked by 'should I add it?'
  • 1. Dense retrieval (embeddings) — the baseline. Semantic recall. Get this working first.
  • 2. Sparse / BM25 in parallel — catches rare terms, IDs, product codes, error strings. Cheap. Add early.
  • 3. Cross-encoder reranking on the top-20 — the biggest quality lever, usually 10–30 point NDCG@10 gain. Add second.
  • 4. Query rewriting / HyDE / multi-query — for ambiguous or short queries. Add when top-K recall is still lacking.

Timeline of the retrieval stack

  1. 1994
    BM25 · Okapi
    Probabilistic term-weighting scheme. Still the sparse-retrieval baseline 30 years later.
  2. 2019
    DPR · dense passage retriever
    Karpukhin et al. — trainable bi-encoder retrieves passages via dense embeddings. Foundation of modern RAG.
  3. 2020
    Sentence-Transformers
    Reimers & Gurevych — reusable off-the-shelf embeddings. Retrieval quality without training your own encoder.
  4. 2022
    HyDE + query rewriting
    Gao et al. — have an LLM produce a hypothetical answer, embed it, retrieve. Massive gains on out-of-domain queries.
  5. 2023
    Cohere Rerank + bge-reranker
    High-quality cross-encoder rerankers become commodity APIs and open models.
  6. 2024
    Hybrid becomes default
    Every serious vector DB (Pinecone, Weaviate, Elastic, pgvector+tsvector) ships hybrid search out of the box.

(b) Visual walkthrough · 15 min

The production retrieval pipeline

Dense vs Sparse — the classic complementarity

Dense (embedding) retriever

Semantic — 'means the same thing'

  • Understands paraphrase and synonymy
  • Handles typos and different phrasings
  • Weak on rare terms, IDs, exact strings
  • Cost: one vector-db query
Sparse (BM25) retriever

Lexical — 'contains the same words'

  • Exact match on product codes, IDs, error strings
  • Interpretable — you can see WHY it matched
  • Poor on synonyms ('car' vs 'vehicle')
  • Cost: one inverted-index query
Hybrid (dense + sparse + fusion)

Both, always

  • Recall gains 10–20 points on mixed workloads
  • Especially strong on enterprise/technical corpora
  • RRF fuses without tuning weights
  • Cost: 2× retrievers, negligible in practice

Reciprocal Rank Fusion — the tuning-free combiner

1no tuning
RRF constant k (usually 60)

For each document, sum 1/(k + rank_i) across all retrievers where it appears. k dampens the boost from being #1 in any single list — prevents one retriever from dominating.

2safe
Fuse ranks, not scores

Never try to combine cosine similarity with BM25 scores directly — they live on different scales. RRF only uses ranks, which are comparable.

3consensus
Docs appearing in both retrievers rise

The whole idea: documents both retrievers agree on get double-weighted (semantically AND lexically relevant).

Cross-encoder reranking

Why cross-encoders win on quality but not on scale

Bi-encoder (embedding model)
Encodes query and chunk SEPARATELY into vectors. Similarity is a single dot product. Fast — vectors precomputed at ingest. Quality: good.
fast
Cross-encoder (reranker)
Encodes query and chunk TOGETHER through a full BERT-style model. Output is a single relevance score. Slow — must run once per (q,c) pair at query time. Quality: dramatically better.
smart
Why the split matters
10M chunks × cross-encoder = infeasible. 20 chunks × cross-encoder = 200ms. So: use bi-encoder to shortlist, cross-encoder to rerank. Best of both.
combine

Query rewriting patterns

Multi-query

LLM generates 3–5 paraphrases; retrieve for each; fuse

  • Helps ambiguous or short queries
  • Cost: 3–5× retrieval calls + 1 LLM call
  • Simple, robust
  • Common LangChain recipe
HyDE

LLM writes a hypothetical answer; embed that; retrieve

  • Great for out-of-distribution queries
  • Turns a bad query into a good pseudo-answer to match on
  • Cost: 1 extra LLM call
  • Downside: LLM makes things up (that's OK for retrieval)
Step-back / decomposition

LLM asks a broader question first, then original

  • Helps multi-hop questions
  • Two retrievals: broad context + specific answer
  • Slower, deeper
  • For research-style bots

Common misconception
✗ What most people think

"Embeddings capture meaning, so dense vector search strictly dominates keyword search. BM25 is legacy — I only need a vector index."

✓ What is actually true

Dense retrieval generalises but cannot guarantee exact matching. Rare identifiers — error codes, SKUs, function names, ticket IDs, drug names — are exactly the tokens embedding models compress away, because they were rare in training. BM25 finds them reliably because it matches the literal term and weights it by inverse document frequency, which rewards rarity. The two methods fail on disjoint query types, which is why hybrid wins.

Why the myth is so sticky

Because the demos that sell dense retrieval are all paraphrase queries — "how do I reset my password" against a doc titled "credential recovery" — where BM25 genuinely fails and embeddings look magical. Nobody demos "find ORA-01555" or "where is `computeDeltaSnapshot` called", where the ranking inverts completely. And the dense failure is silent: it returns plausible, topically-related, wrong documents rather than nothing.

Prove it to yourself

Run both retrievers over the same corpus with two query classes and compare recall@10:

# Class A (paraphrase): 'how do I roll back a failed deployment'
# Class B (exact term) : 'ORA-01555', 'ERR_CACHE_MISS_7', 'v2.14.3-hotfix'
#
# Expect roughly:
#   dense : strong on A, weak on B (the rare token has no distinctive direction)
#   BM25  : weak on A, strong on B (IDF makes the rare token decisive)
#
# Report recall@10 per class, not averaged. The average hides the
# whole phenomenon -- which is why teams ship dense-only and are
# surprised in production.
From first principles
Start with the question

Why does a cross-encoder reranker beat a bi-encoder retriever on quality, yet cannot replace it? Derive why the architecture forces a two-stage system.

  1. 1
    A bi-encoder embeds query and document independently: score = sim(f(q), f(d)). The document's vector is computed without ever seeing the query.
    forced by · independence is what allows documents to be embedded once, offline, and stored in an index
  2. 2
    That independence is exactly what makes sub-linear search possible: a fixed vector per document can be organised into an ANN structure and searched in roughly O(log N).
    forced by · you cannot build an index over a function that depends on a query you have not seen yet
  3. 3
    But it also caps quality. All query–document interaction must be squeezed through a single dot product between two fixed vectors, so the document vector must anticipate every possible query in one representation.
    forced by · one vector cannot be simultaneously optimal for all the different questions a long document could answer
  4. 4
    A cross-encoder instead concatenates query and document and runs full attention over the pair, letting every query token attend to every document token. Relevance can then depend on precise term interactions, negation, and conditions.
    forced by · joint attention removes the information bottleneck entirely
  5. 5
    But now the score is a function of the pair, so nothing can be precomputed — scoring N documents requires N forward passes of a transformer, which is O(N) with a large constant. Over a million documents that is hopeless per query.
    forced by · no index can be built over a function that requires the query as input
⇒ Therefore

Therefore the two-stage architecture is forced, not stylistic: stage one must be a bi-encoder (or BM25) because only decomposable scoring is indexable; stage two can be a cross-encoder because it only runs on the ~50–200 candidates stage one returned.

And note what this predicts: overall quality is bounded by stage-one recall, not its precision. A reranker can only reorder what it was given — if the right document is not in the top-k, no reranker recovers it. So the correct tuning strategy is: maximise recall@k cheaply in stage one (retrieve generously, use hybrid), then let the reranker supply precision. Teams that tune stage-one precision are optimising a quantity that stage two was going to fix anyway.

Mental modelWide net, then fine sieve

Retrieval is a funnel with a hard rule: each stage can only lose relevant documents, never recover them. Stage one casts a wide, cheap net over millions of documents optimised purely for recall. Stage two applies expensive, accurate scoring to the small survivor set, optimised purely for precision.

So the diagnostic question is always "at which stage was the right document lost?" — and it has exactly one answer per failure.

  • Hybrid = dense (semantics, paraphrase) + sparse/BM25 (exact terms, rare identifiers). Fuse with RRF, which needs no score calibration because it uses ranks, not scores.
  • Retrieve generously (k around 50–200) and rerank down to 3–10. Under-retrieving is the most common and most invisible cause of bad RAG.
  • Query transformation happens before retrieval: rewriting a conversational follow-up into a standalone query, or generating several sub-queries. Without it, "and what about the second one?" retrieves nothing.
  • Metadata filters (tenant, date, permissions) are correctness, not ranking. They must be applied inside the index, not after — post-filtering silently reduces your k.
🔔 Fires when you see

Fire this model the moment you see: a RAG system that answers plausibly but wrong · queries containing IDs, codes or exact names failing · a multi-turn chat losing the thread · someone reporting "the model hallucinated" without checking whether the passage was even retrieved · permission-filtered search returning too few results.

The tradeoff

Your first-stage retrieval has adequate recall. Do you add a cross-encoder reranker, spend on a better embedding model, or just widen k and give the LLM more context?

Add a cross-encoder reranker
+ you gain the largest single quality gain available per unit of engineering effort in most RAG systems, because it fixes precision at the exact point where it matters; it is also model-agnostic and can be swapped independently
− you pay real added latency (a transformer pass over every candidate, batched but not free) and a second model to host, monitor and version; and it cannot fix recall failures at all
pick when relevant documents are being retrieved but ranked below irrelevant ones — measurable as recall@50 much higher than recall@5
Upgrade the embedding model
+ you gain improves first-stage recall itself, which raises the ceiling for everything downstream; and it adds no query-time latency once indexed
− you pay requires re-embedding the entire corpus — a real cost at scale and a migration with a dual-index period; gains are usually incremental, and it still cannot match exact rare terms
pick when recall@50 is genuinely low, or your current model is small/old and your domain is far from its training distribution
Widen k and let the LLM sort it out
+ you gain zero extra infrastructure; modern long-context models can absorb many passages, and more context means the answer is at least present somewhere
− you pay cost and latency scale directly with context length; and quality does not scale monotonically — irrelevant passages actively distract, and position effects mean a passage buried mid-context may be ignored entirely
pick when small corpora, prototypes, or when the retrieval quality gap is small enough that brute force is genuinely cheaper than a reranker
What a senior engineer actually does

Measure recall@k and recall@5 separately before choosing — that one comparison identifies the failing stage unambiguously. High recall@50 with low recall@5 is a ranking problem and wants a reranker. Low recall@50 is a retrieval problem and no reranker will help.

In practice hybrid retrieval plus a reranker is the configuration that survives contact with real production queries, because it covers both failure modes rather than optimising one. Adding context length is the tempting shortcut and the one that quietly makes both cost and quality worse.


(c) Hands-on · 25 min

Build a hybrid retriever with reranking on top of the S117 Chroma index. This is the code your production RAG will look like — minus the concurrency, caching, and monitoring.

"""rag_retrieve.py — hybrid retrieval + cross-encoder reranking.
 
Assumes S117's Chroma collection 'docs_v1' already exists.
Requires: pip install sentence-transformers chromadb rank_bm25
"""
from __future__ import annotations
from collections import defaultdict
from sentence_transformers import SentenceTransformer, CrossEncoder
from rank_bm25 import BM25Okapi
import chromadb
 
 
EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"  # tiny + strong
COLLECTION = "docs_v1"
K_RETRIEVE, K_RERANK, K_FINAL = 50, 20, 5
 
 
class HybridRetriever:
    def __init__(self):
        self.client = chromadb.PersistentClient(path="./chroma_store")
        self.coll = self.client.get_collection(COLLECTION)
        self.embed = SentenceTransformer(EMBED_MODEL)
        self.rerank = CrossEncoder(RERANK_MODEL)
 
        # Load all docs once for BM25 (fine up to a few hundred K chunks).
        all_docs = self.coll.get(include=["documents", "metadatas"])
        self.docs = all_docs["documents"]
        self.ids = all_docs["ids"]
        self.metas = all_docs["metadatas"]
        tokenised = [d.lower().split() for d in self.docs]
        self.bm25 = BM25Okapi(tokenised)
 
    # ---------- Dense retrieval ----------
    def dense(self, q: str, k: int = K_RETRIEVE) -> list[tuple[str, str, dict]]:
        qv = self.embed.encode([q])[0].tolist()
        hits = self.coll.query(query_embeddings=[qv], n_results=k,
                               include=["documents", "metadatas"])
        return list(zip(hits["ids"][0], hits["documents"][0], hits["metadatas"][0]))
 
    # ---------- Sparse retrieval (BM25 in-memory) ----------
    def sparse(self, q: str, k: int = K_RETRIEVE) -> list[tuple[str, str, dict]]:
        scores = self.bm25.get_scores(q.lower().split())
        top = sorted(range(len(scores)), key=lambda i: -scores[i])[:k]
        return [(self.ids[i], self.docs[i], self.metas[i]) for i in top]
 
    # ---------- Reciprocal Rank Fusion ----------
    def fuse(self, *result_lists, k_const: int = 60) -> list[tuple[str, str, dict]]:
        score_by_id: dict[str, float] = defaultdict(float)
        doc_by_id: dict[str, tuple[str, dict]] = {}
        for results in result_lists:
            for rank, (id_, doc, meta) in enumerate(results, start=1):
                score_by_id[id_] += 1.0 / (k_const + rank)
                doc_by_id[id_] = (doc, meta)
        ranked_ids = sorted(score_by_id, key=lambda i: -score_by_id[i])
        return [(id_, *doc_by_id[id_]) for id_ in ranked_ids]
 
    # ---------- Cross-encoder rerank ----------
    def rerank_top(self, q: str, candidates, k: int = K_FINAL):
        pairs = [(q, doc) for _, doc, _ in candidates[:K_RERANK]]
        scores = self.rerank.predict(pairs)
        ranked = sorted(zip(scores, candidates[:K_RERANK]),
                        key=lambda x: -x[0])
        return [(s, id_, doc, meta) for s, (id_, doc, meta) in ranked[:k]]
 
    # ---------- One-shot: dense + sparse + fuse + rerank ----------
    def search(self, q: str) -> list[tuple[float, str, str, dict]]:
        dense_hits  = self.dense(q)
        sparse_hits = self.sparse(q)
        fused = self.fuse(dense_hits, sparse_hits)
        return self.rerank_top(q, fused, k=K_FINAL)
 
 
if __name__ == "__main__":
    r = HybridRetriever()
    for q in ("How do I refresh an OAuth token?",
              "error code E-4821 damaged shipment",
              "What is chunking in RAG?"):
        print(f"\n=== {q!r} ===")
        for score, id_, doc, meta in r.search(q):
            print(f"  [{score:+.3f}] {meta.get('breadcrumb','(no bcrumb)')}")
            print(f"          {doc[:120].replace(chr(10),' ')}...")

What each block does

Anatomy of the retriever

BM25Okapi over in-memory tokenised docs
Loads the whole corpus once. Fine up to ~100K–1M chunks. Beyond that, use Elasticsearch or OpenSearch for the sparse leg.
sparse
coll.query(query_embeddings=..., n_results=50)
Chroma's vector search. Under the hood it's HNSW — see S119. Returns top-50 by cosine.
dense
RRF: score = Σ 1/(60 + rank)
The tuning-free combiner. No weights to fit. Documents that appear in both lists (semantically AND lexically relevant) rise to the top naturally.
fuse
CrossEncoder.predict(pairs)
Scores each (query, chunk) pair jointly through a small BERT variant. 20 pairs × ~10ms/pair = 200ms. That's the price of a big quality jump.
rerank
K_RETRIEVE=50, K_RERANK=20, K_FINAL=5
Standard funnel: retrieve wide, rerank narrower, ship narrow. Widening K_RETRIEVE catches more relevant docs at low cost; narrowing K_FINAL keeps LLM context small.
funnel
Try itMeasure the reranker's real gain

Add:

GOLD = [("How do I refresh an OAuth token?", "a1b2c3d4e5"),  # correct chunk id
        # ... 19 more (q, id) pairs from your corpus
]
 
def recall_at_k(fn, k=5):
    hits = 0
    for q, gold_id in GOLD:
        ranks = [id_ for _, id_, _, _ in fn(q)][:k] if fn.__name__=='search' \
                else [id_ for id_, _, _ in fn(q)][:k]
        if gold_id in ranks:
            hits += 1
    return hits / len(GOLD)
 
print("dense only :", recall_at_k(r.dense))
print("hybrid+rrf :", recall_at_k(lambda q: r.fuse(r.dense(q), r.sparse(q))))
print("full stack :", recall_at_k(r.search))

Watch each layer add points.

💡 Hint · Build a tiny eval set: 20 questions where you know the correct chunk id. Run retrieval WITHOUT rerank vs WITH rerank. Compute recall@5 both ways. On any real corpus you should see 15–30 point improvement from the reranker alone. That's the single biggest quality lever in a RAG stack.

(d) Production reality · 15 min

War story Perplexity, You.com, other answer enginesmillions of queries/day
🔥 What broke

Vector-only retrieval kept missing questions that contained exact identifiers — 'CVE-2024-12345', 'HP LaserJet 4200 error 49.4C02', 'section 230 of the CDA'. Users saw plausible-but-wrong answers.

🧯 The fix

Every production answer engine now runs a sparse retriever (BM25 or SPLADE) in parallel with dense, fuses with RRF or a learned merger, and reranks the top-N. Users don't see 'the answer' unless the top reranked chunk contains a strong exact-match signal.

🎓 Lesson to steal
Vector-only is a demo, not a product. Any real corpus contains proper nouns, codes, and rare terms that BM25 will find and embeddings will miss.
War story Common enterprise RAG failure moderepeated pattern every quarter
🔥 What broke

Team tunes chunk size and swaps embedding models for weeks trying to fix 'my bot answers wrong'. Retrieval improvements measured by cosine similarity look fine. But real user questions still get bad answers.

🧯 The fix

Add a cross-encoder reranker on the top-20 dense hits. Retrieval recall was fine; ordering was wrong — the correct chunk was at rank 8 but only top-3 were being stuffed into the prompt. Reranker moves it to rank 1. Fixed with 10 lines of code and one small model.

🎓 Lesson to steal
The single highest-leverage 2 hours in any RAG project is adding a cross-encoder reranker over the top-20 candidates. Do this before touching chunking, embeddings, or prompts.
War story Cohere · Rerank v3· 2024commodity reranking API
🔥 What broke

Pre-Cohere-Rerank / bge-reranker, teams built their own MonoT5 or MiniLM-Marco pipelines. Quality varied wildly by dataset, and the models needed careful serving infra.

🧯 The fix

Commodity rerank APIs (Cohere, Voyage, Jina) + strong open-source models (bge-reranker-v2, mxbai-rerank) made this a solved problem. Latency ~50–200ms for the top-100 rerank; quality often matches or beats hand-tuned retrievers.

🎓 Lesson to steal
Reranking has commoditised. Don't build a custom reranker unless you have >100K labelled relevance judgments. Use a stock API or open model and spend your time on data + evals.
Post-mortem

Where this shows up next

Retrieval is the RAG bottleneck at every scale
S119 · Vector Databases
The engine behind the dense retriever — HNSW / IVF / product quantisation.
S120 · LLM Agents
Agents retrieve mid-conversation; same hybrid+rerank pattern applies.
S122 · LLM Evaluation
You'll measure retrieval quality with recall@K, NDCG@K, MRR — the classical IR metrics.
S125 · Multimodal
CLIP-style embeddings turn images into vectors — the retrieval stack is exactly the same.
S127 · Streaming Analytics
Real-time index updates + retrieval — the pattern is 'incremental index + hybrid + rerank'.
S128 · Cost & Sustainability
Reranking is often 20–40% of RAG cost; understanding the funnel is core to optimising it.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

  1. Why hybrid retrieval beats vector-only?
  2. What does RRF do and why is it tuning-free?
  3. When is a cross-encoder reranker worth adding?

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.