From 2135f4331bb35b00353576752f3c2533ad920cd6 Mon Sep 17 00:00:00 2001 From: linhongyu510 Date: Sun, 30 Aug 2026 03:09:51 +0800 Subject: [PATCH 1/3] 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. --- qdrant_client/local/local_collection.py | 4 +- tests/test_in_memory.py | 54 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/qdrant_client/local/local_collection.py b/qdrant_client/local/local_collection.py index 520e8a8e0..586556883 100644 --- a/qdrant_client/local/local_collection.py +++ b/qdrant_client/local/local_collection.py @@ -734,10 +734,10 @@ def search( 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( diff --git a/tests/test_in_memory.py b/tests/test_in_memory.py index 1018f85b5..d43b85233 100644 --- a/tests/test_in_memory.py +++ b/tests/test_in_memory.py @@ -294,3 +294,57 @@ def test_fusion_dbsf_score_threshold(qdrant: QdrantClient): f"Expected 3 points after filtering (threshold 1.0), got {len(result_with_threshold.points)}. " f"Scores: {[p.score for p in result_no_threshold.points]}" ) + + +@pytest.mark.parametrize( + "distance,boundary_vector,query_vector,boundary_threshold,looser_threshold", + [ + # Cosine: identical vectors produce a score of exactly 1.0 + (models.Distance.COSINE, [1.0, 0.0], [1.0, 0.0], 1.0, 0.999), + # Dot: dot product of exactly 5.0 + (models.Distance.DOT, [5.0, 0.0], [1.0, 0.0], 5.0, 4.99), + # Euclid: distance of exactly 1.0 (local mode reports the raw distance) + (models.Distance.EUCLID, [1.0, 0.0], [0.0, 0.0], 1.0, 1.001), + # Manhattan: distance of exactly 1.0 + (models.Distance.MANHATTAN, [1.0, 0.0], [0.0, 0.0], 1.0, 1.001), + ], +) +def test_score_threshold_is_strict( + qdrant: QdrantClient, + distance: models.Distance, + boundary_vector: list[float], + query_vector: list[float], + boundary_threshold: float, + looser_threshold: float, +): + """score_threshold must keep only points with a score *better* than the + threshold, matching the Qdrant server contract ("Return points with scores + better than this threshold"). A point whose score equals the threshold is + excluded. Local mode used to include it (non-strict comparison).""" + qdrant.create_collection( + collection_name="test_strict_threshold", + vectors_config=models.VectorParams(size=2, distance=distance), + ) + qdrant.upsert( + collection_name="test_strict_threshold", + wait=True, + points=[models.PointStruct(id=1, vector=boundary_vector)], + ) + + # The only point has a score equal to the threshold: it must be excluded. + at_boundary = qdrant.query_points( + collection_name="test_strict_threshold", + query=query_vector, + score_threshold=boundary_threshold, + limit=10, + ).points + assert at_boundary == [] + + # A threshold just on the "better" side must keep the point. + inside = qdrant.query_points( + collection_name="test_strict_threshold", + query=query_vector, + score_threshold=looser_threshold, + limit=10, + ).points + assert [p.id for p in inside] == [1] From e4efbab262cd89c2eb35ba8e3cfb86a13192d26e Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 3 Sep 2026 00:05:04 +0700 Subject: [PATCH 2/3] tests: update tests --- tests/congruence_tests/test_query.py | 67 ++++++++++++++++++++++++++++ tests/test_in_memory.py | 54 ---------------------- 2 files changed, 67 insertions(+), 54 deletions(-) diff --git a/tests/congruence_tests/test_query.py b/tests/congruence_tests/test_query.py index 09a8e7b43..ee8da89cd 100644 --- a/tests/congruence_tests/test_query.py +++ b/tests/congruence_tests/test_query.py @@ -1471,6 +1471,73 @@ def test_dense_query(): raise e +def test_dense_query_score_threshold_boundary(): + def query_score_threshold( + client: QdrantBase, query_vector: list[float], thresholds: list[float] + ) -> list[list[models.ScoredPoint]]: + return [ + client.query_points( + collection_name=COLLECTION_NAME, + query=query_vector, + limit=10, + score_threshold=threshold, + with_payload=False, + ).points + for threshold in thresholds + ] + + # 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 vector, thresholds + ( + 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], + ), + ] + + for distance, vectors, query_vector, 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(query_vector), distance=distance), + ) + + try: + compare_clients_results( + local_client, + http_client, + grpc_client, + query_score_threshold, + query_vector=query_vector, + thresholds=thresholds, + ) + except AssertionError as e: + print(f"\nFailed with distance {distance}") + raise e + + def test_dense_query_orderby(): fixture_points = generate_fixtures(200) diff --git a/tests/test_in_memory.py b/tests/test_in_memory.py index d43b85233..1018f85b5 100644 --- a/tests/test_in_memory.py +++ b/tests/test_in_memory.py @@ -294,57 +294,3 @@ def test_fusion_dbsf_score_threshold(qdrant: QdrantClient): f"Expected 3 points after filtering (threshold 1.0), got {len(result_with_threshold.points)}. " f"Scores: {[p.score for p in result_no_threshold.points]}" ) - - -@pytest.mark.parametrize( - "distance,boundary_vector,query_vector,boundary_threshold,looser_threshold", - [ - # Cosine: identical vectors produce a score of exactly 1.0 - (models.Distance.COSINE, [1.0, 0.0], [1.0, 0.0], 1.0, 0.999), - # Dot: dot product of exactly 5.0 - (models.Distance.DOT, [5.0, 0.0], [1.0, 0.0], 5.0, 4.99), - # Euclid: distance of exactly 1.0 (local mode reports the raw distance) - (models.Distance.EUCLID, [1.0, 0.0], [0.0, 0.0], 1.0, 1.001), - # Manhattan: distance of exactly 1.0 - (models.Distance.MANHATTAN, [1.0, 0.0], [0.0, 0.0], 1.0, 1.001), - ], -) -def test_score_threshold_is_strict( - qdrant: QdrantClient, - distance: models.Distance, - boundary_vector: list[float], - query_vector: list[float], - boundary_threshold: float, - looser_threshold: float, -): - """score_threshold must keep only points with a score *better* than the - threshold, matching the Qdrant server contract ("Return points with scores - better than this threshold"). A point whose score equals the threshold is - excluded. Local mode used to include it (non-strict comparison).""" - qdrant.create_collection( - collection_name="test_strict_threshold", - vectors_config=models.VectorParams(size=2, distance=distance), - ) - qdrant.upsert( - collection_name="test_strict_threshold", - wait=True, - points=[models.PointStruct(id=1, vector=boundary_vector)], - ) - - # The only point has a score equal to the threshold: it must be excluded. - at_boundary = qdrant.query_points( - collection_name="test_strict_threshold", - query=query_vector, - score_threshold=boundary_threshold, - limit=10, - ).points - assert at_boundary == [] - - # A threshold just on the "better" side must keep the point. - inside = qdrant.query_points( - collection_name="test_strict_threshold", - query=query_vector, - score_threshold=looser_threshold, - limit=10, - ).points - assert [p.id for p in inside] == [1] From ba03899d9493a38ec01e9ba7c23093b748db0098 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 3 Sep 2026 00:30:57 +0700 Subject: [PATCH 3/3] tests: update tests to include other query points ways --- tests/congruence_tests/test_query.py | 48 ++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/tests/congruence_tests/test_query.py b/tests/congruence_tests/test_query.py index ee8da89cd..75b055b8e 100644 --- a/tests/congruence_tests/test_query.py +++ b/tests/congruence_tests/test_query.py @@ -1473,12 +1473,12 @@ def test_dense_query(): def test_dense_query_score_threshold_boundary(): def query_score_threshold( - client: QdrantBase, query_vector: list[float], thresholds: list[float] + client: QdrantBase, query: models.Query, thresholds: list[float] ) -> list[list[models.ScoredPoint]]: return [ client.query_points( collection_name=COLLECTION_NAME, - query=query_vector, + query=query, limit=10, score_threshold=threshold, with_payload=False, @@ -1486,12 +1486,25 @@ def query_score_threshold( 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 vector, thresholds + # 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]], @@ -1511,9 +1524,30 @@ def query_score_threshold( [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_vector, thresholds in cases: + for distance, vectors, query, thresholds in cases: fixture_points = [ models.PointStruct(id=idx, vector=vector) for idx, vector in enumerate(vectors, start=1) @@ -1521,7 +1555,7 @@ def query_score_threshold( local_client, http_client, grpc_client = init_clients( fixture_points, - vectors_config=models.VectorParams(size=len(query_vector), distance=distance), + vectors_config=models.VectorParams(size=len(vectors[0]), distance=distance), ) try: @@ -1530,11 +1564,11 @@ def query_score_threshold( http_client, grpc_client, query_score_threshold, - query_vector=query_vector, + query=query, thresholds=thresholds, ) except AssertionError as e: - print(f"\nFailed with distance {distance}") + print(f"\nFailed with distance {distance} and query {type(query).__name__}") raise e