Skip to content

Fix #104: scope _vec_qualifiers kNN to the caller's candidate ids - #120

Merged
emp3thy merged 2 commits into
mainfrom
auto-fix/issue-104-vec-qualifiers-scoped-knn
Jul 29, 2026
Merged

emp3thy merged 2 commits into
mainfrom
auto-fix/issue-104-vec-qualifiers-scoped-knn

Conversation

@emp3thy

@emp3thy emp3thy commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Closes #104

Summary

_vec_qualifiers issued an unfiltered global k=50 kNN against reflection_embeddings / semantic_embeddings. Both vec tables are global across projects, and retire only UPDATEs status without deleting the vector, so a caller's own candidates could be crowded out of the top-50 slice by other projects' or retired reflections. Even when they weren't fully crowded out, the rank returned in vec_r / vec_s was against the global neighbourhood, not the caller's competitors — which then fed a distorted RRF vec contribution.

This PR mirrors the pattern already used by ReflectionService._vec_ranks (reflection.py) — the sibling the issue explicitly references:

  • _vec_qualifiers gains a candidate_ids: list[str] parameter. Empty list short-circuits without touching the DB.
  • k = max(len(candidate_ids), 50) so the window scales with how much the caller actually has to rank.
  • Results are filtered against set(candidate_ids) in Python (sqlite-vec kNN accepts only MATCH ? AND k = ?, no extra predicates), so vec ranks are dense over the caller's own set.
  • retrieve_relevant gathers refl_candidate_ids and sem_candidate_ids from buckets / semantic before the vec query, and reuses the same refl_bucket_order list for the candidate build loop below.

Behaviorally: injection selection benefits because the vec-rank a candidate contributes to RRF now reflects position among competitors instead of position among global neighbours. Orphan or cross-project vector rows no longer appear in vec_r / vec_s at all.

Tests

  • Full tests/services/ (536 tests) + tests/hooks/ (84 tests) pass unchanged.
  • Added test_orphan_vector_row_does_not_leak_via_vec_gate in tests/services/test_relevant.py — a direct _vec_qualifiers regression asserting (a) empty candidate_ids short-circuits to {} and (b) an orphan vec row is filtered out when the caller's candidate list doesn't include it.

Confidence

90% — the fix is exactly what the issue proposes ("Restrict the kNN with the candidate id set … and scale k with candidate count, matching _fuse_by_relevance"), matches an established sibling pattern that already has test coverage, and is a strict improvement: it doesn't materially reduce recall in the normal case (small candidate sets still see k=50 kNN) and it tightens the rank signal in every case.

Deliberately out of scope: the more principled fix of a vec0 partition/aux column filter on project + status, which would need a migration and touches the schema. That would fully solve the pathological 60-neighbours-at-cos-0.99 reproduction cited in the issue; this PR does not, and I noted it as a follow-up direction.


This PR was generated by a scheduled Claude routine.


Generated by Claude Code

claude added 2 commits July 29, 2026 20:15
_vec_qualifiers issued an unfiltered global k=50 kNN against
reflection_embeddings / semantic_embeddings. Both vec tables are global
across projects, and retire only UPDATEs status without deleting the
vector, so a caller's own candidates could be crowded out of the top-50
slice by other projects' or retired reflections -- and the returned rank
was against the global neighbourhood, not the caller's competitors.

Now the caller passes its already-computed candidate id list into
_vec_qualifiers. We scale k with max(len(candidates), 50) and filter
the kNN in Python by the candidate set, matching the sibling pattern
established in ReflectionService._vec_ranks (reflection.py). vec_r /
vec_s ranks are now dense over the caller's actual candidates, which
means their RRF vec contribution reflects position among competitors
rather than global neighbours. Empty candidate lists short-circuit
without touching the DB.

Existing 536 service tests and 84 hook tests pass unchanged. Added
test_orphan_vector_row_does_not_leak_via_vec_gate as a regression that
locks in the candidate scoping via a direct _vec_qualifiers call.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…bal scope

SqliteBackend.relevance_ranks (sqlite.py:353, :359) intentionally wants
the unfiltered rank map -- it's the "server-side gate" analogue used by
retrieve_relevant's agentcore-mode branch (relevant.py:230-234 notes
this callsite is never used from the sqlite branch, but it stays for
protocol completeness). Making candidate_ids required broke pyright on
those two calls.

candidate_ids is now defaulted to None ("no scope filter, keep the
existing global k=50 behaviour"). retrieve_relevant continues to pass
its actual candidate list so its own vec scoping is enforced; [] still
short-circuits explicitly. Regression test still passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@emp3thy
emp3thy merged commit c7b6b66 into main Jul 29, 2026
3 checks passed
@emp3thy
emp3thy deleted the auto-fix/issue-104-vec-qualifiers-scoped-knn branch July 29, 2026 21:27
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.

_vec_qualifiers runs an unfiltered global k=50 kNN, starving the injection evidence gate (relevant.py:123)

2 participants