Course Content
Live Coding Interview Prep
7 sections · 50 lessons
Write code for a hybrid search system (keyword + vector search).
What you need to know
BM25 is the standard keyword-ranking formula used by Elasticsearch and OpenSearch. For each query term it rewards three things:
score(doc) = sum over query terms t of IDF(t) × f × (k1 + 1) / (f + k1 × (1 - b + b × len(doc) / avg_len))f = how many times t appears in the doc (term frequency)IDF(t) = log(1 + (N - n_t + 0.5) / (n_t + 0.5)), n_t = docs containing tk1=1.5 = how fast repeated terms stop adding score (saturation)b=0.75 = how much long documents are penalisedA rare term (high IDF) such as E4021 counts far more than a common one such as the. Repeating a term helps, but with diminishing returns.
Why fuse ranks, not scores. BM25 scores are unbounded (0, 3.7, 12.1…); cosine scores sit between -1 and 1. Adding them lets whichever retriever has the bigger numbers win. Reciprocal rank fusion (RRF) ignores the scores:
RRF(doc) = sum over lists of 1 / (k + rank), rank starts at 1, k = 60A document ranked high in both lists beats one ranked first in only one list. k = 60 is the constant from the original RRF paper; it damps the gap between rank 1 and rank 2.
1import math, re2from collections import Counter3import numpy as np45def tokenize(text: str) -> list[str]:6 return re.findall(r"[a-z0-9]+", text.lower())78class BM25:9 """Okapi BM25 over a small in-memory corpus."""1011 def __init__(self, docs: list[str], k1: float = 1.5, b: float = 0.75) -> None:12 self.k1, self.b = k1, b13 self.docs = [tokenize(d) for d in docs]14 self.n = len(self.docs)15 self.avgdl = sum(map(len, self.docs)) / self.n if self.n else 0.016 self.tf = [Counter(d) for d in self.docs]17 df = Counter(t for d in self.docs for t in set(d))18 self.idf = {t: math.log(1 + (self.n - f + 0.5) / (f + 0.5)) for t, f in df.items()}1920 def scores(self, query: str) -> np.ndarray:21 out = np.zeros(self.n)22 for term in tokenize(query):23 idf = self.idf.get(term)24 if idf is None:25 continue # unseen term adds nothing26 for i, counts in enumerate(self.tf):27 f = counts[term]28 if f:29 norm = 1 - self.b + self.b * len(self.docs[i]) / self.avgdl30 out[i] += idf * f * (self.k1 + 1) / (f + self.k1 * norm)31 return outThe fusion and the search class:
1def reciprocal_rank_fusion(rankings: list[list[int]], k: int = 60) -> list[int]:2 """Fuse ranked id lists; ids in more lists, higher up, come first."""3 fused: dict[int, float] = {}4 for ranking in rankings:5 for rank, doc_id in enumerate(ranking, start=1):6 fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + rank)7 return sorted(fused, key=fused.__getitem__, reverse=True)89class HybridSearch:10 """BM25 + embedding search, fused with RRF."""1112 def __init__(self, docs: list[str], embed_fn) -> None:13 self.docs, self.embed_fn = docs, embed_fn14 self.bm25 = BM25(docs)15 self.matrix = None16 if docs:17 v = np.asarray(embed_fn(docs), dtype=np.float64)18 self.matrix = v / (np.linalg.norm(v, axis=1, keepdims=True) + 1e-10)1920 def search(self, query: str, top_k: int = 5, pool: int = 20) -> list[str]:21 if not self.docs:22 return []23 kw_scores = self.bm25.scores(query)24 kw = [i for i in np.argsort(-kw_scores, kind="stable") if kw_scores[i] > 0][:pool]25 q = np.asarray(self.embed_fn([query])[0], dtype=np.float64)26 vec = np.argsort(-(self.matrix @ (q / (np.linalg.norm(q) + 1e-10))), kind="stable")27 fused = reciprocal_rank_fusion([[int(i) for i in kw], [int(i) for i in vec[:pool]]])28 return [self.docs[i] for i in fused[:top_k]]Two details matter. The keyword list keeps only documents with a BM25 score above zero; otherwise every document that shares no word with the query still gets an RRF bonus just for appearing in the list. And the loops convert NumPy integers to plain int so the fused ids are ordinary dictionary keys.
Complexity: building BM25 is O(total tokens). This scan scores in O(q·N) for q query terms and N documents; an inverted index cuts it to the number of matching postings. Vector search is O(N·d). RRF is O(total list length) plus a sort of the fused ids.
A real-life example
Three documents from a payments help centre:
1docs = ["Error E4021 means the UPI PIN was wrong",2 "Payment failed because the PIN was incorrect",3 "Delivery is delayed by rain"]4bm = BM25(docs)5print(bm.scores("E4021").round(3)) # [0.9 0. 0. ]6print(bm.scores("wrong PIN").round(3)) # [1.331 0.46 0. ]Trace for "E4021": the corpus has 20 tokens over 3 docs, so avg_len = 6.667. e4021 is in 1 doc, so IDF = log(1 + 2.5/1.5) = 0.981. Doc 0 has 8 tokens, so norm = 0.25 + 0.75 × 8/6.667 = 1.15, and the score is 0.981 × 1 × 2.5 / (1 + 1.5 × 1.15) = 0.9. An embedding model would put "E4021" close to any payment error; BM25 finds the one document that names it.
Now fuse a keyword ranking [2, 0, 1] with a vector ranking [0, 1, 2]:
| doc | keyword rank | vector rank | RRF score |
|---|---|---|---|
| 0 | 2 → 1/62 | 1 → 1/61 | 0.03252 |
| 2 | 1 → 1/61 | 3 → 1/63 | 0.03227 |
| 1 | 3 → 1/63 | 2 → 1/62 | 0.03200 |
reciprocal_rank_fusion([[2, 0, 1], [0, 1, 2]]) returns [0, 2, 1]: doc 0 was never first in the keyword list, but it is near the top of both lists, and that is the kind of agreement RRF rewards.
Product search on an Indian marketplace needs both: "boAt Airdopes 141" must match that exact model (keyword), and "wireless earbuds under 1500" must match listings that never use those words (vector).
Follow-up questions to expect
- "Why not a weighted sum like 0.7 × vector + 0.3 × BM25?" — It works only after you normalise both scores to the same range, and the right weight changes per query type. RRF needs no tuning; I'd try a weighted sum only if an eval shows it beats RRF.
- "How would you do this in production?" — OpenSearch or Elasticsearch with a kNN field, or Postgres with
tsvectorfull-text search plus pgvector, fusing in SQL or in the app. - "What about duplicates across the two lists?" — That is the point of the dictionary: a document found by both retrievers is one entry with two contributions. Exact duplicate documents should be removed at index time by content hash.