Skip to the solution
MediumAI Engineering13 min read

What is hybrid search and why combine BM25 with vector search?

226 views

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

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.

Solution

The solution is hidden so you can try first

13 min read Β· 9 sections

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.
const DOCS = [
  ["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."],
];
const QUERIES = [
  ["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"],
];
const STOP = new Set("a an the is are for to of by from if your you i do how my in at and has use".split(" "));
const tok = (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;
const df = (w) => D.filter((d) => d.includes(w)).length;
function bm25(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 similarity
const CONCEPT = {
  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",
};
function embed(text) {
  const v = new Map();
  for (const w of tok(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;
}
const cos = (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));
const vector = (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 Fusion
const order = (hits) => hits.map((h, i) => ({ ...h, i })).sort((a, b) => b.s - a.s || a.i - b.i).map((h) => h.id);
function rrf(lists, k = 60) {
  const score = new Map(), 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);
}

const rankOf = (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] of QUERIES) {
  const L = { bm25: order(bm25(q)), vector: order(vector(q)) };
  L.hybrid = rrf([L.bm25, L.vector]);
  for (const m of Object.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]] of Object.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)}`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 54 of 83 in AI engineering

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

What are guardrails and how do you implement them around a model?

Continue