SevyDevy

Guide · AI and GenAI

Hybrid Search: How BM25 and Vector Search Work Together

25 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Hybrid search runs two searches for every query and merges the two ranked lists into one. One is keyword search scored with BM25, a formula that rewards matching words; the other is vector search, which compares meaning.
  • Keyword search is strong on exact things like part codes and sizes. It misses synonyms unless you give it a synonym list, and misses typos unless fuzzy matching, which accepts near-miss spellings, is switched on.
  • Vector search turns text into lists of numbers, so it can find a part described in different words. It is weaker on exact codes and rare names, so the two methods fail on different queries.
  • The simplest merge is reciprocal rank fusion (RRF): each result earns points from its position in each list, so raw scores never need comparing. A tuned weighted mix of rescaled scores can do better.
  • Beginners can stop after the merging section. Engineers then get measurement code and checks on Lucene 10.5.0, the open-source search library under Solr, Elasticsearch and OpenSearch. In our runs, hyphenated codes split in two, and a fuzzy search for the typo brek ranked break above brake.

It misses results whenever people type in a way that method handles badly. Some people type exact codes, others describe what they want in their own words, and one method rarely serves both. Hybrid search is a common fix, and this guide shows when it is worth it.

Picture a common setup. An online store sells about 40,000 spare parts for bikes, scooters and cars. Each part has a code like SK-4471, a name and a short description. The setup is made up, but the problem is real.

Customers search in two very different ways. Mechanics type codes straight off an old box, like SK-4471. Car owners describe what they need, like "rubber thing that stops the silencer rattling" (silencer is the Indian English word for an exhaust muffler). Both expect the right part on the first screen.

The team tries keyword search first. Mechanics are happy, but car owners see nothing useful. Then it tries vector search, and the complaints flip: descriptions work, but codes come back wrong. The sections below show why each one fails and how hybrid search keeps the best of both.

Picture an old parts shop with two helpers. The counter clerk knows the catalogue by heart and finds any code in seconds, but looks blank at "the round rubber thing". The old mechanic understands every vague description, but mixes up codes that look alike. Hybrid search asks both and combines their answers.

The clerk is keyword search, the mechanic is vector search, and combining their answers is the merge step. Where the picture breaks: real helpers can explain their reasons. Both searches only hand back a ranked list, so the merge sees positions or scores, never reasons.

Four words come up in every section, so here they are in plain terms.

  • Query: whatever the person types into the search box.
  • Document: one thing the search can return. In the store, each part, meaning its code, name and description together, is one document.
  • Index: a structure built ahead of time so search is fast, like the index at the back of a textbook.
  • Ranked list: the results sorted best first, with rank 1 at the top. Each search produces one, and hybrid search merges them.

How does BM25 keyword search work?

BM25 keyword search finds documents that share words with the query, then scores each one by how well those words match. It rewards rare words, rewards repeats up to a limit, and stops long documents winning just because they are long.

First, keyword search splits every document into words, called terms. An inverted index then lists, for every term, the documents that contain it. Look up "gasket" and you get the matching parts at once, without reading every description.

BM25 is a widely used formula for scoring those matches. It is the default scoring in Apache Lucene, the open-source search library at the core of Solr, Elasticsearch and OpenSearch. The diagram shows what it weighs for each query term.

How BM25 scores one part

Notice that rarity, repeats and length all act on each query term before the totals are added.

Diagram connections:

  • Query terms to Weigh rare terms more
  • Weigh rare terms more to Count repeats, with a cap
  • Count repeats, with a cap to Adjust for length
  • Adjust for length to BM25 score
  • Query terms: brake, pad, sk4471
  • Weigh rare terms more: sk4471 is rare, so a match counts a lot
  • Count repeats, with a cap: Each extra repeat adds less than the last
  • Adjust for length: Long descriptions do not win by size alone
  • BM25 score: One number per part, higher is better

Rare words count more. "Brake" appears in thousands of parts, so a match on it tells you little. The code sk4471 appears in one part, so a match on it is strong evidence. This weight is called inverse document frequency, or IDF.

