Limited time: AI code review, hints, mock interviews, whiteboard analysis, and all Pro features are unlocked. Enroll
⏱️ 16 min read

Hybrid Search and Reranking - Complete Deep Dive

Stage 3 - Retrieval Lesson 14 of 36

Prerequisites: Embeddings, Vector Databases, RAG End to End Used in: Advanced Retrieval, Evals, ChatGPT Build it: Lesson 15 - Hybrid Search, RRF, and Reranking implements this as runnable, tested code you can execute offline.


What is Hybrid Search and Reranking?

Hybrid search runs two different retrievers over the same corpus β€” a lexical one that matches words and a dense one that matches meaning β€” and merges their ranked lists. Reranking then takes the merged shortlist and re-scores it with a slower, far more accurate model that reads each query-document pair together.

They are two answers to the same complaint: vector-only retrieval returns plausible-looking passages that are not the ones you needed.

Real-world analogy: Hiring. The first pass over a thousand rΓ©sumΓ©s is a keyword filter plus a recruiter’s rough sense of fit β€” cheap, fast, tuned for not missing anyone good. The second pass is a human reading twenty rΓ©sumΓ©s properly against the actual job description β€” expensive, slow, tuned for picking the right one. You could not read a thousand rΓ©sumΓ©s properly, and you would not want to hire from the keyword filter alone. Retrieval works the same way, and for the same reason.


How Dense-Only Retrieval Actually Fails

Embeddings compress text into a fixed-size vector of meaning. That compression is the feature, and it is also the failure mode: anything whose value lies in its exact surface form gets blurred.

Query type Winner Why
Exact error code such as ERR_5521 Lexical A rare token carries huge information for BM25 and almost no semantic signal for an embedding
Product or SKU code such as XR-7700-B Lexical Neighbouring codes embed almost identically; the digits are what matter
Rare proper noun β€” a person or internal project name Lexical Often out-of-vocabulary, so the embedding is a guess from subword fragments
Quoted phrase the user wants verbatim Lexical Exact-phrase intent is a surface property, not a semantic one
Negation such as β€œdeploys that did not roll back” Lexical helps, neither is reliable Embeddings weakly encode negation; a sentence and its negation sit close together
Paraphrase β€” β€œhow do I reset my password” vs β€œforgot login credentials” Dense Zero term overlap, same intent
Synonyms and jargon variation Dense β€œk8s” and β€œKubernetes” share no tokens
Conceptual question with no obvious keywords Dense Meaning is distributed across the passage
Cross-lingual query Dense Lexical overlap is zero by definition
Short keyword query with a common word Dense BM25 has almost nothing to discriminate on

Read the table as a whole and the design falls out of it. BM25 and dense retrieval do not fail on the same queries. Their error sets barely overlap: lexical fails on vocabulary mismatch, dense fails on exact-form matching. Two retrievers whose weaknesses are correlated would give you nothing by combining them. Two whose weaknesses are complementary give you a union that covers both β€” which is the entire justification for hybrid search, and the reason it is a bigger win than tuning either retriever alone.

The negation row deserves its own warning. Neither retriever handles it well, and hybrid search does not fix it. That is a query-understanding problem, covered in Advanced Retrieval.


Fusing Two Ranked Lists

Merging is harder than it looks, because BM25 scores and cosine similarities are not on a comparable scale. BM25 is unbounded, corpus-dependent, and query-dependent β€” a score of 14 means nothing in isolation. Cosine similarity is bounded but distributed in a narrow, model-specific band where 0.80 might be excellent for one embedding model and mediocre for another. Adding them, or comparing them against a shared threshold, is a category error.

Reciprocal Rank Fusion

RRF sidesteps the problem entirely by throwing the scores away and using only rank position.

For each document d:
    RRF(d) = sum over each retriever's list of  1 / (k + rank(d))

rank(d) is d's 1-based position in that list; documents absent from a list contribute nothing.
k is a smoothing constant, conventionally around 60.

Two properties make this the sensible default:

The k constant controls how much a top rank dominates. Smaller k sharpens the advantage of rank 1; larger k flattens the curve and weights breadth of agreement more heavily. Treat it as a tuning parameter with a sane default, not a magic number.

Score normalisation as the alternative

The other approach is to map both score distributions onto a common range β€” min-max over the returned candidates, or z-scores β€” then take a weighted sum: alpha * dense_norm + (1 - alpha) * lexical_norm.

This buys you a genuinely useful thing that RRF cannot give you: a tunable weight, so a corpus of code and identifiers can lean lexical while a corpus of prose leans dense. The cost is fragility. Min-max normalisation depends on the candidate set, so the same document scores differently depending on what else came back. Outliers distort the range. And alpha needs re-tuning whenever either retriever changes.

