AI Engineer Dojo Contents
Chapter 5

Lexical & Hybrid Retrieval

Most teams start with embeddings. The retrievers that do best in production usually run two systems, one matching words and one matching meaning, and combine the results. This chapter explains why neither is enough alone and how to merge them without tuning any weights.

Case study

PartsPro runs search over an industrial-parts catalog, and most queries are exact part numbers like M8-1.25-A2. Embedding search treated those as fuzzy text and returned similar-looking parts. Recall@5 on part-number queries was 0.62, and wrong-part orders cost real money.

They added a BM25 keyword index next to the vector index and merged the two rankings with reciprocal rank fusion. Recall@5 on part numbers rose to 0.94, and conceptual queries didn't get any worse.

Two retrievers, two blind spots

Lexical retrieval (BM25 is the standard) scores documents by the exact words they share with the query, giving rare words more weight than common ones. Dense retrieval embeds the query and documents and ranks by cosine similarity, matching on meaning. They fail on different kinds of query:

QueryBetter retrieverWhy the other one misses
"XR-500 firmware"LexicalDense treats XR-500 as close to XR-400; the signal is the exact token
"how do I stop the app from crashing on launch"DenseLexical can't match a page titled "resolving startup failures"
"SELECT with GROUP BY error"LexicalDense blurs code and error strings
"ways to make onboarding less painful"DenseLexical can't connect "less painful" with "improve" or "simplify"

Lexical search handles exact terms: identifiers, error codes, rare jargon, part numbers and names. Dense search handles paraphrase, where the user's words differ from the document's but the meaning matches. Most real query logs contain both.

What hybrid buys you

Here's a typical result on a 50-query labeled set over a mixed technical corpus, measured as recall@10:

Retrieverrecall@10Wins on
BM25 (lexical)0.62IDs, codes, exact terms
Dense (embeddings)0.71Paraphrase and concepts
Hybrid (RRF)0.83Both

The hybrid score is higher than either input, not somewhere between them. That's because the two retrievers miss different queries. Fusion keeps what each one found, so together they cover more than the better one alone.

Reciprocal rank fusion

You could combine the two with a weighted sum of scores, but BM25 scores and cosine scores sit on different scales, so the weight has to be retuned for every corpus. Reciprocal rank fusion (RRF) ignores the scores and uses only each document's rank in each list:

RRF(d) = Σ  1 / (k + rank_i(d))     for each retriever i that ranked d
         i                              k = 60 by convention

Being near the top of any list earns a document a good score: rank 1 adds 1/61, while rank 50 adds only 1/110. Because the contributions are added up, a document near the top of both lists gets the biggest boost. Ranks mean the same thing in both lists even though the raw scores don't, which is why there's nothing to normalize or tune.

Here's RRF settling a disagreement on "XR-500 firmware crash on launch," a query that's half exact ID and half paraphrase:

DocumentBM25 rankDense rankRRF score (k=60)
A: "XR-500 firmware v4 release notes"180.0311
B: "resolving crash-on-startup issues"1410.0299
C: "XR-400 firmware overview"230.0320

All three end up in the top three. BM25 alone would have put the wrong product, C, above B. Dense alone would have put B first and buried A. With fusion, each retriever covers the other's weak spot.

Try it · ~30 minFree · no API key

Build BM25, dense and RRF, and measure the lift

Run three retrievers over a small labeled corpus with a mix of exact-ID and paraphrase queries, and compute recall@5 for each. You'll need pip install rank-bm25; the dense retriever is a deterministic stand-in so the numbers are repeatable. Find one query that lexical saved and one that dense saved.

Starter code

from rank_bm25 import BM25Okapi
import re

def toks(s): return re.findall(r"[a-z0-9]+", s.lower())

CORPUS = [
    "XR-500 firmware v4 release notes and changelog",          # 0  exact-ID
    "Resolving crash-on-startup and boot failures",            # 1  paraphrase
    "XR-400 firmware overview",                                 # 2  distractor
    "How to streamline employee onboarding",                   # 3  paraphrase
    "Onboarding checklist: keyword-matchable steps for hires", # 4  lexical
    "SELECT ... GROUP BY returns aggregate error",             # 5  exact-code
    "Improving the new-hire experience and reducing friction", # 6  paraphrase
    "Vault /export endpoint rate limit reference",             # 7  exact-term
]