Repeats help, but only up to a point. A description that says "clutch" twice scores higher than one that says it once, but each extra repeat adds less. A setting called k1 controls how quickly this levels off.

Long documents get a penalty. A 300-word description matches more words just by being long, so BM25 compares each document's length with the average and scales its score down. A setting called b controls how strong this penalty is.

BM25 works out these effects for each query term and adds them up. Lucene's defaults are k1 = 1.2 and b = 0.75, a sensible place to start. Sorting the parts by that total gives the keyword search's ranked list.

How does vector search find results by meaning?

Vector search turns text into lists of numbers and returns the documents whose numbers sit closest to the query's numbers. Text with similar meaning gets similar numbers, so it can match parts that share no words with the query.

The tool that does this is an embedding model: a model trained to turn any text into a fixed-length list of numbers. That list is called a vector, or an embedding. Searching this way is also called dense retrieval. Dense means almost every number in the vector is non-zero, while a keyword view of a document is sparse: only its own few terms count.

The team runs every part description through the embedding model once and stores the vectors. At search time it embeds the query too, then looks for the stored vectors nearest to it. Closeness is often measured with cosine similarity, which checks whether two vectors point the same way; 1 means exactly the same direction.

So a car owner who types "silencer rattle rubber" can find a part named "exhaust mounting bush", even though no word matches. In a 2020 paper, a learned dense retriever beat a strong BM25 system on four of five question-answering datasets, measured by top-20 accuracy. That is the share of questions where a passage containing the answer appeared in the first 20 results. BM25 still won on the fifth, where questions reused the passages' own words.

Searching millions of vectors quickly needs an approximate nearest-neighbour index. It finds very close vectors without comparing the query against every stored one. That is the job of a vector database, or of the vector support inside a search engine.

Where do keyword search and vector search each fail?

Keyword search fails when people use different words from the catalogue, and vector search tends to fail on exact codes and numbers. Their failures are mostly different, and that is why combining them helps.

How each method tends to handle the store's queries (the vector column is typical behaviour; test your own model)
QueryWhat the customer meansBM25 keyword searchVector search
SK-4471An exact part codeFinds it at rank 1 if codes are cleanedMay rank look-alike SK-4417 first
silencer rattle rubberExhaust mounting bushMisses: no shared wordsCan find it: the meanings are close
dickey lockBoot lid lock (dickey is the Indian English word for a car boot)Matches only on lock, so any lock may rank first; a synonym list tells it dickey means bootFinds it only if the model learned the word
brek padBrake pad, with a typoMatches only on pad; brek finds nothing unless fuzzy matching is onMay land close; test your typos
6204 bearingOne exact bearing sizeFinds exact 6204 partsMay mix in 6205 or 6304
squeak when braking hardA symptom, not a part nameMatches any part that says squeak or brakingBetter at the overall meaning

Codes trip up vector search because a code carries little meaning that a general model has learned. A 2021 study found dense retrievers did far worse than keyword methods on simple questions about named people, places and things. They generalised mainly to common names unless training covered that question pattern. Part codes may behave like rare names, so test yours.

BM25 treats a cleaned code like sk4471 as one exact term, so a near miss such as sk4417 earns nothing. To see the risk on your own model, embed two look-alike codes and check how close their vectors land.

Typos hurt BM25 because "brek" is simply a different term from "brake". Many search engines offer fuzzy matching, which also accepts terms one or two edits away. An edit is one inserted, deleted, changed or swapped letter.

Vector search may or may not place a typo near the right word, and it varies by model. Put your customers' common typos in your test set and see where they land.

What is hybrid search and how does it work?

Hybrid search runs a keyword search and a vector search for the same query, then merges the two ranked lists into one. Each method covers the other's blind spots.

Follow the query from the search box to the results in the diagram below. Notice that only the vector side needs the embedding model.

The store's hybrid search

Two searches run side by side and meet only at the merge step, so either side can miss without sinking the result.

