Hybrid Search: Combining Keyword and Vector Search
Vector search is great at meaning ("cancel my subscription" finds "how to end your plan") but weak at exact tokens: error codes, product SKUs, function names. Keyword search is the opposite. Hybrid search runs both and merges the results.
Keyword search in PostgreSQL
ALTER TABLE doc_chunks
ADD COLUMN tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;
CREATE INDEX doc_chunks_tsv_idx ON doc_chunks USING gin (tsv);
SELECT id, ts_rank_cd(tsv, q) AS rank
FROM doc_chunks, websearch_to_tsquery('english', 'ERR_CONN_RESET proxy') q
WHERE tsv @@ q
ORDER BY rank DESC
LIMIT 20;
Merging results with Reciprocal Rank Fusion
Scores from the two searches aren't on the same scale, so merge by rank instead. Reciprocal Rank Fusion (RRF) gives each document 1 / (k + rank) from each list and sums them.
WITH semantic AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rnk
FROM doc_chunks ORDER BY embedding <=> $1 LIMIT 20
),
keyword AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv, q) DESC) AS rnk
FROM doc_chunks, websearch_to_tsquery('english', $2) q
WHERE tsv @@ q
ORDER BY ts_rank_cd(tsv, q) DESC LIMIT 20
)
SELECT c.id, c.content,
COALESCE(1.0 / (60 + s.rnk), 0) + COALESCE(1.0 / (60 + k.rnk), 0) AS score
FROM semantic s
FULL OUTER JOIN keyword k ON k.id = s.id
JOIN doc_chunks c ON c.id = COALESCE(s.id, k.id)
ORDER BY score DESC
LIMIT 8;
The same idea in application code
def rrf(*ranked_lists, k=60):
scores = {}
for results in ranked_lists:
for rank, doc_id in enumerate(results, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
merged = rrf(vector_ids, keyword_ids)[:8]
When hybrid helps most
- Technical docs with identifiers, version numbers and error messages.
- Legal or medical text where exact terms matter.
- Short queries of one or two words, where embeddings have little to work with.
Was this article helpful?
Comments
No comments yet. Have a question or a tip? Be the first to share it.