Β  Reciprocal Rank Fusion Score normalisation
Needs comparable scores No Yes
Tunable per corpus Barely β€” only k Yes, via alpha
Stability across candidate sets High Lower β€” depends on the batch
Re-tuning after a model swap None Required
Sensible default Yes Only with an eval set to tune against

Start with RRF. Move to weighted normalisation only when you have an evaluation set good enough to prove the weight helps. Several vector stores β€” Qdrant / Weaviate / OpenSearch / pgvector paired with Postgres full-text β€” expose one or both natively, which is worth more than implementing fusion yourself.


The Two-Stage Funnel

Fusion improves which candidates come back. Reranking fixes what order they are in. The architecture is a funnel: each stage is cheaper per document and less accurate than the next.

flowchart LR
    Q[User Query] --> B[BM25 over full corpus]
    Q --> D[Dense ANN over full corpus]
    B --> F[Fusion - RRF merge]
    D --> F
    F --> P[Permission filter on metadata]
    P --> R[Cross encoder reranker]
    R --> T[Top 5 for the prompt]
    T --> G[Generator LLM]

    classDef client fill:#f97316,stroke:#c2410c,color:#fff
    classDef service fill:#10b981,stroke:#065f46,color:#fff
    classDef data fill:#fbbf24,stroke:#92400e,color:#000

    class Q client
    class B,D,F,P,R,G service
    class T data

The candidate count narrows sharply at each hop β€” millions of chunks in the corpus, a few hundred returned by each first-stage retriever, a few dozen surviving fusion and filtering, a handful reaching the prompt. The economics only work because of that narrowing: the expensive model never sees more than a few dozen documents.

Stage 1 is optimised for recall. Its only job is to make sure the right chunk is somewhere in the candidate set. Precision is the reranker’s problem. A first stage that returns the right document at rank 90 has done its job; a first stage that misses it entirely has failed unrecoverably, because no downstream stage can retrieve what stage 1 did not return. Recall at the first stage is the ceiling on the whole system.

Stage 2 is optimised for precision. It reads a shortlist properly and sorts it by genuine relevance.


Bi-Encoder vs Cross-Encoder

This is the mechanism that makes the funnel possible, and it is the question most likely to be asked in an interview.

A bi-encoder β€” which is what an embedding model is β€” pushes the query and the document through the encoder independently and compares the two resulting vectors with a cheap distance function.

doc_vec   = encode(document)      # precomputable, done at ingestion
query_vec = encode(query)         # once per request
score     = cosine(query_vec, doc_vec)

Because the document never sees the query during encoding, every document vector can be computed ahead of time and stored in an index. At query time you encode once and do vector arithmetic against millions of precomputed vectors. That is the property that makes corpus-scale search possible at all.

A cross-encoder concatenates the query and the document and pushes the pair through the model together, letting attention run across both:

score = model(query + " [SEP] " + document)   # one forward pass per pair

Now every token of the query can attend to every token of the document. The model can register that this specific error code appears in this specific sentence, that the passage is about the negation of the query’s condition, that the match is on a heading rather than a body mention. That joint reading is why it is dramatically more accurate.

It is also why nothing can be precomputed. The score depends on the pair, and the pair does not exist until the query arrives. Scoring N documents costs N forward passes through a transformer. At corpus scale that is hopeless. On a shortlist of a few dozen, it is a bounded, affordable cost β€” one batched inference call.

Β  Bi-encoder Cross-encoder
Encoding Query and document separately Query and document jointly
Precomputable Yes β€” index at ingestion No β€” depends on the pair
Cost per query One encode plus an ANN lookup One forward pass per candidate
Scales to Millions of documents Tens of documents
Accuracy Good Substantially better
Role First-stage recall Second-stage precision

The tradeoff is exact and not a matter of engineering effort: you can precompute or you can read the pair, not both. Two-stage retrieval is the standard resolution β€” use the precomputable model to get the candidate set small enough that the non-precomputable model becomes affordable.

Between them sit late-interaction models such as the ColBERT family, which store per-token vectors and do a cheaper joint comparison at query time. They land between the two on both accuracy and index cost, and are worth knowing about, but hybrid plus cross-encoder is the path with fewer operational surprises.


Choosing the First-Stage Candidate Count

The knob is how many candidates each first-stage retriever returns before fusion. It trades recall against rerank cost, and the curve has a knee.

Measure rather than guess: sweep the candidate count against recall at that count on a labelled query set, and find where the curve flattens. Set the count just past the knee. Then two adjustments. Filtering shrinks the pool, so if permission filtering removes a large fraction of candidates, widen the first stage to compensate β€” a heavily restricted user otherwise ends up reranking almost nothing. And harder query distributions need wider first stages, so if your traffic skews toward vague conceptual questions, expect the knee to sit further out.

Order matters for cost: filter before you rerank. Reranking documents the user is not entitled to see is money spent to discard results, and running the filter after the reranker is also a leak waiting to happen. See RAG End to End for the access-control argument in full.


