teachyou.ai academy
← All posts
RAG

Hybrid Search vs Pure Vector Search for RAG: A Practical Guide

Ira Menon · Jun 3, 2026 · 15 min read

You ship a RAG demo, everyone claps, and then three weeks into production someone asks the bot "what's the SKU for the part number ending in 4471?" and it confidently retrieves the wrong document. This is the moment almost every team discovers that pure vector search has a blind spot, and it's the moment this article exists to help you skip. The debate over hybrid search vs vector search isn't academic — it's the difference between a retrieval layer that handles clean natural-language questions and one that survives contact with real users typing part numbers, error codes, acronyms, and half-remembered phrases. I've rebuilt retrieval pipelines for three different production systems now, and every single time, the fix for "retrieval feels randomly wrong" turned out to be some flavor of hybrid search. Let's walk through why, and how to actually build it.

Why pure vector search breaks down in practice

Vector search embeds your query and your documents into the same high-dimensional space and finds nearest neighbors by cosine similarity or dot product. It's genuinely great at semantic matching — "how do I cancel my membership" will retrieve a chunk titled "Ending Your Subscription" even though they share almost no words. That's the whole selling point of embeddings, and it's real.

But embeddings compress meaning, and compression loses precision. A few categories of queries reliably break pure vector retrieval:

  • Exact identifiers: order numbers, product SKUs, error codes like ECONNREFUSED, ticket IDs, legal citation numbers. Embedding models weren't trained to preserve exact character sequences — they were trained to cluster meaning. INV-2024-88831 and INV-2024-88832 will embed almost identically, which is exactly backwards from what you want.
  • Rare or out-of-vocabulary terms: internal codenames, niche technical jargon, a client's specific product name. If the embedding model barely saw a term during training, its vector representation is noisy and unreliable.
  • Negation and specificity: "database migrations without downtime" can retrieve chunks about migrations with downtime, because the embedding mostly captures "database migrations" as the dominant signal.
  • Short, keyword-heavy queries: a two or three word query gives the embedding model very little context to work with, and it tends to default to the most generically "on-topic" chunk rather than the most specific one.

None of this means vector search is bad. It means vector search is solving a different problem than exact or near-exact term matching, and most real-world query distributions contain both problem types mixed together. That's the entire premise behind hybrid search vs vector search comparisons: it's not that one approach wins, it's that they cover each other's weaknesses.

What hybrid search actually is

Hybrid search runs two retrieval methods in parallel against the same query — typically a sparse lexical method like BM25 and a dense vector method using embeddings — and then combines the two ranked lists into a single result set. The lexical side handles exact term overlap and rare tokens well; the dense side handles paraphrase and semantic similarity well. You get both.

BM25 (Best Matching 25) is the workhorse lexical algorithm here. It's a refinement of TF-IDF that scores documents based on term frequency, inverse document frequency, and document length normalization. It's decades old, extremely fast, and it will find your exact SKU number every single time because it's literally counting token matches, not approximating meaning in vector space.

Here's a minimal illustration of the two scoring approaches side by side, using rank_bm25 for lexical and a sentence embedding model for dense retrieval:

from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer
import numpy as np

documents = [
    "Refund policy: items can be returned within 30 days of purchase.",
    "Order INV-2024-88831 was shipped via express courier on March 3rd.",
    "To reset your password, go to account settings and click Forgot Password.",
    "Order INV-2024-88832 was flagged for manual review due to billing mismatch.",
]

tokenized_corpus = [doc.lower().split() for doc in documents]
bm25 = BM25Okapi(tokenized_corpus)

model = SentenceTransformer("all-MiniLM-L6-v2")
doc_embeddings = model.encode(documents, normalize_embeddings=True)

def dense_search(query, top_k=4):
    query_emb = model.encode([query], normalize_embeddings=True)[0]
    scores = doc_embeddings @ query_emb
    return sorted(zip(documents, scores), key=lambda x: -x[1])[:top_k]

