Skip to content

Local mode keeps points whose score equals score_threshold, but the server excludes them (strict threshold) #1386

Description

@linhongyu510

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions