Hybrid Search in Postgres: Full-Text + pgvector in One Query, No New Infrastructure

6 min read · Last verified July 29, 2026

Here's the whole trick up front: if your documents already live in Postgres with pgvector, hybrid search is one SQL query: a full-text subquery, a vector subquery, and Reciprocal Rank Fusion to merge the two rankings. No Elasticsearch, no second datastore, no sync pipeline. Most teams that "add hybrid search" to their RAG system are twenty lines of SQL away from it, and this guide is those twenty lines plus the decisions around them.

Why one retriever isn't enough

The two retrieval families fail in opposite, complementary ways:

QueryVector search (semantic)Full-text search (lexical)
"how do I reset a password" vs doc saying "credential recovery"Finds it: paraphrase is the whole pointMisses: no shared terms
ERR_CONN_REFUSED_5432Misses: error codes embed poorlyNails it: exact token match
"refund policy for enterprise plans"Good: concept matchGood: term match
SKU-4471-B, invoice IDs, function namesReliably badReliably exact

Production search traffic is full of the second and fourth rows. Users paste error messages, order numbers, and API names; embeddings turn those into fuzzy soup while a lexical index matches them exactly. The reverse holds for natural-language questions. Run both retrievers and you cover the matrix; that's the entire argument, and it's why hybrid is the default in every serious RAG stack rather than an optimization.

One naming honesty note, since the SERP for this topic is littered with the confusion: the classic lexical ranking algorithm is BM25, and Postgres full-text search does not implement it. ts_rank_cd weighs term frequency and proximity but has no corpus-wide IDF term. Under rank fusion this distinction rarely changes outcomes (more below), but if someone on your team asks "is this BM25?", the accurate answer is "no, and for this architecture it doesn't need to be."

The schema: two indexes on the table you already have

Assuming the chunked-documents table from the pgvector setup, hybrid needs one generated column and one index on top of it:

sql
ALTER TABLE chunks
  ADD COLUMN embedding vector(1536),
  ADD COLUMN fts tsvector
    GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;
 
CREATE INDEX chunks_embedding_idx ON chunks
  USING hnsw (embedding vector_cosine_ops);
CREATE INDEX chunks_fts_idx ON chunks USING gin (fts);

The GENERATED ALWAYS column is the maintenance win: the lexical index can never drift from the source text, the same transactional-consistency argument that makes pgvector attractive in the first place. Pick the text search configuration ('english' here) to match your corpus language; for corpora full of identifiers and code, 'simple' (no stemming, no stopwords) is often the better default.

The query: both retrievers, fused by rank

Reciprocal Rank Fusion scores each document by summing 1 / (k + rank) across the result lists it appears in (Cormack et al., 2009). Documents that rank well in either list surface; documents that rank decently in both beat documents that top one list and miss the other. The constant k (conventionally 60) damps the head of each list.

sql
WITH semantic AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
  FROM chunks
  WHERE tenant_id = $3
  ORDER BY embedding <=> $1
  LIMIT 20
),
lexical AS (
  SELECT id, ROW_NUMBER() OVER
    (ORDER BY ts_rank_cd(fts, websearch_to_tsquery('english', $2)) DESC) AS rank
  FROM chunks
  WHERE fts @@ websearch_to_tsquery('english', $2)
    AND tenant_id = $3
  LIMIT 20
)
SELECT c.id, c.title, c.body,
       COALESCE(1.0 / (60 + s.rank), 0) +
       COALESCE(1.0 / (60 + l.rank), 0) AS rrf_score
FROM chunks c
LEFT JOIN semantic s ON s.id = c.id
LEFT JOIN lexical  l ON l.id = c.id
WHERE s.id IS NOT NULL OR l.id IS NOT NULL
ORDER BY rrf_score DESC
LIMIT 6;

Three details that separate this from the copy-paste versions:

  • websearch_to_tsquery, not to_tsquery. It safely parses raw user input (quotes, ORs, dashes) instead of throwing syntax errors on the first unescaped character a user types.
  • Your existing filters ride along in both subqueries. Tenant isolation, language, live-document status: plain WHERE clauses, evaluated before ranking, the thing dedicated engines make you think much harder about.
  • Candidate depth (LIMIT 20) is a recall knob. Fusing the top 20 of each list to return 6 gives the fusion room to work. Too shallow and hybrid degenerates into whichever single list was deeper; 3-5x your final k is a sane default.

