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:
| Query | Vector search (semantic) | Full-text search (lexical) |
|---|---|---|
| "how do I reset a password" vs doc saying "credential recovery" | Finds it: paraphrase is the whole point | Misses: no shared terms |
ERR_CONN_REFUSED_5432 | Misses: error codes embed poorly | Nails it: exact token match |
| "refund policy for enterprise plans" | Good: concept match | Good: term match |
| SKU-4471-B, invoice IDs, function names | Reliably bad | Reliably 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:
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.
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, notto_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
WHEREclauses, 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
kis a sane default.
The application side stays one call wide, per the single-chokepoint discipline:
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
| Approach | Setup cost | Catches paraphrases | Catches identifiers | New infra |
|---|---|---|---|---|
| Pure vector (pgvector) | You have it | Yes | No | None |
| Pure full-text (tsvector) | One column + index | No | Yes | None |
| Hybrid RRF in Postgres | The query above | Yes | Yes | None |
| Dedicated engine (Qdrant / Elastic) | Sync pipeline + ops | Yes | Yes | A 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.