Hybrid search: keywords plus vectors
Keyword search is great at exact words, codes and names. Vector search is great at meaning. Hybrid search runs both and fuses the results.
In 60 seconds
Hybrid search: keywords plus vectors
Keyword search is great at exact words, codes and names. Vector search is great at meaning. Hybrid search runs both and fuses the results.
E-4012, a form number like Form 16, a person's name, a product id, a section number. Embeddings can blur these. Keyword search matches the exact terms and is very good at them.BM25: the classic keyword score
- it contains the query terms, and more times is better, but with diminishing returns (the tenth mention adds little);
- the terms are rare across the collection (matching "E-4012" matters more than matching "the");
- the document is not unusually long, since long documents match many words just by chance.
k1 (how fast repeats stop helping) and b (how much length matters). Common starting values are around k1 = 1.2 to 2 and b = 0.75. Most search engines and many databases include BM25 or something similar.| Query | Keyword search (BM25) | Vector search |
|---|---|---|
| "error E-4012 on checkout" | Strong: finds the exact code | May return any checkout error |
| "money cut but order not placed" | Weak if documents say "payment debited, order failed" | Strong: same meaning, different words |
| "Form 16 kab milega" (Hinglish) | Finds "Form 16" exactly | Depends on the model's language coverage |
| A person's rare surname | Strong | Often weak |
Fusing two ranked lists: reciprocal rank fusion
1 / (k + rank) from each list it appears in, and the parts are added. A common default for k is 60. Documents that rank well in both lists rise to the top.def rrf(rankings, k=60):
"""Fuse several ranked lists of document ids into one ranked list.
rankings: a list of lists, each ordered best first.
k: dampening constant; 60 is a common default.
"""
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
# Sort by fused score, highest first; ties broken by id for stable output.
return sorted(scores, key=lambda d: (-scores[d], d))
keyword_hits = ["faq-12", "manual-3", "faq-7"]
vector_hits = ["faq-7", "faq-12", "blog-1"]
print(rrf([keyword_hits, vector_hits]))
# ['faq-12', 'faq-7', 'manual-3', 'blog-1']faq-12 is first in one list and second in the other, so it wins. blog-1 appears only once, in third place, so it comes last. RRF needs no training and no score tuning, which is why it is a popular first choice.A typical hybrid pipeline
- 1
Apply filters first
Permissions, language, date or document type, enforced in code. - 2
Keyword search
Take the top results from BM25, for example the top 20 to 50. - 3
Vector search
Take the top results from the vector index, the same number. - 4
Fuse
Combine with reciprocal rank fusion. - 5
Re-rank and send
Optionally re-rank the fused list (next lesson), then send the best few to the model.
["manual-3", "faq-12"]. Before running it, guess the new order. Then check.Watch and read more
Lab
A tiny hybrid search engine in pure Python: BM25 plus made-up vectors, fused with RRF.
The problem
Run the starter with Python 3. It uses only the standard library. The vectors are made up by hand: in a real system an embedding model would produce them.
- Run it and read the three result lists for each query.
- Find one query where keyword search is right and vector search is wrong, and one where the opposite is true. Write both down.
- Add two documents of your own, with made-up vectors, and one new query with its own made-up vector.
- Change RRF's
kto 1 and then to 1000. Describe what changes in the fused order.
Practice lab
Edit the code and run it. Python runs in your browser — nothing is sent to our server.
No output. Add print(...) to see values.
You are done when
Hard questions
Try to answer before you reveal. If you can answer these, you understood the lesson.
Q1In the starter, why does the tokenizer keep "e-4012" as one token instead of splitting it into "e" and "4012"?Reveal
Because the code is the meaningful unit. If it were split, "e" would match almost nothing useful and "4012" might match unrelated numbers such as prices or years. Keeping hyphenated codes together makes the rare, exact term count fully in BM25. Real search engines have analysers for this; check how yours handles codes, ids and hyphens.
Q2Keyword search returns only 1 result and vector search returns 3. How does RRF treat a document found only by vectors?Reveal
It still gets a score of 1/(k + rank) from the vector list, just nothing from the keyword list. So it can appear in the fused result, usually below documents found by both. RRF does not need both lists to be the same length, and a missing document simply adds zero from that list.
Questions people ask
Is keyword search old-fashioned now?
No. It is fast, cheap, easy to explain and excellent at exact terms. Many strong RAG systems use hybrid search because each method covers the other's blind spots.
Why is the k in RRF usually around 60?
It was the value used when RRF was first described, and it works well in many settings. A bigger k makes the gap between rank 1 and rank 5 smaller. You can tune it on your test set, but results are often not very sensitive to it.
How does keyword search handle Indian languages?
It depends on the tokenizer and text analysis your search engine uses. Check that it splits words correctly for each script, and remember that a query in Latin letters ("paisa") will not match text in Devanagari (पैसा) by keywords alone. The next lesson covers this.
Do I need two separate systems for hybrid search?
Not always. Some search engines and databases support both keyword and vector search in one place. Check the current docs of the tools you already run before adding another.
Lesson test
5 questions. Get 3 right (60%) to pass and complete this lesson.