Vector search and keyword search fail in opposite, complementary ways. Vector similarity finds semantically related results even when the wording is completely different, but it can miss an exact match on a rare product code, an error string, or a proper noun buried in a document. Keyword search (BM25 and its relatives) nails exact and near-exact term matches but has no concept of meaning — search for "car" and it won't find a document that only says "automobile." Hybrid search runs both and combines the results, and it consistently outperforms either alone on real-world query mixes.
Why neither retrieval method alone is enough
BM25 is a term-frequency ranking function — it scores documents based on how often query terms appear, adjusted for document length and how rare each term is across the corpus. It's excellent at exact matches: SKUs, error codes, names, acronyms — anything where the literal string matters. It's weak at paraphrase and synonym handling, because it has no model of meaning, only of term overlap. Vector search embeds both the query and the documents into a shared numeric space using an embedding model, then finds documents whose vectors are closest to the query's vector by cosine similarity or dot product. This captures semantic relatedness well but can underperform on queries where the literal terms matter more than the general topic — a search for a specific part number can retrieve semantically "related" but wrong results if the embedding model wasn't trained to preserve exact-match significance for that kind of token.
Reciprocal rank fusion: combining two ranked lists
The standard way to merge a BM25 ranking and a vector ranking into one result list is reciprocal rank fusion (RRF). Instead of trying to normalize and compare two different scoring scales directly (a BM25 score and a cosine similarity score aren't on the same scale and don't combine meaningfully by simple addition), RRF only looks at each document's rank position in each list and combines those.
RRF_score(doc) = sum over each ranked list L of: 1 / (k + rank_L(doc))
-- k is a constant (commonly 60) that dampens the impact
-- of very high ranks and keeps the score well-behaved
-- for documents that only appear in one list
-- Example: a document ranked #2 by BM25 and #5 by vector search,
-- with k=60:
-- score = 1/(60+2) + 1/(60+5) = 0.01613 + 0.01538 = 0.03151
Documents that rank well in both lists get the highest combined score, which is exactly the property you want — a result that's both a strong keyword match and a strong semantic match is almost certainly the right answer. A document appearing only in one list still contributes, just with a smaller score, so hybrid search doesn't simply throw away results that only one method found.
When to weight one retrieval method more heavily
RRF treats both lists equally by default, but real query sets aren't uniform — a support search tool where users often paste exact error messages benefits from weighting BM25 higher; a conversational Q&A interface where users describe what they want in their own words benefits from weighting vector search higher. Most hybrid search implementations (Elasticsearch's hybrid queries, Weaviate, Qdrant, pgvector paired with Postgres full-text search) let you apply a weight to each side before or during fusion, and the right weight is something you tune against a labeled evaluation set for your actual query distribution, not a value to guess upfront.
Hybrid search tuning without a held-out set of representative queries and relevance judgments is guesswork. Even a rough evaluation set of 50-100 real queries with manually judged relevant documents is enough to compare BM25-only, vector-only, and hybrid configurations meaningfully, and to catch a fusion weight that helps one query type while quietly hurting another.
Implementation: two indexes, one merge step
In practice, hybrid search means maintaining two indexes over the same corpus — a full-text/inverted index for BM25 (Postgres full-text search, Elasticsearch, or a dedicated library like Lucene) and a vector index for embeddings (pgvector, a dedicated vector database, or a vector-capable extension of your existing search engine). Both need to stay in sync with the same underlying document set, which means your ingestion pipeline writes to both on every insert or update — a common failure mode is one index falling behind the other after a partial write failure, which silently degrades hybrid quality without an obvious error.
How you split documents into chunks for embedding matters a lot for vector search quality (too large and the embedding is diluted, too small and you lose context) but matters much less for BM25, which just needs the term to appear somewhere in the chunk. If you're chunking primarily for the vector side, verify BM25 recall didn't quietly get worse from the same chunk boundaries splitting a keyword match across two chunks.
| Method | Strong at | Weak at |
|---|---|---|
| BM25 / keyword | Exact terms, codes, names, rare tokens | Synonyms, paraphrase, conceptual queries |
| Vector similarity | Semantic relatedness, natural language queries | Exact-match precision on literal strings |
| Hybrid (RRF) | Both — combines rank positions from each | Requires maintaining two indexes in sync |
Wrapping up
Hybrid search isn't a marginal improvement over either method alone — it fixes the specific failure modes each one has on its own, using reciprocal rank fusion to combine two rankings without needing to normalize incompatible scores. The implementation cost is real (two indexes, a fusion step, tuning against real queries) but for any search product where users mix exact-match and conceptual queries, it's usually worth it.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.