Hybrid Search: BM25, Vectors and a Recall Test

Combine BM25 and vector search with RRF or weighted scores. Add hybrid retrieval in your existing store and measure recall on labelled queries.

Published

Hybrid Search: BM25, Vectors and a Recall Test

If your search understands the question but misses the product code, add a lexical retrieval branch before replacing your embedding model. Hybrid search combines 2 candidate lists, keyword matches and vector matches, then merges them into one ranking. Start with reciprocal rank fusion, preserve exact identifiers explicitly, and keep the change only if a labelled query set shows better recall without breaking the queries that already work.

Documentation verified on 24 October 2026. The implementation guidance below follows the current Elasticsearch, Weaviate and Pinecone documentation. The evaluation settings are a proposed starting protocol, not measured improvements or vendor benchmarks.

Hybrid search lets literal wording and meaning both influence what gets retrieved. In the architecture covered here, keyword search produces one ranked list, vector search produces another, and a fusion method combines them. Weaviate's explanation describes both branches and their merger.

Think of two researchers answering the same support question. One checks the exact names and error strings in an index. The other reads for related ideas, including answers phrased differently. You combine their shortlists instead of asking either researcher to do both jobs alone.

BM25, a lexical ranking function, scores documents using the query terms they contain. Term frequency and document length affect that score; it is more useful than simply counting matching words. Elasticsearch uses BM25 as its default text similarity. Elastic's similarity reference documents those factors.

Vector search compares embeddings, numerical representations of content, to find similar meaning. It can connect “I keep getting signed out” with a document about session expiration even when the wording differs. Our semantic search guide explains that retrieval path.

For an illustrative query such as ERR_SESSION_EXPIRED after login, the lexical branch gives the literal error string a route into the results. The semantic branch can contribute guidance about authentication sessions and renewal. Fusion decides their final order. That example describes the intended behavior; it is not a claim that an unconfigured store will produce a particular ranking.

A query branches into BM25 and vector retrieval paths, which join at fusion before reaching results
Retrieve through both branches, then merge their candidates into one ranking.

Hybrid vector search still needs an exact-term policy

A keyword branch is not an exact-identifier guarantee. Text analysis, the process that turns strings into searchable tokens, can change what gets indexed. Elastic's standard analyzer, for example, splits a hyphenated phrase in its documented example. Check what happens to your own SKUs and error codes with the configured analyzer. Standard analyzer documentation.

Keep identifiers in a dedicated exact-match field when their spelling defines the requested item. Elastic documents its keyword field for this kind of structured value. A query for the fictional SKU AX-BLUE should not silently become a recommendation for AX-GREEN because the descriptions are similar. Keyword field reference.

Decision rule: route a recognized, unique identifier to an exact lookup when that is the product contract. Use hybrid ranking for questions that combine an identifier with a broader need, such as finding troubleshooting guidance for that item.

RRF or weighted scores: how to merge the results

Start with reciprocal rank fusion when you need a baseline that does not require calibrating unrelated score scales. Consider weighted scores when you have labels to tune the balance and a reason to preserve the gaps between scores.

Reciprocal rank fusion uses positions

Reciprocal rank fusion, or RRF, adds a contribution from each list in which a document appears:

RRF(document) = sum of 1 / (c + rank) across the retrieved lists.

Ranks start at the first position. A missing document contributes nothing from that list. Gordon V. Cormack, Charles L. A. Clarke and Stefan Büttcher introduced RRF in their SIGIR 2009 paper. They fixed the constant at 60 for their experiments. The paper calls it k; this article calls it c to avoid confusing it with the number of results in recall@k.

RRF is convenient because a high BM25 score and a high vector score do not need to mean the same thing. Only their positions matter. Pinecone's RRF implementation makes the one-based ranks and missing-document behavior explicit.

There is a consequence worth checking. In a constructed example, document A is first in the keyword list and absent from the vector list. Document B is second in both. With c = 60, A gets 1/61; B gets 2/62. B wins. That is our arithmetic from the formula, not a search benchmark.

Weighted scores preserve score differences

Weighted score fusion combines the relevance scores after making their scales comparable. A common form is:

score = alpha × normalized vector score + (1 − alpha) × normalized keyword score.

