01 · Hybrid Search — Dense + Sparse (BM25)¶
Why dense search alone isn't enough¶
Dense embeddings are great at matching meaning. Ask for "how do I reset my password" and a dense retriever will happily surface a document that says "recovering account access" — no shared words, same intent.
But dense retrieval is surprisingly bad at exact matches: product SKUs, error
codes, rare proper nouns, part numbers, acronyms. A query like ERR_CONN_RESET
or SKU-48213-B often gets embedded into a vague "this looks like an error
code" region of space, and the one document that contains that exact string
can lose to five documents that are merely topically similar.
Classic keyword search (BM25) is the mirror image: perfect on exact terms, useless on paraphrase. Hybrid search runs both and merges the results.
BM25 in five lines¶
Install rank_bm25:
from rank_bm25 import BM25Okapi
corpus = [
"Connection reset error ERR_CONN_RESET occurs when the TLS handshake times out",
"To recover your account, reset your password from the login screen",
"SKU-48213-B is the replacement part for the model X200 fan assembly",
"Semantic search uses dense vector embeddings to rank documents by meaning",
]
tokenized_corpus = [doc.lower().split() for doc in corpus]
bm25 = BM25Okapi(tokenized_corpus)
query = "ERR_CONN_RESET"
scores = bm25.get_scores(query.lower().split())
print(scores)
# doc 0 (the exact error code) scores far above the rest
BM25 scores each document by term frequency (how often query terms appear)
weighted by inverse document frequency (how rare those terms are across the
whole corpus) and normalized for document length. It has no notion of
meaning — ERR_CONN_RESET and connection reset are different tokens to it —
but it is unbeatable when the query contains the literal string you need.
Running dense and sparse side by side¶
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("all-MiniLM-L6-v2")
doc_embeddings = model.encode(corpus, normalize_embeddings=True)
def dense_search(query, top_k=4):
q_emb = model.encode([query], normalize_embeddings=True)[0]
sims = doc_embeddings @ q_emb
ranked = np.argsort(-sims)
return [(int(i), float(sims[i])) for i in ranked[:top_k]]
def sparse_search(query, top_k=4):
scores = bm25.get_scores(query.lower().split())
ranked = np.argsort(-scores)
return [(int(i), float(scores[i])) for i in ranked[:top_k]]
query = "how do I recover my login"
print("dense :", dense_search(query))
print("sparse:", sparse_search(query))
For a paraphrased query like this, dense search ranks doc 1 (the password reset doc) highly even though it shares almost no words with the query. Sparse search, with no overlapping tokens beyond "my", ranks it poorly. That asymmetry is exactly why you want both running at once.
Combining results with Reciprocal Rank Fusion¶
You now have two ranked lists with incompatible scales — BM25 scores are
unbounded term-frequency weights, cosine similarity is bounded in [-1, 1].
You can't just add them. Reciprocal Rank Fusion (RRF) sidesteps the scale
problem entirely by fusing on rank position, not raw score.
def rrf_fuse(rankings: list[list[int]], k: int = 60) -> list[tuple[int, float]]:
"""rankings: list of ranked doc-id lists (best first), one per retriever."""
scores: dict[int, float] = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
return sorted(scores.items(), key=lambda pair: -pair[1])
dense_ids = [doc_id for doc_id, _ in dense_search(query, top_k=4)]
sparse_ids = [doc_id for doc_id, _ in sparse_search(query, top_k=4)]
fused = rrf_fuse([dense_ids, sparse_ids])
print(fused)
A document that ranks well in either list gets a meaningful score boost; a document that ranks well in both rises to the top. No tuning of relative weights required — that's the whole appeal of RRF over naive score averaging.
How It Actually Works¶
- Why rank, not score. Different retrievers produce scores on incomparable scales (and sometimes non-linear ones — BM25 scores for a 2-word query and a 10-word query aren't directly comparable either). Rank position is always comparable: "1st place" means the same thing regardless of retriever.
- The formula. For each retriever
rand each documentd, RRF contributes1 / (k + rank_r(d) + 1). These contributions are summed across retrievers. A document absent from a retriever's list simply contributes 0 from that retriever. - The constant
k.k(commonly 60) dampens the difference between adjacent ranks. Without it, rank 1 vs. rank 2 would differ by 50% (1/1vs1/2); withk=60, it's under 2% (1/61vs1/62). Largerkmakes fusion more forgiving of a retriever getting the exact top spot wrong by one or two positions. - Why it beats weighted score averaging in practice. Picking weights
for score averaging (e.g.
0.7 * dense + 0.3 * sparse) requires tuning per-dataset and breaks when one retriever's score distribution shifts. RRF needs no such tuning and is remarkably robust across very different retrieval signals — it's the default fusion method in most production hybrid-search stacks (Elasticsearch, Vespa, Weaviate's hybrid mode). - What it doesn't fix. RRF assumes each retriever's ranking is individually decent. If one retriever's list is essentially random, RRF will still let its noise dilute the final ranking somewhat — it's a merging strategy, not a quality filter. That's what re-ranking (next lesson) is for.
Exercise¶
Extend the corpus to 12-15 documents mixing exact identifiers (error codes,
SKUs, order numbers) with paraphrasable natural-language content. Write a
hybrid_search(query, top_k) function that runs dense_search and
sparse_search over the full corpus, fuses with rrf_fuse, and returns the
top-k fused results. Try three queries — one that's pure keyword, one that's
pure paraphrase, one that's a mix — and confirm hybrid search beats both
individual retrievers on the mixed query.