Latency Budget

The rerank stage is a real addition to the request path, and it needs a line in the budget rather than being discovered in production.

A hybrid-plus-rerank request spends time on: query embedding, the two first-stage retrievals, fusion, permission filtering, reranking, and generation. Two facts shape the budget. The two first-stage retrievals are independent and must run in parallel β€” issuing BM25 and the ANN search serially doubles the cheapest part of the pipeline for no reason. And reranking scales with candidate count, so it is the one stage whose cost you directly control, by changing how many candidates you let through.

Generation almost always dominates the total, which is the useful framing: reranking adds a bounded, predictable increment to a path already dominated by token generation. If time to first token is the metric users feel, the fix is usually streaming the answer rather than cutting the reranker. Practical levers when the budget is tight β€” batch all candidate pairs into one rerank call rather than looping, keep the reranker warm and co-located to avoid cold starts and cross-region hops, cache rerank scores for repeated query-document pairs, and cap candidates by latency rather than by a fixed constant so a slow first stage degrades to a narrower rerank instead of blowing the budget. Full treatment in Latency Engineering, with the caching angle in Prompt and Semantic Caching and Caching.


When Reranking Is Not Worth It

Reranking is usually the best accuracy-per-unit-effort move available, but not always.


When to Use

βœ… Use when:

❌ Don’t use when:


Common Interview Questions

Q1: Why does combining BM25 and dense retrieval help, rather than just picking the better one?

Because they fail on different queries. Lexical search fails on vocabulary mismatch β€” paraphrases, synonyms, cross-lingual queries. Dense search fails where the exact surface form is the signal β€” identifiers, error codes, rare proper nouns, quoted phrases. Their error sets are largely disjoint, so the union covers substantially more than either alone. If their weaknesses were correlated, combining them would gain you almost nothing; complementary weakness is the entire premise.

Q2: Explain the difference between a bi-encoder and a cross-encoder and why you need both.

A bi-encoder encodes query and document independently, so document vectors are precomputed at ingestion and a query is one encode plus a vector lookup β€” that is what makes corpus-scale search possible. A cross-encoder reads the query and document jointly in a single forward pass, so attention runs across both and it is far more accurate, but nothing can be precomputed because the score depends on the pair. That makes it linear in candidate count and unusable over a whole corpus. You use the bi-encoder to cut millions of documents down to a few dozen, then the cross-encoder to order those few dozen properly.

Q3: Why use Reciprocal Rank Fusion instead of just adding the two scores together?

BM25 scores and cosine similarities are not on a comparable scale β€” BM25 is unbounded and corpus-dependent, cosine sits in a narrow model-specific band β€” so adding them lets whichever has the larger numeric range dominate arbitrarily. RRF discards the scores and combines by rank position, which is directly comparable across retrievers. It also rewards agreement: a document ranked reasonably by both retrievers can beat one ranked first by only one, which is usually the right call. Score normalisation is the alternative and gives you a tunable weight, at the cost of needing re-calibration whenever either retriever changes.

Q4: How do you pick how many candidates the first stage returns?

Empirically, from a labelled query set. Sweep the candidate count against recall at that count and find where the curve flattens, then set the count just past the knee β€” beyond it you pay linear rerank cost for negligible recall gain. Two adjustments: widen the first stage if permission filtering removes a large share of candidates, and expect the knee further out if your traffic skews toward vague conceptual queries. The asymmetry matters β€” a candidate set that is too small loses the right chunk permanently, since no later stage can retrieve what the first stage never returned.

Q5: Reranking pushed your P95 over budget. What do you cut?

First check what actually dominates, because generation is usually the largest term and the reranker a bounded increment on top of it. If the reranker is genuinely the problem, reduce candidate count to the knee of the recall curve rather than removing the stage, batch all pairs into one call instead of looping, keep the model warm and co-located, and cache scores for repeated query-document pairs. Then make the cap latency-driven so a slow first stage narrows the rerank instead of overrunning the budget. If perceived latency is the real complaint, stream the generated answer β€” that moves time to first token far more than anything in the retrieval path.


Build it in code: Agentic AI Course · Fundamentals: Core Concepts

Free system design + DSA prep. If it helped you crack an interview, consider supporting.

SensAI SensAI
Beta
Listening...
Tap mic to stop voice mode

Shape what we build next

Every piece of feedback is read by the team and directly influences our roadmap.

What type of feedback?

Install SystemCraft

Add to your home screen for instant access, offline reading, and a distraction-free experience.

Offline reading Faster loads No browser tabs App-like feel

Unlock AI Features

One click to activate - no payment, no credit card. Just sign in and you're in.

AI code review and hints
SensAI chat assistant
AI mock interviews
Whiteboard analysis
100% free during early access