Hybrid search explained
Combine keyword and vector retrieval without inventing a universal accuracy gain. Includes PostgreSQL queries, reciprocal rank fusion, and an evaluation plan.
- Updated
- Reading time
- 7 min read
11 sections
- Where each retriever helps
- Keyword retrieval in PostgreSQL
- Vector retrieval with the same boundary
Vector search and keyword search fail in different ways. A vector retriever can connect "end my plan" with "cancel a subscription," but it may miss an exact error code. Keyword search finds the code and misses the paraphrase.
Hybrid search runs both, then combines their ranked results. That is the whole idea. Whether it improves your agent depends on the corpus, questions, filters, and fusion method. There is no honest percentage that applies to every support system.
In brief
- Keyword retrieval protects exact terms such as product names, invoice fields, and error codes.
- Vector retrieval finds related wording and concepts.
- Reciprocal Rank Fusion combines positions without pretending the two score scales are equal.
- A labeled query set decides whether hybrid search is better for your content.
Where each retriever helps
Consider a help center with these passages:
Cancel a subscription before its renewal date
Resolve checkout error E-1234
Update SAML single sign-on settingsA customer might ask:
How do I stop my plan from renewing?Vector search should connect that paraphrase to the cancellation article. Keyword search may not.
Now consider:
What does E-1234 mean?The exact code is the best clue. Keyword search should preserve it, while a dense embedding may place general checkout errors nearby.
Hybrid search keeps both candidates available for ranking.
Keyword retrieval in PostgreSQL
PostgreSQL full-text search can store a tsvector next to each chunk.
ALTER TABLE knowledge_chunks
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', coalesce(title, '') || ' ' || content)) STORED;
CREATE INDEX knowledge_chunks_search_vector_idx
ON knowledge_chunks USING GIN (search_vector);Query it with the same workspace boundary used by vector search:
SELECT id,
ts_rank_cd(search_vector, websearch_to_tsquery('english', $2)) AS score
FROM knowledge_chunks
WHERE workspace_id = $1
AND search_vector @@ websearch_to_tsquery('english', $2)
ORDER BY score DESC, id
LIMIT $3;Choose the text-search configuration for the language you support. If the corpus is multilingual, test a language-aware strategy rather than applying English stemming to every document.
Vector retrieval with the same boundary
With pgvector, a cosine-distance query can look like this:
SELECT id, embedding <=> $2 AS distance
FROM knowledge_chunks
WHERE workspace_id = $1
ORDER BY embedding <=> $2, id
LIMIT $3;The embedding model determines the vector dimension and quality. Keep that model fixed while evaluating the addition of keyword search. Changing both at once makes the result hard to diagnose.
Run both searches in parallel
Both branches must apply the same authorization and content filters.
const [keywordResults, vectorResults] = await Promise.all([
keywordSearch({ workspaceId, query, limit: 20 }),
vectorSearch({ workspaceId, queryEmbedding, limit: 20 }),
])
const fused = reciprocalRankFusion([keywordResults, vectorResults])
const context = fused.slice(0, 8)The limits are examples. Increase them only when the evaluation set shows that relevant passages are being cut off before fusion.
Combine ranks with RRF
Keyword scores and vector distances use different scales. Adding them directly creates a weighting problem. Reciprocal Rank Fusion, or RRF, uses each result's position.
For a document at rank (r), its contribution is:
1 / (k + r)If the document appears in both lists, add both contributions.
type RankedResult = {
id: string
content: string
}
function reciprocalRankFusion(lists: RankedResult[][], k = 60) {
const byId = new Map<string, { result: RankedResult; score: number }>()
for (const list of lists) {
list.forEach((result, index) => {
const current = byId.get(result.id)
byId.set(result.id, {
result,
score: (current?.score? 0) + 1 / (k + index + 1),
})
})
}
return [...byId.values()]
.sort((a, b) => b.score - a.score)
.map(item => item.result)
}The constant 60 is a common starting value, not a promised optimum. Evaluate it alongside other settings.
Add weights only for a measured reason
You can weight a branch when the query contains a reliable signal. An error code, quoted phrase, SKU, or proper product name may justify more keyword influence. A conversational question without exact identifiers may benefit from semantic retrieval.
Keep the rule inspectable:
const hasExactIdentifier = /\b[A-Z]{1,5}-\d{2,}\b/.test(query)
const keywordWeight = hasExactIdentifier? 1.5: 1Do not ask a second model to choose weights until a simpler rule fails on a labeled set. An opaque router adds cost and makes retrieval incidents harder to explain.
Rerank a small candidate set
Fusion ranks documents from retrieval signals. An optional reranker can examine the question and a small candidate set together.
Use reranking when the initial lists regularly contain the right passage but place it too low. Do not use it to hide poor workspace filters, broken chunking, or missing content.
Measure the extra model or cross-encoder time. Voice and other tight-latency channels may not have room for it.
Build the evaluation set
Collect representative questions and label at least one supporting passage for each answerable question. Include:
- Paraphrases with no shared keywords
- Exact codes, plan names, and API fields
- Short ambiguous questions
- Queries with filters, such as product version or country
- Questions with no approved answer
Evaluate keyword-only, vector-only, and hybrid retrieval on the same set. Useful measures include:
- Recall at a fixed result limit
- Reciprocal rank of the first supporting passage
- No-answer precision
- Median and tail latency
- Retrieved tokens passed to the answer model
Then run an answer evaluation for citation support and handoff behavior. Better retrieval does not excuse an answer that invents details outside the selected passages.
Diagnose common failures
Exact term still missing. Check tokenization, stemming, punctuation, and the text-search language. Codes may need a dedicated normalized field.
General pages dominate. Reduce oversized chunks, add title or section fields, and inspect duplicates before changing fusion weights.
One language performs poorly. Verify the text-search configuration and embedding coverage for that language. Test each supported language separately.
Latency doubles. Confirm the searches run in parallel, filters use indexes, and candidate lists are bounded. Remove reranking while isolating the slow stage.
Wrong workspace appears. Stop. Fix authorization and data scoping before any relevance tuning.
When hybrid search is unnecessary
Keyword-only retrieval may be enough for a narrow catalog dominated by exact identifiers. Vector-only retrieval may be enough for a small, conversational corpus with no codes or names and a strict latency budget.
Do not add a second index because hybrid search is fashionable. Add it when one retriever has a repeatable failure that the other one fixes on your evaluation set.
For broader retrieval design, see vector search explained and query expansion for better retrieval.
Frequently asked questions
What is hybrid search?
Hybrid search combines results from keyword and semantic retrieval. A fusion method, often RRF, ranks the combined candidates before they reach the answer model.
Is hybrid search always more accurate?
No. It helps when the corpus contains both exact terms and conceptual language. It can add noise or latency in a narrow corpus where one retriever already works.
Does hybrid search require a separate search service?
No. PostgreSQL can provide full-text search and pgvector in one database. Separate services may still make sense for scale, operations, or language features.
How should I choose fusion weights?
Start with unweighted RRF. Add a clear rule only when labeled queries show a repeatable failure, then test the rule against the full set.