The weight alpha controls the balance. A starting value of 0.5 gives equal coefficients; it does not establish equal usefulness or an optimal setting. Document normalization and weighting together, because changing either can change the ranking.

Elasticsearch's linear retriever supports a weighted sum with normalizers such as minmax. Weaviate's relative-score fusion retains the relative gaps within each result set, whereas rank-based fusion discards those gaps.

Use RRF as the first comparison; keep weighted fusion only when held-out queries support it. Do not add raw BM25 scores to vector similarity scores and assume the sum is meaningful. Also record candidate depth: changing which results enter a normalized list can change its score range.

Hybrid helps when the keyword branch retrieves relevant material that the vector branch misses, and the merger keeps that material high enough to be useful. This is a condition you can inspect, not a universal accuracy claim.

The strongest candidates for a trial are:

  • Support search: literal error strings, function names and account terminology mixed with descriptions of symptoms.
  • Catalog search: exact product names or SKUs mixed with needs such as compatibility or replacement parts.
  • Internal knowledge search: project names and abbreviations mixed with natural-language questions.

Pinecone's hybrid search overview specifically identifies product codes, error strings and names as keyword signals, alongside dense retrieval for meaning.

For pure identifier lookup, start with a dependable exact lookup. For a corpus and query set already served well by vector search, wait for evidence of a retrieval problem before adding another branch.

The practical limit is candidate coverage. Fusion can only order the union of the documents its branches retrieved. If neither branch finds the relevant passage, changing the merger cannot recover it. If the passage is present before fusion but absent from the final results, investigate ranking and truncation instead.

Add hybrid search to the store you already use

Keep the current store when it offers the retrieval primitives you need. Changing databases and changing retrieval strategy at the same time makes it harder to identify what helped.

The distinction below is about where the combination happens, not a ranking of vendors.

StoreDocumented combinationWhat you configure
ElasticsearchNative RRF or normalized linear fusionChild retrievers, candidate window, filters and merger
WeaviateNative BM25F plus vector fusionSearch properties, fusion method and explicit alpha
PineconeNative dense/sparse vector combination; BM25 plus dense RRF in the clientChoose the API pattern, maintain IDs and tune the correct layer

Elasticsearch, a search engine supporting text and vector retrieval, can put a standard text retriever and a knn vector retriever under an rrf retriever. The official RRF retriever reference documents this arrangement and its candidate window.

Elastic's hybrid search product page
Elasticsearch hybrid search

For an existing index, keep the searchable text and its embedding on the same document ID. Query both branches over the same eligible content, then apply fusion. Keep tenant, permission and product-version restrictions consistent across the branches.

Here is a request you can run after setting ES_URL and ES_API_KEY. It assumes an existing index named docs, a text field named text, a dense-vector field named embedding, and query-vector.json containing the actual embedding array for the query. Generate that array with the model used for the indexed documents. The command requires jq and curl.

Bash
jq -n --slurpfile v query-vector.json \
  --arg q 'ERR_SESSION_EXPIRED after login' \
  '{
    size: 10,
    retriever: {
      rrf: {
        retrievers: [
          {standard: {query: {match: {text: $q}}}},
          {knn: {
            field: "embedding",
            query_vector: $v[0],
            k: 50,
            num_candidates: 100
          }}
        ],
        rank_window_size: 50,
        rank_constant: 60
      }
    }
  }' > hybrid-request.json

curl --fail-with-body "$ES_URL/docs/_search" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H 'Content-Type: application/json' \
  --data-binary @hybrid-request.json

These are illustrative evaluation settings. size is the final result count; rank_window_size controls how many results from each child can participate in fusion. Do not copy this unfiltered example into a permissioned application without adding its eligibility constraints. Elastic documents a parent filter that applies to the child retrievers, according to their specifications.

The wall: a narrow child window can discard the very result you hoped hybrid retrieval would rescue. Compare branch candidates with final results before tuning the merger. If you switch to the linear retriever, configure normalization along with weights.

Weaviate, a database with text and vector retrieval, exposes hybrid search through collection.query.hybrid. Its lexical component is BM25F, the variant that supports multiple fields. The hybrid query documentation covers searchable properties, fusion selection and score explanations.

