Skip to content

fix(local): honor score direction in DBSF fusion - #1455

Open
2sumtech wants to merge 1 commit into
qdrant:devfrom
2sumtech:fix/local-dbsf-score-direction
Open

2sumtech wants to merge 1 commit into
qdrant:devfrom
2sumtech:fix/local-dbsf-score-direction

Conversation

@2sumtech

Copy link
Copy Markdown
Contributor

All Submissions:

  • Contributions should target the dev branch. Did you create your branch from dev?
  • Have you followed the guidelines in our Contributing document?
  • Have you checked to ensure there aren't other open Pull Requests for the same update/change?

Changes to Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

What

Local-mode Fusion.DBSF returns the worst matches first on Euclid and Manhattan
collections — the fused ranking is exactly reversed.

from qdrant_client import QdrantClient, models

c = QdrantClient(":memory:")
c.create_collection("c", vectors_config={"e": models.VectorParams(size=2, distance=models.Distance.EUCLID)})
c.upsert("c", [models.PointStruct(id=i, vector={"e": [float(i), 0.0]}) for i in range(5)])

print([p.id for p in c.query_points("c", query=[0.0, 0.0], using="e", limit=5).points])
# [0, 1, 2, 3, 4]  -- nearest first, correct

print([p.id for p in c.query_points("c", prefetch=[
    models.Prefetch(query=[0.0, 0.0], using="e", limit=5),
    models.Prefetch(query=[0.1, 0.0], using="e", limit=5),
], query=models.FusionQuery(fusion=models.Fusion.DBSF), limit=5).points])
# [4, 3, 2, 1, 0]  -- farthest first (expected [0, 1, 2, 3, 4])

RRF is unaffected: it fuses ranks, not scores.

Why

distribution_based_score_fusion normalizes each source with
(score - (mean - 3*std)) / (6*std) and then sorts the summed scores descending
(qdrant_client/hybrid/fusion.py). That assumes a higher input score is a better match.

In core it is: DBSF runs on the internal similarity, which is oriented "bigger is better"
for every metric (Euclid/Manhattan similarity is the negated distance, converted back only
during post-processing), and ScoreFusion::dbsf() therefore hardcodes order: Order::LargeBetter:

https://github.com/qdrant/qdrant/blob/master/lib/segment/src/common/score_fusion.rs

In local mode LocalCollection._search scores a plain nearest query with the raw distance
and sorts ascending when distance_to_order(...) is SMALLER_IS_BETTER, so the prefetch
results handed to DBSF are oriented the opposite way — and normalization silently flips them.

Fix: distribution_based_score_fusion takes an optional smaller_is_better flag per source and
negates those scores before normalizing, which is exactly the orientation core normalizes.
LocalCollection computes the flag per prefetch (_prefetch_scores_are_smaller_better): only
plain NearestQuery on a dense/multi vector inherits the metric's direction — fusion, recommend,
discovery, context, formula and sparse (always DOT) sources are all bigger-is-better.

Evidence: test fails before, passes after
$ git stash push qdrant_client/ && python -m pytest tests/test_in_memory.py -q
E       assert [4, 3, 2, 1, 0] == [0, 1, 2, 3, 4]
E         At index 0 diff: 4 != 0
FAILED tests/test_in_memory.py::test_dbsf_fusion_respects_score_direction[Euclid]
FAILED tests/test_in_memory.py::test_dbsf_fusion_respects_score_direction[Manhattan]
2 failed, 7 passed in 1.85s

$ git stash pop && python -m pytest tests/test_in_memory.py -q
9 passed in 2.30s

$ python -m pytest tests/test_in_memory.py qdrant_client/local/tests -q
145 passed in 1.13s

$ python -m pytest qdrant_client/hybrid/test_reranking.py tests/embed_tests/test_inspectors.py -q
10 passed in 0.66s

$ python -m mypy qdrant_client/hybrid/fusion.py qdrant_client/local/local_collection.py
Success: no issues found in 2 source files

ruff-format --line-length=99 clean on all three touched files.

Duplicate check (2026-09-19T18:25Z and re-checked 2026-09-19T18:27Z UTC)

Searched qdrant/qdrant-client issues and PRs in all states for dbsf, fusion,
euclid, distribution based score, distribution_based_score_fusion, score fusion order.

Prior DBSF work is unrelated: #871/#872/#875 (zero variance / zero division), #815/#817
(empty responses), #703 (original DBSF implementation), #1136/#1137/#1138 (score_threshold
after fusion), #1372/#1373 (root filters after fusion).

Open Euclid/Manhattan score-direction work is a different code path — #1370/#1378 and PRs
#1371/#1374/#1439 are about score_threshold and ordering for recommend/discovery/context/
feedback queries in LocalCollection.search; none of them touch fusion.

Listed all 80 open PRs and their changed files: no open PR touches
qdrant_client/hybrid/fusion.py
, and no open PR changes _merge_sources / the DBSF path in
qdrant_client/local/local_collection.py.

Disclosure: prepared with AI assistance (Claude Code); I reviewed the change and take responsibility for it.
🤖 Generated with Claude Code

Local-mode DBSF normalized every prefetch's raw score and sorted the fused
result descending. On Euclid/Manhattan collections a plain nearest-neighbour
search scores with the raw distance, where a lower value is the better match,
so the normalization mapped the farthest point to the highest fused score and
the whole ranking came out reversed.

Core normalizes the internal similarity, which is oriented "bigger is better"
for every metric, so it does not have this problem. Negate the scores of the
smaller-is-better sources before normalizing to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@netlify

netlify Bot commented Sep 19, 2026

Copy link
Copy Markdown

Deploy Preview for poetic-froyo-8baba7 ready!

Name Link
🔨 Latest commit 79bd9cf
🔍 Latest deploy log https://app.netlify.com/projects/poetic-froyo-8baba7/deploys/6aaed445beafe00008e1c015
😎 Deploy Preview https://deploy-preview-1455--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 Sep 19, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: f2115153-f2d0-4ec8-915a-d34500bfaad1

📥 Commits

Reviewing files that changed from the base of the PR and between 589a87a and 79bd9cf.

📒 Files selected for processing (3)
  • qdrant_client/hybrid/fusion.py
  • qdrant_client/local/local_collection.py
  • tests/test_in_memory.py

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


📝 Walkthrough

Walkthrough

distribution_based_score_fusion now accepts per-response score-direction flags and validates their length. It negates lower-is-better scores before normalization. Local query and nested prefetch merging determine score direction from the query type, vector type, and distance metric. New parametrized tests verify Euclidean and Manhattan ordering, including mixed cosine fusion.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Suggested reviewers: joein

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the local DBSF score-direction bug, the implementation approach, affected metrics, tests, and validation results.
Title check ✅ Passed The title clearly and concisely identifies the fix: local DBSF now honors score direction.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

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