What is hybrid search and why combine BM25 with vector search?
226 views
54 of 83
1
The problem
Question presented to candidate:
"Our RAG search uses embeddings only, and support agents complain that searching for an error code like ERR-4012 or a part number returns the wrong articles, even though plain-English questions work well. What is hybrid search, and why would combining BM25 with vector search fix this?"
What a strong answer should cover:
What each retriever is: BM25 is lexical (sparse) scoring of exact term matches, weighted by term rarity (IDF), term-frequency saturation and length normalisation; vector search is dense retrieval by embedding similarity, which matches meaning and paraphrase
Their complementary failures: embeddings blur exact tokens such as error codes, SKUs, names, version numbers and rare jargon; BM25 misses synonyms and paraphrases ("money back" vs "refund") and has no notion of meaning
Hybrid search: run both retrievers on the same query (usually in parallel), then fuse the two ranked lists into one, most often with Reciprocal Rank Fusion because the two score scales are not comparable
Where it sits in the pipeline: before a reranker, which reorders the fused candidates; recall of the candidate set is what hybrid improves
Practical costs and options: two indexes to keep in sync, a little extra latency, engine support (search engines and several vector databases fuse natively; with Postgres you fuse in SQL), and the need to measure on both query types
Code / implementation expected: Optional β BM25 scoring, a cosine search, and a fusion of the two ranked lists on a toy corpus is a strong signal.
hybrid-search
bm25
rrf
retrieval
2
Try it yourself
Sketch your approach before you read the solution. That is what the interview tests.
Stuck? Get a hint.
A short nudge toward the approach. It will not give the answer away.
3
Solution
Target Audience: Engineers whose embedding-only RAG search misses exact terms, and anyone who needs to explain lexical vs semantic retrieval and why teams combine them.
Difficulty: Medium
Verified with: Node 22.22.0 and Python 3.12.3 (BM25 written from scratch, a deterministic stub embedding, and RRF fusion on an eight-article knowledge base; no model or search engine is called). Engine facts were checked in each vendor's documentation in October 2026.
How to read this doc: Every idea is explained in plain language first, then tagged with π Interview term: so you also learn the words an interviewer expects. All program output shown was produced by actually running the example; the Verified section quotes it in full.
1. Why This Even Matters β A Story First
A hardware shop has two assistants. The first has memorised every part number in the catalogue: say "S
The solution is hidden so you can try first
13 min read Β· 9 sections
4
Read the code
BM25 vs a stub embedding vs hybrid RRF on exact codes and paraphrases
// BM25 vs a stub "embedding" vs hybrid (RRF) on a tiny support knowledge base.// The stub maps words to concepts, so paraphrases match; codes collapse into one "code" concept,// which is the real weakness of dense vectors exaggerated: they know it is an error code, not which one.constDOCS = [
["kb-02", "Error ERR-4013 means the card expired."],
["kb-01", "Error ERR-4012 means the payment card was declined by the bank."],
["kb-03", "Refunds are issued to the original payment method within 5 business days."],
["kb-04", "If login fails, reset your password from the sign-in page."],
["kb-06", "Batteries for the X300 use part SKU-88B715 and ship separately."],
["kb-05", "Replacement battery for the X200 is part SKU-88B714."],
["kb-07", "Two factor codes are sent by text message to your registered phone."],
["kb-08", "Cancel a subscription from the billing page at any time."],
];
constQUERIES = [
["ERR-4012", "kb-01", "exact code"],
["how do I get my money back", "kb-03", "paraphrase"],
["battery for X200", "kb-05", "model number"],
["I cannot sign in", "kb-04", "paraphrase"],
["card declined ERR-4012", "kb-01", "mixed"],
["expired card", "kb-02", "keywords"],
];
constSTOP = newSet("a an the is are for to of by from if your you i do how my in at and has use".split(" "));
consttok = (s) => s.toLowerCase().split(/[^a-z0-9-]+/).filter((w) => w && !STOP.has(w));
// ---- BM25 (Lucene-style idf, k1 = 1.2, b = 0.75)const D = DOCS.map(([, t]) =>tok(t));
const avgdl = D.reduce((a, d) => a + d.length, 0) / D.length;
constdf = (w) => D.filter((d) => d.includes(w)).length;
functionbm25(query) {
const q = tok(query);
return D.map((d, i) => {
let s = 0;
for (const w of q) {
const tf = d.filter((x) => x === w).length, n = df(w);
if (!tf) continue;
s += Math.log(1 + (D.length - n + 0.5) / (n + 0.5)) * (tf * 2.2) / (tf + 1.2 * (0.25 + 0.75 * d.length / avgdl));
}
return { id: DOCS[i][0], s };
}).filter((h) => h.s > 0);
}
// ---- stub embedding: bag of concepts, cosine similarityconstCONCEPT = {
refund: "refund", refunds: "refund", money: "refund", back: "refund", reimburse: "refund",
login: "login", "sign-in": "login", sign: "login", password: "login", fails: "fail", cannot: "fail",
card: "card", payment: "pay", billing: "pay", declined: "decline", expired: "expire",
battery: "battery", batteries: "battery", error: "error",
subscription: "subscription", cancel: "cancel", codes: "otp", message: "otp", phone: "otp",
};
functionembed(text) {
const v = newMap();
for (const w oftok(text)) {
const c = /^err-\d+$/.test(w) ? "errcode" : /^sku-/.test(w) ? "partno" : /^x\d+$/.test(w) ? "model" : CONCEPT[w];
if (c) v.set(c, (v.get(c) || 0) + 1);
}
return v;
}
constcos = (a, b) => {
let dot = 0, na = 0, nb = 0;
for (const [k, x] of a) { dot += x * (b.get(k) || 0); na += x * x; }
for (const x of b.values()) nb += x * x;
return na && nb ? dot / Math.sqrt(na * nb) : 0;
};
const E = DOCS.map(([, t]) =>embed(t));
constvector = (query) => { const q = embed(query); return E.map((e, i) => ({ id: DOCS[i][0], s: cos(q, e) })).filter((h) => h.s > 0); };
// ---- ranking + Reciprocal Rank Fusionconstorder = (hits) => hits.map((h, i) => ({ ...h, i })).sort((a, b) => b.s - a.s || a.i - b.i).map((h) => h.id);
functionrrf(lists, k = 60) {
const score = newMap(), first = [];
for (const list of lists) list.forEach((id, r) => { if (!score.has(id)) first.push(id); score.set(id, (score.get(id) || 0) + 1 / (k + r + 1)); });
return first.map((id, i) => ({ id, s: score.get(id), i })).sort((a, b) => b.s - a.s || a.i - b.i).map((h) => h.id);
}
constrankOf = (list, id) => { const r = list.indexOf(id); return r < 0 ? "-" : String(r + 1); };
console.log("query type BM25 vector hybrid (rank of the right doc)");
const tot = { bm25: [0, 0], vector: [0, 0], hybrid: [0, 0] };
for (const [q, gold, type] ofQUERIES) {
const L = { bm25: order(bm25(q)), vector: order(vector(q)) };
L.hybrid = rrf([L.bm25, L.vector]);
for (const m ofObject.keys(tot)) {
const r = L[m].indexOf(gold);
if (r === 0) tot[m][0]++;
if (r >= 0) tot[m][1] += 1 / (r + 1);
}
console.log(`${q.padEnd(29)}${type.padEnd(13)}${rankOf(L.bm25, gold).padStart(4)}${rankOf(L.vector, gold).padStart(6)}${rankOf(L.hybrid, gold).padStart(6)}`);
}
console.log("\nmethod recall@1 MRR");
for (const [m, [r1, mrr]] ofObject.entries(tot)) console.log(`${m.padEnd(8)}${(r1 + "/" + QUERIES.length).padStart(8)}${(mrr / QUERIES.length).toFixed(3)}`);
console.log('\nwhy "ERR-4012" needs BM25:');
const vb = order(vector("ERR-4012")), bb = order(bm25("ERR-4012"));
console.log(` BM25 list : ${bb.join(", ")}`);
console.log(` vector list : ${vb.slice(0, 2).join(", ")} (both docs are just "an error code" to the vector)`);
console.log(` RRF kb-01 = 1/(60+1) + 1/(60+2) = ${(1 / 61 + 1 / 62).toFixed(5)} kb-02 = 1/(60+1) = ${(1 / 61).toFixed(5)}`);
5
Discussion
Other approaches, follow-ups and how it went in real interviews.