Fusion inside a prefetch is computed per shard, so its scores and score_threshold depend on shard_number
Current Behavior
When fusion (RRF or DBSF) is used inside a prefetch instead of as the root query, the result depends on the collection's shard_number. Same points, same query, exact search — different scores, different points, and score_threshold lets through a different number of points.
It looks like each shard fuses its own local prefetch results and the collection then merges those per-shard fused scores. DBSF normalizes each list against that shard's own mean ± 3σ, and RRF scores by that shard's local positions, so a point at the top of one shard's local list gets a near-maximal score no matter how it compares globally. The merged scores aren't on a common scale.
Root-level fusion is not affected: intermediates_to_final_list fuses the merged per-prefetch lists once at the collection level (lib/collection/src/collection/query.rs:381). Nested fusion goes through fusion_rescore on every shard (lib/collection/src/shards/local_shard/query.rs:424-437).
Measured on 1.19.1 (6ab21cac), 400 points, two named Cosine vectors, all leaf prefetches exact: true:
| 1 shard | 2 shards | 4 shards | |
|---|---|---|---|
nested DBSF, score_threshold: 0.9 |
2 points pass | 7 | 44 |
nested RRF, score_threshold: 0.4 |
2 points pass | 4 | 9 |
| nested DBSF top 10 matching the same fusion computed client-side | 10/10 | 4/10 |
The single-shard result is the one that matches the fusion computed by hand from the exact leaf results.
Steps to Reproduce
- Run a Qdrant 1.19.1 node on
localhost:6333. - Run the script below with plain
python3(standard library only). It createsfusion_1_shards,fusion_2_shardsandfusion_4_shardswith identical points. - Compare the output across shard counts.
import json
import math
import random
import sys
import urllib.request
BASE = f"http://localhost:{sys.argv[1] if len(sys.argv) > 1 else 6333}"
DIM, N = 8, 400
def call(method, path, body=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"content-type": "application/json"})
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def query(coll, body):
return call("POST", f"/collections/{coll}/points/query", body)["result"]["points"]
rng = random.Random(7)
points = [{"id": i, "vector": {"a": [rng.uniform(-1, 1) for _ in range(DIM)],
"b": [rng.uniform(-1, 1) for _ in range(DIM)]}} for i in range(N)]
qa = [rng.uniform(-1, 1) for _ in range(DIM)]
qb = [rng.uniform(-1, 1) for _ in range(DIM)]
for shards in (1, 2, 4):
name = f"fusion_{shards}_shards"
call("DELETE", f"/collections/{name}")
call("PUT", f"/collections/{name}", {"shard_number": shards, "vectors": {
"a": {"size": DIM, "distance": "Cosine"}, "b": {"size": DIM, "distance": "Cosine"}}})
call("PUT", f"/collections/{name}/points?wait=true", {"points": points})
exact = {"exact": True}
leaf_a = {"query": qa, "using": "a", "limit": 40, "params": exact}
leaf_b = {"query": qb, "using": "b", "limit": 40, "params": exact}
print("1) score_threshold on a nested fusion (limit 80, so nothing is truncated)")
for method, threshold in (("dbsf", 0.9), ("rrf", 0.4)):
thr = {"prefetch": [{"prefetch": [leaf_a, leaf_b], "query": {"fusion": method},
"limit": 80, "score_threshold": threshold}],
"query": qa, "using": "a", "limit": 80, "params": exact}
counts = [len(query(f"fusion_{shards}_shards", thr)) for shards in (1, 2, 4)]
print(f" {method} >= {threshold}: 1 shard {counts[0]}, 2 shards {counts[1]}, 4 shards {counts[2]} points pass")
print("\n2) nested DBSF ranking vs the same fusion computed client-side")
la, lb = query("fusion_1_shards", leaf_a), query("fusion_1_shards", leaf_b)
fused = {}
for pts in (la, lb):
s = [p["score"] for p in pts]
mean = sum(s) / len(s)
std = math.sqrt(sum((x - mean) ** 2 for x in s) / (len(s) - 1))
for p in pts:
fused[p["id"]] = fused.get(p["id"], 0.0) + (p["score"] - (mean - 3 * std)) / (6 * std)
expected = [i for i, _ in sorted(fused.items(), key=lambda kv: -kv[1])][:10]
nested = {"prefetch": [{"prefetch": [leaf_a, leaf_b], "query": {"fusion": "dbsf"}, "limit": 80}],
"query": {"fusion": "rrf"}, "limit": 10}
print(f" expected : {expected}")
for shards in (1, 4):
got = [p["id"] for p in query(f"fusion_{shards}_shards", nested)]
print(f" {shards} shard(s): {got} ({len(set(got) & set(expected))}/10 expected points)")
print("\n3) control: the same DBSF at the root is identical across shard counts")
root = {"prefetch": [leaf_a, leaf_b], "query": {"fusion": "dbsf"}, "limit": 10}
print(" identical:", [p["id"] for p in query("fusion_1_shards", root)]
== [p["id"] for p in query("fusion_4_shards", root)])Output:
1) score_threshold on a nested fusion (limit 80, so nothing is truncated)
dbsf >= 0.9: 1 shard 2, 2 shards 7, 4 shards 44 points pass
rrf >= 0.4: 1 shard 2, 2 shards 4, 4 shards 9 points pass
2) nested DBSF ranking vs the same fusion computed client-side
expected : [76, 178, 159, 396, 251, 336, 331, 9, 255, 306]
1 shard(s): [76, 178, 159, 396, 251, 336, 331, 9, 255, 306] (10/10 expected points)
4 shard(s): [396, 178, 76, 255, 175, 23, 332, 302, 314, 316] (4/10 expected points)
3) control: the same DBSF at the root is identical across shard counts
identical: TrueExpected Behavior
A fusion inside a prefetch should give the same scores, the same points, and the same score_threshold cut regardless of shard_number, the way root-level fusion already does.
Possible Solution
Nested fusion needs its normalization statistics (DBSF) and positions (RRF) taken over the merged candidates from all shards rather than per shard — the same thing root-level fusion already gets from merging the per-prefetch lists before fusing.
Context (Environment)
Official qdrant-aarch64-apple-darwin 1.19.1 binary, single node, macOS. The shards are local, so this doesn't need a cluster — shard_number > 1 on one node is enough.
Where it bites: a threshold on fused hybrid scores is a common relevance cutoff when retrieving chunks for RAG. Here the same score_threshold: 0.9 passes 2 points on one shard and 44 on four, with no change to the query, so moving a collection to more shards silently changes what gets retrieved.
Two things I checked are not involved, to save review time:
- Not the per-shard prefetch
limitfrom #7159. Every nested prefetch here haslimit: 80, and the two leaves return at most 80 candidates, so nothing is truncated on any shard. With truncation ruled out, a nested fusion followed by a plain vector rerank at the root returns identical results on 1, 2 and 4 shards — only the things that consume the nested fused scores (a nestedscore_threshold, or a fusion at the root) diverge. - Not shard-level undersampling. Every
limit + offsetis belowSHARD_QUERY_SUBSAMPLING_LIMIT(128) and every leaf isexact, somodify_shard_query_for_undersampling_limitsreturns the requests unchanged.
The ordering of points that tie on an equal RRF score also varies between shard counts, but that looks like #6814, not this.
Source: qdrant/qdrant