Hybrid search explained: BM25, vector search and rank fusion
Hybrid search runs a keyword search and a vector search on the same query and merges the two result lists, usually with reciprocal rank fusion. How each side works, a worked example, runnable code and how it applies to agent memory.

Hybrid search is a retrieval method that runs a keyword search (usually BM25) and a vector search on the same query, then merges the two ranked lists into one, most often with reciprocal rank fusion (RRF). You use it because each side finds what the other misses: keyword search catches exact terms like error codes and product IDs, and vector search catches the same meaning in different words.
Below is how each side works, how RRF merges them with a worked example you can check by hand, code you can run, when to weight one side or add a reranker, which databases do it for you, and how it applies to agent memory.
What is hybrid search?
Hybrid search is retrieval that combines two kinds of matching on every query: lexical matching on the words themselves, and semantic matching on what the words mean. Most systems run both searches at the same time, take the top results from each, and fuse them into one ranked list before anything reaches the model.
The two sides fail in opposite ways, which is why the combination works. Here's how that plays out on common questions:
| Question | Signal likely to help | Failure to test |
|---|---|---|
| "What does ERR-482 mean?" | Exact keyword matching | A similar but different code ranks first |
| "How do I stop invoices renewing?" | Vector search | The source says "cancel recurring billing" |
| "Does the enterprise plan support SSO?" | Both | A consumer-plan passage uses similar language |
| "Which service depends on the failed database?" | Explicit relationships or a structured lookup | A passage mentions both but doesn't say one depends on the other |
These are hypotheses to test on your own corpus. A good keyword setup can expand synonyms, and a vector model sometimes gets an exact code right.
The word "hybrid" also gets used for other things. Some products call a search over several content types hybrid. In supermemory's API, for example, searchMode: "hybrid" means returning extracted memories and document chunks together (search docs). This post is about keyword plus vector.
How does hybrid search combine BM25 and vector search?
It runs both on the same query and merges the results, so it helps to know how each side scores documents and where each has blind spots.
BM25 scores exact terms
BM25 is the standard keyword ranking function, and the default in Elasticsearch. For each query term a document contains, it adds a weight that grows with how often the term appears in that document and how rare the term is across the collection, then adjusts for document length. In Robertson and Zaragoza's formulation (The Probabilistic Relevance Framework: BM25 and Beyond, 2009), a term's weight is:
weight(term, doc) = idf(term) × tf / (tf + k1 × (1 - b + b × doc_length / avg_doc_length))
Two parameters tune it. k1 controls saturation: a term's contribution levels off as it repeats, so the tenth mention adds much less than the first. b controls length normalization: at 1, term counts are fully normalized by document length, and at 0, length is ignored. The paper reports that values such as 0.5 to 0.8 for b and 1.2 to 2 for k1 work reasonably well in many cases, and Lucene defaults to k1 = 1.2 and b = 0.75 (Lucene BM25Similarity).
BM25 is a sparse method: each document is effectively a vector with one dimension per word in the vocabulary, almost all of them zero. That makes it exact. It matches the exact tokens in a string like "ERR-482" (how a code splits into tokens depends on the analyzer), and it can't see that "automobile" and "car" mean the same thing.
Vector search scores meaning
Vector search turns the query and each chunk into embeddings, dense vectors of a few hundred to a few thousand numbers from an embedding model, and ranks chunks by how close their vectors are, usually by cosine similarity. "How do I stop feeling overwhelmed at work" can find a passage titled "stress management strategies" with no word in common. The vector search guide covers indexes and tradeoffs.
Where each one fails
- BM25 misses synonyms and paraphrases. "Cancel recurring billing" scores zero for "stop invoices renewing" unless some word overlaps.
- Vector search blurs identifiers. "PROD-SKU-7842X" and "PROD-SKU-7842Y" look almost the same to an embedding model, so the wrong one can rank first with high similarity.
- Both can return stale or unauthorized content. Neither knows which version is current or who may see it. That's for filters, covered below.
Users send both kinds of query, often in the same session, so a retrieval layer that only does one will keep failing on the other.
What is reciprocal rank fusion (RRF)?
Reciprocal rank fusion merges ranked lists using only each document's position in each list, not its score. Each document gets 1 / (k + rank) from every list it appears in, and the totals decide the final order:
RRF_score(doc) = sum over each ranked list of 1 / (k + rank of doc in that list)
RRF comes from a short paper by Cormack, Clarke and Büttcher at SIGIR 2009, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. They set k = 60 during a pilot study and didn't change it afterwards. In that pilot, 60 was near-optimal, but the choice wasn't critical. The constant keeps one list's top results from dominating: with k = 60, rank 1 is worth 1/61 and rank 10 is worth 1/70, so a document that shows up in both lists usually beats one that tops only one. Across their pilot and TREC experiments, RRF beat Condorcet fusion, CombMNZ and the best single system by 4% to 5% on average.
Ranks work where scores don't. BM25 scores depend on the query and the collection and have no fixed range, while cosine similarities fall between -1 and 1. Adding the raw numbers lets whichever side produces bigger numbers win by default. RRF avoids normalizing scores at all.
It isn't always the best fusion, though. In An Analysis of Fusion Functions for Hybrid Retrieval (Bruch, Gai and Ingber), a weighted sum of normalized scores (a convex combination) beat RRF both in and out of domain, RRF turned out to be sensitive to its parameters, and the weighted sum needed only a small labeled set to tune. RRF is the safe default when you have no labels. If you have a labeled set, even a small one, test a tuned weighted sum against it.
A worked example
Take five help-center articles and the query "payment failed with ERR-482".
| ID | Article |
|---|---|
| kb-12 | Error ERR-482: the card issuer declined the payment. Ask the customer to retry or use another card. |
| kb-07 | Why payments fail, and how to retry a declined charge. |
| kb-31 | Error ERR-481: the checkout session expired before payment. |
| kb-19 | How to update billing details and change the card on file. |
| kb-02 | Refunds reach the customer's card in 5 to 10 business days. |
BM25 (the code below, with Lucene's default parameters) returns two articles: kb-12, which contains both "err-482" and "payment", and kb-31, which contains only "payment". Their scores are 0.86 and 0.46, numbers that only mean something next to each other.
Now say the vector search returns kb-07, kb-12 and kb-19, in that order: a general guide to failed payments is closest in meaning, followed by the exact error article. With k = 60:
| Article | BM25 rank | Vector rank | RRF score |
|---|---|---|---|
| kb-12 | 1 | 2 | 1/61 + 1/62 = 0.032522 |
| kb-07 | Not returned | 1 | 1/61 = 0.016393 |
| kb-31 | 2 | Not returned | 1/62 = 0.016129 |
| kb-19 | Not returned | 3 | 1/63 = 0.015873 |
kb-12, the article that actually answers the question, comes out on top because both searches found it. Vector search alone would have put a general guide first, and BM25 alone returns only articles that share exact words with the query, including one about a different error code, and misses the general guide entirely.
How do you implement hybrid search?
Run both searches, then fuse the two lists. This version runs as is with Python's standard library. The BM25 class follows the formula above with Lucene's idf, and the vector ranking comes from whatever vector store you use:
import math
import re
from collections import Counter
def tokenize(text):
# lowercase words; keeps codes like "err-482" as one token
return re.findall(r"[a-z0-9]+(?:-[a-z0-9]+)*", text.lower())
class BM25:
"""BM25 as in Robertson and Zaragoza (2009), with Lucene's idf. k1 and b are Lucene's defaults."""
def __init__(self, docs, k1=1.2, b=0.75):
self.k1, self.b = k1, b
self.tf = {doc_id: Counter(tokenize(text)) for doc_id, text in docs.items()}
self.length = {doc_id: sum(tf.values()) for doc_id, tf in self.tf.items()}
self.avg_length = sum(self.length.values()) / len(docs)
df = Counter(term for tf in self.tf.values() for term in tf)
n = len(docs)
self.idf = {term: math.log(1 + (n - d + 0.5) / (d + 0.5)) for term, d in df.items()}
def rank(self, query, top_k=10):
scores = {}
for doc_id, tf in self.tf.items():
norm = self.k1 * (1 - self.b + self.b * self.length[doc_id] / self.avg_length)
score = sum(self.idf[t] * tf[t] / (tf[t] + norm) for t in tokenize(query) if t in tf)
if score > 0:
scores[doc_id] = score
return sorted(scores, key=lambda d: -scores[d])[:top_k]
def rrf(rankings, k=60, weights=None):
"""Reciprocal rank fusion (Cormack, Clarke and Buettcher, 2009): sum 1 / (k + rank) over each list."""
weights = weights or [1.0] * len(rankings)
scores = {}
for ranking, weight in zip(rankings, weights):
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + weight / (k + rank)
return sorted(scores.items(), key=lambda item: -item[1])
docs = {
"kb-12": "Error ERR-482: the card issuer declined the payment. Ask the customer to retry or use another card.",
"kb-07": "Why payments fail, and how to retry a declined charge.",
"kb-31": "Error ERR-481: the checkout session expired before payment.",
"kb-19": "How to update billing details and change the card on file.",
"kb-02": "Refunds reach the customer's card in 5 to 10 business days.",
}
query = "payment failed with ERR-482"
keyword = BM25(docs).rank(query)
vector = ["kb-07", "kb-12", "kb-19"] # from your vector store, for the same query
print("BM25: ", keyword)
print("Vector:", vector)
for doc_id, score in rrf([keyword, vector]):
print(f"{doc_id} {score:.6f}")
It prints:
BM25: ['kb-12', 'kb-31']
Vector: ['kb-07', 'kb-12', 'kb-19']
kb-12 0.032522
kb-07 0.016393
kb-31 0.016129
kb-19 0.015873
In production, run both searches in parallel and take a few dozen results from each before fusing (Elasticsearch calls this depth rank_window_size), then tune it on your own queries. In LlamaIndex, QueryFusionRetriever with mode="reciprocal_rerank" and num_queries=1 does the same fusion over a vector retriever and a BM25 retriever (at its default of 4, it also has an LLM write extra versions of the query) (LlamaIndex example).
Which databases support hybrid search?
You don't need two systems. All of these run both searches in one database, and all but Postgres have fusion built in (checked against each project's docs on October 16, 2026):
| Database | How you run it | Fusion |
|---|---|---|
| Elasticsearch | An rrf retriever over a BM25 retriever and a kNN retriever |
RRF, rank_constant defaults to 60 |
| OpenSearch | A hybrid query (added in 2.11) plus a search pipeline |
Score normalization and combination, or RRF through the score ranker processor |
| Weaviate | A hybrid query with an alpha weight (0.75 by default) |
Relative score fusion by default since v1.24, or ranked fusion |
| Qdrant | Sparse and dense prefetch queries under one query |
RRF with k set to 2 by default and configurable since v1.16, or distribution-based score fusion |
| Milvus | hybrid_search over several vector fields |
RRF ranker with k 60 by default, or a weighted ranker |
| Postgres with pgvector | Postgres full-text search next to a vector query | No built-in fusion: write RRF in one SQL query or in your code, or rerank with a cross-encoder |
Watch the defaults. A k of 2 in Qdrant weighs top ranks far more heavily than the 60 in Elasticsearch and Milvus, and Weaviate's default fuses normalized scores rather than ranks, so the same two lists can come out in a different order.
The cost is mostly storage and upkeep: you keep an inverted index and a vector index over the same content, and both need updating when a document changes. Batch the embedding work at ingestion rather than per request, and cache query embeddings for repeated queries.
When to weight one side
A 50/50 blend is a starting point. Some query types need one side to lead:
- Exact lookups (error codes, SKUs, version strings, ticket IDs): lean on keyword matching.
- Conceptual questions ("why does my auth flow break under load"): lean on vectors.
- Mixed queries ("OAuth error OA-403 in production"): keep both.
A cheap router can make the call before either search runs. A regular expression catches identifier patterns and raises the keyword list's weight, and everything else gets equal weights. With the rrf function above, that's rrf([keyword, vector], weights=[2, 1]) for an identifier-heavy query. Weaviate's alpha and Qdrant's weighted RRF (from v1.17) do the same inside the database. Tune the weights per query type on held-out queries rather than by feel, and if one class of query is the only one that benefits, consider routing just that class to hybrid.
Add reranking after fusion
Fusion decides which candidates make the shortlist. A reranker then reads the query and each candidate together and re-scores the top of the fused list, which catches relevance that two separate scores miss.
Two kinds are common. A cross-encoder scores each query and document pair in one pass of a model, which is accurate but slower per candidate. ColBERT (Khattab and Zaharia, SIGIR 2020) encodes the query and documents separately and then compares every query token with every document token, a "late interaction" that its authors report runs two orders of magnitude faster than BERT-based rankers, with four orders of magnitude fewer FLOPs per query.
A reranker can't recover a passage that neither search returned, so check candidate recall first. It also adds latency and cost, and if it's a hosted service, it's one more place your candidate text goes. Add it when the right passages show up in the candidates but rank too low, and measure it on the same queries. The contextual reranking guide shows how to test that stage on its own.
Apply permissions to every retrieval path
If the keyword side respects the tenant filter and the vector side doesn't, the fused list can leak data. Apply the same authorized scope to both searches, to the reranker's input, to caches and to any step that expands a chunk into its parent document. Removing a citation afterwards isn't enough: an inaccessible document shouldn't reach the model's prompt at all. The isolation checklist walks through testing the full path.
Version filters matter too. A stale policy that matches the exact words can outrank the current one, so keep old versions only when the question allows history, and label them.
Hybrid search for agent memory
Memory queries have the same mix as document search. People ask about exact things (a ticket number, a person's name, an error code) and about fuzzy things ("what did we decide about pricing?"), so the keyword-plus-vector logic carries over. Memory adds two problems retrieval alone doesn't solve: knowing which fact is current, and keeping each user's memories separate.
In supermemory, one search call covers the content side. searchMode: "hybrid" (the default) returns extracted memories and raw document chunks together, results carry an isLatest flag so an updated fact wins over the old one, and rerank: "order" re-scores the results (search docs):
const results = await supermemory.search("user_123", {
query: "ERR-482 on my last payment",
searchMode: "hybrid", // memories and document chunks in one call
rerank: "order", // re-score for relevance
limit: 5,
});
The search docs note that reranking adds some latency, and a successful rerank can bill one extra operation. Operation prices are on the pricing page. On the self-hosted server, keyword matching backs up the vectors: its docs note that with an English embedding model on, say, German text, dense recall can fail while hybrid keyword search still finds rare tokens (self-hosting docs). For a chunks-only setup, SuperRAG indexes documents without extracting memories.
How do you know hybrid search is helping?
Label a sample of real queries by type (exact identifiers, paraphrases, mixed, and no-answer cases) with the chunks that answer them. Compare keyword-only, vector-only and fused results before adding a reranker, with filters, corpus version and answer settings held fixed.
Track whether the supporting chunk lands in the top results and how high it ranks, plus duplicate context, unsupported answers and time spent in each stage. Report results by query type, since an average can hide the exact-ID failures your support team hits most. If fusion brings no clear gain on your workload, keep the simpler path.
Common questions
Hybrid search or vector search: which should I use?
If your users ever search for exact strings (codes, names, IDs, version numbers), start with hybrid, since vector search alone tends to blur them. If every query is conversational and your corpus has few identifiers, vector search may be enough. Test both on labeled queries from your own logs.
What k should I use for reciprocal rank fusion?
Start with 60, the value from the original paper and the default in Elasticsearch and Milvus. Lower values give top ranks more weight. If you have labeled queries, tune k, or test a weighted score fusion against it.
Is hybrid search the same as reranking?
No. Hybrid search merges two independent searches to widen the candidate pool. Reranking re-scores a candidate list with a slower, more careful model. Many pipelines use both: fuse first, then rerank the top.
Do I need two databases for hybrid search?
No. Elasticsearch, OpenSearch, Weaviate, Qdrant and Milvus all fuse keyword or sparse results with vector results in one query, and Postgres can do it with full-text search and pgvector.