Summary
Local mode uses non-strict comparisons when applying score_threshold, so it keeps points whose score equals the threshold. The Qdrant server keeps only points whose score is strictly better than the threshold ("Return points with scores better than this threshold", per the Query points API docs), i.e. points at the exact threshold boundary are excluded.
This makes code tested against local mode diverge from server behavior for every distance metric in direct vector search.
Reproduction
qdrant-client master, Qdrant server v1.19.0 (verified via both the Python client and raw REST calls).
from qdrant_client import QdrantClient, models
# Cosine: identical vectors produce a score of exactly 1.0
for client, label in [(QdrantClient(":memory:"), "local"),
(QdrantClient(url="http://localhost:6333"), "server")]:
client.create_collection("c", vectors_config=models.VectorParams(size=2, distance=models.Distance.COSINE))
client.upsert("c", points=[models.PointStruct(id=1, vector=[1.0, 0.0])])
hits = client.query_points("c", query=[1.0, 0.0], score_threshold=1.0).points
print(label, [p.id for p in hits])
local [1] # point with score == threshold is kept
server [] # point with score == threshold is excluded
Same divergence for the other metrics (a point exactly at the boundary):
| Metric |
Point / query |
Threshold |
Local |
Server |
| Cosine |
sim = 1.0 |
1.0 |
keeps |
excludes |
| Dot |
dot = 5.0 |
5.0 |
keeps |
excludes |
| Euclid |
distance = 1.0 |
1.0 |
keeps |
excludes |
| Manhattan |
distance = 1.0 |
1.0 |
keeps |
excludes |
Raw REST confirmation (server side, no client filtering):
POST /collections/b_cos/points/query {"query":[1,0],"score_threshold":1.0}
-> {"points": []}
POST /collections/b_cos/points/query {"query":[1,0],"score_threshold":0.9999}
-> {"points": [{"id":1,"score":1.0}]}
POST /collections/b_euclid/points/query {"query":[0,0],"score_threshold":1.0}
-> points: [(2, 0.999)] # point at exactly 1.0 excluded
POST /collections/b_euclid/points/query {"query":[0,0],"score_threshold":1.0001}
-> points: [(2, 0.999), (1, 1.0)]
Root cause
In qdrant_client/local/local_collection.py, LocalCollection.search() filters with non-strict comparisons:
if score_threshold is not None:
if required_order == DistanceOrder.BIGGER_IS_BETTER:
if score < score_threshold: # keeps score == threshold
break
else:
if score > score_threshold: # keeps distance == threshold
break
Note the same boundary on the fusion / formula post-filters behaves differently: I verified against the server that RRF fusion and formula rescoring apply the threshold inclusively (e.g. an RRF score of exactly 0.25 with score_threshold=0.25 is kept), and local mode's existing >= there already matches — so only the direct vector-search comparison needs the strict inequality.
Suggested fix
Use strict inequalities in search() only:
if required_order == DistanceOrder.BIGGER_IS_BETTER:
if score <= score_threshold:
break
else:
if score >= score_threshold:
break
I have a branch ready with this change plus a parametrized regression test over all four distance metrics. It passes tests/test_in_memory.py, qdrant_client/local/tests/, and the tests/congruence_tests/ suites (search, distance matrix, query, recommendation, discovery, group search/recommend, sparse search, query batch) against a real v1.19.0 server — happy to open the PR if this direction looks right.
Interaction with score-direction fixes
Open PRs #1371, #1374, and #1379 fix a separate bug in the same branch: transformed Recommend/Discover/Context/Feedback scores can be higher-is-better even on Euclid/Manhattan collections, so sort and threshold direction must share one flag. They intentionally keep equality inclusive and therefore do not resolve this issue.
The strict-boundary patch should remain separate. If one of those direction PRs merges first, this two-operator change must be rebased onto its unified higher_score_is_better flag rather than reintroducing required_order as the threshold owner. Fusion and formula post-filters remain inclusive.
Summary
Local mode uses non-strict comparisons when applying
score_threshold, so it keeps points whose score equals the threshold. The Qdrant server keeps only points whose score is strictly better than the threshold ("Return points with scores better than this threshold", per the Query points API docs), i.e. points at the exact threshold boundary are excluded.This makes code tested against local mode diverge from server behavior for every distance metric in direct vector search.
Reproduction
qdrant-client master, Qdrant server v1.19.0 (verified via both the Python client and raw REST calls).
Same divergence for the other metrics (a point exactly at the boundary):
Raw REST confirmation (server side, no client filtering):
Root cause
In
qdrant_client/local/local_collection.py,LocalCollection.search()filters with non-strict comparisons:Note the same boundary on the fusion / formula post-filters behaves differently: I verified against the server that RRF fusion and formula rescoring apply the threshold inclusively (e.g. an RRF score of exactly 0.25 with
score_threshold=0.25is kept), and local mode's existing>=there already matches — so only the direct vector-search comparison needs the strict inequality.Suggested fix
Use strict inequalities in
search()only:I have a branch ready with this change plus a parametrized regression test over all four distance metrics. It passes
tests/test_in_memory.py,qdrant_client/local/tests/, and thetests/congruence_tests/suites (search, distance matrix, query, recommendation, discovery, group search/recommend, sparse search, query batch) against a real v1.19.0 server — happy to open the PR if this direction looks right.Interaction with score-direction fixes
Open PRs #1371, #1374, and #1379 fix a separate bug in the same branch: transformed Recommend/Discover/Context/Feedback scores can be higher-is-better even on Euclid/Manhattan collections, so sort and threshold direction must share one flag. They intentionally keep equality inclusive and therefore do not resolve this issue.
The strict-boundary patch should remain separate. If one of those direction PRs merges first, this two-operator change must be rebased onto its unified
higher_score_is_betterflag rather than reintroducingrequired_orderas the threshold owner. Fusion and formula post-filters remain inclusive.