Postgres Hybrid Search for RAG: pgvector + tsvector + RRF
Every RAG prototype works on twenty documents. Then the real corpus shows up with its real shape: maintenance procedures that cite API 610, drilling reports that name a PZ-7 mud pump, permits and work orders full of tag numbers. Ask a pure vector index for “PZ-7 liner torque” and it cheerfully returns five documents about pumps that have never seen a PZ-7. Ask a pure keyword index “how do we keep the mud pumps from shaking themselves apart” and it returns nothing at all. Neither arm is good enough on its own, and combining them badly is worse than either.
The fix is to keep both indexes on the same row and fuse the two ranked lists by position rather than by score. That last part matters more than people expect. Everything below was run against a real instance while writing this: 8,000 documents, 1,536-dimension vectors, pgvector 0.8.6 on PostgreSQL 17.
Why you cannot just add the two scores
Run the two arms separately and look at what they hand back.
-- keyword arm: exact identifiers, standards numbers, tag numbers
SELECT id, title,
ts_rank_cd(fts, websearch_to_tsquery('english', 'PZ-7 liner torque')) AS kw_score
FROM documents
WHERE fts @@ websearch_to_tsquery('english', 'PZ-7 liner torque')
ORDER BY kw_score DESC;
id | title | kw_score
----+---------------------------------+----------
1 | Mud pump PZ-7 liner torque spec | 1.37544
-- vector arm: meaning, paraphrase, synonyms
SELECT id, title, embedding <=> '[1,0,0]' AS cos_distance
FROM documents
ORDER BY embedding <=> '[1,0,0]'
LIMIT 3;
id | title | cos_distance
----+-------------------------------------+--------------
3 | Centrifugal pump seal replacement | 0.0202
8 | Compressor overhaul scope of work | 0.0329
2 | Refinery vibration limits | 0.0513
One arm scores near 1.4, the other returns distances near 0.02. One counts up-to-down, the other down-to-up. And ts_rank_cd is not normalised — it grows with term frequency and document length, so a long procedure that repeats a word ten times outranks a short exact hit. Add the two columns and whichever arm happens to produce the bigger numbers decides the answer, which is the opposite of what hybrid search is for. Normalise both to 0..1 and you inherit a new problem: the distributions move every time the corpus does, so the weights need re-tuning forever.
Rank fusion sidesteps all of it. Reciprocal Rank Fusion (Cormack, Clarke and Büttcher, SIGIR 2009) scores each document as the sum of 1 / (k + rank) over the lists it appears in. Only position matters, so there is nothing to calibrate. The paper’s own sweep over k put the best MAP at k = 60 with k = 0 the worst, which is why 60 is the default in the function below.
One table, two indexes
The storage layer is unglamorous: a generated tsvector column and a vector column on the same row.
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector 0.8.6
CREATE TABLE documents (
id bigserial PRIMARY KEY,
source text NOT NULL,
title text NOT NULL,
body text NOT NULL,
embedding halfvec(1536) NOT NULL,
fts tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED
);
CREATE INDEX documents_fts_idx ON documents USING gin (fts);
CREATE INDEX documents_embedding_idx ON documents USING hnsw (embedding halfvec_cosine_ops);
Three details are doing real work. The fts column is generated, so application code never writes a tsvector and can never let it drift out of sync. setweight tags title lexemes A and body lexemes B, which lets ranking prefer a title hit. And the keyword side is cheap: over 8,000 documents the GIN index came to 2.1 MB against 23 MB for the HNSW index — and that vector column was already half-precision. Full-precision vector(1536) on those same 8,000 rows built a 59 MB HNSW index. Keyword search is the inexpensive half of hybrid search; there is no reason to leave it out. Load your data first and build both indexes afterwards, then ANALYZE.
The fusion query
Each arm produces a ranked list, the fusion joins them, and the scores are summed reciprocal ranks. The whole thing fits in one SQL function, which means no extra service between your app and the database.
CREATE OR REPLACE FUNCTION hybrid_search(
query_text text,
query_embedding halfvec(1536),
match_count int DEFAULT 10,
rrf_k int DEFAULT 60,
keyword_weight float8 DEFAULT 1.0,
semantic_weight float8 DEFAULT 1.0,
candidate_limit int DEFAULT 50
)
RETURNS TABLE (
doc_id bigint,
doc_title text,
doc_source text,
rrf_score float8,
keyword_rank bigint,
semantic_rank bigint
)
LANGUAGE sql STABLE AS $$
WITH q AS (
SELECT websearch_to_tsquery('english', query_text) AS tsq
),
keyword AS (
SELECT d.id,
row_number() OVER (ORDER BY ts_rank_cd(d.fts, q.tsq) DESC) AS rank
FROM documents d, q
WHERE d.fts @@ q.tsq
ORDER BY ts_rank_cd(d.fts, q.tsq) DESC
LIMIT candidate_limit
),
semantic AS (
SELECT d.id,
row_number() OVER (ORDER BY d.embedding <=> query_embedding) AS rank
FROM documents d
ORDER BY d.embedding <=> query_embedding
LIMIT candidate_limit
),
fused AS (
SELECT COALESCE(k.id, s.id) AS id,
keyword_weight * COALESCE(1.0 / (rrf_k + k.rank), 0) +
semantic_weight * COALESCE(1.0 / (rrf_k + s.rank), 0) AS score,
k.rank AS k_rank,
s.rank AS s_rank
FROM keyword k
FULL OUTER JOIN semantic s ON k.id = s.id
)
SELECT d.id, d.title, d.source, f.score, f.k_rank, f.s_rank
FROM fused f
JOIN documents d ON d.id = f.id
ORDER BY f.score DESC, d.id
LIMIT match_count;
$$;
-- call it with the user's text and that same text through your embedding model
SELECT doc_id, doc_title, rrf_score, keyword_rank, semantic_rank
FROM hybrid_search('PZ-7 liner torque', $1::halfvec(1536), 10);
Returning both ranks is what makes the function debuggable. When a result looks wrong you can see immediately whether the keyword arm or the vector arm put it there, instead of guessing at embeddings.
What RRF actually buys you
On an eight-document test set with a deliberately conflicted query — one document matched the identifier exactly but sat seventh on the vector list, while another was the vector winner with no keyword match at all — the fusion ranked the identifier document first:
doc_id | title | rrf_score | kw | sem
--------+--------------------------------------+-----------+----+-----
1 | Mud pump PZ-7 liner torque spec | 0.031319 | 1 | 7
3 | Centrifugal pump seal replacement | 0.016393 | | 1
8 | Compressor overhaul scope of work | 0.016129 | | 2
A document the two arms agree on beats a document one arm loves. That property is what makes hybrid search hold up on messy industrial text: no single arm can dominate the result, and appearing in both lists is itself evidence of relevance.
The weights are a dial, not a fudge factor. Setting keyword_weight to 3.0 moved that same document to 0.064106 and left the rest of the order intact — a controlled way to say “part numbers and standard citations matter more here.” Set it from labelled queries, not taste.
What it costs at scale
On 8,000 documents each arm used its index: an index scan on the HNSW index for the vector arm (0.8 ms for a top-50 list) and a bitmap index scan on the GIN index for a selective keyword term (0.13 ms). The fused query returned in 2.2 ms when the keyword arm matched a handful of rows.
The number worth worrying about is the other one. When the query term matched every row in the table, the keyword arm degenerated into ranking all 8,000 rows and the fused query took 14.5 ms — a sixfold jump over the ordinary case. Low-selectivity terms are the failure mode, not large corpora. Keep candidate_limit modest, and when you see the keyword arm matching tens of thousands of rows, treat it as a query-parsing problem: websearch_to_tsquery ANDs the user’s words together, so the fix is usually dropping the terms that add no selectivity.
Where this fits in Houston
- Upstream and midstream document search. Maintenance procedures, drilling reports and vendor manuals, where operators type part numbers and tag numbers as often as they type sentences.
- Industrial supply catalogues. Exact SKU lookup on one side, “what does this part do” on the other, served from one endpoint and one transaction.
- Compliance and safety libraries. Standard citations and section numbers have to match exactly, but the question that arrives is almost always a paraphrase.
Measure before you trust it. Pull 100 real queries from your logs, label the right documents once, and track recall@10 for the keyword arm, the vector arm and the fusion. If the fusion is not beating the better of the two arms, the problem is your chunking or your candidate list, not the fusion method.
When to reach for more
Two extensions are worth knowing before you hit a wall. pg_trgm adds trigram similarity, which catches the hyphenated and mistyped part numbers a strict tsvector match misses — similarity() is a cheap third arm. pgvectorscale adds a DiskANN index (CREATE INDEX ... USING diskann) for corpora where the HNSW graph no longer fits comfortably in RAM; it complements pgvector rather than replacing it, and the 0.9.x line adds PostgreSQL 18 support and concurrent index builds.
If you are starting from zero, work through the pgvector setup guide first, then come back here. If your corpus is already past a few million vectors, the billion-scale indexing playbook covers the storage arithmetic and build tuning this post assumes. Full-text ranking internals live in Controlling Text Search, and the pgvector README has a short hybrid search section pointing at RRF and cross-encoder alternatives.
The short version: one generated tsvector column, one GIN index, one HNSW index, two ranked lists, one RRF join. Both arms are inexpensive, the fusion costs a single extra sort, and you get a search that survives the part numbers your users actually type.