Weaviate's hybrid search feature page
Weaviate hybrid search

Point query_properties at the fields that carry useful lexical evidence, such as titles, error text and product names. Supply the query vector when your application generates embeddings, or use the collection's configured vectorization path. Set fusion_type explicitly when comparing fusion methods, and request explain_score while diagnosing why a result moved.

Set alpha explicitly too: 0 selects keyword search and 1 selects vector search. Current docs warn that the effective weighting when it is omitted depends on the client. relativeScoreFusion is the default fusion method; rankedFusion is also available. Weaviate's fusion reference.

The wall: a weight cannot repair a missing searchable field or a damaged identifier token. Inspect the lexical contribution before concluding that the embedding model needs replacement. Use the vector-only and keyword-only endpoints of the weight range as diagnostic baselines, then evaluate the combined setting.

Pinecone, a retrieval database, documents distinct hybrid patterns. Pick the pattern before copying an example: the server-side dense/sparse combination and client-side RRF are different implementations. Pinecone's hybrid overview.

Pinecone documentation distinguishing filtering, client-side fusion and server-side combination
Pinecone hybrid search documentation

For BM25 plus dense retrieval, the Documents API path runs a full-text search and a dense-vector search, then merges their ordered document IDs with RRF in your application. Pinecone's RRF guide includes that client-side implementation.

For native dense plus sparse-vector combination, the Vectors API can store both representations on each record and combine them in a single query. Its single-index guide requires vector_type="dense" and metric="dotproduct"; the client scales the query vectors to set their relative influence.

A sparse vector is a representation, not proof that BM25 produced it. Keep the lexical model or full-text scorer explicit in your architecture notes.

The wall: a keyword filter followed by dense ranking is not the same as letting both signals affect ranking. A filter removes candidates; it cannot rescue a synonym match it already excluded. Use a filter for a mandatory condition, and fusion when either retrieval branch should be able to contribute a result.

What is hybrid search in RAG?

Hybrid search changes the retrieval stage of retrieval-augmented generation, or RAG, where an application supplies retrieved source material to a language model before it answers. It does not replace the generation stage or establish that the answer is correct.

Keep the unit of retrieval consistent. If the keyword branch returns whole documents and the vector branch returns passages, decide how their IDs map before merging. Otherwise repeated passages from one source can occupy the shortlist while other relevant sources disappear. Our vector database explainer covers the storage and retrieval foundation.

Agentic RAG adds decisions about which retrieval action to take or whether to search again. A hybrid retriever can be one of those actions. For a support bot missing a literal error code, repair and evaluate that retriever before adding more orchestration.

Keep the generator and prompt fixed during the retrieval comparison. If the exact-term slice improves but paraphrase retrieval remains weak, use the same labelled queries to compare embedding models. Changing the embedding, chunking and fusion simultaneously leaves you unable to attribute the outcome.

Evaluate recall@k before and after

Use a small, labelled query set and compare keyword-only, vector-only and hybrid retrieval against the same answers. A fluent generated response is not a retrieval metric.

Recall@k = relevant IDs retrieved in the first k results ÷ all labelled relevant IDs for that query. Average the per-query values to obtain macro recall@k. If your labels are incomplete, call the result recall against known relevance judgments; it does not establish recall over every relevant item in the corpus.

  1. Build a small query set

    Start with 60 answerable queries: 20 exact-term queries, 20 paraphrases and 20 mixed queries. These are suggested workload slices, not a sample-size guarantee. Draw from real searches where possible, and include names, SKUs and error strings that currently fail.

    Reserve half of each slice for development and half for a held-out check. Add 6 deliberately unanswerable queries separately; review their behavior, but exclude them from recall because their relevant-set denominator is empty.

  2. Label the relevant retrieval units

    For each query, record its ID, slice and every relevant document or passage ID you can establish. Pool candidates from all three retrieval methods, add known answers that none retrieved, and judge them without seeing which method supplied them. Use stable IDs and the same document-or-passage unit throughout.

  3. Freeze the comparison

    Use the same corpus snapshot, permissions, embedding model and chunking. Start with a final k of 10 and 50 candidates from each hybrid branch. Those are initial settings to investigate, not optimal values. Keep reranking off for the first comparison.

    Export the final ordered IDs from each method. Also save the keyword and vector candidate lists before fusion, so you can distinguish a missing-candidate problem from a ranking problem.

  4. Tune, then open the held-out set

    Tune fusion and candidate depth on the development half. Select the configuration, then evaluate the held-out half once. Report every slice as well as the overall average, and inspect the individual queries that regressed. A small positive average is a reason to investigate further, not statistical proof.

