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.
What goes wrong when a search box uses only one kind of search?
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.
Which words do you need before hybrid search?
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
Drag to exploreNotice 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.
| Query | What the customer means | BM25 keyword search | Vector search |
|---|---|---|---|
| SK-4471 | An exact part code | Finds it at rank 1 if codes are cleaned | May rank look-alike SK-4417 first |
| silencer rattle rubber | Exhaust mounting bush | Misses: no shared words | Can find it: the meanings are close |
| dickey lock | Boot 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 boot | Finds it only if the model learned the word |
| brek pad | Brake pad, with a typo | Matches only on pad; brek finds nothing unless fuzzy matching is on | May land close; test your typos |
| 6204 bearing | One exact bearing size | Finds exact 6204 parts | May mix in 6205 or 6304 |
| squeak when braking hard | A symptom, not a part name | Matches any part that says squeak or braking | Better 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
Drag to exploreTwo 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.
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.
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.
How do you tune and measure hybrid search?
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.
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.
| Method | Recall at 10 |
|---|---|
| BM25 keyword search alone | 0.71 |
| Vector search alone | 0.68 |
| Hybrid with RRF | 0.86 |
| Hybrid with weighted mix, alpha 0.4 | 0.88 |
Fix the test set
Collect real queries with the right answers marked. Tag each one by type: code, description, typo or local word.
Measure each method alone
Run BM25 only, then vector search only. These are your baselines, the numbers hybrid must beat.
Try RRF first
Use k = 60 and 50 candidates per list. Compare recall at 10 and MRR with both baselines.
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.
Check each query type
A better average can hide a worse result for codes. Make sure no type of query gets clearly worse.
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
| What we scored | Result |
|---|---|
| Common term brake (in 600 documents), on one document | 0.24 |
| Rare term sk4471 (in 1 document), on the same document | 2.98 |
| clutch once vs 19 times in a 20-term document | 2.31 vs 4.75; the ceiling is its IDF in this index, 5.05 |
| gasket once in a 5-term vs a 300-term document | 3.56 vs 0.40 |
| Two vectors at right angles (cosine 0), as a Lucene vector score | 0.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?
Drag to exploreNotice 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.
Describe the queries
Say what users actually type. In the parts store, mechanics type exact codes and car owners type loose descriptions.
Start with a keyword baseline
Propose BM25 with code clean-up first. It is cheap, fast, needs no training and is easy to explain.
Show where it fails
Give one synonym query BM25 misses, then add vector search for it. Mention that vectors can confuse look-alike codes.
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.
Measure it
Describe the test set, recall at 10 and MRR, and the two baselines hybrid must beat.
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
- Cormack, Clarke and Buettcher (SIGIR 2009): Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (formula, k = 60, Table 1)
- Bruch et al. (2022): An Analysis of Fusion Functions for Hybrid Retrieval (abstract: convex combination vs RRF)
- Thakur et al. (2021): a heterogeneous benchmark of 18 datasets for zero-shot retrieval (abstract: BM25 as a strong baseline)
- Karpukhin et al. (2020): open-domain question answering retrieval paper (Table 2, top-20 accuracy vs BM25)
- Sciavolino et al. (2021): Simple Entity-Centric Questions Challenge Dense Retrievers (abstract)
- Apache Lucene home page (opened 19 October 2026): search core of Solr, Elasticsearch and OpenSearch; 10.5.2 release news
- Apache Lucene 10.5.0 API: BM25Similarity (default k1 = 1.2, b = 0.75)
- Apache Lucene 10.5.0 API: Similarity (names BM25Similarity the default implementation)
- Apache Lucene 10.5.0 changes: TopDocs#rrf added in 10.2.0 (2025-04-10)
- Apache Lucene 10.5.0 API: TopDocs (the rrf method)
- Apache Lucene 10.5.0 API: FuzzyQuery (at most 2 edits, Damerau-Levenshtein)
- Apache Lucene 10.5.0 API: VectorSimilarityFunction (COSINE vs DOT_PRODUCT advice)