Appearance
Hybrid Search
Vector similarity search finds documents that are semantically close to your query. That covers maybe 80% of queries. The other 20% break it: acronyms like "RFC 7519," product codes like "SKU-88421-T," error messages with exact strings, and legal citations where the specific phrase matters more than the surrounding meaning. For those, keyword search catches what embeddings miss.
Hybrid search combines both approaches and fuses the results into a single ranked list. This lesson builds it with pgvector: cosine similarity for vectors and PostgreSQL full-text search for keywords, merged with reciprocal rank fusion.

What you'll learn
- Vector search fails on rare terms, acronyms, product codes, and exact phrases because embeddings compress meaning to semantic proximity, not lexical matching
- PostgreSQL full-text search (
tsvector/tsquery) gives you keyword search inside the same database as your vectors, no separate index needed - Reciprocal rank fusion (RRF) merges two ranked lists without trying to compare incommensurable scores like 0.87 cosine similarity against 4.2 text rank
The problem: when vector search misses
Imagine a user searches for "JWT expiration handling." The vector embedding for that query captures the general concept of token expiry. It retrieves documents about session timeouts, refresh tokens, OAuth flows. All relevant. But none of them mention RFC 7519 -- and the user actually needed the spec reference.
Embeddings are lossy. When a 1536-dimensional vector represents an entire paragraph, distinctive tokens like "RFC 7519" get averaged into the semantic neighborhood of "internet standards document." The vector doesn't know that this specific token is the user's target. Keyword search knows exactly.
The same pattern appears with:
- Product codes: "WD-40-BLACK-12PK" in a warehouse inventory system
- Error messages: exact strings like "ORA-00001: unique constraint violated"
- Legal references: "26 U.S.C. § 162(a)" where the section number is the retrieval target
- Names and entities: "Dr. Chen" in a corpus where "physician" or "researcher" appears far more often
In every case, vector search returns documents about the topic while keyword search returns documents containing the term. You need both.
Options & when to use each
| Approach | Best for | Costs you | When to pick |
|---|---|---|---|
PostgreSQL full-text (tsvector) | Acronyms, codes, error strings; colocated with pgvector | Less sophisticated than BM25; no term frequency saturation | You already use pgvector and want zero new infrastructure |
| Elasticsearch BM25 | Large corpora where relevance ranking matters | Separate service to run, index, and monitor | You need production-grade full-text search at scale |
| Sparse vector embeddings (SPLADE) | Sparse lexical-semantic hybrid in one vector space | Requires generating sparse embeddings alongside dense ones | You want a single index covering both semantic and lexical |
| Reciprocal rank fusion | Merging any two ranked lists | Assumes rank position, not score magnitude, is the signal | Your vector and text scores have different scales and distributions |
For this lesson we use PostgreSQL full-text because it lives in the same database as pgvector. No new services, no separate index to keep in sync.
Build it: pgvector hybrid search with RRF
Step 1: add full-text search columns
Here is a concrete example demonstrating the implementation details.
python
import psycopg2
conn = psycopg2.connect("dbname=rag_demo user=postgres")
# Add a tsvector column and a GIN index for fast text search
conn.execute("""
ALTER TABLE documents
ADD COLUMN IF NOT EXISTS text_search tsvector
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;
CREATE INDEX IF NOT EXISTS idx_text_search
ON documents USING GIN (text_search);
""")
conn.commit()The GENERATED ALWAYS AS ... STORED column means PostgreSQL keeps tsvector in sync with content automatically. The GIN index makes full-text queries fast.
Step 2: build the hybrid search function
Here is a concrete example demonstrating the implementation details.
python
def hybrid_search(
query: str,
top_k: int = 10,
vector_weight: float = 0.5,
text_weight: float = 0.5,
) -> list[dict]:
"""
Hybrid search combining pgvector cosine similarity
and PostgreSQL full-text search with reciprocal rank fusion.
"""
# Generate query embedding
embedding = get_embedding(query) # from your embedding provider
# Run both searches in a single query using a CTE
results = conn.execute("""
WITH vector_results AS (
SELECT
id, content,
ROW_NUMBER() OVER (ORDER BY embedding <=> %s::vector) AS vector_rank
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT %s
),
text_results AS (
SELECT
id, content,
ROW_NUMBER() OVER (
ORDER BY ts_rank(text_search, websearch_to_tsquery('english', %s)) DESC
) AS text_rank
FROM documents
WHERE text_search @@ websearch_to_tsquery('english', %s)
ORDER BY ts_rank(text_search, websearch_to_tsquery('english', %s)) DESC
LIMIT %s
)
SELECT
COALESCE(v.id, t.id) AS id,
COALESCE(v.content, t.content) AS content,
(
%s / (60 + COALESCE(v.vector_rank, 1000)) +
%s / (60 + COALESCE(t.text_rank, 1000))
) AS rrf_score
FROM vector_results v
FULL OUTER JOIN text_results t ON v.id = t.id
ORDER BY rrf_score DESC
LIMIT %s
""", (
embedding, embedding, top_k * 2,
query, query, query, top_k * 2,
vector_weight, text_weight,
top_k
)).fetchall()
return [
{"id": row[0], "content": row[1], "rrf_score": row[2]}
for row in results
]The reciprocal rank fusion formula: score = w / (k + rank). The constant k=60 dampens the difference between rank 1 and rank 2 so the fusion doesn't over-weight the first result from either list. A document that appears in both lists gets a higher combined score because both COALESCE values contribute. A document that only appears in one list gets penalized on the other side (default rank 1000).
Step 3: tune the weights
Here is a concrete example demonstrating the implementation details.
python
# For code-heavy corpora with lots of identifiers:
results = hybrid_search("validate_jwt_token()", vector_weight=0.3, text_weight=0.7)
# For natural-language questions about concepts:
results = hybrid_search("how do I handle token expiration", vector_weight=0.7, text_weight=0.3)
# Default: equal weight
results = hybrid_search("JWT expiration", vector_weight=0.5, text_weight=0.5)The websearch_to_tsquery function parses the query string the way a web search engine would: it treats unquoted words as OR terms and quoted phrases as exact matches. For "JWT expiration" it becomes 'jwt' & 'expir' (stemmed to match "expiration," "expires," "expiry").
What goes wrong
| Mistake | How you notice it | The fix |
|---|---|---|
Text search returns nothing (no @@ match) | Text rank column defaults to 1000, vector results dominate | Lower the vector_weight threshold or add websearch_to_tsquery('simple', query) for exact matching without stemming |
| RRF k constant too small | Rank 1 from text search dominates everything | Increase k to 60 or higher. k=0 means 1/rank which overweights the top position |
| Full-text stemming removes exact match capability | "running" matches "run" but you needed the exact word | Use to_tsvector('simple', content) instead of 'english' for exact lexical matching. Create a second tsvector column if you need both |
| GIN index not used on large tables | EXPLAIN ANALYZE shows sequential scan | Verify the index exists with \di idx_text_search. Rebuild if fragmented: REINDEX INDEX idx_text_search |
| Vector and text rankings disagree completely | RRF score is low for all results, both lists have different documents at the top | Your corpus may need better chunking or the query may be truly ambiguous. Consider query rewriting first |
Confirm it worked
Here is a concrete example demonstrating the implementation details.
python
# Insert a document with a rare product code that semantic search would miss
conn.execute("""
INSERT INTO documents (content, embedding)
VALUES (
'Defect report for WD-40-BLACK-12PK: canister leaked during shipment. Batch 2026-07-14.',
%s::vector
)
""", (get_embedding("Defect report for WD-40-BLACK-12PK"),))
conn.commit()
# Pure vector search won't rank this highly for "WD-40-BLACK-12PK"
vector_only = conn.execute("""
SELECT content, embedding <=> %s::vector AS distance
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT 3
""", (get_embedding("WD-40-BLACK-12PK"), get_embedding("WD-40-BLACK-12PK"))).fetchall()
# Hybrid search should return it at rank 1
hybrid = hybrid_search("WD-40-BLACK-12PK", vector_weight=0.3, text_weight=0.7)
print("Vector-only top result:", vector_only[0][0][:80] if vector_only else "none")
print("Hybrid top result:", hybrid[0]["content"][:80] if hybrid else "none")
# Expected: hybrid search surfaces the exact product code match,
# while vector search returns documents about "shipments" or "defects"Next: Re-Ranking