Siddhant Deval
Siddhant Deval
backend5 min read

Hybrid Search: Dense Vectors Meet Sparse Lexical BM25

Pure vector search is blind to exact alphanumeric identifiers — error codes, invoice numbers, variable names — failing silently on the queries engineers care most about. This article implements hybrid search in PostgreSQL by fusing dense pgvector similarity with sparse BM25 full-text search via Reciprocal Rank Fusion (RRF) SQL CTEs.

Series·Part 10 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Hybrid Search: Dense Vectors Meet Sparse Lexical BM25

A user types "ERR_CONTEXT_OVERFLOW 0x4A2F" into the Workspace knowledge search. Dense vector search finds documents about "error handling" and "context management" — conceptually related, but none containing that specific error code. The exact 12-character string the user is debugging is invisible to semantic embeddings. This failure is silent: the system returns confident results that simply miss the answer.

This article implements hybrid search entirely inside PostgreSQL: dense pgvector ANN similarity fused with sparse BM25 full-text search via Reciprocal Rank Fusion (RRF) CTEs. No additional search infrastructure, no Elasticsearch, no synchronisation hazard — one database, one query, two retrieval signals merged deterministically.

Architectural Note

Who this is for: Engineers who have deployed vector search and noticed it fails on exact identifiers, version strings, error codes, and domain-specific terminology. After this article, hybrid search is a single SQL query away.


1. The Two Retrieval Signals and Their Failure Modes

Signal Strength Failure Mode
Dense (Vector) Semantic intent, paraphrase, concept matching Exact alphanumeric strings, version numbers, error codes
Sparse (BM25) Exact keyword matching, domain-specific terms Synonyms, paraphrasing, natural language intent
Dense misses: "ERR_0x4A2F", "v1.2.3-beta", "userId", "getServerSideProps"
Sparse misses: "how do I handle connection pooling" → misses "database concurrency"
Hybrid catches: both — dense handles intent, sparse handles exact matches

2. Full-Text Search Setup in PostgreSQL

PostgreSQL's built-in tsvector / tsquery provides BM25-style ranking via ts_rank_cd. It indexes lexemes (word roots after stemming) and supports proximity queries.

SQL
-- Add tsvector column for BM25 full-text search
ALTER TABLE chunks ADD COLUMN fts_vector tsvector
  GENERATED ALWAYS AS (
    to_tsvector('english', coalesce(heading, '') || ' ' || content)
  ) STORED;

-- GIN index for fast full-text lookups
CREATE INDEX CONCURRENTLY chunks_fts_gin_idx ON chunks USING gin(fts_vector);

The GENERATED ALWAYS AS ... STORED column means PostgreSQL automatically maintains the fts_vector on every INSERT/UPDATE — zero application-level maintenance.


3. Reciprocal Rank Fusion SQL

3.1 Why Rank Position, Not Raw Scores

Dense similarity scores (0.0–1.0) and BM25 scores (0.0–unbounded) live on completely different scales. You cannot simply add them. RRF normalises both by rank position:

RRF_score(document d) = Σ(1 / (k + rank(d, query_i)))

Where k = 60 is a smoothing constant (prevents top-ranked documents from dominating) and rank(d, query_i) is the document's position in each retrieval list.

3.2 The Complete Hybrid Search Query

SQL
-- ✅ Hybrid RRF search — single PostgreSQL query, no external services
-- Parameters bound at application layer: $1=embedding, $2=query_text, $3=org_id, $4=limit, $5=k

