Skip to content

perf(core): read only the projects in scope from the vector indexes - #1569

Closed
phernandez wants to merge 8 commits into
feat/1558-scoped-search-3b-scoped-routefrom
feat/1558-scoped-search-4a-ann-prefilter
Closed

phernandez wants to merge 8 commits into
feat/1558-scoped-search-3b-scoped-routefrom
feat/1558-scoped-search-4a-ann-prefilter

Conversation

@phernandez

Copy link
Copy Markdown
Member

Summary

Sixth PR in the #1558 stack, on top of #1568. First half of the "vector order and ANN pre-filter" item: the scope becomes a real pre-filter in both built-in vector adapters, and the pgvector statement actually uses its HNSW index. The second half (filling the window when structured filters reject most of the nearest candidates) follows as its own PR on top of this one.

What was wrong. Both adapters ranked the k nearest vectors across the whole database and applied the scope afterwards. In a shared database (a local vault with many projects, or a tenant with several) a small project could get an under-filled or empty page for a query its own notes answered, because a larger neighbour's vectors filled the window first. This predates the stack; the scoped route just makes it easier to hit.

sqlite-vec. project_id becomes a vec0 partition key. The KNN ranks each partition in scope and the outer ORDER BY merges them. Existing local storage is carried into the partitioned table through a temporary table inside one transaction: vectors and ready manifests survive, nothing is re-embedded. Dimension and source_hash mismatches still rebuild as before.

pgvector. Three things, found while measuring:

  • The tie-break sort keys in the old statement kept the planner off the HNSW index entirely. Every Postgres vector query was an exact scan and sort of the whole table. The window is now taken by distance alone inside a materialized CTE and re-sorted with the tie-breaks outside it.
  • hnsw.ef_search was never set, so an HNSW scan would have returned at most 40 rows whatever the candidate window asked for. It is now set per transaction to the window the query must fill (capped at the server maximum of 1000).
  • hnsw.iterative_scan = relaxed_order lets the scan continue until the scope and manifest filters have admitted enough rows. That needs pgvector 0.8. An older extension raises SemanticDependenciesMissingError at first use, which the API reports as a bad request, instead of quietly returning short windows. Neon tenants run 0.8.0; the test image runs 0.8.5.

Measurements

20k chunks x 384 dims, three projects, project 1 holds 2% of the vectors. Window of 100 rows. Medians of 5 runs.

Engine Statement Scope Rows Median
sqlite-vec 0.1.9 before project 1 (2%) 1 3.1 ms
sqlite-vec 0.1.9 after project 1 (2%) 100 0.2 ms
sqlite-vec 0.1.9 before projects 1,2 53 3.1 ms
sqlite-vec 0.1.9 after projects 1,2 100 2.0 ms
pgvector 0.8.5 before (exact scan) all 100 14.5 ms
pgvector 0.8.5 after, iterative off projects 1,2 60 3.3 ms
pgvector 0.8.5 after, relaxed_order projects 1,2 100 5.6 ms
pgvector 0.8.5 after, relaxed_order all 100 3.8 ms

The "iterative off" row is why pgvector 0.8 is required: with the index in use and a scope covering half the table, an ef_search-sized scan alone under-fills the window. For the 2% scope the planner chose the project btree index and an exact sort on its own, so that case is exact under both statements.

Test plan

  • just fast-check
  • Adapter tests on SQLite: partition scope fills a small project's window while a neighbour's vectors sit closer; legacy storage is carried over with vectors and readiness intact; pgvector sizes ef_search to the window, always requests the iterative scan, and refuses pgvector < 0.8 before creating storage
  • tests/repository tests/services tests/api on SQLite (2175 passed)
  • Postgres container: repository, pgvector adapter, vector sync, scoped route, semantic service (132 passed)
  • test-int vec0 and semantic coverage (7 passed)
  • just doctor

Refs #1558

🤖 Generated with Claude Code

https://claude.ai/code/session_019YW9ysxugGGBCNEGzsxtFV

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