Vector and hybrid retrieval runs are compared using the same queries, relevance labels and recall metric
Use identical queries and relevance labels for both runs; the illustration does not report an outcome.

Run the local recall evaluator

Save the following as evaluate_recall.py. It uses only the Python 3 standard library. qrels.jsonl contains one JSON object per query, with qid, slice and relevant fields. Each result file maps a query ID to its ordered list of returned IDs.

For example, a label record could be {"qid":"q-exact","slice":"exact","relevant":["guide-login"]}. A result file could contain {"q-exact":["guide-reset","guide-login"]}. Those are format illustrations, not search results from a tested system. Include every labelled query in every result file, using an empty list when retrieval returned nothing.

Python
import json
import sys
from collections import defaultdict
from statistics import mean

qrels_path, bm25_path, vector_path, hybrid_path, cutoff = sys.argv[1:]
k = int(cutoff)
if k < 1:
    raise ValueError("k must be positive")

with open(qrels_path, encoding="utf-8") as f:
    labels = [json.loads(line) for line in f if line.strip()]
if not labels or len({r["qid"] for r in labels}) != len(labels):
    raise ValueError("Labels must be nonempty with unique query IDs")

runs = {}
for name, path in (("bm25", bm25_path), ("vector", vector_path),
                   ("hybrid", hybrid_path)):
    with open(path, encoding="utf-8") as f:
        runs[name] = json.load(f)
    missing = {r["qid"] for r in labels} - runs[name].keys()
    if missing:
        raise ValueError(f"{name} is missing query IDs: {sorted(missing)}")

groups = defaultdict(list)
skipped = 0
for row in labels:
    relevant = set(row["relevant"])
    if not relevant:
        skipped += 1
        continue
    scores = {}
    for name, run in runs.items():
        unique_ids = list(dict.fromkeys(run[row["qid"]]))
        scores[name] = len(relevant & set(unique_ids[:k])) / len(relevant)
    groups["overall"].append(scores)
    groups["slice:" + row["slice"]].append(scores)

if not groups:
    raise ValueError("No answerable queries to score")
for group, rows in sorted(groups.items()):
    values = {name: mean(r[name] for r in rows) for name in runs}
    delta = values["hybrid"] - values["vector"]
    report = " ".join(f"{name}={v:.3f}" for name, v in values.items())
    print(f"{group} n={len(rows)} recall@{k} {report} delta={delta:+.3f}")
print(f"Excluded no-answer queries: {skipped}")

Run it on the held-out labels and exports:

Bash
python3 evaluate_recall.py qrels.jsonl bm25.json vector.json hybrid.json 10

The script removes duplicate IDs before taking the cutoff. Use that same deduplicated result policy in the application. It reports the hybrid change relative to vector search and also shows the keyword baseline, so a strong lexical system does not disappear from the comparison.

Ship when the relevant query slices improve and the regressions are acceptable under your product requirements. If candidates improve but final recall does not, adjust fusion or the result cutoff. If final recall improves, separately review result ordering, generated-answer correctness and end-to-end latency. Recall does not measure any of those for you.

What is overhyped, and what to do next

“Hybrid gives the best of both worlds” hides the engineering decision. It can widen useful coverage, but it also gives another retrieval branch influence over the final list. RRF can demote an exact hit; weighted fusion can favor whichever score scale or normalization behaves differently; neither repairs missing source content.

Builders should act now when inspection shows relevant exact-term documents absent from vector candidates. Add the lexical branch, keep document identity and permissions consistent, and run the labelled comparison.

Operators should wait on rollout when the current problem is stale content, missing permissions or missing relevance labels. Fix those prerequisites, then compare the retrieval strategies.

Teams with reliable identifier lookup are unaffected until they need broader discovery. Keep the dependable lookup path and evaluate hybrid separately for natural-language questions.

