Skip to content

fix(core): restrict the vector filter pass to its own candidates - #1443

Merged
phernandez merged 1 commit into
mainfrom
fix/1431-vector-filter-intersection
Sep 3, 2026
Merged

fix(core): restrict the vector filter pass to its own candidates#1443
phernandez merged 1 commit into
mainfrom
fix/1431-vector-filter-intersection

Conversation

@phernandez

Copy link
Copy Markdown
Member

Closes #1431. Part of #1438.

The defect

A filtered vector or hybrid query cannot evaluate a structured filter against embeddings, so it asked an FTS-mode pass which of its candidates the filter admits. That pass requested a page of the filter's whole match set, capped at VECTOR_FILTER_SCAN_LIMIT (50,000), and every candidate outside the page was intersected away as disallowed.

The loss is not arbitrary. A filter-only pass carries search_text=None, so there is no relevance signal in the ordering: PostgreSQL falls through to its search_index.id ASC tiebreak and keeps the earliest-indexed rows; SQLite has no tiebreak at all. Newer content is what disappears, identically on every rerun — result-completeness, not a derived-state race that a later write or index pass repairs.

Reproduction

A project with 50,001 rows under one file_path_prefix, one genuinely matching row indexed last, and a vector hit on that row. Verified on both backends before touching any source:

filter page size=50000  first=0  last=49999   target in page: False
unbounded match count=50002                   target present: True
filtered vector search returned []            expected [10000000]

Identical on SQLite and PostgreSQL (BASIC_MEMORY_TEST_POSTGRES=1).

The fix

Push the candidate keys into the filter query. The pass now asks which of these rows the filter admits, so its answer is bounded by the question and no cap is needed on that path. VECTOR_FILTER_SCAN_LIMIT stays where it belongs — bounding the adapter rescan it was written for.

  • New keyword-only candidate_keys on the abstract search(), the SearchRepository protocol, and both backends.
  • Both backends build the predicate from one shared helper, candidate_key_restriction_condition, following file_path_prefix_condition and metadata_filter_content_type_condition. A restriction that admitted different rows per dialect would hand semantic search a different candidate set depending on which database is underneath.
  • Keys are grouped by row type, so each branch is one type-scoped IN list: one bind per key rather than two, and a shape both planners drive from the index.
  • The key list is split at the existing VECTOR_HYDRATION_BATCH_SIZE (250) bound, since both engines cap bind parameters — the same bound the file already uses for manifest hydration, not a second one. (reindex fails on Postgres projects above ~32k entities: unchunked find_by_ids #1442 is the live instance of that cap on another path.)

Scoped to the whole intersection rather than one filter: all ten filters share the single call site, and fixing only the newest would have left the more reachable ones broken while making the path look safe.

The two rejected alternatives from the issue are still rejected — exhausting the pages removes the safety valve, and raising the constant moves the cliff.

Performance

Measured on a 50,001-row project, best of three, both backends:

PostgreSQL SQLite
old capped page (50,000 rows hydrated) 522 ms 592 ms
100 candidates (1 round trip) 27 ms 30 ms
250 candidates (1 round trip) 33 ms 32 ms
1000 candidates (4 round trips) 112 ms 130 ms

PostgreSQL plans the restricted form as an Index Scan using search_index_pkey with id = ANY(...) — 0.8 ms in-database — where the old page was a Seq Scan plus an external merge sort spilling 1.6 MB to disk (70 ms).

Round trips: a default query's candidate pool is ~100 rows (semantic_vector_k=100), so the common case keeps its single round trip. Batching only adds round trips on deep pages, and four cheap indexed lookups still beat one unbounded scan by ~4.6x.

Tests

tests/repository/test_vector_filter_candidate_restriction.py — the two database tests seed a genuinely over-window project (50,001 rows) rather than mocking the limit, and assert the matching row is returned:

  • over-window candidate survives the filter
  • a candidate pool past the bind bound is split, every admitted candidate comes back, out-of-scope ones are rejected, and no statement carries more than 250 keys
  • the restriction separates rows of different types that share an id
  • an empty candidate set admits nothing
  • helper unit tests for the grouping and the false-not-vacuously-true empty case

Fail-before / pass-after confirmed by reverting the intersection hunk: the two database tests fail on both backends, and pass with it restored.

Verification

  • uv run ruff check src tests test-int — clean
  • uv run ruff format --check . — clean
  • uv run ty check src tests test-int — clean
  • tests/repository tests/api tests/services on SQLite — 1828 passed, 40 skipped
  • tests/repository tests/api tests/services on PostgreSQL — 1808 passed, 60 skipped

🤖 Generated with Claude Code

https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp

A filtered vector or hybrid query cannot evaluate a structured filter against
embeddings, so it asked an FTS-mode pass which candidates the filter admits.
That pass requested a page of the filter's *whole* match set, capped at
VECTOR_FILTER_SCAN_LIMIT (50,000), and every candidate outside the page was
then intersected away as disallowed.

The loss was not arbitrary. A filter-only pass carries search_text=None, so
there is no relevance signal in the ordering: Postgres fell through to its
search_index.id ASC tiebreak and kept the earliest-indexed rows, and SQLite had
no tiebreak at all. Newer content was what disappeared, identically on every
rerun — a result-completeness bug, not a derived-state race that a later write
or index pass repairs.

Push the candidate keys into the filter query instead. The pass now asks which
of *these* rows the filter admits, so its answer is bounded by the question and
the cap is no longer needed on that path (VECTOR_FILTER_SCAN_LIMIT still bounds
the adapter rescan it was written for). Both backends build the restriction
from one shared helper, following file_path_prefix_condition: a restriction that
admitted different rows per dialect would hand semantic search a different
candidate set depending on the database underneath.

The key list is split at the existing VECTOR_HYDRATION_BATCH_SIZE bound, since
both engines cap bind parameters. Keys are grouped by row type so each branch is
one type-scoped IN list — one bind per key rather than two, and a shape both
planners drive from the index.

Scoped to the whole intersection, not one filter: all ten filters share the
single call site, and fixing the newest alone would have left the more reachable
ones broken while making the path look safe.

Measured on a 50,001-row project (both backends, best of three):

  old capped page      522 ms (pg) / 592 ms (sqlite), 50,000 rows hydrated
  100 candidates        27 ms (pg) /  30 ms (sqlite), one round trip
  250 candidates        33 ms (pg) /  32 ms (sqlite), one round trip
  1000 candidates      112 ms (pg) / 130 ms (sqlite), four round trips

Postgres plans the restricted form as an Index Scan on search_index_pkey
(0.8 ms) where the old page was a Seq Scan plus an external merge sort spilling
1.6 MB to disk (70 ms). A default query's candidate pool is ~100 rows, so the
common case keeps its single round trip; only deep pages add any.

Closes #1431. Refs #1438.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmKq6bqCi6Zp6BTHuZjrp
Signed-off-by: phernandez <paul@basicmachines.co>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T12:20:29.944685Z 2b0e712 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@phernandez
phernandez merged commit 34ac0a3 into main Sep 3, 2026
25 checks passed
@phernandez
phernandez deleted the fix/1431-vector-filter-intersection branch September 3, 2026 12:52
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.

Vector/hybrid filter intersection drops candidates beyond VECTOR_FILTER_SCAN_LIMIT

1 participant