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
prefetchbranches throughquery_pointsand combine them withFusionQuery(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.IDFon 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 throughmodels.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.
- 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]" - Create a collection with a dense vector and a BM25 sparse vector. The dense side holds semantics; the sparse side holds keywords. The
IDFmodifier 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, ), }, ) - Index your documents. Wrap each text in
models.Documentand 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) ], ) - Run the hybrid query. Each
prefetchis 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, ) - Read the fused results. The top hit should be the exact
error 429line, 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.
| Strategy | Catches meaning | Catches exact keywords | Relative cost | Best for |
|---|---|---|---|---|
| Dense only | Yes | Weak | Low | Conversational queries, paraphrase-heavy content, FAQs |
| BM25 sparse only | No | Yes | Low | Codes, IDs, legal or medical terms, short exact lookups |
| Hybrid (dense + BM25, RRF) | Yes | Yes | Medium | Mixed real-world queries where users type both prose and identifiers |
| Hybrid + cross-encoder rerank | Yes | Yes | High | High-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
- Add a cross-encoder reranker on top of the fused candidates when the top results need to be exactly right. See how to add a reranker to your RAG pipeline.
- Fix your chunking before you tune retrieval. Bad chunks cap the ceiling of any search method. Start with chunking documents for RAG.
- Measure the change instead of trusting a demo query. Evaluating a RAG pipeline with Ragas gives you context precision and recall numbers to compare dense-only against hybrid.
- Compare this against a relational option if you already run Postgres, using pgvector for semantic search, and decide whether a dedicated vector database earns its place.
- Read the primary reference for advanced patterns: multi-stage prefetch, matryoshka rescoring, and per-branch weights in the Qdrant Hybrid Queries documentation.