◈Home ▸Blog ◇Contact
Home/Blog/How to Build Hybrid Search in Qdrant (Dense +…
How toLLM EngineeringQdrantHybrid SearchRAG

How to Build Hybrid Search in Qdrant (Dense + Sparse)

9 min readBy Miloš Mitrović

Semantic search finally understands what a user means, and then it whiffs on the one thing that should be trivial: a product SKU, an error code, a person's last name. Dense embeddings blur exact tokens into "close enough," so a query for error 429 can rank a friendly essay about rate limits above the line that actually names the code. Hybrid search fixes this by running a dense vector query and a BM25 keyword query at the same time and fusing the two result lists. In Qdrant you do it in a single query_points call: two prefetch branches, one fusion step.

Key Takeaways

  • The short answer: define one dense named vector and one BM25 sparse vector on a Qdrant collection, then send two prefetch branches through query_points and combine them with FusionQuery(fusion=Fusion.RRF).
  • Dense vectors catch meaning and paraphrase; BM25 sparse vectors catch exact keywords, codes, and rare terms. Hybrid keeps both.
  • BM25 in Qdrant needs modifier=models.Modifier.IDF on the sparse vector config, or your scores ignore term rarity and rank poorly.
  • Reciprocal Rank Fusion (RRF) combines the two lists by rank, not raw score, so you never have to normalize embedding distances against keyword scores.
  • With qdrant-client[fastembed], Qdrant embeds text for you through models.Document, so you write no separate encoder step.

What You Need Before You Start

  • Python 3.9 or newer.
  • A running Qdrant instance. Docker is the fastest path: docker run -p 6333:6333 qdrant/qdrant. An in-memory client (QdrantClient(":memory:")) works for a first test.
  • The client with FastEmbed extras installed, which pulls the local embedding models: pip install "qdrant-client[fastembed]".
  • A small set of documents to index. The example below uses three short strings so you can run it end to end in under a minute.

Build a Hybrid Search Query in Qdrant

Run these five steps in order. When you finish step five you have working hybrid retrieval that you can point at real data.

  1. Install the client with FastEmbed. This gives you both the Qdrant client and the local models that turn text into dense and sparse vectors.
    pip install "qdrant-client[fastembed]"
  2. Create a collection with a dense vector and a BM25 sparse vector. The dense side holds semantics; the sparse side holds keywords. The IDF modifier tells Qdrant to weight rare terms higher, which is what makes BM25 behave like BM25.
    from qdrant_client import QdrantClient, models
    
    client = QdrantClient(url="http://localhost:6333")
    
    DENSE_MODEL = "sentence-transformers/all-MiniLM-L6-v2"  # 384 dims
    SPARSE_MODEL = "Qdrant/bm25"
    
    client.create_collection(
        collection_name="docs",
        vectors_config={
            "dense": models.VectorParams(
                size=384,
                distance=models.Distance.COSINE,
            ),
        },
        sparse_vectors_config={
            "bm25": models.SparseVectorParams(
                modifier=models.Modifier.IDF,
            ),
        },
    )
  3. Index your documents. Wrap each text in models.Document and Qdrant runs FastEmbed for you, once for the dense vector and once for the sparse vector. No separate encoding call.
    docs = [
        "Reset your password from the account settings page.",
        "Error 429 means you exceeded the API rate limit.",
        "Refunds are issued within 30 days of purchase.",
    ]
    
    client.upsert(
        collection_name="docs",
        points=[
            models.PointStruct(
                id=i,
                vector={
                    "dense": models.Document(text=text, model=DENSE_MODEL),
                    "bm25": models.Document(text=text, model=SPARSE_MODEL),
                },
                payload={"text": text},
            )
            for i, text in enumerate(docs)
        ],
    )
  4. Run the hybrid query. Each prefetch is one retriever. Fetch a wide candidate pool (20 here) from each, then let RRF decide the final order.
    query = "how do I fix error 429?"
    
    results = client.query_points(
        collection_name="docs",
        prefetch=[
            models.Prefetch(
                query=models.Document(text=query, model=DENSE_MODEL),
                using="dense",
                limit=20,
            ),
            models.Prefetch(
                query=models.Document(text=query, model=SPARSE_MODEL),
                using="bm25",
                limit=20,
            ),
        ],
        query=models.FusionQuery(fusion=models.Fusion.RRF),
        limit=5,
        with_payload=True,
    )
  5. Read the fused results. The top hit should be the exact error 429 line, because BM25 nailed the token and dense retrieval agreed it was on topic.
    for point in results.points:
        print(round(point.score, 4), point.payload["text"])
The whole point of the two-branch pattern: dense retrieval finds documents that share meaning, BM25 finds documents that share words, and fusion promotes the documents both retrievers liked. You get semantic recall without losing the exact match.

Which Retrieval Strategy Fits Your Data?

Hybrid is not automatically the right call for every collection. Use this to decide where to start.

StrategyCatches meaningCatches exact keywordsRelative costBest for
Dense onlyYesWeakLowConversational queries, paraphrase-heavy content, FAQs
BM25 sparse onlyNoYesLowCodes, IDs, legal or medical terms, short exact lookups
Hybrid (dense + BM25, RRF)YesYesMediumMixed real-world queries where users type both prose and identifiers
Hybrid + cross-encoder rerankYesYesHighHigh-stakes RAG where the top 3 results must be exactly right

A safe default: start hybrid, measure, and add a reranker only if the top results still need sharpening. Rerankers add real latency, so earn them with numbers rather than assume them.

How the Fusion Step Actually Works

Reciprocal Rank Fusion scores each document by its position in each result list, not by its raw similarity. A document ranked second by dense search and first by BM25 lands near the top of the fused list, even though a cosine distance and a BM25 score live on completely different scales.

That scale-independence is the reason RRF is the common default. You never have to normalize a 0.83 cosine similarity against a BM25 score of 14.2. Rank is the great equalizer.

RRF or DBSF?

Qdrant also ships Distribution-Based Score Fusion (DBSF), which normalizes each retriever's raw scores using their distribution and then adds them. DBSF can edge out RRF when both retrievers produce well-calibrated, comparable scores, but it is more sensitive to outliers. Swap it in with one line and compare on your own queries:

query=models.FusionQuery(fusion=models.Fusion.DBSF)

If you have no evaluation set yet, stay on RRF. It is the more forgiving of the two.

Weighting the Two Retrievers

When one signal matters more for your domain, weight the branches. A product catalog full of model numbers might lean toward BM25; a support knowledge base might lean dense. You can express that with the explicit RRF form and per-branch weights, keeping the same two-prefetch shape.

Troubleshooting the Common Failures

BM25 Scores Look Random or Rank Badly

You almost certainly skipped modifier=models.Modifier.IDF on the sparse vector config. Without it, Qdrant does not down-weight common words, so "the" counts as much as your rare keyword. Recreate the collection with the modifier set. Inverse document frequency is corpus-wide, so Qdrant computes it at query time, which is why it lives in the config rather than the encoder.

A Vector Dimension Mismatch on Upsert

The size in VectorParams must match your dense model's output width. all-MiniLM-L6-v2 emits 384 dimensions; BAAI/bge-small-en-v1.5 also emits 384; larger models like bge-base emit 768. If you switch models, update size to match or the upsert fails.

The First Run Hangs or Stalls

FastEmbed downloads the model weights on first use and caches them locally. The initial call looks frozen while it fetches a few hundred megabytes. Run one tiny indexing call during startup to warm the cache before you serve traffic.

The Sparse Branch Returns Nothing

BM25 matches tokens, so a query with zero overlapping terms returns an empty sparse list. That is expected, and it is exactly the case where the dense branch carries the query. Confirm your fusion still returns results; if only sparse is empty, the system is working as designed.

Named Vectors Not Found

The string in using= must exactly match the key you defined in vectors_config or sparse_vectors_config. A query against "sparse" when you named the field "bm25" raises a not-found error. Keep the names in one constant and reuse it.

What to Do Next

Sources

M
Miloš Mitrović
Email Marketing for Ecommerce

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.