Dense retrieval understands meaning; it is terrible at part numbers, error codes, legal clause references, and rare names — exactly what enterprise queries are full of. Sparse retrieval (BM25) nails exact terms and misses paraphrase.
Tier: Core · Workflow area: RAG data pipelines
Production RAG over enterprise corpora defaults to both: run dense and sparse in parallel and fuse with Reciprocal Rank Fusion. The payoff is measured, not theoretical — Anthropic's contextual-retrieval experiments found adding BM25 to embeddings cut top-20 retrieval failures by roughly 49%. This playbook wires hybrid search into the schema you built in pb-31 and proves the gain on your golden set.
Split the golden set by query type. Tag each pb-28 golden-set question as semantic (paraphrase-style) or lexical (contains identifiers/exact terms). Add lexical questions if you have fewer than 10 — real query logs are the best source. This split is how you will see where hybrid earns its keep.
Add the sparse leg next to the dense one. In pgvector-land, that is Postgres full-text search on the same table — no new system:
ALTER TABLE rag_chunks ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;
CREATE INDEX rag_chunks_tsv_idx ON rag_chunks USING gin(tsv);
Hosted/dedicated equivalents: Qdrant sparse vectors or Weaviate hybrid, OpenSearch BM25 alongside its k-NN field. For true BM25 ranking in Postgres, ts_rank_cd is an adequate stand-in at chunk scale; rank quality differences wash out in the fusion step.
Run both legs and fuse with RRF. RRF needs only ranks — no score normalization across incompatible scales, which is why it beats weighted score sums as a default:
def rrf(dense_ids: list[str], sparse_ids: list[str], k: int = 60, top: int = 20):
scores = {}
for ranking in (dense_ids, sparse_ids):
for rank, cid in enumerate(ranking, 1):
scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)[:top]
dense = dense_search(q, top=50) # pgvector <=> ORDER BY, tenant/ACL filters applied
sparse = sparse_search(q, top=50) # tsv @@ websearch_to_tsquery, same filters
fused = rrf(dense, sparse)
Keep k=60 (the literature default) unless a sweep proves otherwise. Critically: apply tenant and ACL filters (pb-31, pb-38) inside both legs — a sparse leg without filters is a data-leak path, not a retrieval improvement.
Measure the fusion against each leg alone. The metric that proves this step worked: top-20 retrieval failure rate (share of golden-set questions whose gold document is absent from the top 20), reported for dense-only, sparse-only, and hybrid, overall and per query-type slice.
Expected shape of the result:
dense-only : fail@20 = 12.5% (lexical slice: 28%)
sparse-only: fail@20 = 17.5% (semantic slice: 30%)
hybrid RRF : fail@20 = 6.0% (both slices under 10%)
Hybrid should beat both legs overall; if it doesn't, one leg is returning garbage ranks (usually the sparse leg on a corpus of OCR'd text — check tokenization).
Sweep the two knobs that matter. Candidate depth per leg (50 is a good default; below 20 starves fusion) and the RRF k. Optionally weight legs by duplicating one ranking's contribution — but prefer the reranker (pb-34) over elaborate fusion weighting; that is where precision work belongs.
Ship it behind the single retrieval function. Hybrid replaces the dense call inside the one shared retrieval entry point, so every consumer — and every later playbook — inherits it. Log both legs' latencies separately; sparse is usually sub-millisecond and dense dominates.
Dense finds what the query means, sparse finds what the query says, and RRF lets them vote without comparable scores. On identifier-heavy enterprise corpora this is the single cheapest recall win available — take it before paying for bigger models.
websearch_to_tsquery('english', ...) stems aggressively — part numbers like X-2041b can be mangled. Verify tokenizer behavior on your identifier formats; consider a simple config column for code-like fields.Hybrid search adds no new data movement — both legs run inside whatever store the client already approved, which makes this one of the easiest RAG improvements to land in restricted environments. The care points are the golden set (client queries are confidential; keep the eval harness and its results inside the client boundary and report slice-level metrics only) and the sparse index itself (a tsvector column is another copy of document text — confirm it inherits the same encryption and access controls as the source column). Demonstrate the technique to stakeholders on a public corpus first, then re-run the identical harness on client data inside their environment.