def lexical_search(query, top_k=4):
    tokenized_query = query.lower().split()
    scores = bm25.get_scores(tokenized_query)
    return sorted(zip(documents, scores), key=lambda x: -x[1])[:top_k]

query = "status of INV-2024-88832"
print("Dense results:", dense_search(query))
print("Lexical results:", lexical_search(query))

Run this and you'll typically see the lexical search nail the exact invoice number immediately, while the dense search sometimes ranks a semantically related but wrong invoice higher, because "order," "shipped," and "review" all cluster near each other in embedding space regardless of the specific number attached. That gap is the entire reason hybrid search exists.

Combining the two: reciprocal rank fusion

The hard part of hybrid search isn't running two searches — it's merging two differently-scaled ranking signals into one. BM25 scores are unbounded and corpus-dependent; cosine similarity scores are bounded between -1 and 1. You can't just add them together meaningfully.

The most common and robust solution is Reciprocal Rank Fusion (RRF). Instead of combining raw scores, RRF combines rank positions, which sidesteps the scaling problem entirely.

def reciprocal_rank_fusion(rank_lists, k=60):
    """
    rank_lists: list of ranked lists, each a list of doc_ids in rank order
    k: constant that dampens the influence of high ranks (60 is a common default)
    """
    fused_scores = {}
    for ranked_docs in rank_lists:
        for rank, doc_id in enumerate(ranked_docs):
            fused_scores.setdefault(doc_id, 0)
            fused_scores[doc_id] += 1 / (k + rank + 1)
    return sorted(fused_scores.items(), key=lambda x: -x[1])

# Example: dense search ranked doc_2 first, lexical ranked doc_2 first too
dense_ranking = ["doc_2", "doc_4", "doc_1", "doc_3"]
lexical_ranking = ["doc_2", "doc_3", "doc_1", "doc_4"]

fused = reciprocal_rank_fusion([dense_ranking, lexical_ranking])
for doc_id, score in fused:
    print(doc_id, round(score, 4))

RRF is popular because it requires no score normalization, no training, and no tuning of relative weights between the two systems — it just rewards documents that show up near the top of either list, and rewards them even more if they show up near the top of both. Most production hybrid search implementations (Elasticsearch, Weaviate, Qdrant, Vespa) support RRF natively or something functionally equivalent.

The alternative is a weighted linear combination — final_score = alpha * dense_score + (1 - alpha) * lexical_score after normalizing both to a 0-1 range. This gives you more control (you can bias toward lexical or dense depending on domain) but requires you to actually tune alpha, which means you need an evaluation set to tune against. I'd start with RRF unless you have a specific reason to need the extra control.

Building it with a vector database that supports hybrid natively

You don't have to hand-roll BM25 plus fusion logic yourself. Most modern vector databases now ship hybrid search as a first-class feature. Here's what it looks like with Qdrant, which supports sparse and dense vectors in the same collection:

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")

client.create_collection(
    collection_name="support_docs",
    vectors_config={
        "dense": models.VectorParams(size=384, distance=models.Distance.COSINE),
    },
    sparse_vectors_config={
        "sparse": models.SparseVectorParams(),
    },
)

