Hybrid Search: Combining Keywords and Vectors
Vector search finds meaning but fumbles exact strings like part numbers. This shows how keyword and vector results are merged with reciprocal rank fusion, worked by hand, and where a reranker fits.

A technician types "torque spec for HX-220B" into the company's document assistant. Vector search returns five passages about torque specifications for other pumps, because to an embedding model "HX-220B" and "HX-202B" can look almost the same. The service bulletin that names HX-220B ranks fourth or doesn't show up at all. Hybrid search, running a keyword search and a vector search over the same documents and then merging the two ranked lists, fixes this kind of miss without giving up what vectors do well. The merge step usually uses a method called reciprocal rank fusion. It is simple enough to work out by hand, and that is done further down.
This article covers the retrieval step only. The full pipeline from ingest to cited answer is in private RAG over internal documents.
Why vector search misses exact strings
An embedding model turns a passage into a list of numbers that captures what the passage is about. Two passages that say the same thing in different words end up close together. That is the whole appeal: a question about "time off after having a baby" finds the "parental leave" section even though no words match.
The same property hurts with strings whose meaning is their exact spelling:
- Part and model numbers. HX-220B and HX-202B differ by two characters and refer to different machines.
- Codes and identifiers. Fault code E-217, invoice 40417, contract clause 14.3(b), a ticket number.
- Names. A rare surname or a supplier name the model never saw in training often has no strong meaning of its own, so the vector leans on the surrounding text.
- Internal jargon and acronyms. A project code name means nothing to a general model.
Embedding models break text into subword pieces before encoding it, and a string like "220B" becomes a couple of fragments that carry little signal. The vector for a passage is dominated by its topic. Two service bulletins about torque on similar pumps are close in vector space whatever the model number says.
Keyword search has the opposite profile. It matches exact terms and is blind to synonyms. A search for "parental leave" will not find a policy that only says "maternity and paternity absence". Neither method is good on its own across a real mix of staff questions, which is why the usual answer is to run both.
How keyword ranking works: BM25
The standard keyword ranking function is BM25. For each query term found in a document, it adds a score made of two parts:
- Rarity of the term (IDF). A term that appears in few documents scores high; a term in nearly every document scores close to zero. "HX-220B" in a 50,000-chunk collection might appear in 12 chunks, so it carries far more weight than "torque".
- Term frequency, with saturation. More occurrences in the document raise the score, but with diminishing returns, and long documents are discounted so they do not win just by being long.
Written out, the score for a document D is the sum over query terms q of
IDF(q) * f(q,D) * (k1 + 1) / ( f(q,D) + k1 * (1 - b + b * |D| / avgdl) )
where f(q,D) is how often the term appears in D, |D| is the document length and avgdl is the average length. Two constants tune it: k1 controls how fast repeated terms stop adding score, and b controls how much length matters. Elasticsearch uses BM25 as its default similarity with k1 = 1.2 and b = 0.75, and those are the usual starting values elsewhere too.
The IDF part is what rescues the part number. It is a corpus-wide statistic, and that detail matters for PostgreSQL, as the next section shows.
Where PostgreSQL full text plus pgvector fits
Most private RAG systems store their chunks in Postgres with the pgvector extension; the case for that choice is made in pgvector versus a dedicated vector database. The pgvector README itself recommends pairing it with Postgres full-text search for hybrid search, and suggests merging the results with reciprocal rank fusion or a cross-encoder.
Postgres full-text search is built in. A tsvector column holds the processed terms of each chunk, a GIN index makes lookups fast, and websearch_to_tsquery turns a typed query into a search expression. The Postgres docs note that this function never raises syntax errors, so raw user input can go straight in.
One difference from BM25 matters. The Postgres docs state that the ranking functions ts_rank and ts_rank_cd "do not use any global information". They score each chunk by its own term frequencies and positions, with no notion of how rare a term is across the collection. A chunk that mentions "torque" eight times can outrank one that mentions "HX-220B" once. For many collections that is acceptable, because the match filter (@@) already restricts results to chunks containing the terms, and the fusion step only uses the order of the list, not the scores.
Where true BM25 inside Postgres is wanted, an extension can provide it. pg_textsearch, from Tiger Data (formerly Timescale), adds a bm25 index type, is released under the PostgreSQL License and, per its README, supports PostgreSQL 17 and 18 in October 2026. It is one more extension to install, upgrade and back up, so it is worth adding only after a test set shows the built-in ranking is the weak point.
Check how the parser splits identifiers before trusting it. The default parser reports a hyphenated token both whole and as parts, so "HX-220B" is indexed as the whole string and as "hx" and "220b". Running SELECT * FROM ts_debug('english', 'HX-220B'); on a sample of real codes shows exactly what gets indexed. If stemming mangles codes, a second tsvector built with the simple configuration, which does not stem, can be searched alongside.
A minimal schema and query, assuming a chunks table that already has an embedding column:
ALTER TABLE chunks ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;
CREATE INDEX ON chunks USING gin (tsv);
WITH semantic AS (
SELECT id, RANK() OVER (ORDER BY embedding <=> $1) AS r
FROM chunks ORDER BY embedding <=> $1 LIMIT 40
),
keyword AS (
SELECT id, RANK() OVER (ORDER BY ts_rank_cd(tsv, q) DESC) AS r
FROM chunks, websearch_to_tsquery('english', $2) q
WHERE tsv @@ q
ORDER BY ts_rank_cd(tsv, q) DESC LIMIT 40
)
SELECT COALESCE(s.id, k.id) AS id,
COALESCE(1.0 / (60 + s.r), 0) + COALESCE(1.0 / (60 + k.r), 0) AS score
FROM semantic s FULL OUTER JOIN keyword k ON s.id = k.id
ORDER BY score DESC LIMIT 10;
$1 is the query embedding and $2 the query text. This follows the shape of the RRF example in the pgvector-python repository. Permission filters belong inside both subqueries, not after the merge, for the reasons covered in filtering retrieval by document permissions.
Merging the lists: reciprocal rank fusion
The two searches produce scores on unrelated scales. A cosine distance of 0.21 and a ts_rank_cd of 0.4 cannot be added in any meaningful way, and normalizing them is fragile because the score spread changes from query to query. Reciprocal rank fusion sidesteps the problem by ignoring scores and using only positions.
The method comes from a 2009 SIGIR paper by Cormack, Clarke and Buttcher. Each document gets
RRF(d) = sum over lists of 1 / (k + rank(d))
where rank(d) is its position in each list (1 for the top) and a list that does not contain the document contributes nothing. The paper fixed k = 60 during a pilot and found the choice "was not critical". Elasticsearch uses 60 as its default rank_constant.
A worked example
Take the HX-220B query. Vector search returns A, B, C, D, E in that order. Keyword search returns D, F, A. Document D is the service bulletin with the answer.
| Doc | Vector rank | Keyword rank | Score with k = 60 |
|---|---|---|---|
| A | 1 | 3 | 1/61 + 1/63 = 0.01639 + 0.01587 = 0.03226 |
| D | 4 | 1 | 1/64 + 1/61 = 0.01563 + 0.01639 = 0.03202 |
| B | 2 | none | 1/62 = 0.01613 |
| F | none | 2 | 1/62 = 0.01613 |
| C | 3 | none | 1/63 = 0.01587 |
| E | 5 | none | 1/65 = 0.01538 |
The bulletin rises from fourth to second. A stays first because both searches found it. Documents found by both lists beat any document found by one, which is the core behavior: agreement wins.
The constant controls how flat the curve is. With k = 1, D scores 1/5 + 1/2 = 0.70 and B scores 1/3 = 0.33, a far larger gap between "found once near the top" and "found by both". With k = 60 the top position is worth 1/61 and the tenth 1/70, so being in a list matters more than the exact slot. That is why a document ranked first by only one search can still lose to one ranked fifth by both.
Two practical settings sit around RRF. The depth of each list (40 above) decides what can be rescued: a document at position 80 in the keyword list never enters the merge. And some systems weight one list, multiplying its terms by, say, 1.5, when the question set shows keyword matches matter more. Change one setting at a time and measure.
Rerankers: a second, slower look
RRF decides order from positions alone. A reranker reads the question and each candidate passage together and scores how well the passage answers it. This kind of model is called a cross-encoder. One openly licensed example is BAAI's bge-reranker-v2-m3: 0.6 billion parameters, multilingual, Apache-2.0 per its model card in October 2026.
The cost is that every candidate is a separate model pass. Reranking the top 30 fused results, with a 60-token question and 400-token chunks, means reading 30 x 460 = 13,800 tokens per query. A GPU handles that comfortably for interactive use; on a CPU the same work can add seconds per query, so time it on the actual hardware before committing. The usual pattern is to fuse a generous candidate list, rerank the top 20 to 50, and hand the best 5 or so to the language model.
A reranker improves the order of what retrieval found. It cannot bring back a passage that neither search returned, which is why it complements hybrid search and does not replace it.
The sensible order of work: add the keyword index and RRF first, since it costs a column and a query. Then run a fixed question set, as in how to evaluate a RAG system, and add a reranker only if the right passage is retrieved but ranked too low.
Frequently asked questions
Does hybrid search help if documents have no codes or part numbers?
Often still yes, though by less. Keyword search also catches names, rare terms and exact phrases staff copy from a document. Measure on real questions: if hit rate barely moves, the keyword side is cheap to keep but not urgent.
Should keyword and vector search use different chunk sizes?
Usually not. Using the same chunk IDs for both keeps the full outer join simple and lets one passage collect votes from both lists. Separate chunking makes the merge harder to reason about and rarely pays for itself.
Is RRF better than a weighted sum of normalized scores?
It is more predictable. Normalized score sums can beat RRF when tuned carefully on a good test set, but the weights tend to drift as documents and queries change. RRF needs no tuning beyond list depth, which makes it the safer default.
Ready to Modernize Your Business?
Get your AI automation roadmap in minutes, not months.
Analyze Your Workflows →