Diagram connections:

  • Customer search box to Search service
  • Search service to Keyword index (Cleaned query)
  • Search service to Embedding model (Cleaned query)
  • Embedding model to Vector index (Query vector)
  • Keyword index to Merge step (Top 50 by words)
  • Vector index to Merge step (Top 50 by meaning)
  • Merge step to Top 10 parts
  • Customer search box: Takes SK-4471 or a loose description
  • Search service: Cleans the query and sends it both ways
  • Embedding model: Turns the query into a vector
  • Keyword index: Scores codes and descriptions with BM25
  • Vector index: Stores one vector per part
  • Merge step: Fuses both ranked lists into one
  • Top 10 parts: Shown to the customer

Each search returns its top 50 candidates, not just its top 10. That way, a part ranked 30th by one method can still rise if the other method also likes it.

The two searches run at the same time. So the wait is roughly the slower of two paths: the keyword search, or the query embedding plus the vector search. The merge itself is a few lines of arithmetic.

Every new or edited part must reach both indexes. Otherwise a new part can be found by its code but not by its description. The team updates both indexes in the same job.

How do you merge BM25 and vector search results?

You merge them with reciprocal rank fusion, which uses only each result's position, or with a weighted mix of rescaled scores. Start with reciprocal rank fusion, because it needs almost no tuning.

You cannot just add the raw scores. A BM25 score has no fixed top: it grows with the number of query terms and how rare they are. A cosine similarity never goes above 1, so in a plain sum the BM25 number usually drowns out the vector number.

How does reciprocal rank fusion work?

Reciprocal rank fusion, or RRF, gives each document 1 divided by (k + its rank) points from each list, then adds the points up. The 2009 paper that introduced RRF used k = 60, a value it fixed in a pilot study, and this guide starts there too.

Because it ignores raw scores, RRF never has to compare a BM25 score of 14.2 with a cosine similarity of 0.82.

Take the query "SK-4471 brake pads". BM25 ranks the SK-4471 pad set first, a brake pad spring second and a brake cable third. Vector search ranks the look-alike SK-4417 pad set first, the SK-4471 set second and the spring third. To keep the sums short, count only each list's top three.

  • SK-4471 pad set: 1/61 + 1/62, about 0.0325, so it finishes first.
  • Brake pad spring: 1/62 + 1/63, about 0.0320, so it comes second.
  • SK-4417 pad set: only 1/61, about 0.0164, because it is not in the keyword top three.
  • Brake cable: only 1/63, about 0.0159, because it is not in the vector top three.

Here is RRF in Python. It takes any number of ranked lists of part ids, best first. The example lists are the ones above, so you can run it and check the order.

python
def rrf(result_lists, k=60):
    scores = {}
    for results in result_lists:
        for rank, part_id in enumerate(results, start=1):
            scores[part_id] = scores.get(part_id, 0) + 1 / (k + rank)
    return sorted(scores.items(), key=lambda item: item[1], reverse=True)

keyword_hits = ["SK-4471 pads", "pad spring", "brake cable"]
vector_hits = ["SK-4417 pads", "SK-4471 pads", "pad spring"]
for part_id, score in rrf([keyword_hits, vector_hits]):
    print(f"{part_id:13} {score:.4f}")

Run it with Python 3 and it prints four lines in this order: SK-4471 pads 0.0325, pad spring 0.0320, SK-4417 pads 0.0164 and brake cable 0.0159. In production, each list would hold the top 50 from its search.

The line that matters sits inside both loops. There, enumerate gives each part its rank, starting from 1. The part then gains 1 / (k + rank) points on top of anything it earned from the other list.

When should you use weighted scores instead of RRF?

Use weighted scores when you have test queries to tune on and want more control than RRF gives. First put both scores on the same 0 to 1 scale, then mix them with one weight.

Putting scores on one scale is called normalisation. The simplest kind is min-max: in each list, the top score becomes 1, the lowest becomes 0, and the rest fall in between. The final score is alpha times the vector score plus (1 minus alpha) times the keyword score.

Alpha is a number from 0 to 1 that you choose. The code below uses the same four parts, now with made-up raw scores.

python
def min_max(scores):
    if not scores:
        return {}
    low, high = min(scores.values()), max(scores.values())
    if high == low:
        return {pid: 1.0 for pid in scores}
    return {pid: (s - low) / (high - low) for pid, s in scores.items()}

