Skip to content
bits
Retrieval without tears · Lesson 4 of 9

Keywords, and why hybrid wins

Tutor:

Embeddings are great at meaning and surprisingly bad at exact strings. Ask about “error E-417” and an embedding search happily returns a page about errors in general. It knows the topic. It doesn't care which code.

The old fix still works: keyword search. The standard scoring method is BM25. It scores a chunk higher when it contains the query's words, gives rare words more weight than common ones (a match on “E-417” counts for far more than a match on “the”), gives less and less credit for each repeat of a word, and evens out long and short chunks.

Each method fails where the other shines:

Embeddings only vs Keywords (BM25) onlyillustrative: made-up numbers that show the idea, not output from a real modelcompare

promptError E-417 on my e-bike display

Embeddings only

Battery troubleshooting: if your display shows a warning, charge the battery fully and restart the bike.

Keywords (BM25) only

E-417: motor sensor fault. Unplug the sensor cable, check the pins, and plug it back in.

promptmy bike won't start

Embeddings only

Power issues: if the e-bike doesn't switch on, check the battery is seated and charged.

Keywords (BM25) only

Starting a return: log in, open your order and choose ‘Start return’.

Tutor:

Embeddings miss the exact code. Keywords match the word “start” and miss the meaning. So use both.

Tutor:

Hybrid search runs both searches and merges the two ranked lists. You can't just add the scores: BM25 scores have no fixed range, and cosine scores sit between −1 and 1. So you merge by rank instead.

The usual method is reciprocal rank fusion (RRF). Each chunk gets 1 ÷ (60 + its rank) from every list it appears in, and you add those up. A chunk that's high in both lists beats one that tops only one. The 60 comes from the paper that introduced it; it stops the top spot from dominating.

// Merge ranked lists of chunk ids (best first) with reciprocal rank fusion.
export function fuse(lists: string[][], k = 60): string[] {
const scores = new Map<string, number>();
for (const list of lists) {
list.forEach((id, i) => {
scores.set(id, (scores.get(id) ?? 0) + 1 / (k + i + 1));
});
}
return [...scores.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
}
console.log(fuse([
["battery", "sensor", "power"], // embeddings
["sensor", "returns", "battery"], // keywords
]));
TypeScriptUTF-8● lines 5–7: 1 ÷ (60 + rank)
OUTPUT
npx tsx fuse.ts
[ 'sensor', 'battery', 'returns', 'power' ]
Tutor:

“sensor” was second and first, so it wins. “battery” was first and third, so it comes second. Each of the others showed up in only one list. Anthropic's tests found embeddings plus BM25 beat embeddings alone, and Cloudflare's managed AI Search uses hybrid search with RRF by default (as of October 2026).

quick check+20 neurons
Tutor:

Why does hybrid search merge by rank instead of adding the two scores?

    Type your answer or tap an option · Enter checks