WITH
-- ── Dense retrieval: vector ANN ─────────────────────────────────────────────
dense_results AS (
  SELECT
    id,
    content,
    heading,
    document_id,
    token_count,
    RANK() OVER (ORDER BY embedding <#> $1::vector) AS rank
  FROM chunks
  WHERE org_id = $3::uuid   -- RLS also enforces this
  ORDER BY embedding <#> $1::vector
  LIMIT 50   -- Retrieve 50 candidates for fusion (more than final limit)
),

-- ── Sparse retrieval: BM25 full-text ────────────────────────────────────────
sparse_results AS (
  SELECT
    id,
    content,
    heading,
    document_id,
    token_count,
    RANK() OVER (ORDER BY ts_rank_cd(fts_vector, query) DESC) AS rank
  FROM chunks,
       plainto_tsquery('english', $2) AS query
  WHERE
    org_id = $3::uuid
    AND fts_vector @@ query
  ORDER BY ts_rank_cd(fts_vector, query) DESC
  LIMIT 50
),

-- ── RRF fusion ──────────────────────────────────────────────────────────────
rrf_scores AS (
  SELECT
    COALESCE(d.id, s.id)           AS id,
    COALESCE(d.content, s.content) AS content,
    COALESCE(d.heading, s.heading) AS heading,
    COALESCE(d.document_id, s.document_id) AS document_id,
    COALESCE(d.token_count, s.token_count) AS token_count,
    COALESCE(1.0 / ($5 + d.rank), 0.0) +   -- k = 60 smoothing constant
    COALESCE(1.0 / ($5 + s.rank), 0.0) AS rrf_score
  FROM dense_results d
  FULL OUTER JOIN sparse_results s ON d.id = s.id
)

SELECT id, content, heading, document_id, token_count, rrf_score
FROM rrf_scores
ORDER BY rrf_score DESC
LIMIT $4;

3.3 TypeScript Wrapper

TYPESCRIPT
// ✅ Hybrid search with type-safe parameter binding
// file: packages/db/src/retrieval/HybridSearch.ts

import { db } from '../drizzle'
import { sql } from 'drizzle-orm'
import { embedAndNormalise } from '../embeddings'

export interface HybridSearchResult {
  id: string
  content: string
  heading: string
  documentId: string
  tokenCount: number
  rrfScore: number
}

export async function hybridSearch(
  orgId: string,
  queryText: string,
  limit = 10,
  k = 60,
): Promise<HybridSearchResult[]> {
  // Embed the user query — same model and normalisation as stored chunks
  const queryEmbedding = await embedAndNormalise(queryText)
  const vectorLiteral = `[${queryEmbedding.join(',')}]`

  const results = await db.execute(sql`
    WITH
    dense_results AS (
      SELECT id, content, heading, document_id, token_count,
        RANK() OVER (ORDER BY embedding <#> ${vectorLiteral}::vector) AS rank
      FROM chunks
      WHERE org_id = ${orgId}::uuid
      ORDER BY embedding <#> ${vectorLiteral}::vector
      LIMIT 50
    ),
    sparse_results AS (
      SELECT id, content, heading, document_id, token_count,
        RANK() OVER (ORDER BY ts_rank_cd(fts_vector, query) DESC) AS rank
      FROM chunks, plainto_tsquery('english', ${queryText}) AS query
      WHERE org_id = ${orgId}::uuid AND fts_vector @@ query
      ORDER BY ts_rank_cd(fts_vector, query) DESC
      LIMIT 50
    ),
    rrf_scores AS (
      SELECT
        COALESCE(d.id, s.id) AS id,
        COALESCE(d.content, s.content) AS content,
        COALESCE(d.heading, s.heading) AS heading,
        COALESCE(d.document_id, s.document_id) AS document_id,
        COALESCE(d.token_count, s.token_count) AS token_count,
        COALESCE(1.0 / (${k} + d.rank), 0.0) +
        COALESCE(1.0 / (${k} + s.rank), 0.0) AS rrf_score
      FROM dense_results d
      FULL OUTER JOIN sparse_results s ON d.id = s.id
    )
    SELECT id, content, heading, document_id, token_count, rrf_score
    FROM rrf_scores
    ORDER BY rrf_score DESC
    LIMIT ${limit}
  `)

  return results.rows as HybridSearchResult[]
}

4. Tuning RRF for the Workspace

4.1 The k Parameter

The smoothing constant k determines how aggressively high-ranked results dominate. With k = 0, a rank-1 document scores 1.0 and rank-2 scores 0.5 — a 2× gap. With k = 60, rank-1 scores 1/61 ≈ 0.0164 and rank-2 scores 1/62 ≈ 0.0161 — almost equal. Higher k flattens the fusion curve.

k value Character Best for
10 Aggressive — top ranks dominate When one signal is clearly more reliable
60 Balanced — standard RRF General knowledge retrieval (Workspace default)
100 Conservative — near-uniform fusion When both signals are equally trustworthy

4.2 Score Monitoring Query

TYPESCRIPT
// ✅ Debug helper — see which signal found which result
export async function hybridSearchWithDebug(orgId: string, queryText: string) {
  const results = await db.execute(sql`
    WITH
    dense_results AS (
      SELECT id, RANK() OVER (ORDER BY embedding <#> ${await embedLiteral(queryText)}::vector) AS dense_rank
      FROM chunks WHERE org_id = ${orgId}::uuid
      ORDER BY embedding <#> ${await embedLiteral(queryText)}::vector LIMIT 50
    ),
    sparse_results AS (
      SELECT id, RANK() OVER (ORDER BY ts_rank_cd(fts_vector, q) DESC) AS sparse_rank
      FROM chunks, plainto_tsquery('english', ${queryText}) q
      WHERE org_id = ${orgId}::uuid AND fts_vector @@ q LIMIT 50
    )
    SELECT
      c.id, c.content,
      d.dense_rank,
      s.sparse_rank,
      COALESCE(1.0/(60+d.dense_rank),0) + COALESCE(1.0/(60+s.sparse_rank),0) AS rrf_score,
      CASE WHEN d.id IS NOT NULL AND s.id IS NOT NULL THEN 'both'
           WHEN d.id IS NOT NULL THEN 'dense_only'
           ELSE 'sparse_only' END AS found_by
    FROM chunks c
    LEFT JOIN dense_results d ON c.id = d.id
    LEFT JOIN sparse_results s ON c.id = s.id
    WHERE d.id IS NOT NULL OR s.id IS NOT NULL
    ORDER BY rrf_score DESC
    LIMIT 10
  `)
  return results.rows
}

Summary

Concept Rule
Dense-only failure Pure vector search misses exact identifiers; always pair with BM25 in production
RRF over score addition Rank-based fusion avoids cross-scale normalisation problems entirely
k = 60 Standard smoothing constant; prevents any single top-ranked result from dominating
Single-DB architecture FULL OUTER JOIN inside one PostgreSQL query — no synchronisation, no second system
Maintained fts_vector GENERATED ALWAYS AS ... STORED column auto-updates with no application code

What's Next

Part 11 adds a second retrieval stage: after 50 candidates from hybrid RRF, a Cross-Encoder reranker computes full attention between the query and each candidate, selecting the 5 most semantically precise results — without violating P99 latency budgets.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Hybrid Search#BM25#pgvector#RRF#PostgreSQL#Full-Text Search#RAG
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.

Series·Part 10 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Curriculum Roadmap
01The Full-Stack AI Paradigm Shift: Beyond Toy Wrappers & Prompt Concatenation
02The Anatomy of an LLM Call: Tokens, Context Windows & Probabilistic Mechanics
03Structured Outputs & Formal Invariants: Taming Non-Determinism with Zod & TypeBox
04Full-Stack Streaming Protocols: SSE, Fetch Streams & WebSockets
05V8 Streams, Server-Side Backpressure & Cancellations in Node.js
06React 18/19 Streaming UI & Generative Components
07Generative UI & Interactive Canvas Synchronization
08High-Dimensional Vector Embeddings & Similarity Math
09Vector Storage & Indexing at Scale: HNSW vs IVF in PostgreSQL (pgvector)
10Hybrid Search: Dense Vectors Meet Sparse Lexical BM25
Reading
11Multi-Stage Retrieval & Cross-Encoder Reranking
12Advanced RAG Architectures: Contextual Retrieval & Parent-Document Splitting
13Query Transformation, Decomposition & Multi-Query Expansion
14Dynamic Context Window Management & Long-Context Packing
15Agent Loops & Deterministic State Machines
16Model Context Protocol (MCP) in Full-Stack Architecture
17Human-in-the-Loop & Durable Agent Workflows
18Multi-Agent Orchestration & Collaborative Swarms
19Semantic Caching & Provider Cost Optimization
20Multi-Provider AI Gateways: Fallbacks, Rate Limiting & Hedged Requests
21Client-Side AI & Edge Hybrid Execution: WebGPU & Transformers.js
22Production Evals & Guardrails: Measuring Retrieval and Generation
23Full-Stack AI Observability & Tracing with OpenTelemetry
24Production Capstone: Assembling The Enterprise Collaborative AI Workspace