Skip to content

[selective filter 1] Route filtered queries by selectivity, and search allowlists exhaustively - #1871

Draft
edwinyyyu wants to merge 1 commit into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:feat/vector-store-selective-filter
Draft

edwinyyyu wants to merge 1 commit into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:feat/vector-store-selective-filter

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Purpose of the change

A filtered query on the SQLite store gave its search engine allowed_keys, a container backed by SQL that ran one point query per candidate the approximate search visited: hnswlib asked it through a filter callback, and usearch fetched more and widened until enough candidates passed, up to the whole index. A selective filter made the search visit most of the index, each candidate a round trip, and none of those queries used the declared columns' indexes that [declared schema 1/2] (#1628) adds. _KeyFilter also kept a second, synchronous SQLAlchemy engine for the store.

The query now first selects the row ids the filter admits, through the declared columns' indexes, reading at most one row past selective_filter_limit (default 10,000). A filter admitting at most that many records gives the engine those row ids as an allowlist. A broader one is applied to the best limit * overfetch_factor candidates (default 64) of a search without it, with one SQL query per query vector, so its cost is bounded, and a query returns fewer than limit matches when fewer candidates pass.

search takes allowlist in place of allowed_keys. Every vector stored under one of its keys is scored as it would be scored without one, so none is missed, at a cost proportional to the number of keys; a key with no vector stored is ignored. hnswlib and usearch gather the stored vectors by key and score them exactly, by the engine's metric, with one shared function, exact_search_results; hnswlib refuses a batch with any key it does not hold, so it then gathers key by key. An engine whose index restricts its own search to a list of ids, scoring as it does without one, can answer an allowlist with it. Without an allowlist, hnswlib no longer threads a filter through its fallbacks, and usearch makes one search. _KeyFilter was the only user of the store's synchronous SQLAlchemy engine and its foreign-keys hook, which go with it.

This rebuilds #1602, deferred on speedkick until declared properties were indexed columns, on #1628. It differs from #1602 in three ways:

Measurements

100,000 clustered 768-dimensional unit vectors (1,000 clusters, each query near one), cosine, limit 10, 20 queries per row: median latency, and recall@10 against the exact top 10 among the admitted records. A spread filter admits records across all clusters; a far filter admits only clusters away from every query. Before is the per-candidate path, measured under a load average of 13 to 24 from other work, after under 4 to 6, so recall compares like for like and latency only roughly.

                 usearch                        hnswlib
admitted         before        after           before          after
spread 1,000     104 ms 0.93    3.6 ms 1.00    1,509 ms 1.00     25 ms 1.00
spread 10,000     12 ms 0.985    22 ms 1.00      196 ms 1.00    247 ms 1.00
spread 50,000    7.6 ms 0.985    17 ms 1.00       47 ms 1.00     17 ms 1.00
far 1,000        157 ms 0.815   3.1 ms 1.00    1,876 ms 0.995    24 ms 1.00
far 10,000        36 ms 0.635    20 ms 1.00      316 ms 0.875   239 ms 1.00
far 50,000        15 ms 0.615    18 ms 0.875      66 ms 0.775    17 ms 0.855

With limit 20, a broad filter's 1,280 candidates take 26 to 40 ms. hnswlib's allowlist search costs about 25 µs per key, most of it in gathering the stored vectors, which is what its 247 ms at 10,000 admitted is; usearch's costs about 2 µs.

An engine backed by turbovec, as #1599 adds on speedkick, answers an allowlist with its index's own restricted search. Through a minimal adapter of #1599's engine (turbovec 1.0.0, 4-bit) on the same data, every row above took 1.8 to 21 ms, with recall from 0.845 to 1.00 against 0.885 for its search without a filter, which is its quantization's ceiling. Sending every filter through the allowlist instead returned the same results, and took 46 to 49 ms at 100,000 admitted.

Stack