def weighted_fusion(keyword_scores, vector_scores, alpha=0.5):
    kw, vec = min_max(keyword_scores), min_max(vector_scores)
    final = {pid: alpha * vec.get(pid, 0) + (1 - alpha) * kw.get(pid, 0)
             for pid in set(kw) | set(vec)}
    return sorted(final.items(), key=lambda item: item[1], reverse=True)

keyword_scores = {"SK-4471 pads": 14.2, "pad spring": 9.1, "brake cable": 7.4}
vector_scores = {"SK-4417 pads": 0.86, "SK-4471 pads": 0.84, "pad spring": 0.79}
for part_id, score in weighted_fusion(keyword_scores, vector_scores):
    print(f"{part_id:13} {score:.3f}")

It prints SK-4471 pads 0.857, SK-4417 pads 0.500, pad spring 0.125 and brake cable 0.000. The high == low check stops a divide by zero when every score in a list is the same.

Notice the pad spring. It appears in both lists, yet it gets 0 from the vector side, because min-max turns the lowest candidate in each list into 0. So changing the candidate count can change every normalised score.

With alpha = 0.5, both methods count equally. A lower alpha, such as 0.2, leans on keywords and suits queries full of codes. A higher alpha leans on meaning and suits long descriptions.

A 2022 study compared the two merges on public datasets. A tuned weighted mix, which the paper calls a convex combination, beat RRF on both familiar and new kinds of data. It needed only a small set of examples to tune its one weight.

The same study found RRF more sensitive to its settings than earlier work had reported. The 2009 paper had found k = 60 near-best and the exact choice not critical in its tests.

You measure hybrid search on a fixed set of real queries with known right answers. Then you change one setting at a time and keep only what helps. Without that test set, tuning is guesswork.

The team pulls 300 real queries from its search log, a saved record of every search. They cover codes, descriptions, typos and local words in roughly the mix customers use. Counter staff mark the right part for each query, and the set stays fixed between experiments.

Recall at 10 measures what share of the right parts show up anywhere in the top 10. When each query has one right part, it is simply the share of queries where that part makes the top 10. If it does for 255 of 300 queries, recall at 10 is 0.85.

MRR, short for mean reciprocal rank, rewards putting the right part near the top. For each query, take 1 divided by the rank of the first right part, then average across all queries. A right part at rank 1 scores 1, at rank 2 it scores 0.5, and a miss scores 0.

Here are both measures in Python, run on a three-query test set. The second query finds its part at rank 2, and the third misses.

python
def recall_at_10(results, right):
    found = sum(1 for q, part in right.items() if part in results[q][:10])
    return found / len(right)

def mrr(results, right):
    total = 0.0
    for q, part in right.items():
        if part in results[q]:
            total += 1 / (results[q].index(part) + 1)
    return total / len(right)

right = {"SK-4471": "SK-4471 pads", "silencer rattle rubber": "exhaust bush",
         "dickey lock": "boot lock"}
results = {"SK-4471": ["SK-4471 pads", "SK-4417 pads"],
           "silencer rattle rubber": ["heat shield", "exhaust bush"],
           "dickey lock": ["door lock", "fuel cap"]}
print(round(recall_at_10(results, right), 2), round(mrr(results, right), 2))

It prints 0.67 0.5. Recall at 10 is 2 of 3 queries, and MRR is (1 + 0.5 + 0) divided by 3. A common variant, MRR at 10, also counts a part found below rank 10 as a miss.

Here are the team's made-up results on its 300 test queries. Hybrid beat both single methods, and most of the gain came from queries that mix a code with words.

Recall at 10 on the store's 300 test queries (made up)
MethodRecall at 10
BM25 keyword search alone0.71
Vector search alone0.68
Hybrid with RRF0.86
Hybrid with weighted mix, alpha 0.40.88
  1. Fix the test set

    Collect real queries with the right answers marked. Tag each one by type: code, description, typo or local word.

  2. Measure each method alone

    Run BM25 only, then vector search only. These are your baselines, the numbers hybrid must beat.

  3. Try RRF first

    Use k = 60 and 50 candidates per list. Compare recall at 10 and MRR with both baselines.

  4. Change one setting at a time

    Try a few values for k, the candidate count or alpha. Keep a change only if it helps on the test set.

  5. Check each query type

    A better average can hide a worse result for codes. Make sure no type of query gets clearly worse.

  6. Watch it after launch

    Track searches with no click and searches with no results. Add the worst ones to the test set.

