RAGRetrieval

Hybrid search

Run keyword search next to vector search and fuse the two ranked lists, so exact strings stop falling through the cracks.

WHY THIS, FOR YOU

Someone searched your docs for the error code ERR_CONN_RESET and got back three cheerful passages about network reliability, none of which mentioned the code. The chunk that names it exists. Your index has it. Vector search just did not put it first, because embeddings are built to blur the difference between similar things and that error code has no similar things. This lesson gives you the usual fix: keep the vector index, add a keyword index, and merge the two result lists with a formula you can write in eight lines. You will see where the exact chunk lands before and after the merge, why two stores that both say RRF can return different orders, and the quiet ways the keyword half stops working.

1.What embeddings are built to lose

What an embedding really is said an embedding is a point in space where nearby means similar in meaning. That is the whole value and it is also the whole problem.

To place "the refund window is fourteen days" near "how long do I have to get my money back", the model has to throw away the exact words. It keeps the gist. That is a feature when the user paraphrases, and a bug when the user types something whose gist is nothing.

Take ERR_CONN_RESET. The tokeniser splits it into fragments, the model blends them into the meaning of the sentence around them, and the vector lands near "connection problem". So does every other chunk about connections. The chunk that names the error is in that neighbourhood, but it is not clearly the closest point in it.

The fix is a second, word-matching search run next to the vector search, with the two lists merged. As of October 2026 the docs of Elasticsearch, OpenSearch, Qdrant, Weaviate, Milvus and Pinecone each describe a way to do it, and the pgvector README points to Postgres full-text search. That makes hybrid a documented pattern. It does not count what production systems run, and none of those pages claims to.

QueryDense vectorsKeyword search
ERR_CONN_RESETBlurry - lands near "network errors"Exact - the term is rare, so it dominates
INC-88213Near randomExact
"how do I get my money back"Strong - pure paraphraseWeak - shares no words with "refund"
"what does SOC2 CC6.1 require"Half - gets SOC2, loses CC6.1Half - gets CC6.1, loses the intent

The failure runs both ways. In one study, BrowseComp-Plus (August 2025), BM25 alone found 1.2% of the evidence documents for hard, paraphrased, multi-clue questions in its top five (recall at 5). An 8-billion-parameter dense embedder, Qwen3-Embedding-8B (as of August 2025), found 14.5%. That benchmark is built to be indirect and never tested a hybrid, so read it as a limit of keywords alone, not a verdict on hybrid.

Embeddings find text that means the same thing. Keyword search finds text that says the same thing. Most real questions need both.

2.What the keyword half does

The keyword half is almost always BM25, the default scoring function in Lucene-based engines such as Elasticsearch, OpenSearch and Solr. Three ideas carry it.

Rare terms count for more. A query word found in nine documents out of ten tells you nothing. One found in three out of a million tells you almost everything. This is why BM25 nails identifiers.

Repeats saturate. A page that says "refund" ten times is more about refunds than one that says it once, but not ten times more.

Long documents are penalised. A match inside a short, focused chunk is worth more than one buried in a sprawling page.

No training, no embeddings: corpus statistics, and a hard baseline to beat. Its weakness: it only matches words the text contains.

Learned sparse models relax that. A model such as SPLADE (paper July 2021; SPLADE-v3, March 2024, as of October 2026) weights every word in a vocabulary of about 30,000, including words the text never used, so "refund" can light up on a passage about getting money back. The index is still an inverted index, mostly zeros. The v3 authors report beating BM25 across more than 40 query sets, their own claim and not checked here. Elasticsearch offers its own, ELSER (docs, as of October 2026). The price is a model pass at index time and for every query, so try it only after plain BM25 hybrid is measured and still misses.

The failure to test for: the identifier that was never one word. A keyword index searches what its analyser made of your text. The Elasticsearch standard tokenizer splits on hyphens (its docs, as of October 2026), so RTX-4090-FE is stored as rtx, 4090 and fe. SQLite's FTS5 default splits underscores too: in a quick test (SQLite 3.53.1), a query for ERR matched both ERR_CONN_RESET and ERR_NAME_NOT_RESOLVED. Nothing errors. The exact chunk is found, ranked among its look-alikes. Search the identifier as a phrase, or index it whole, and check that ten real identifiers come back first.

3.Merging two ranked lists

Adding the two scores does not work. A cosine similarity is bounded, and most chunks cluster in a band such as 0.6 to 0.85. A BM25 score is unbounded and means something only relative to the other scores for that query. There is no exchange rate, and min-max normalising per query is unstable because one outlier drags the scale.

Reciprocal Rank Fusion ignores scores. For each list, a document at position r contributes 1 / (k + r). Add the contributions and sort.

The queryOne string, sent to both indexes
Two ranked lists come back
  1. Vector index - nearest neighbours by meaning
  2. Keyword index - BM25 on exact terms
