← All posts
·5 min read

Why hybrid search beats vector search

Dense embeddings and BM25 catch different failures. Reciprocal rank fusion in pgvector, and when pure vector search is still the right call.

Pure vector search loses to hybrid on most real corpora, and the reason is specific rather than general: dense embeddings and lexical search fail on different queries, and a corpus of real user questions has both kinds. This is the Postgres-only version — pgvector plus ts_vector, no Elasticsearch, no separate search cluster.

Who this is for: an engineer running vector-only retrieval who's noticed it misses exact terms — product codes, error strings, names — that a keyword search would have caught instantly.

Dense and BM25 are not competing for the same queries. Fusing their rankings is closer to a union of strengths than an average of the two.

What each method actually catches

Dense embeddings are trained to place semantically similar text near each other in vector space — which means they're strong on paraphrase and weak on precision. "How do I reset my access" and "password recovery steps" land close together even with no shared vocabulary, which is the entire point. But that same training pressure blurs near-synonyms, so an embedding model treats ERR_4092 and a nearby error code as more similar than they should be, because nothing in training taught it that identifiers don't tolerate approximation.

BM25 is the inverse. It's exact-match lexical scoring, weighted by term rarity — which means it's precise on identifiers, product codes, error strings, and names, and blind to paraphrase. A user who asks "how do I get back into my account" gets nothing from a BM25 index built on documents that only say "password reset," because there's no shared token for it to match on.

Neither is wrong. They're answering different halves of a real query distribution, and a corpus of actual user questions has both halves in it.

Reciprocal rank fusion, briefly

The combination that works without needing to calibrate two different scoring scales against each other: run both searches independently, then combine by rank rather than by score.

def reciprocal_rank_fusion(dense_results, bm25_results, k=60):
    scores = {}
    for rank, doc_id in enumerate(dense_results):
        scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
    for rank, doc_id in enumerate(bm25_results):
        scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
    return sorted(scores, key=scores.get, reverse=True)

The k constant dampens the effect of exact rank position — a document at rank 1 in one list and absent from the other still surfaces near the top, because a top rank in either list carries real weight regardless of what the other list thinks. This is what makes fusion robust to the two methods disagreeing completely, which they do constantly, by design.

The lift you'll actually see depends on how identifier-heavy your own corpus is, which is exactly why a generic number is less useful than the method: run your eval set through dense-only and through the fused version, and diff the two result sets the same way you'd diff any other retrieval change. The queries that flip are the ones that just told you whether this was worth building.

Implementation: pgvector plus ts_vector, no separate cluster

The reason this doesn't need Elasticsearch: Postgres has both primitives already. pgvector for the dense side, ts_vector with a GIN index for BM25-equivalent lexical scoring, both queryable in the same transaction against the same rows — which means fusion happens in application code after two queries, not as a separate infrastructure integration.

SELECT id, ts_rank_cd(fts, query) AS bm25_score
FROM documents, plainto_tsquery('english', $1) query
WHERE fts @@ query
ORDER BY bm25_score DESC
LIMIT 50;

Run that alongside a standard pgvector cosine-distance query, fuse the two ID lists with RRF, and the result is hybrid search with one database, one connection pool, and one backup strategy — the same operational argument that makes pgvector the right default before you've proven you need more, and the same database that also replaces your job queue and your tenant-isolation layer.

When pure vector search is still better

Rare, but real: a corpus that's genuinely paraphrase-heavy with few exact identifiers a user would search for — long-form narrative content, for instance, where nobody's typing an error code. If your actual query logs skew almost entirely toward semantic questions with no lexical anchors, the second index is overhead bought against a failure mode you don't have. Checking your eval set for how many queries are identifier-heavy is the fastest way to find out before you build it.

For nearly everyone else, hybrid search is a checklist item for a reason: it's the difference between a system that handles paraphrase and one that handles paraphrase and the exact term a user actually typed.

If your retrieval is vector-only and missing the queries with exact terms in them, that gap is usually fast to close.

Shanker Dhand
Shanker Dhand
AI Engineer & Technical Lead

I design and ship production AI systems — RAG pipelines, agents, and evaluation infrastructure — built on 10+ years of full-stack engineering.

Related posts