One more trick suits the store if it uses the weighted mix. If the query looks like a part code, the search service uses a lower alpha, such as 0.2, to favour keywords. A simple pattern check in code is enough.

What does Lucene 10.5 do with part codes, typos and scores?

It splits hyphenated codes into two terms, matches typos up to two edits away but ranks closer words higher, and gives BM25 and cosine scores very different ranges. We ran each check below on Lucene 10.5.0 on 19 October 2026. The scoring, fuzzy, vector and RRF source files are unchanged in 10.5.2, the newest release listed on the Lucene site that day.

Hyphenated codes become two terms

StandardAnalyzer is the text splitter Lucene's IndexWriterConfig, the settings object for building an index, uses when you name none. It turns SK-4471 into two terms, sk and 4471, and splits "sk 4471" the same way. SK4471 stays one term, sk4471.

We indexed four parts and searched. The query SK-4471 found the SK-4471 pad set first, but also returned a fuel cap coded SK-1002 and the SK-4417 pads, because every code shares the term sk. The query SK4471 found only the part indexed as SK4471, not the SK-4471 pads.

That is the case for the clean-up tip earlier: one form of every code, applied when you index and again when you search.

Fuzzy matching can prefer the wrong word

Lucene's FuzzyQuery accepts terms up to 2 edits away by default, and counts swapping two neighbouring letters as one edit. A fuzzy search for brek matched brake, but ranked break and brew above it, because they are only one edit away.

Set the limit to 1 edit and brake dropped out entirely. So fuzzy matching widens the net, but something else, such as the vector side or a synonym list, has to put the right word first.

Scores live on different scales

BM25 and cosine scores measured on Lucene 10.5.0 with default settings (test index of 1,014 short documents)
What we scoredResult
Common term brake (in 600 documents), on one document0.24
Rare term sk4471 (in 1 document), on the same document2.98
clutch once vs 19 times in a 20-term document2.31 vs 4.75; the ceiling is its IDF in this index, 5.05
gasket once in a 5-term vs a 300-term document3.56 vs 0.40
Two vectors at right angles (cosine 0), as a Lucene vector score0.50

Two results matter for merging. A boost is a per-query multiplier that is 1 unless you set it. In Lucene, with default boosts, each matching query term adds at most its IDF, so a one-term query and a five-term query produce very different score ranges. Lucene also reports cosine similarity as (1 + cosine) / 2, so 0.5 means vectors at right angles (cosine 0), not half similar.

The docs only say the cosine score is normalised to be positive; the formula is in the source. That is undocumented as of 19 October 2026, so pin your version. The docs also recommend scaling vectors to length 1 and using the DOT_PRODUCT function instead. A dot product multiplies matching numbers and adds them up.

RRF is built into Lucene since 10.2

Lucene 10.2, released in April 2025, added TopDocs.rrf(topN, k, hits). TopDocs is Lucene's type for a ranked result list, and this method fuses any number of them by rank. It rejects k below 1 and breaks ties by internal document id. On the worked example above it returned the same order and scores, from 0.03252 down to 0.01587. It also fused a BM25 list and a vector list taken from one index, where each part held both a text field and a vector field.

When is hybrid search not worth it?

Hybrid search is not worth it when one method already handles almost all your queries, or when the extra work outweighs a small gain. Your test set should decide, not fashion.

Compared with keyword search alone, hybrid means two indexes to keep in sync, an embedding step on every write and every search, and more settings to tune. Skip it in cases like these:

  • Your queries are almost all exact codes, IDs or names. Good keyword search with code clean-up may be all you need.
  • Your queries are almost all plain questions, and vector search alone already finds the right answer nearly every time on your test set.
  • Your speed budget, the longest a search may take, is very tight, and the embedding step pushes searches past it.
  • Your test set shows hybrid beating the better single method by only a tiny margin.