The application side stays one call wide, per the single-chokepoint discipline:

ts
export async function hybridSearch(question: string, tenantId: string) {
  const embedding = await embed(question);       // one embedding call
  const { rows } = await db.query(HYBRID_SQL, [
    toSql(embedding), question, tenantId,        // $1 vector, $2 raw text, $3 filter
  ]);
  return rows;                                    // ranked chunks for the prompt
}

Tuning: the three knobs that matter

The text search configuration. Stemming ("running" matches "run") helps prose and hurts identifiers. If your queries mix both, index two tsvector columns ('english' and 'simple') and add the 'simple' match as a third RRF list; it's the same pattern at three lists instead of two.

Weighting the lists. Vanilla RRF treats both retrievers equally, which is the right default. If evaluation shows one side should dominate (support search skews lexical; discovery search skews semantic), multiply that list's contribution rather than reinventing score blending: 0.7/(60+s.rank) + 0.3/(60+l.rank) stays scale-free.

What comes after fusion. RRF gets the right candidates into the top 20; it doesn't guarantee the best one is first. The next quality jump is a reranker (a cross-encoder scoring query-document pairs), which is a model call, not a SQL trick, and belongs with the retrieval-quality material in the track's RAG module. Ship hybrid first; measure; rerank when the data says so.

When Postgres stops being enough

The honest ceiling, as of mid-2026: this pattern is comfortable through a few million chunks and normal SaaS query rates, the same envelope as pgvector generally. Signals you've outgrown it: HNSW build times measured in hours colliding with your maintenance windows, filtered recall collapsing at high tenant cardinality, or lexical workloads that genuinely need BM25-grade relevance (rare for RAG chunk retrieval, common for user-facing site search). The escalation paths, in order of added surface: the pg_search extension for real BM25 inside Postgres, Qdrant with native sparse vectors for one engine holding both sides, or Elasticsearch when lexical search is the product. Each is a bigger operational bill than a CTE; make the eval suite prove you need it.

The decision table

ApproachSetup costCatches paraphrasesCatches identifiersNew infra
Pure vector (pgvector)You have itYesNoNone
Pure full-text (tsvector)One column + indexNoYesNone
Hybrid RRF in PostgresThe query aboveYesYesNone
Dedicated engine (Qdrant / Elastic)Sync pipeline + opsYesYesA service you run

If you remember one thing: hybrid search is not a product you adopt, it's a query shape. Two subqueries you already know how to write, one fusion expression, zero new pagers. Spend the saved operational budget on the thing that actually moves answer quality: what you feed the model and how you measure it.


Sources & further reading: Postgres full-text search docs · pgvector · Reciprocal Rank Fusion (Cormack et al., SIGIR 2009) · ParadeDB pg_search

FAQ

Do I need hybrid search for RAG, or is vector search enough?

You need it sooner than you think. Pure vector search excels at paraphrase and concept matching but reliably misses exact identifiers: SKUs, error codes, function names, invoice numbers. Users type those constantly. Full-text search nails them and misses paraphrases. Hybrid runs both and fuses the rankings, which is why every mature RAG system converges on it.

Is Postgres full-text search the same as BM25?

No. Postgres ts_rank and ts_rank_cd count term frequency and proximity but have no corpus-level IDF component, which is the heart of BM25. In a hybrid setup this rarely matters: Reciprocal Rank Fusion consumes ranks, not scores, so the lexical side just needs sensible ordering. If you need true BM25 inside Postgres, the pg_search extension (ParadeDB) provides it.

Why Reciprocal Rank Fusion instead of adding the two scores together?

Because the scores live on incompatible scales: cosine distance is bounded and dense, ts_rank is unbounded and spiky, and any weighted sum you hand-tune on ten queries breaks on the eleventh. RRF ignores scores entirely and combines rank positions, which makes it scale-free, robust, and effectively zero-config: one constant, k, that you almost never need to change from 60.

When should I move hybrid search out of Postgres?

The same thresholds as vector search generally: past several million documents, hard sub-10ms latency targets, or heavy filtered-search recall problems, a dedicated engine (Qdrant with native sparse vectors, or Elasticsearch for lexical-heavy workloads) earns its operational cost. Below that, one Postgres query beats operating a second search system, and the migration later is a re-index, not a rewrite.