# Query with both a dense vector and a sparse (BM25-like) vector,
# then fuse using RRF server-side
results = client.query_points(
    collection_name="support_docs",
    prefetch=[
        models.Prefetch(query=dense_query_vector, using="dense", limit=20),
        models.Prefetch(query=sparse_query_vector, using="sparse", limit=20),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=10,
)

Weaviate and Elasticsearch have equivalent patterns — Elasticsearch actually makes this especially natural since BM25 was already its default text scoring mechanism, and you just add a knn clause alongside your existing lexical query, combined via a rank parameter. If you're already running Elasticsearch or OpenSearch for logging or other search needs, adding vector fields to get hybrid search is often less infrastructure work than standing up a dedicated vector database.

One operational detail that trips people up the first time: sparse and dense vectors usually need to be generated by two separate pipelines that stay in sync. Your ingestion job has to tokenize each chunk for the sparse/lexical side (or run it through a SPLADE encoder) and also send it through your embedding model for the dense side, then write both representations to the same document record. If you add a new document, both indexes need it. If you delete or edit one, both need to reflect that. Teams that bolt hybrid search on as an afterthought often end up with an embedding pipeline and a totally separate lexical indexing pipeline that drift out of sync within a few weeks, which produces exactly the kind of "sometimes it finds the doc, sometimes it doesn't" flakiness that's hardest to debug. Building both writes into the same ingestion function from day one avoids this entirely.

Where sparse embeddings fit in

There's a third option worth knowing about: sparse neural embeddings, like SPLADE. These aren't BM25 and they aren't dense vectors — they're learned sparse representations where a neural model predicts term importance weights over a large vocabulary, producing a sparse vector where most dimensions are zero but the nonzero ones carry learned semantic weight rather than raw term frequency.

The pitch for SPLADE-style sparse retrieval is that it captures some of the "query expansion" benefit of dense embeddings (it can activate related terms that don't literally appear in the query) while remaining an inverted-index-friendly sparse representation that's fast to search at scale and still interpretable — you can look at which terms got weighted and why. In my experience it's worth evaluating if you're already deep into a hybrid setup and want to squeeze out more recall, but BM25 plus dense plus RRF is a perfectly strong default that's much simpler to reason about and debug. Don't reach for SPLADE as your first hybrid implementation — reach for it once you've measured that plain lexical-plus-dense hybrid search isn't hitting your recall target.

Reranking after retrieval

Hybrid search solves the recall problem — making sure the right document is somewhere in your candidate set. It doesn't automatically solve precision at the very top of the list. That's where a reranker earns its keep.

A cross-encoder reranker takes the query and each candidate chunk together as a single input (rather than encoding them separately like a bi-encoder does for retrieval) and produces a much more accurate relevance score, at the cost of being too slow to run over your entire corpus. The standard pattern is: hybrid search retrieves the top 50-100 candidates cheaply, then a cross-encoder reranks just those candidates and you keep the top 5-10 for your context window.

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

query = "how do I dispute a charge on my invoice"
candidates = [
    "Order INV-2024-88832 was flagged for manual review due to billing mismatch.",
    "Refund policy: items can be returned within 30 days of purchase.",
    "To dispute a billing charge, open a support ticket under Billing Issues.",
]

pairs = [[query, doc] for doc in candidates]
scores = reranker.predict(pairs)

for doc, score in sorted(zip(candidates, scores), key=lambda x: -x[1]):
    print(round(float(score), 4), doc)

I'd treat the pipeline as three distinct stages with three distinct jobs: hybrid retrieval for broad, cheap recall; reranking for precision on a small candidate set; and then your LLM for synthesis. Trying to make any single stage do all three jobs is where most underperforming RAG systems go wrong — they either skip reranking and stuff 50 mediocre chunks into context, or they skip hybrid retrieval and simply never surface the right chunk at all because it lost a purely semantic popularity contest.

Chunking and metadata still matter more than the search algorithm

It's tempting to treat the hybrid search vs vector search decision as the single lever that fixes retrieval quality, but chunking strategy and metadata filtering usually matter more than which search algorithm you pick. A perfectly tuned hybrid search over badly-chunked documents (500-token chunks that split a table in half, or chunks with no source attribution) will still perform poorly.

A few things that consistently move the needle regardless of your search architecture:

  • Chunk boundaries that respect document structure — split on headings and paragraph boundaries, not fixed token counts, so you don't sever an identifier from its context.
  • Metadata filtering before or alongside search — if a user is clearly asking about "invoices from March," pre-filter to documents tagged with that date range rather than relying on search alone to figure it out. This is often a bigger accuracy win than any change to the retrieval algorithm itself.
  • Keeping identifiers and codes intact within a single chunk — don't let a chunking pipeline split INV-2024-88832 across a chunk boundary; you'll lose it from both lexical and dense retrieval.
  • Storing the original text alongside the embedding, not a paraphrased or summarized version, so your lexical index has the literal tokens users will search for.

If you're building this out for the first time and want the full grounding — chunking strategy, embedding model choice, evaluation loops, and how retrieval fits into the broader LLM application — that's exactly the ground we cover step by step in Introduction to RAG, including the same hybrid retrieval patterns discussed here built out as working code you can extend.

A practical decision framework

Given all that, here's how I'd actually decide, project by project:

  1. Start with pure vector search if your query distribution is genuinely conversational and paraphrase-heavy (general Q&A over prose documents, no identifiers, no codes) and you need to ship something fast to validate the product idea.
  2. Move to hybrid search as soon as your documents contain identifiers, product codes, names, acronyms, or numbers that users will type verbatim — which, in practice, is most enterprise, support, legal, and technical documentation.
  3. Add a reranker once hybrid retrieval is reliably surfacing the right document somewhere in the top 20-50 but not consistently in the top 3-5 — that's the textbook symptom of a recall problem being solved but a precision problem remaining.
  4. Evaluate with a real test set, not vibes. Build even a small set of 30-50 representative queries with known correct chunks, and measure recall@k before and after adding hybrid search. This is the only way to know if RRF's default k=60 is right for your corpus, or whether you need to tune weights instead.
  5. Don't add `SPLADE` or exotic sparse embeddings until you've measured that BM25 plus dense plus RRF has a real gap. Complexity should be earned by evidence, not added preemptively.

The pattern I've seen across every production system: teams launch with pure vector search because it's one API call and a vector database, hit a wall within weeks when real users type exact terms the embedding model wasn't built to preserve, and then bolt on BM25 and RRF fusion as the fix. Building hybrid in from day one, even in a lightweight form, saves you that entire cycle of debugging "why did retrieval get this so wrong" tickets.

A handful of implementation mistakes show up repeatedly when teams build their first hybrid pipeline, on top of the decisions above:

  • Normalizing scores incorrectly before summing them — if you're not using RRF, remember BM25 scores have no fixed range, so a raw weighted sum without min-max or z-score normalization will let one signal silently dominate.
  • Using the same `top_k` for both retrieval arms as your final result count — you want a wider candidate pool from each arm (say, 20-50) before fusion, then narrow down, not a narrow pool from each arm merged into an even narrower final list.
  • Forgetting to keep the lexical index in sync with the vector index — if documents get updated or deleted, both indexes need to reflect that, or you'll get stale hybrid results that are inconsistent with each other.
  • Assuming hybrid search fixes a bad embedding model — if your dense embeddings are a poor fit for your domain (e.g., a general-purpose model on highly specialized medical or legal text), hybrid search will mask some of that weakness but won't fix the underlying embedding quality. Sometimes the actual fix is fine-tuning or swapping the embedding model, not adding lexical search.
  • Not testing with actual user queries — synthetic test queries written by the engineering team tend to be cleaner and more semantically complete than what real users type. Pull actual query logs once you have them and re-evaluate.

Wrapping up

The honest answer to "hybrid search vs vector search" is that it's rarely a binary choice in a mature system — it's a spectrum, and where you land depends on how much of your query traffic contains exact terms versus paraphrased intent. Pure vector search is simpler to stand up and genuinely excellent at semantic matching, but it quietly fails on identifiers, codes, and rare terms in ways that are easy to miss in a demo and painful to discover in production. Hybrid search, combining a lexical method like BM25 with dense embeddings through something like reciprocal rank fusion, closes that gap with a modest increase in infrastructure complexity that most production-grade vector databases now handle natively. Add a reranker on top once recall is solid and precision at the very top of the list becomes the bottleneck, and don't forget that chunking and metadata discipline will often matter more than the retrieval algorithm itself. If retrieval quality is the thing standing between your RAG prototype and a system people actually trust, hybrid search is very likely where the fix lives — and once you've got retrieval solid, the natural next step is turning that retrieved knowledge into something that compounds over time, which is exactly the territory we get into in Building a Second Brain with AI Agents.

Hybrid Search vs Pure Vector Search for RAG: A Practical Guide · TeachYou Academy