Reciprocal Rank Fusion1/(k + rank), added from each list
One merged ranked list
One query becomes two ranked lists, and Reciprocal Rank Fusion is the only place they meet - it works on rank position alone, so the two systems' scores never have to be made comparable.

Here is the opening problem with invented ranks. Vector search puts the page naming the error sixth. Keyword search puts it first. If you send five chunks to the model, the dense list alone never shows it.

# Merge ranked lists with Reciprocal Rank Fusion.
# Only positions are used, so nothing needs normalising.

def rrf(rankings, k=60):
    scores = {}
    for ranking in rankings:
        for position, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + position)
    return sorted(scores.items(), key=lambda pair: -pair[1])

vector_hits = ["net-1", "net-2", "net-3", "net-4", "net-5", "reset"]
keyword_hits = ["reset", "guide-9", "guide-4"]

for doc_id, score in rrf([vector_hits, keyword_hits])[:3]:
    print(f"{doc_id}  {score:.4f}")

# reset  0.0315   6th for vectors, 1st for keywords
# net-1  0.0164   1st for vectors, absent from keywords
# net-2  0.0161   2nd for vectors, absent from keywords

Agreement wins, and the constant k sets how much. Cormack and colleagues (SIGIR 2009) fixed k = 60 in a pilot that fused 30 configurations of one search engine and found it near-optimal, and the choice not critical. That is not dense plus BM25, so treat 60 as a starting point. A document ranked 1st in one list scores 0.0164 at k = 60, against 0.0308 for one ranked 5th in both. At k = 2 the order flips: 0.333 against 0.286.

60
DefaultThe value from the original paper, and a fine place to start.
The one knob RRF has. A low k lets a single system's top pick win; a high k means only agreement between both counts.

Stores do not agree on the defaults, so read yours before you compare results from two of them.

Store, from its docs as of October 2026How it fuses
Elasticsearch, OpenSearch (RRF since 2.19)RRF, rank_constant 60 in both
Qdrant (Query API since v1.10)RRF with default constant 2 and ranks counted from zero, settable since v1.16; also score-based DBSF and weighted RRF
Weaviate (BM25F plus vectors)Relative score fusion is the default since v1.24; ranked fusion is an option
PineconeNew text workloads: Documents API, dense search plus full-text fields, merged with RRF on the client if you want it. Older Vectors API: one sparse and one dense vector per record scaled by weight, or two indexes you merge. Its page gives no release status, so check the current one

Weighted blending (w * dense + (1 - w) * sparse on normalised scores) can beat RRF when you have an eval set to tune w on. It breaks quietly when the corpus grows, so start with RRF.

Hybrid can also mean two tools instead of one fused list, with an agent choosing lexical or semantic search per query: see Retrieval as a tool the agent calls. Either way the fused list is a set of candidates, and Reranking decides the order.

What it costs

PieceCost
Second indexReal: build it, keep it in sync on every write
Second queryOne extra round trip, usually in parallel
FusionA dictionary and a sort
CandidatesTwo lists of 50 give at most 100 for the next stage

The second failure is quiet. If the keyword index goes stale, or its errors are caught and swallowed, RRF returns the vector list on its own and nothing looks broken. Log how many results each half returned, and read Keeping the index fresh without paying twice for keeping the two in step.

Where it clearly pays

Hybrid is a clear win when the corpus is full of names that only exist in it: error codes, SKUs, ticket ids, service names, control numbers, invented acronyms. That often describes engineering docs, support corpora and compliance sets.

When not to use it. Skip it when the corpus is general prose and users ask in general language, such as marketing pages or essays: vocabulary overlaps anyway and dense search copes. Delay it for a small corpus where a second index is a burden and a reranker already fixes the order. Hybrid often improves recall; Measuring retrieval is where you check whether it improved yours, not a benchmark on someone else's corpus.

WHAT YOU TAKE AWAY

  1. Reach for keyword search whenever the query contains an identifier, a code or a rare name.
  2. Explain BM25 as rare terms count more and long documents are penalised.
  3. Merge two ranked lists with Reciprocal Rank Fusion instead of adding raw scores.
  4. Never compare a cosine similarity to a BM25 score - they are not on the same scale.
  5. Check how your keyword index splits identifiers, and that both the vector and the keyword search still return results.

RECALL NO SCROLLING BACK

  1. A user searches for the part number RTX-4090-FE. Why does pure vector search often miss the chunk that contains it?

  2. Roughly what does BM25 reward?

  3. Why is Reciprocal Rank Fusion a safe default for combining vector and keyword results?

  4. A keyword search for the fragment ERR returns ERR_CONN_RESET and ERR_NAME_NOT_RESOLVED together. What should you check first?

  5. Which query is hybrid search least likely to help?

GO DEEPER