RAG · Module 6 of 14 · 9 concepts · about 15 min

Retrieval: Keyword, Dense and Hybrid

The search step itself. Keyword search matches words, dense search matches meaning, each fails where the other succeeds, and combining them is the standard production design.

At work: The part of a RAG system that decides what the model gets to read: finding the right policy clause, product page or past ticket among millions of passages.

Keyword search and BM25

What it is

Keyword search (also called sparse or lexical search) finds chunks that contain the words of the query. BM25 is the standard scoring formula. It gives a chunk a higher score when:

  • a query word appears in it more often (with diminishing returns);
  • that word is rare across the collection (a rare word such as KK-4471 tells you far more than a common one such as order);
  • the chunk is short (a match in a short passage means more than the same match in a long one).

Why it matters

It has been the backbone of search engines for decades, and it is still strong: exact product names, error codes, people's names and legal terms are matched precisely, with no model and no training. Every interviewer expects you to know what it does and why it has not been replaced.

In the real world: A parts distributor's customers search by exact part number; keyword search finds the single matching page instantly, where semantic search returns a dozen similar-looking parts.

Visual example

Computed BM25

Keyword scores for “refund for late order”. Rare words and full matches push a passage up.

BM25(“refund for late order”)
chunktextBM25
c1 Late order refundIf your order arrives more than 30 minutes late, we refund the full delivery fee to your wallet.3.08
c2 Damaged item refundReport a damaged item within 48 hours with a photo and we refund the item price.1.02
c3 Delivery feeThe delivery fee is 25 rupees for orders under 199 rupees and free above that.0
c4 Kart Plus membershipKart Plus membership costs 149 rupees a month and gives free delivery on every order.0.62
c5 Change addressYou can change the delivery address until the rider picks up the order.0.76
c6 Error KK-4471Error KK-4471 means your bank declined the payment. Retry with another card.0

Filler words such as 'for', 'the' and 'your' are ignored before scoring.

Query: “refund for late order”. BM25 score of each help-centre passage:

  • c1 Late order refund: 3.08
  • c2 Damaged item refund: 1.02
  • c3 Delivery fee: 0.00
  • c4 Kart Plus membership: 0.62
  • c5 Change address: 0.76
  • c6 Error KK-4471: 0.00

c1 Late order refund wins because it contains all three query words. Passages sharing only one common word score low, and those sharing none score zero.

Assuming keyword search is obsolete because embeddings exist. On questions that contain an exact identifier, BM25 regularly beats a good embedding model. Leaving it out is the most common avoidable weakness in a RAG design.

Practise it: Design a Semantic Search System

Dense retrieval

What it is

Dense retrieval (semantic or vector search) is the embedding approach from the earlier modules: embed the query, find the chunks whose vectors are closest. It matches on meaning, so it does not need the query and the chunk to share any words.

Why it matters

People rarely phrase a question the way the document is written.

  • A customer types 'money back'; the policy says 'refund'.
  • A customer writes 'my food never showed up'; the article is titled 'Missing orders'.
  • Dense retrieval bridges that gap, which keyword search cannot.
  • It also works across languages with a multilingual model.

In the real world: A government helpline's assistant matches a citizen's plain-language question about 'help with electricity bills' to a scheme document that only uses its official name.

q_vec = model.encode(question, normalize_embeddings=True)
hits = index.search(q_vec, k=20)        # the 20 closest chunks by meaning

Expecting it to behave like a keyword match. Dense search returns the closest vectors even when nothing relevant exists, so it always returns something. An off-topic question still gets 20 confident-looking results.

Where each one fails

What it is

Keyword search fails on paraphrase: when the question and the passage mean the same thing in different words, the score is zero. Dense search fails on exact tokens: an identifier the embedding model has never seen is turned into a vague vector that sits near everything and close to nothing.

Why it matters

The two failures are complementary, and that is the whole case for combining them.

  • In the table, the paraphrased question shares no meaningful word with the right passage, so keyword search finds only a passage that happens to mention 'rider'.
  • The error-code question is matched exactly by keyword search, while the toy dense vector for it is nearly featureless and ranks the right passage low.

In the real world: A support team sees the assistant handle 'where is my stuff' well and fail on 'status of ORD-88213'; the test set had contained only the first kind of question.

