BXUP Track progress & test yourself — free
Data Engineering · Playbook · Core · ~4 min read
DATA ENGINEERING · PLAYBOOK · CORE

Implement Hybrid Search with Reciprocal Rank Fusion

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.

When to reach for this

  • Retrieval eval shows failures on queries containing identifiers, SKUs, codes, exact names, or quoted phrases.
  • Your corpus is enterprise-typical: manuals, tickets, contracts — dense-only recall plateaus below target.
  • You are on pgvector or OpenSearch where sparse search is nearly free to add.
  • Before reaching for a bigger embedding model: hybrid is usually cheaper and bigger.

The workflow

  1. 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.

  2. 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.

  3. 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.

  4. 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).

  5. 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.

  6. 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.

Key Takeaway:

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.

Verify before you trust it

  • fail@20 comparisonhybrid ≤ each individual leg, on the full set and on both slices; a hybrid that wins overall but loses the lexical slice is misconfigured fusion.
  • Lexical probes pass10 queries for exact identifiers you know exist (error codes, clause numbers) each surface the right chunk in the top 5.
  • Both legs verified filter-safethe pb-31 leakage test re-run against the hybrid endpoint returns 0 cross-tenant results.
  • Latency budget holdsp95 of the fused query (both legs parallelized) stays within the retrieval SLA; sequential legs are the usual accidental regression.

Pitfalls

  • Normalizing and summing raw scores instead of RRF. Cosine similarities and BM25 scores live on different scales per query; min-max normalization is unstable at the extremes. Rank fusion sidesteps the whole problem.
  • Sparse leg without stemming/tokenizer thought.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.
  • Fusing shallow candidate lists. RRF over top-10 lists barely differs from dense-only. Give it 50 per leg; fusion is cheap.
  • Forgetting filters on the new leg. Every access-control property you proved for dense search must be re-proved for sparse and for the fused path.
  • Reporting only aggregate recall. Hybrid's gain concentrates in the lexical slice; aggregate-only reporting hides regressions on semantic queries when the sparse leg is noisy.

Tools

  • pgvector + tsvectorfull hybrid stack inside Postgres; zero new infrastructure and transactional consistency between legs.
  • Qdrant / Weaviatenative hybrid APIs with sparse vectors (SPLADE/BM25-style) and built-in fusion when you are already on a dedicated engine.
  • OpenSearchBM25 heritage plus k-NN vectors; the natural choice when an Elastic/OpenSearch estate already exists.

Client-safe variant

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.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in