Your Monday move: collect the failing searches, label the expected sources, and run the existing vector retriever beside a keyword baseline. Add fusion after you can see what each branch contributes. Keep the configuration that earns its place on the held-out queries.

Frequently asked questions

Is BM25 hybrid search?

No. BM25 is a lexical ranking function. Combining its results with a vector retrieval branch is one form of hybrid search.

Does ChatGPT use semantic search?

You cannot establish the retrieval algorithm from a generated answer alone. For a system you build around a language model, inspect the configured retrieval path instead of assuming it uses BM25, vectors or RRF.

What is semantic search with an example?

A query such as “I keep getting signed out” can match guidance about session expiration despite different wording. That is meaning-based retrieval; an exact error string may still need lexical matching.

Can you provide an example of hybrid search?

For ERR_SESSION_EXPIRED after login, retrieve literal error-string matches with the keyword branch and related session guidance with the vector branch. Merge the candidates, then check the expected sources against your relevance labels.

What is the main difference between hybrid search and vector search?

Pure vector search retrieves by embedding similarity. The hybrid approach described here also retrieves through a keyword branch and combines the evidence into one result list.

Is hybrid search the same as semantic search?

No. Semantic retrieval can be one component of hybrid search. Hybrid describes the combination of retrieval signals; it does not specify a single fusion algorithm.

Is Google a semantic search?

Google Search uses semantic understanding among its ranking systems. Google documents BERT and neural matching in its ranking systems guide. That does not establish that its overall architecture is the BM25-plus-vector design described here.

What is RAG vs semantic search?

RAG retrieves source material and uses it during answer generation. Semantic search is a retrieval approach that can supply that material. A RAG application can also use keyword or hybrid retrieval.

What is hybrid search and how does it work?

In this guide's architecture, run keyword and vector retrieval over eligible content, merge candidates by stable ID, and rank them with RRF or a deliberate weighted-score method. Evaluate the final results against labelled queries before rollout.

Get practical build guides in the newsletter.

Published
Category
Build
Related Articles
Mixture of Experts Explained: Active vs Total Parameters

Mixture of Experts Explained: Active vs Total Parameters

Understand MoE routers, experts and active vs total parameters, with maker-sourced examples and practical rules for API use and self-hosting.Oct 24, 2026Build
Eval Driven Development: A Practical Loop for LLM Changes

Eval Driven Development: A Practical Loop for LLM Changes

Learn eval driven development with a 20-case example: define checks, record a baseline, change one thing, and gate the release on the rerun.Oct 24, 2026Build
Tokens per Second: How to Measure LLM Output Speed

Tokens per Second: How to Measure LLM Output Speed

Understand LLM tokens per second, first-token delay and end-to-end latency, then measure your own streamed response.Oct 24, 2026Build
LLMOps: What It Is and What to Put in Place First

LLMOps: What It Is and What to Put in Place First

What LLMOps covers, how it differs from MLOps, and the release, evaluation, tracing and incident controls a production team needs first.Oct 23, 2026Build
Structured Outputs: OpenAI, Claude and Gemini Explained

Structured Outputs: OpenAI, Claude and Gemini Explained

Understand structured outputs across OpenAI, Claude and Gemini, with schema limits, invoice extraction, validation, retries and streaming.Oct 23, 2026Build
Railway Alternatives in 2026: Render, Fly.io, Northflank, Heroku and More

Railway Alternatives in 2026: Render, Fly.io, Northflank, Heroku and More

Compare Railway alternatives for services, Postgres, workers and cron, with current starting prices, secrets handling and a staged migration checklist.Oct 22, 2026Build
Gumloop Review

Gumloop Review

Gumloop reviewed for lead research, scraping, documents and email: a step-by-step setup, current credits, approval controls and alternatives.Oct 22, 2026Build
Synthetic Data Generation: LLM Training and Test Data

Synthetic Data Generation: LLM Training and Test Data

Generate LLM evals, fine-tuning examples and test fixtures with a worked recipe, filtering, deduplication, human checks and provider terms.Oct 22, 2026Build
Newsletter

One letter, every Sunday.Working systems, not hot takes.

Weekly. No spam. Unsubscribe anytime.