Visual example

Computed

Two questions, two retrievers. Each one wins exactly where the other loses.

Top result from each retriever (BM25 on the real text; cosine on the toy vectors)
questionright passagekeyword top resultdense top result
“money back when the rider is delayed”c1 Late order refundc5 Change address ✗c1 Late order refund ✓
“error KK-4471”c6 Error KK-4471c6 Error KK-4471 ✓c1 Late order refund ✗

Why

paraphrase: keyword search matched only the word 'rider'; dense scored c1 at 0.99

error code: keyword search matched 'KK-4471' exactly; dense ranked c6 at position 4 of 6

Evaluating a retriever only on naturally phrased questions. Real traffic includes order numbers, SKU codes, names and acronyms. Put both kinds in the test set, or the dense-only system will look better than it is.

Hybrid retrieval: keyword and dense together

What it is

Hybrid retrieval runs both searches for every query, a BM25 search and a vector search, and merges the two result lists into one. A chunk found by either method can make the final list, and a chunk found by both rises to the top.

Why it matters

It is the default production design because it covers both failure cases at once with very little extra cost: the two searches run at the same time, and merging is trivial arithmetic. In an interview, proposing hybrid retrieval and explaining which queries each half is there for is one of the clearest signals that you have built a real system.

In the real world: An online pharmacy's search handles both 'something for a dry cough' and an exact medicine name with one pipeline, because each query is answered by whichever half understands it.

keyword_hits = bm25_index.search(question, k=50)     # ids, best first
dense_hits   = vector_index.search(embed(question), k=50)

fused = reciprocal_rank_fusion([keyword_hits, dense_hits])
candidates = fused[:50]                             # on to the reranker

Merging by adding the raw scores. BM25 scores run from 0 to 20 or more; cosine scores run from 0 to 1. Added together, the keyword score swamps the dense one. Merge by rank, or rescale both scores first.

Practise it: Design a Semantic Search System, Design a Production RAG System

Reciprocal rank fusion (RRF)

What it is

RRF is the standard way to merge ranked lists.

  • It ignores the scores and uses only positions.
  • Each list gives a chunk 1 / (k + rank) points, where rank is its position in that list (1 for the top) and k is a constant, normally 60.
  • A chunk's final score is the sum of its points from every list.

Why it matters

Because it uses ranks, it does not care that BM25 and cosine scores are on different scales, and it needs no tuning.

  • A chunk ranked high in both lists scores highest; a chunk in only one list still gets credit.
  • The constant 60 flattens the difference between neighbouring ranks, so being found by both lists matters more than being first in one.

In the real world: A search team replaces a hand-tuned weighted sum of scores, which broke every time the embedding model changed, with RRF, which needed no retuning.

Visual example

Computed RRF

Two ranked lists for “refund for late order” fused with k = 60. Chunks found by both lists come first.

score = 1/(60 + keyword rank) + 1/(60 + dense rank)
chunkkeyword rankdense rankRRF scorefinal rank
c1 Late order refund110.03281
c2 Damaged item refund220.03232
c5 Change address340.03153
c3 Delivery feenot found30.01594
c4 Kart Plus membership4not found0.01565

A chunk missing from a list simply gets no points from that list.

def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> list[str]:
    scores: dict[str, float] = {}
    for ranking in rankings:
        for position, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + position)
    return sorted(scores, key=scores.get, reverse=True)

Worked for c1: rank 1 in keyword and rank 1 in dense gives 1/(60+1) + 1/(60+1) = 0.0328.

Reading an RRF score as a measure of relevance. The values are tiny and only their order means anything. They cannot be compared between queries or used as a cut-off for 'relevant enough'.

Practise it: Design a Semantic Search System

Choosing top-k

What it is

Top-k is how many chunks the retriever returns.

  • Too few and the passage holding the answer may be left out.
  • Too many and the prompt fills with loosely related text that distracts the model, costs more and slows the answer.

Why it matters

The usual design uses two different k values.

  • The first stage retrieves generously, 50 to 100 candidates, because missing the right chunk here cannot be repaired later.
  • A reranker then sorts those candidates, and only the best 3 to 10 go into the prompt. Wide first, narrow second.

In the real world: A legal-research tool retrieves 100 candidate clauses, reranks them, and sends the top 8 to the model, after measuring that the right clause was in the top 100 far more often than in the top 10.

Visual example

Two stages

The first stage must not miss. The second must not flood.

A different k at each stage
stagetypical kgoalfailure if wrong
First-stage retrieval50 to 100Get the right chunk somewhere in the listToo small: the answer is never seen
After reranking, into the prompt3 to 10Only the most useful chunksToo large: noise, cost, slower answers

Using one small k for the whole pipeline (retrieve 5, send 5). If the right chunk was ranked eighth, it is gone, and no prompt wording will bring it back.

Score thresholds and the empty result

What it is

A vector search always returns its k nearest chunks, however far away they are. A score threshold drops results whose similarity is below a chosen value, so that an off-topic question can return nothing at all.

Why it matters

Returning nothing is useful information.

  • If no chunk clears the bar, the system can say 'I could not find this in the help centre' instead of handing the model five unrelated passages and inviting it to improvise.
  • This is the retrieval half of abstention, covered in the Generation module.

In the real world: A bank's assistant, asked about cricket scores, replies that it can only help with banking questions, because no chunk passed the relevance bar and the pipeline stopped there.

Copying a threshold from a blog post. Similarity scores are not comparable between embedding models: 0.75 is a strong match for one model and a weak one for another. Find the value on your own data, by looking at the scores of known-good and known-bad matches. Reranker scores make better thresholds than embedding similarities.

Diversity: maximal marginal relevance (MMR)

What it is

The top results of a search are often near-copies of each other: five chunks that all say the same thing.

  • MMR picks results one at a time, each time choosing the chunk that is most relevant to the query and least similar to the chunks already picked.
  • A setting, lambda (λ), between 0 and 1 sets the balance: 1 is pure relevance, lower values push for more variety.

Why it matters

The prompt has room for only a few chunks, so each should add something new. For a question such as 'what are the refund rules?', the best context is the late-order rule, the damaged-item rule and the cancellation rule, not three wordings of the first one.

In the real world: A market-research assistant asked for 'customer complaints about packaging' returns five different complaints instead of five reposts of the same viral review.

import numpy as np

def mmr(q, vecs, k=5, lam=0.7):
    """q and vecs are normalised. Returns indices of k varied, relevant chunks."""
    relevance = vecs @ q
    chosen: list[int] = []
    while len(chosen) < min(k, len(vecs)):
        best, best_score = -1, -np.inf
        for i in range(len(vecs)):
            if i in chosen:
                continue
            overlap = max((vecs[i] @ vecs[j] for j in chosen), default=0.0)
            score = lam * relevance[i] - (1 - lam) * overlap
            if score > best_score:
                best, best_score = i, score
        chosen.append(best)
    return chosen

Pushing diversity on a question with one precise answer. For 'what is the delivery fee?' variety just swaps the right chunk's supporting neighbours for unrelated ones. Diversity helps broad questions and hurts narrow ones.

Metadata filters and self-querying

What it is

A metadata filter restricts the search using the labels stored on each chunk: language = en, updated_at >= 2025-01-01, doc_type = policy. Self-querying is having a language model read the user's question and produce both the search text and the filter: "refund rules updated this year" becomes the search text 'refund rules' plus the filter updated_at >= 2025-01-01.

Why it matters

Embeddings handle topics well and conditions badly. 'This year', 'in Mumbai', 'for Kart Plus members' are exact constraints, and a filter enforces them exactly while similarity only nudges towards them. Splitting a question into what it is about (for the vector search) and what it is restricted to (for the filter) improves precision sharply.

In the real world: A property portal turns 'two-bedroom flats in Pune under 80 lakh' into a filter on city, bedrooms and price plus a semantic search over the descriptions.

User question:
  What changed in the refund rules this year?

Model output (structured):
  { "search_text": "refund rules changes",
    "filter": { "doc_type": "policy", "updated_at": { "gte": "2025-01-01" } } }

Letting the model invent filter fields or values. Give it the exact list of allowed fields and values, and validate what it returns. A filter on a field that does not exist either errors or silently matches nothing.