The chart below puts these questions in order. Start at the top and follow the answer that fits your search.

Is hybrid search worth it for you?

Notice that hybrid is only reached after a measured gain on your own queries.

Diagram connections:

  • Codes and words mixed? to Use one search method (No)
  • Codes and words mixed? to Clear gain on test queries? (Yes)
  • Clear gain on test queries? to Keep the simpler search (No)
  • Clear gain on test queries? to Fits your speed budget? (Yes)
  • Fits your speed budget? to Use hybrid search (Yes)
  • Fits your speed budget? to Speed it up first (No)
  • Speed it up first to Use hybrid search (Then measure again)
  • Codes and words mixed?: Do real queries include both kinds?
  • Use one search method: Pick the one your test set favours
  • Clear gain on test queries?: Compared with the better single method
  • Keep the simpler search: Less to build, sync and tune
  • Fits your speed budget?: With both searches and the embedding step
  • Speed it up first: Fewer candidates, a smaller embedding model
  • Use hybrid search: Start with RRF, then tune

Run the store through it. Its queries mix codes and words, hybrid clearly won on its test set, and results still arrive within its speed target. So hybrid stays.

How do you explain hybrid search in a system design interview?

Explain it by showing where each method fails, then how you merge them, then how you would prove it helps. Interviewers want to see that you can justify the extra parts with measurements.

  1. Describe the queries

    Say what users actually type. In the parts store, mechanics type exact codes and car owners type loose descriptions.

  2. Start with a keyword baseline

    Propose BM25 with code clean-up first. It is cheap, fast, needs no training and is easy to explain.

  3. Show where it fails

    Give one synonym query BM25 misses, then add vector search for it. Mention that vectors can confuse look-alike codes.

  4. Merge with RRF

    Run both searches in parallel, take 50 candidates from each, and fuse them with k = 60. Mention weighted scores as the tuned upgrade.

  5. Measure it

    Describe the test set, recall at 10 and MRR, and the two baselines hybrid must beat.

  6. Cover the costs

    Name the two indexes to keep in sync, the embedding call per query and the extra wait. Say when you would skip hybrid.

Questions people ask

Is BM25 still used today?
Yes, very widely. BM25 is the default scoring in Apache Lucene 10.5, the library at the core of Solr, Elasticsearch and OpenSearch, and retrieval research uses it as the standard baseline. It is fast, needs no training and is hard to beat on exact terms like part codes.
What is the difference between hybrid search and semantic search?
Semantic search usually means vector search alone, which matches meaning. Hybrid search combines that with keyword search and merges the two result lists. So hybrid keeps the meaning matching of semantic search and adds exact matching for codes, names and numbers.
What value of k should I use in reciprocal rank fusion?
Start with k = 60, the value fixed in the original 2009 paper, which found the exact choice not critical in its tests. A smaller k gives more weight to the very top ranks, and a larger k spreads points more evenly. A 2022 study found RRF more sensitive to its settings, so try a few values on your test set.
Is hybrid search better than vector search for RAG?
Often, but not always. Hybrid usually helps when your documents contain codes, product names, error messages or rare terms that vectors handle poorly. If your questions are plain language and vector search already finds the right pieces, hybrid may add work for little gain. Measure it on your own questions.
Do I need a vector database for hybrid search?
Not necessarily. Lucene 10.5, for example, can hold a text field and a vector field in the same document and ships RRF, and some vector databases also offer keyword search. Pick the setup your team can run and keep in sync. What matters most is that both searches cover the same documents.
Should hybrid search use cosine similarity or dot product?
For vectors scaled to length 1, the two give exactly the same ranking, because the dot product then equals the cosine. Lucene 10.5's docs recommend scaling vectors that way and using DOT_PRODUCT, keeping COSINE for vectors you cannot change.
What is sparse vs dense retrieval?
Sparse retrieval is keyword-style search like BM25, where each document is described by the exact words it contains. Dense retrieval is vector search, where each document becomes a fixed-length list of numbers that captures its meaning. Hybrid search runs both and merges the results.

Sources and further reading

Practise this with Lala

Practise