fix(local): apply score_threshold strictly to match server semantics (#1387)

* fix(local): apply score_threshold strictly to match server semantics

The Qdrant server keeps only points whose score is *better* than
score_threshold (strict inequality): a point whose score equals the
threshold is excluded. Local mode used non-strict comparisons, so such
boundary points were incorrectly kept for Cosine/Dot/Euclid/Manhattan.

Fusion and formula post-filters remain inclusive, matching observed
server behavior for those paths.

Includes parametrized regression tests covering all four distance metrics.

* tests: update tests

* tests: update tests to include other query points ways

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
This commit is contained in:
hylin
2026-09-16 00:26:23 +07:00
committed by George Panchuk
co-authored by George Panchuk
parent 3ea381504d
commit e37b9ea25e
2 changed files with 103 additions and 2 deletions
+2 -2
View File
@@ -734,10 +734,10 @@ class LocalCollection:
if score_threshold is not None:
if required_order == DistanceOrder.BIGGER_IS_BETTER:
if score < score_threshold:
if score <= score_threshold:
break
else:
if score > score_threshold:
if score >= score_threshold:
break
scored_point = construct(
+101
View File
@@ -1471,6 +1471,107 @@ def test_dense_query():
raise e
def test_dense_query_score_threshold_boundary():
def query_score_threshold(
client: QdrantBase, query: models.Query, thresholds: list[float]
) -> list[list[models.ScoredPoint]]:
return [
client.query_points(
collection_name=COLLECTION_NAME,
query=query,
limit=10,
score_threshold=threshold,
with_payload=False,
).points
for threshold in thresholds
]
recommend = models.RecommendQuery(
recommend=models.RecommendInput(
positive=[[0.5, 0.0]], strategy=models.RecommendStrategy.SUM_SCORES
)
)
discover = models.DiscoverQuery(
discover=models.DiscoverInput(
target=[1.0, 0.0],
context=[models.ContextPair(positive=[1.0, 0.0], negative=[-1.0, 0.0])],
)
)
# Vectors are picked so that every score is exactly representable in float32,
# which makes the equality boundary reproducible on both sides. Each threshold is
# equal to the score of one of the points: that point must be dropped, since a
# threshold keeps only strictly better scores.
cases = [
# distance, vectors, query, thresholds
# nearest queries are scored by the metric itself
(
models.Distance.COSINE,
[[1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [-1.0, 0.0]],
[1.0, 0.0],
[1.0, 0.0, -1.0],
),
(models.Distance.DOT, [[2.0, 0.0], [1.0, 0.0], [0.5, 0.0]], [1.0, 0.0], [2.0, 1.0, 0.5]),
(
models.Distance.EUCLID,
[[0.5, 0.0], [1.0, 0.0], [2.0, 0.0]],
[0.0, 0.0],
[0.5, 1.0, 2.0],
),
(
models.Distance.MANHATTAN,
[[0.5, 0.0], [1.0, 0.0], [2.0, 0.0]],
[0.0, 0.0],
[0.5, 1.0, 2.0],
),
# recommend and discovery score higher-is-better whatever the metric is, but
# the threshold keeps following the metric's own direction, so on euclid the
# best-scoring point already stops the scan
(
models.Distance.DOT,
[[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]],
recommend,
[1.5, 1.0, 0.5, 0.0],
),
(
models.Distance.EUCLID,
[[1.0, 0.0], [2.0, 0.0], [3.0, 0.0]],
recommend,
[-0.25, -2.25],
),
(
models.Distance.COSINE,
[[1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [-1.0, 0.0]],
discover,
[1.75, 0.5, -0.75],
),
]
for distance, vectors, query, thresholds in cases:
fixture_points = [
models.PointStruct(id=idx, vector=vector)
for idx, vector in enumerate(vectors, start=1)
]
local_client, http_client, grpc_client = init_clients(
fixture_points,
vectors_config=models.VectorParams(size=len(vectors[0]), distance=distance),
)
try:
compare_clients_results(
local_client,
http_client,
grpc_client,
query_score_threshold,
query=query,
thresholds=thresholds,
)
except AssertionError as e:
print(f"\nFailed with distance {distance} and query {type(query).__name__}")
raise e
def test_dense_query_orderby():
fixture_points = generate_fixtures(200)