Qdrant hybrid search with the Query API: dense + sparse, RRF vs DBSF
Qdrant hybrid search runs dense and BM25 prefetches and fuses them with RRF or DBSF. We ran both on Qdrant 1.20.0 and show where their rankings split, why Qdrant's RRF k defaults to 2, and a rescoring trap with quantized prefetches.
Qdrant hybrid search is one Query API request: a prefetch array with a dense vector query and a BM25 query, and a main query that fuses them with {"rrf": {}} or {"fusion": "dbsf"}. RRF scores each point by its rank in each prefetch, and DBSF rescales each prefetch's raw scores by their mean and three standard deviations, then adds them. Start with RRF, set k explicitly because Qdrant's default is 2 while Elasticsearch and OpenSearch default to 60, and switch to DBSF only when labelled queries show it wins.
Everything below ran on qdrant/qdrant:v1.20.0 in Docker with qdrant-client 1.20.0 and fastembed 0.9.1, on 31 October 2026. Dense vectors come from the open-source BAAI/bge-small-en-v1.5 model (384 dimensions). The corpus is 18 short help-centre articles for a fictional cloud product, the same style as our hybrid search scoring post, so read the rankings as a demonstration of how fusion behaves. They are not a benchmark.
Dense and sparse vectors on one point
A Qdrant point can carry several named vectors. For hybrid search you need one dense vector for semantics and one lexical signal for exact terms. Qdrant 1.20 gives you two ways to get the lexical signal.
| Lexical option | Config | Since | Who computes BM25 |
|---|---|---|---|
| Sparse vector with IDF modifier | sparse_vectors: {"bm25": {"modifier": "idf"}} | 1.10.0 | Client (FastEmbed Qdrant/bm25) or server inference; Qdrant applies IDF at query time |
| Full-text index with scoring | payload index {"type": "text", "scoring": true} | 1.20.0 | Qdrant, from the payload text |
| Learned sparse model | sparse vector, no modifier | 1.7.0 | Your model, for example SPLADE++ (prithivida/splade_pp_en_v1) or miniCOIL |
The sparse vector search page now says "We recommend running BM25 on a full-text index rather than on sparse vectors." The reason is statistics: with sparse BM25, term frequency and document length are baked into the vector by the client, using an avg_len you guess (default 256). The full-text index computes average length from your data, and k (default 1.2) and b (default 0.75) are set per query. The IDF modifier on sparse vectors uses ln((N - n + 0.5) / (n + 0.5) + 1) per shard, as the indexing docs give it.
We built both on the same collection so you can compare them:
from qdrant_client import QdrantClient, models
from fastembed import TextEmbedding, SparseTextEmbedding
client = QdrantClient(url="http://localhost:6333")
dense_model = TextEmbedding("BAAI/bge-small-en-v1.5") # 384 dims
bm25_model = SparseTextEmbedding("Qdrant/bm25")
client.create_collection(
"help",
vectors_config={"dense": models.VectorParams(size=384, distance=models.Distance.COSINE)},
sparse_vectors_config={"bm25": models.SparseVectorParams(modifier=models.Modifier.IDF)},
)
texts = [f"{title}. {body}" for title, body in DOCS]
client.upsert("help", points=[
models.PointStruct(
id=i,
vector={"dense": d.tolist(),
"bm25": models.SparseVector(indices=s.indices.tolist(), values=s.values.tolist())},
payload={"title": DOCS[i][0], "text": texts[i]},
)
for i, (d, s) in enumerate(zip(dense_model.embed(texts), bm25_model.embed(texts)))
])
# Qdrant 1.20+: BM25 straight from the payload. Stemming and stopwords are off by
# default; the docs say to add English ones to match the Qdrant/bm25 sparse model.
client.create_payload_index("help", "text", models.TextIndexParams(
type=models.TextIndexType.TEXT,
stemmer=models.SnowballParams(type=models.Snowball.SNOWBALL, language=models.SnowballLanguage.ENGLISH),
stopwords=models.Language.ENGLISH,
scoring=True,
))
Querying the text field before the index exists fails with Bad request: A text query needs a text index on `text`, which has none. A typo in using fails with Wrong input: Not existing vector name error: sparse, and a dense query without using on a collection with only named vectors fails with the same message and an empty name.
The two tokenizers differ, and an identifier query shows it. For ERR_429, the sparse BM25 model produced a single token and matched two articles. The full-text index with the default word tokenizer split it at the _ and also matched ERR_401, ERR_403 and ERR_500 on the shared err term. If your users search for codes like this, check your tokenizer before you blame fusion.
The Qdrant hybrid search query: prefetch, then fuse
When a request has at least one prefetch, Qdrant runs the prefetches first and applies the main query to their results. The hybrid queries docs add one rule worth printing: offset applies only to the main query, so each prefetch limit must be at least limit + offset or a later page can come back empty. When we left limit off a prefetch, it returned 10 points.
def hybrid(q: str, fusion, n: int = 10, limit: int = 5):
s = next(bm25_model.query_embed(q))
return client.query_points(
"help",
prefetch=[
models.Prefetch(query=models.SparseVector(indices=s.indices.tolist(), values=s.values.tolist()),
using="bm25", limit=n),
models.Prefetch(query=next(dense_model.query_embed(q)).tolist(), using="dense", limit=n),
],
query=fusion,
limit=limit,
with_payload=["title"],
).points
rrf = models.RrfQuery(rrf=models.Rrf()) # {"rrf": {}}, k = 2
rrf60 = models.RrfQuery(rrf=models.Rrf(k=60)) # {"rrf": {"k": 60}}, 1.16+
dbsf = models.FusionQuery(fusion=models.Fusion.DBSF) # {"fusion": "dbsf"}, 1.11+
To use the 1.20 full-text index instead, swap the sparse prefetch for models.Prefetch(query=models.TextQuery(text=q), using="text", limit=n). The older {"fusion": "rrf"} form still works in 1.20.0 and behaves as {"rrf": {}} with k = 2.
flowchart LR
Q(["User query"]) --> E["Embed query<br/>dense + BM25 terms"]
E --> P1["Prefetch 1: BM25<br/>sparse or text index, limit n"]
E --> P2["Prefetch 2: dense HNSW<br/>quantized codes, limit n"]
P2 --> R2["Nested stage: rescore<br/>full-precision vectors"]
P1 --> F{"Fuse: rrf or dbsf"}
R2 --> F
F --> X["Optional final rescore<br/>multi-vector or formula"]
X --> O(["Top limit"])How RRF and DBSF treat scores
Qdrant's RRF uses zero-based ranks and a default k of 2, so the top hit in one prefetch contributes 1 / (2 + 0) = 0.5. With two prefetches, a point ranked first in both scores 1.0. The formula with weights (1.17+) is 1 / (k + (r + 1) / w - 1), which reduces to 1 / (k + r) when w is 1. Elasticsearch and OpenSearch use one-based ranks with a default of 60, so k: 60 in Qdrant gives 1 / (60 + r) with zero-based r, which equals their 1 / (60 + rank). A small k makes the curve steep: first place is worth 1.5 times second place at k = 2, and 1.017 times at k = 60. The scoring post walks through the general RRF and normalization math, so this section sticks to what is specific to Qdrant.
DBSF computes the mean μ and sample standard deviation σ of each prefetch's returned scores, maps each score with (s - (μ - 3σ)) / (6σ), and sums across prefetches. Scores are not clipped. If a prefetch returns one point, or all scores are equal, DBSF gives each of them 0.5. A point missing from a prefetch gets nothing from it.
RRF vs DBSF on the same query
The query why is the API throttling my requests is a paraphrase. The two prefetches, limit: 10 each, returned:
| Article | BM25 score | BM25 rank | Dense score | Dense rank |
|---|---|---|---|---|
| Use the REST API | 3.8560 | 1 | 0.5927 | 8 |
| API rate limits by plan | 3.8094 | 2 | 0.6683 | 3 |
| ERR_429: Too many requests | 3.7280 | 3 | 0.7342 | 1 |
| ERR_401: Invalid API key | 2.0000 | 5 | 0.6241 | 5 |
| Best practices for retries and backoff | 1.7399 | 7 | 0.6490 | 4 |
| Why requests slow down during traffic spikes | 1.7310 | 9 | 0.7090 | 2 |
BM25 returned nine matches ("Request a refund" was fourth, on the term request). The fused top 5 from our run:
| Rank | RRF, default k = 2 | RRF, k = 60 | DBSF |
|---|---|---|---|
| 1 | ERR_429 (0.7500) | ERR_429 (0.0328) | ERR_429 (1.5112) |
| 2 | Use the REST API (0.6111) | API rate limits (0.0325) | API rate limits (1.3265) |
| 3 | API rate limits (0.5833) | Use the REST API (0.0316) | Use the REST API (1.1064) |
| 4 | Traffic spikes (0.4333) | ERR_401 (0.0312) | Traffic spikes (1.1001) |
| 5 | ERR_401 (0.3333) | Traffic spikes (0.0311) | Best practices for retries (0.9206) |
All three agree on first place. Below that they split, and each split has a cause you can compute.
Default RRF puts "Use the REST API" second. It was first for BM25, and at k = 2 first place earns 1/2, so 1/2 + 1/(2+7) = 0.6111 beats the rate-limits article's 1/3 + 1/4 = 0.5833. The BM25 scores behind those ranks are 3.8560 and 3.8094, a gap of 1.2%. RRF turned a near tie into a large lead because it only sees ranks.
DBSF sees the tie. The BM25 prefetch has μ = 2.4834 and σ = 0.9923, so the top three map to 0.7305, 0.7227 and 0.7091, almost equal. The dense prefetch is tighter (μ = 0.6339, σ = 0.0553), so its scores spread from 0.8022 down to 0.2960 and decide the order:
| Article | BM25 normalized | Dense normalized | DBSF sum |
|---|---|---|---|
| API rate limits by plan | 0.7227 | 0.6037 | 1.3264 |
| Use the REST API | 0.7305 | 0.3759 | 1.1064 |
| Best practices for retries | 0.3751 | 0.5455 | 0.9206 |
| ERR_401: Invalid API key | 0.4188 | 0.4705 | 0.8893 |
We computed those by hand from the prefetch scores and the documented formula. They match the server's scores to within 0.0002 of float rounding. Fifth place differs too: DBSF prefers "Best practices for retries" because its dense score sits well above the dense mean, while RRF ranks ERR_401 above it on rank arithmetic (1/6 + 1/6 against 1/8 + 1/5).
The edge case shows up on how do I get my money back. BM25 matched one article, so DBSF gave it exactly 0.5 from that prefetch, and its final score was 0.5 + 0.7986 = 1.2986. "Request a refund", which never uses the word "money", got only its dense share (0.7835). Both methods kept the same top 5 on this query and on ERR_429.
Neither ordering of the throttling query is wrong. A user asking about throttling wants the rate-limits article above the generic REST API page, which DBSF and RRF at k = 60 deliver here. On another query, the steep k = 2 curve will be the one that rescues a strong exact match. That is why the decision belongs to an evaluation set, and the hybrid search playground is a quick way to build intuition for how window size and fusion method move a ranking before you write one.
Weights
Weighted RRF (1.17+) takes one weight per prefetch, in prefetch order. With weights: [1.0, 3.0] and the full-text prefetch, ERR_429 scored 1/(2+3-1) + 1/(2+1/3-1) = 0.25 + 0.75 = 1.0, as the formula predicts. A length mismatch fails with Wrong input: Validation failed: RRF weights length (1) does not match number of prefetches (2), and k: 0 fails with must be 1 or larger. The docs say not to hand-tune weights without an eval set, and we agree.
Filters inside prefetch
A top-level filter (query_filter in Python) is applied to every prefetch. Qdrant's Query API course states this, and we confirmed it: with prefetch limit: 3 and a filter on area: account, none of the unfiltered top 3 were account articles, yet the filtered request returned three account articles. If the filter ran only after fusion, the result would have been empty. Put a filter on a single prefetch when only that retriever needs it, for example a stricter recency rule on the BM25 side.
Multi-stage: quantized prefetch, then full-precision rescore
The standard two-stage pattern fetches candidates from cheap vectors and rescores them with accurate ones. We built a second collection with 1-bit TurboQuant on the dense vector (indexing_threshold: 1, so the small corpus is actually quantized), prefetched 10 candidates with rescore: false, and rescored with the same vector in the main query.
POST /collections/help_q/points/query
{
"prefetch": {
"query": [0.01, 0.45, 0.67, ...],
"using": "dense",
"limit": 10,
"params": { "quantization": { "rescore": false } }
},
"query": [0.01, 0.45, 0.67, ...],
"using": "dense",
"limit": 5,
"params": { "quantization": { "ignore": true } }
}
The params on the main query matter. Without them, the main stage scored the 10 candidates with the quantized codes too, rescored only the top limit × oversampling of those, and returned [0, 3, 1, 4, 5]. The exact top 5 by cosine is [0, 3, 1, 2, 4]: "Best practices for retries" (id 2, cosine 0.6490) was sixth in the prefetch, inside the candidate set, and still dropped. With "ignore": true, or "rescore": true, "oversampling": 2.0, the main stage returned the exact top 5. We observed this on 1.20.0. The hybrid queries page does not describe it, so test it on your own version.
sequenceDiagram
participant C as Client
participant Q as Qdrant
C->>Q: prefetch limit 10, quantized, rescore false
Q->>Q: HNSW over 1-bit codes, 10 candidates
C->>Q: main query limit 5, no params
Q->>Q: rank the 10 by 1-bit score, rescore top 5
Q-->>C: ids 0 3 1 4 5, one true hit missed
C->>Q: main query limit 5, ignore true
Q->>Q: rank all 10 by original vectors
Q-->>C: ids 0 3 1 2 4, the exact top 5The docs also suggest m: 0 in the HNSW config for vectors used only for rescoring, because rescoring never touches the graph. Our TurboQuant guide covers which encodings rescore by default and why 1-bit codes lose neighbours on small models.
Evaluating the fusion choice
Run each candidate fusion over the same prefetches and score them against labelled queries:
- Collect 50 or more real queries with known relevant articles, mixing identifier lookups and paraphrases. Split them in half.
- On the first half, compare
{"rrf": {}},{"rrf": {"k": 60}}and{"fusion": "dbsf"}on recall@10 and nDCG@10. If you try weights, tune them here. - Confirm the winner on the second half. Qdrant's docs warn that measuring on the queries you tuned on inflates the result, and link a notebook with a
tune_rrf_weightshelper. - Check each prefetch alone as well. If dense alone beats every fusion, fix the lexical side before you tune fusion.
- For DBSF, raise prefetch
limitand rerun. Its statistics come from the prefetch top-k, so a smalllimitmakes them noisy.
For RAG, retrieval recall caps answer quality, which is the argument in an agent is only as good as its retriever. Vespa users hit a similar window effect in global-phase, covered in our Vespa reciprocal rank fusion post, and if you are choosing an engine for hybrid workloads, the Elasticsearch vs Qdrant comparison sets the two side by side.
Where we can help
We design Qdrant collections for hybrid retrieval, pick the lexical path and fusion settings, and build the labelled-query evaluation that shows which choice wins on your data. See our Qdrant consulting service.
Abhilash B is an advisor at Query Quotient and an information retrieval and AI expert who holds patents in the field. Abhilash brings that retrieval depth to the search, RAG and agent systems Query Quotient builds for clients.