Directly on feat/horizontal-scaling at 0fa52b38b, since [declared schema 1/2] (#1628), which it was stacked on, merged as 5c9d3ae99. This PR is its one commit, 3f0e97373 (8 files).

Verification

At this PR's head 3f0e97373, on 2026-11-02, rebased onto feat/horizontal-scaling at 0fa52b38b, after [sqlite write transactions 1] (#1844) merged. The one conflict was the SQLite store's imports: #1844 moved the store's engine checks into common/sql.py (validate_engine_connections), and this PR removes the imports that only the synchronous engine and _KeyFilter used, so the store imports neither StaticPool nor ConnectionPoolEntry. The commit's message is unchanged, and its patch differs only in those import lines. Checks: ruff check and ruff format --check clean; ty check clean as CI runs it; uv lock --check clean. The unit tests pass, 2398 tests (8 skipped).

Before the rebase onto #1844's merge: At this PR's head 9220a34a8, on 2026-10-30, moved onto feat/horizontal-scaling at 690116e37 (#1628's squash 5c9d3ae99, then #1858) with git rebase --onto: the patch is unchanged (git range-diff reports the commit equal). The base gained #1628's last commits (the declared-property column names built without Column objects, and two contract test commits), #1831, and #1858. Checks: ruff check and ruff format --check clean; ty check clean as CI runs it; uv lock --check clean. The vector store and filter unit tests pass, 592 tests (5 skipped).

Before the move onto feat/horizontal-scaling: At this PR's head 07aae3bea, on 2026-10-28, replayed onto #1628's df716e47b, which reverts the oversized Milvus upsert test's partition deletion: the patch is unchanged (git range-diff reports the commit equal), so the tests at 8e1faae00 stand. Checks: ruff check and ruff format --check clean; ty check clean as CI runs it; uv lock --check clean.

Before the replay onto df716e47b: At this PR's head 8e1faae00, on 2026-10-28: ruff check and ruff format --check clean; ty check clean as CI runs it (uv run --frozen --all-extras ty check --project packages/server); uv lock --check clean. The unit tests pass, 2359 tests (8 skipped). The integration tests did not run here; the change touches only the SQLite store and its search engines.

The filter tests and the declared-schema contract run on both paths. New tests check that a selective filter finds an admitted record however far it is from the query; that a broad filter is applied only to the overfetched candidates and keeps at most limit of them; and that an engine's allowlist search keeps to the allowlist, scores as a search without one does on each metric, ignores keys with no vector, and finds nothing for an empty allowlist. The test of a query overtaken by its partition's deletion covers each path. Each of these fails with its path removed: the store never or always using the allowlist, the broad filter keeping more than limit or fetching the whole index, an engine ignoring the allowlist, and hnswlib's key-by-key gather dropping every key.

🤖 Generated with Claude Code

This was referenced Oct 28, 2026
@edwinyyyu
edwinyyyu force-pushed the feat/vector-store-selective-filter branch 2 times, most recently from 07aae3b to 9220a34 Compare October 30, 2026 23:18
…vely

A filtered query on the SQLite store gave the engine `allowed_keys`, a
container backed by SQL that ran one point query per candidate the
approximate search visited: hnswlib asked it through a filter callback,
and usearch fetched more and widened until enough candidates passed, up
to the whole index. A selective filter made the search visit most of the
index, each candidate a round trip, and none of those queries used the
declared columns' indexes.

The query now first selects the row ids the filter admits, through the
declared columns' indexes, reading at most one row past
`selective_filter_limit` (default 10,000). A filter admitting at most
that many records gives the engine those row ids as an allowlist. A
broader one is applied to the best `limit * overfetch_factor` candidates
(default 64) of a search without it, with one SQL query per query
vector, so its cost is bounded, and a query returns fewer than `limit`
matches when fewer candidates pass.

`search` takes `allowlist` in place of `allowed_keys`. Every vector
stored under one of its keys is scored as it would be scored without
one, so none is missed, at a cost proportional to the number of keys; a
key with no vector stored is ignored. hnswlib and usearch gather the
stored vectors by key and score them exactly, by the engine's metric,
with one shared function, `exact_search_results`; hnswlib refuses a
batch with any key it does not hold, so it then gathers key by key. An
engine whose index restricts its own search to a list of ids, scoring as
it does without one, can answer an allowlist with it. Without an
allowlist, hnswlib no longer threads a filter through its fallbacks, and
usearch makes one search. `_KeyFilter` was the only user of the store's
synchronous SQLAlchemy engine and its foreign-keys hook, which go with
it.

Measured on 100,000 clustered 768-dimensional unit vectors (1,000
clusters, each query near one), cosine, limit 10, 20 queries per row:
median latency, and recall@10 against the exact top 10 among the
admitted records. A spread filter admits records across all clusters; a
far filter admits only clusters away from every query. Before is the
per-candidate path, measured under a load average of 13 to 24 from other
work, after under 4 to 6, so recall compares like for like and latency
only roughly.

                 usearch                        hnswlib
admitted         before        after           before          after
spread 1,000     104 ms 0.93    3.6 ms 1.00    1,509 ms 1.00     25 ms 1.00
spread 10,000     12 ms 0.985    22 ms 1.00      196 ms 1.00    247 ms 1.00
spread 50,000    7.6 ms 0.985    17 ms 1.00       47 ms 1.00     17 ms 1.00
far 1,000        157 ms 0.815   3.1 ms 1.00    1,876 ms 0.995    24 ms 1.00
far 10,000        36 ms 0.635    20 ms 1.00      316 ms 0.875   239 ms 1.00
far 50,000        15 ms 0.615    18 ms 0.875      66 ms 0.775    17 ms 0.855

With limit 20, a broad filter's 1,280 candidates take 26 to 40 ms.

The filter tests and the declared-schema contract run on both paths. New
tests check that a selective filter finds an admitted record however far
it is from the query; that a broad filter is applied only to the
overfetched candidates and keeps at most `limit` of them; and that an
engine's allowlist search keeps to the allowlist, scores as a search
without one does on each metric, ignores keys with no vector, and finds
nothing for an empty allowlist. The test of a query overtaken by its
partition's deletion covers each path. The test that shutdown closes the
synchronous engine's connections goes with the engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@edwinyyyu
edwinyyyu force-pushed the feat/vector-store-selective-filter branch from 9220a34 to 3f0e973 Compare November 2, 2026 18:03

This branch has not been deployed

No deployments
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