phernandez and others added 5 commits September 15, 2026 18:55
…d path to scope (#1563)

Signed-off-by: phernandez <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
…ch repository (#1565)

Signed-off-by: phernandez <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
…over a ProjectScope (#1566)

Signed-off-by: phernandez <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
Base automatically changed from feat/1558-scoped-search-5-mcp-scoped to feat/1558-scoped-search-3b-scoped-route September 15, 2026 23:56
@phernandez
phernandez added this pull request to stack #1572 September 15, 2026 23:56
phernandez and others added 3 commits September 15, 2026 18:59
`search_notes(search_all_projects=True)` used to run one project search per
project and merge the pages client-side. It now groups the accessible projects
by the database they live in (the local database, or one cloud workspace each)
and runs one `QUERY /v2/search/` per group through the new `ScopedSearchClient`,
passing the group's internal project ids. The server ranks the union in one
query and attributes each hit with `project_external_id`; the tool qualifies
permalinks and paths from that attribution rather than from which request a
result came back on, so a name collision across workspaces can no longer route
a hit to the wrong project.

A new `projects` parameter searches a chosen subset by name or external id.
It shares the grouping and merge with the all-projects path; an unknown name is
an error before any query runs. Cross-database results are still merged by
score and sliced to the requested page, a failing database is skipped with a
warning and an inexact total, a retryable outage fails the merged page, and a
server too old to attribute its results raises a version-skew error like the
existing valid-time skew check.

`list_memory_projects` now carries the internal `id` in its merged project
rows, which is what the scoped route addresses projects by. The temporal
confirmation check moves to a module function shared by both search clients.

Refs #1558

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_019YW9ysxugGGBCNEGzsxtFV
Signed-off-by: phernandez <[email protected]>
…meter

`tests/test_man_pages.py` compares the rendered SYNOPSIS and PARAMETERS of
search-notes(3) with the tool registry; the new `projects` parameter left the
committed page stale.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_019YW9ysxugGGBCNEGzsxtFV
Signed-off-by: phernandez <[email protected]>
Both built-in vector adapters took the k nearest vectors across the whole
database and applied the scope afterwards. In a shared database a small project
could get an under-filled or empty page for a query its own notes answered,
because a larger neighbour's vectors filled the window first. On a 20k-chunk
corpus where one project holds 2% of the vectors, a limit-100 query for that
project returned 1 row from sqlite-vec.

sqlite-vec: `project_id` becomes a vec0 partition key, so the KNN ranks each
partition in scope and the outer ORDER BY merges them (100 of 100 rows, 0.2 ms
against 3.1 ms). Existing local storage is carried into the partitioned table
through a temporary table inside one transaction; vectors and ready manifests
survive, nothing is re-embedded. Dimension and source_hash mismatches still
rebuild as before.

pgvector: the statement's tie-break sort keys had kept the planner off the HNSW
index entirely, so every vector query was an exact scan of the table. The window
is now taken by distance alone inside a materialized CTE and re-sorted with the
tie-breaks outside it. `hnsw.ef_search` is set per transaction to the window the
query must fill (the default 40 capped every larger candidate pool), and
`hnsw.iterative_scan = relaxed_order` lets the scan continue until the scope and
manifest filters have admitted enough rows. That scan needs pgvector 0.8; an
older extension now raises SemanticDependenciesMissingError at first use rather
than returning short windows. Neon tenants run 0.8.0.

Refs #1558

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_019YW9ysxugGGBCNEGzsxtFV
Signed-off-by: phernandez <[email protected]>
@phernandez

Copy link
Copy Markdown
Member Author

Superseded by #1574: same branch and commit, opened against main because GitHub refuses to retarget a PR that is part of a stack once its base has been merged.

@phernandez phernandez closed this Sep 16, 2026
@phernandez
phernandez deleted the feat/1558-scoped-search-4a-ann-prefilter branch September 16, 2026 00:01
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