# (query, index of the ONE correct doc)
GOLD = [
    ("XR-500 firmware",                       0),   # exact ID  -> lexical should win
    ("app keeps crashing when it starts",     1),   # paraphrase -> dense should win
    ("make onboarding easier for new hires",  6),   # paraphrase -> dense
    ("GROUP BY error",                        5),   # exact code -> lexical
    ("export endpoint rate limit",            7),   # exact term -> lexical
]

bm25 = BM25Okapi([toks(d) for d in CORPUS])

def lexical_ranks(query):
    scores = bm25.get_scores(toks(query))
    return sorted(range(len(CORPUS)), key=lambda i: scores[i], reverse=True)

# Toy "dense" retriever: cosine over hashed word-set vectors. Not a real embedding
# model — but it rewards semantic *overlap of concepts* enough to stand in for one
# offline, and keeps the lab free + reproducible. (Production: a real embedding model.)
def dense_ranks(query):
    def vec(s): return set(toks(s))
    q = vec(query)
    def sim(i):
        d = vec(CORPUS[i])
        return len(q & d) / ((len(q) * len(d)) ** 0.5 or 1)
    return sorted(range(len(CORPUS)), key=sim, reverse=True)

# TODO Step 2: def rrf(rank_lists, k=60) -> fused ranking (list of doc indices)
# TODO Step 3: def recall_at(ranker, k=5) -> fraction of GOLD whose doc is in top-k
Worked solution
def rrf(rank_lists, k=60):
    fused = {}
    for ranks in rank_lists:                     # ranks = [doc_idx in rank order]
        for position, doc in enumerate(ranks):   # position is 0-based
            fused[doc] = fused.get(doc, 0) + 1.0 / (k + position + 1)
    return sorted(fused, key=fused.get, reverse=True)

def recall_at(ranker, k=5):
    hits = sum(gold_doc in ranker(q)[:k] for q, gold_doc in GOLD)
    return hits / len(GOLD)

lex   = lambda q: lexical_ranks(q)
den   = lambda q: dense_ranks(q)
hyb   = lambda q: rrf([lexical_ranks(q), dense_ranks(q)])

print(f"lexical recall@5: {recall_at(lex):.2f}")
print(f"dense   recall@5: {recall_at(den):.2f}")
print(f"hybrid  recall@5: {recall_at(hyb):.2f}")

Lexical and dense should each land around 0.60, each missing a different pair of queries, while hybrid comes in between 0.80 and 1.00. Lexical gets "XR-500 firmware" and "GROUP BY error" and misses "app keeps crashing when it starts." Dense does the opposite. You didn't set any weights or normalize any scores, and you still got a retriever better than either input.

To see how it happens, print each query's gold-document rank under all three. A gold document at rank 6 in dense and rank 1 in lexical gets pulled into the hybrid top 5 by its lexical rank. Swap in a real embedding model later and run the same harness; the numbers change and the method stays the same.

Running case · Acme Vault

Acme adds BM25 and fuses it with dense retrieval using RRF. Exact method-name queries like vault.export go from 0.55 to 0.93 recall@5, and "how do I…" questions hold steady.

If you're starting out

"How would you combine keyword and vector search?" is a very common interview question. A solid answer has three parts: they miss different kinds of queries, so you want both; merge on rank with RRF instead of adding raw scores; and prove the gain with recall@k on a labeled set, expecting hybrid to beat both inputs.

BM25 is worth understanding beyond the name. Read how term frequency and inverse document frequency combine in its formula. It takes an hour and comes up again and again.

If you run the team

If your team is using embeddings alone and your users search by codes, IDs or names, a whole class of questions is being missed. Hybrid search is a well-understood fix, and it's usually cheaper than a new model. Ask the team to show you recall on identifier queries before and after.

Be wary of anyone who calls keyword search "legacy." In practice, most strong production search systems still use it alongside embeddings.

Quiz · Chapter 5

  1. Hybrid recall@10 is 0.83; lexical is 0.62 and dense is 0.71. Why is hybrid above both?
  2. A user searches for part number XR-500, and dense-only retrieval returns XR-400 and XR-550. Why?
  3. Why does RRF use ranks instead of raw scores?
  4. With k=60, rank 1 contributes 1/61 and rank 50 contributes 1/110. What does that encode?
  5. Dense-only recall is poor on identifier queries but fine on paraphrases. What's the best fix?
Back Continue

The AI Search / RAG Engineer · aiengineerdojo.com