Skip to content

fix(retrieval): honor score_threshold in hybrid search (was silently dropped)#22

Open
yuzushi-dev wants to merge 2 commits into
mainfrom
fix/hybrid-score-threshold
Open

fix(retrieval): honor score_threshold in hybrid search (was silently dropped)#22
yuzushi-dev wants to merge 2 commits into
mainfrom
fix/hybrid-score-threshold

Conversation

@yuzushi-dev

Copy link
Copy Markdown
Owner

Problem

MilvusVectorStore.hybrid_search() accepted no score_threshold and never applied one, while the dense-only search() did. Since hybrid (dense+SPLADE) is the default active path in prod, the configured relevance threshold was silently ignored there — low-relevance chunks passed through and could feed misleading citations.

Surfaced while diagnosing a negative-feedback retrieval miss: a cited chunk with cosine ~0.4751 (below the tenant's similarity_threshold) was returned anyway, because the threshold is dead code in the hybrid path.

Change

  • milvus.py / vector_store_port.py: hybrid_search() now accepts score_threshold and filters candidates below it, mirroring the dense path.
  • vector.py: forwards score_threshold to the store (previously dropped — only used in the dense-fallback). The dense fallback does not forward it (fusion scale ≠ cosine scale).
  • retrieval_service.py: the hybrid call site no longer passes the tenant's cosine similarity_threshold into hybrid (left None).

Why the call site stays None (anti-regression)

Hybrid fusion (RRF) scores are ~0.01–0.03, not cosine 0–1. Wiring a cosine 0.7 into the now-honored hybrid filter would zero out every result. So the param is correctly plumbed and opt-in, to be tuned on the fusion scale separately.

No behaviour change in prod: the call site resolves to None today (as it did before, where the value was silently dropped), so hybrid ranking is unchanged. This PR removes the silent-drop and makes a hybrid-scale threshold usable without a regression.

Review

Diff reviewed end-to-end by a separate model (Fable): confirmed no prod ranking change (traced RetrievalConfig.score_threshold default None, never set by any prod constructor), filter logic matches the dense path, protocol in sync, test is non-tautological. Verdict: approve.

Test

tests/unit/test_hybrid_score_threshold.py (stubs the pymilvus Collection, no real Milvus): score_threshold=None → all candidates; explicit threshold → below-threshold dropped.

Known follow-up (pre-existing, out of scope)

The dense fallback inside hybrid_search() (milvus.py) drops document_ids/collection_name, i.e. the document ACL filter is lost if hybrid throws and falls back to dense. Not introduced here — tracked separately.

…dropped)

MilvusVectorStore.hybrid_search() accepted no score_threshold and never applied
one, while the dense-only search() did. Since hybrid (dense+SPLADE) is the
default active path, the relevance threshold was silently ignored there:
low-relevance chunks passed through and could produce misleading citations.

- milvus.py / vector_store_port.py: hybrid_search() now accepts
  score_threshold and filters candidates below it, mirroring the dense path.
- vector.py: forward score_threshold to the store (it was dropped before); the
  dense fallback does NOT forward it (fusion scale != cosine scale).
- retrieval_service.py: the hybrid call site no longer passes the tenant's
  cosine similarity_threshold into hybrid (left None). Hybrid fusion scores are
  ~0.01-0.03, not cosine 0-1; reusing a cosine 0.7 there would zero out every
  result. The param is now correctly plumbed and opt-in, to be tuned on the
  fusion scale separately.

No behaviour change in prod (call site resolves to None today, as before, so the
ranking is unchanged); this removes the silent-drop and makes a hybrid-scale
threshold usable without a regression.

Verified: diff reviewed end-to-end (no prod ranking change); unit test in
tests/unit/test_hybrid_score_threshold.py (None = no filter; explicit threshold
drops below-threshold candidates).
Both the dense (search) and the new hybrid (hybrid_search) filters used
`if score_threshold and hit.score < score_threshold`, so an explicit 0.0 —
a valid fusion-scale threshold (RRF scores are tiny positives) — was falsy and
silently disabled filtering instead of keeping scores >= 0. Use `is not None`
on both paths. Regression test asserts 0.0 drops a negative-score hit.
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