GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — [selective filter 1] Route filtered queries by selectivity, and search allowlists exhaustively by edwinyyyu · Pull Request #1871 · MemMachine/MemMachine

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Oct 28, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

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:

  • Its probe runs over [declared schema 1/2] Make a vector store filter only on the properties it declares #1628's indexed columns, where DEFERRED (until lifecycle/DDL changes): Route filtered queries by selectivity, and score allowlists exactly (speedkick) #1602's ran over the JSON column, a full scan.
  • It scores each of the engines' metrics (cosine, dot, and Euclidean) as a search without an allowlist does, where DEFERRED (until lifecycle/DDL changes): Route filtered queries by selectivity, and score allowlists exactly (speedkick) #1602, on cosine-only engines, scaled vectors to unit length when they were added and clamped inner products onto the cosine range.
  • A broad filter gets one fetch of limit * overfetch_factor candidates, where DEFERRED (until lifecycle/DDL changes): Route filtered queries by selectivity, and score allowlists exactly (speedkick) #1602 widened four times per round up to that many.

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
This was referenced Oct 28, 2026
Make no memory request create a project #1624
Draft
Refuse a filter value of the wrong type for a datetime column, and answer an invalid list filter with 422 (port of #1620) #1786
Draft
Refuse property values that some store refuses or alters where an episode enters #1792
Draft
Refuse a repeated record UUID or a non-finite property value at upsert, and state the datetime property contract #1788
Closed
[declared schema 1/2] Make a vector store filter only on the properties it declares #1628
Merged
[sqlite write transactions 1] Take SQLite's write lock at BEGIN in every store on a relational SQLite engine #1844
Merged
[session storage 1/2] Create a session's storage with the session, never on a request #1622
Draft
[session storage 2/2] Look a partition up with get_partition, and remove open-or-create from both stores and close from the segment store #1625
Draft
[search results, deferred] Score every vector search by cosine similarity, and name scores for it (port of #1598's cosine half) #1663
Draft
[declared schema 2/2] Close the filter union, type each filter field when it is parsed, check records at upsert, and make negation the complement on every backend but NebulaGraph #1616
Draft
[sqlite store fixes 1/7] Publish vector index files atomically (but not durably) #1460
Draft
[sqlite store fixes 2/7] Never reuse a row id in SQLiteVectorStore #1469
Draft
[sqlite store fixes 3/7] Own the search engine's concurrency in the store, not in each engine (port of #1612) #1672
Draft
[sqlite store fixes 4/7] Serialize a partition's writes so the engine sees them in order (port of #1607) #1673
Draft
[sqlite store fixes 5/7] Refuse a pending row replay cannot honor, instead of dropping it (port of #1608) #1674
Draft
[sqlite store fixes 7/7] Give every write a fresh row id, so a key names one version (port of #1610) #1676
Draft
[sqlite store fixes 8] Hold the engine lock until a cancelled engine call's thread finishes #1843
Draft
[qdrant options] Let a deployment tune a Qdrant collection's HNSW, optimizers and quantization #1618
Draft
[milvus options] Let a deployment tune a Milvus collection's vector index and its searches #1741
Draft
Pack vector store upserts by serialized size, and state what cancelling one guarantees #1841
Closed
[event memory recovery 1/2] Give EventMemory a store's shape, with a handle per partition #1839
Merged
[event memory recovery 2] Keep event memory's two stores consistent through failed ingests and forgets #1840
Draft
[property keys] Refuse an illegal metadata key before any store writes, and reserve memmachine_ key prefix #1828
Merged
[schema migration 1/2] Add memmachine-server migrate, which creates every configured store's schema before servers start #1825
Draft
[schema migration 2/2] Make startup check what migrate created, and create nothing at startup #1826
Draft
@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
@edwinyyyu @claude
Route filtered queries by selectivity, and search allowlists exhausti…
3f0e973
…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 <[email protected]>
@edwinyyyu
edwinyyyu force-pushed the feat/vector-store-selective-filter branch from 9220a34 to 3f0e973 Compare November 2, 2026 18:03
@edwinyyyu edwinyyyu mentioned this pull request Nov 6, 2026
[partition registry] Mint every store's incarnation in one partition registry per memory system #1947
Draft

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

Reviewers

No reviews

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

1 participant

@edwinyyyu
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.