Skip to content

fix(local): compare score_threshold in the query's own score direction (#1370) - #1371

Open
Anai-Guo wants to merge 1 commit into
qdrant:devfrom
Anai-Guo:fix-local-score-threshold-reco
Open

fix(local): compare score_threshold in the query's own score direction (#1370)#1371
Anai-Guo wants to merge 1 commit into
qdrant:devfrom
Anai-Guo:fix-local-score-threshold-reco

Conversation

@Anai-Guo

Copy link
Copy Markdown

Fixes #1370.

Problem

In local/in-memory mode, score_threshold is compared in the wrong direction for Recommend (best_score / sum_scores), Discover and Context queries whenever the collection's distance is Euclid or Manhattan. The threshold silently drops every point that should have passed, so query_points returns an empty result set.

LocalCollection.search() decides the sort order like this:

required_order = distance_to_order(distance)

if required_order == DistanceOrder.BIGGER_IS_BETTER or isinstance(
    query_vector,
    (DiscoveryQuery, ContextQuery, RecoQuery,
     MultiDiscoveryQuery, MultiContextQuery, MultiRecoQuery),
):
    order = np.argsort(scores)[::-1]
else:
    order = np.argsort(scores)

The isinstance(...) arm is there because calculate_recommend_best_scores, calculate_recommend_sum_scores, calculate_discovery_scores and calculate_context_scores all build their score on top of calculate_distance_core, which already negates Euclid/Manhattan, and then run it through scaled_fast_sigmoid / fast_sigmoid. For these query types bigger is always better, whatever the collection's distance is.

The threshold check a few lines below did not repeat that override:

if score_threshold is not None:
    if required_order == DistanceOrder.BIGGER_IS_BETTER:   # <-- ignores query type
        if score < score_threshold:
            break
    else:
        if score > score_threshold:
            break

On a Euclid/Manhattan collection required_order is SMALLER_IS_BETTER, so the points — already sorted biggest-first — are cut with score > score_threshold, and the very first point breaks the loop.

Fix

Hoist the decision into a single bigger_is_better flag and use it for both the sort order and the threshold comparison, so the two cannot drift apart again. The flag is the exact expression the sort branch already used, so ordering behaviour is unchanged for every query type (including NaiveFeedbackQuery, which keeps following required_order as before).

Reproduction

from qdrant_client import QdrantClient, models

c = QdrantClient(":memory:")
c.create_collection("t", vectors_config=models.VectorParams(size=2, distance=models.Distance.EUCLID))
c.upsert("t", points=[models.PointStruct(id=i, vector=v) for i, v in
         [(1, [1.0, 0.0]), (2, [0.9, 0.1]), (3, [0.0, 1.0]), (4, [-1.0, 0.0])]], wait=True)

q = models.RecommendQuery(recommend=models.RecommendInput(
    positive=[[1.0, 0.0]], negative=[[-1.0, 0.0]],
    strategy=models.RecommendStrategy.BEST_SCORE))

print([(p.id, p.score) for p in c.query_points("t", query=q, limit=10).points])
# [(1, 0.5), (2, 0.4902), (3, -0.1667), (4, -0.5)]

print([p.id for p in c.query_points("t", query=q, limit=10, score_threshold=-0.51).points])
# before: []            <- threshold below every score, yet nothing comes back
# after:  [1, 2, 3, 4]

Verification

Run against dev, for Cosine / Euclid / Manhattan:

check before after
Recommend best_score, threshold below every score Cosine ok; Euclid/Manhattan return [] all three return every point
Recommend, thresholds that should cut (0.45, 0.0, -0.2) n/a (returned nothing) matches score >= threshold exactly
Discover / Context on Euclid, threshold below every score returned [] returns every point
Plain dense search on Euclid (smaller is better) matches score <= threshold unchanged, still matches

ruff-format --line-length=99 (v0.4.3, per .pre-commit-config.yaml) reports the file already formatted. tests/test_in_memory.py and tests/conversions pass; the 4 failures in tests/test_local_persistence.py are a pre-existing Windows tempfile-locking artifact and reproduce identically on an unmodified tree.

🤖 Generated with Claude Code

Recommend (best_score/sum_scores), discovery and context queries score
through a sigmoid, so bigger is always better for them regardless of the
collection's distance. The sort order already special-cased these query
types on top of distance_to_order(), but the score_threshold comparison a
few lines below branched on required_order alone. On a Euclidean or
Manhattan collection it therefore cut with the operator meant for raw
distances and dropped every point that should have passed.

Hoist the decision into a single bigger_is_better flag used by both the
sort and the threshold check, so the two cannot drift apart again.
@netlify

netlify Bot commented Aug 22, 2026

Copy link
Copy Markdown

Deploy Preview for poetic-froyo-8baba7 ready!

Name Link
🔨 Latest commit 789320a
🔍 Latest deploy log https://app.netlify.com/projects/poetic-froyo-8baba7/deploys/6a8a1f8202367800081a3d2a
😎 Deploy Preview https://deploy-preview-1371--poetic-froyo-8baba7.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 86181249-aa50-45a9-b3a1-f3a8ef3f5dc1

📥 Commits

Reviewing files that changed from the base of the PR and between a50a16a and 789320a.

📒 Files selected for processing (1)
  • qdrant_client/local/local_collection.py

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

LocalCollection.search now identifies discovery, context, and recommendation queries as higher-score-is-better queries regardless of collection distance. The same ordering flag controls result sorting and score_threshold comparisons. Other query types continue to use the ordering derived from the configured distance.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 78932

This localized change aligns score-threshold filtering with the query’s score direction and fixes empty results for affected local Recommend, Discover, and Context queries; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: joein

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 1 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the local score_threshold direction fix and references the linked issue.
Description check ✅ Passed The description directly explains the bug, root cause, fix, reproduction, and verification for the changed behavior.
Linked Issues check ✅ Passed The change fulfills issue [#1370] by aligning threshold comparisons with synthetic query score direction while preserving dense-search behavior.
Out of Scope Changes check ✅ Passed The changes are limited to LocalCollection score ordering and threshold handling required by issue [#1370].
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant