Repository navigation
DEFERRED (until lifecycle/DDL changes): Route filtered queries by selectivity, and score allowlists exactly (speedkick) - #1602
Draft
edwinyyyu wants to merge 1 commit into
Conversation
This was referenced Sep 10, 2026
edwinyyyu
force-pushed
the
feat/vector-store-selective-filter-speedkick
branch
2 times, most recently
from
September 10, 2026 17:13
aacaaed to
3f62a90
Compare
edwinyyyu
force-pushed
the
feat/vector-store-selective-filter-speedkick
branch
7 times, most recently
from
September 10, 2026 18:03
374d247 to
f7bd59f
Compare
A property filter reached the engine as a `Container[int]` -- a predicate backed by SQL, one point query per candidate the search touched, memoized within a call. An HNSW traversal touches hundreds to thousands of candidates, so a filtered query paid that many round trips, and a container that only answers `in` cannot be handed to an engine that has a native allowlist. The filter now resolves before the search. A LIMIT probe fetches one row past `selective_filter_limit`: at or under it the probe already holds every matching row_id, which goes to the engine as an allowlist; over it, the search runs unrestricted and survivors are post-filtered with bounded widening, up to `limit * max_overfetch_factor`, returning what survived rather than widening without end. `allowed_keys: Container[int]` therefore becomes `allowlist: Collection[int]`, and hnswlib and usearch answer an allowlist by gathering those vectors and scoring them directly. That is exact where the previous path was not: overfetching and post-filtering can miss allowed vectors the unrestricted search never reaches, while gather-and-score reaches every one. hnswlib no longer threads a filter callback through its knn_query fallbacks, and turbovec can now be handed the allowlist its native search takes. `_KeyFilter` was the only consumer of the store's synchronous SQLAlchemy engine, so that engine and its connect hook go with it; the store opens one engine instead of two. Scoring a gathered vector by cosine meant dividing by its norm, and that division was most of the work: the row-norm pass is unvectorized where the matmul is BLAS, so it cost six times the similarity it guarded. It was also repeated per query vector, over a matrix gathered once. Vectors are scaled to unit length on the way in instead -- usearch on add, where its scale-invariant metric is indifferent to the change, and hnswlib already does it under the cosine space -- and queries at the top of the search, before the allowlist branch. Scoring is then a plain inner product, which for unit vectors is the cosine similarity, and the helpers say so: `cosine_similarities` becomes `inner_products`, `unit_normalize` is where scaling happens, and `top_k_matches` states the precondition it ranks under. `scoring.py` is `utils.py` now, since it holds an ingest helper beside the scoring ones, matching the sibling packages. Scores are clamped onto the cosine range, which f16 storage can otherwise carry a fraction outside. Tests come with it: neither engine had an allowlist test, neither knob was exercised, and no test passed a vector that was not already unit. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NKmF9xNph9QH3ozNw3ZnJL
edwinyyyu
force-pushed
the
feat/vector-store-selective-filter-speedkick
branch
from
September 10, 2026 18:08
f7bd59f to
7365cf3
Compare
Contributor
Author
|
Worse than before without indexes due to full scan. Wait for DDL/lifecycle changes to implement properly if that would help, or close if determined that would not help. Performance improves greatly with properties promoted to columns and proper indexes. |
edwinyyyu
marked this pull request as draft
September 10, 2026 18:42
edwinyyyu
added a commit
to edwinyyyu/MemMachine
that referenced
this pull request
Sep 11, 2026
An in-memory index over 4-bit TurboQuant codes. It scans every vector like the engines beside it, so it changes no asymptotics; what it changes is the memory traffic that an exhaustive scan is bound by, which 4-bit codes cut about eightfold. Landing after the read-back removal is deliberate: the engine does not keep the vectors it is given -- the codes are lossy and the originals are not retained -- so against the older contract it would have had to supply a `get_vectors` that could only raise. The one method a conforming engine was allowed to refuse would have been introduced and retired inside one stack. Scores are clamped onto [-1, 1]: quantization can carry an inner product slightly outside it, and a similarity that reads 1.0000001 is a lie about the range the contract promises. Vectors are zero-padded to a multiple of eight, which is the width the index can hold, and normalized on the way in so the inner product it computes is cosine. turbovec has a native allowlist search, but it needs the allowed ids enumerated, and the engine contract's `allowed_keys` only answers membership. So the engine fetches unrestricted, drops what the filter rejects, and widens the fetch until `limit` survive or the whole index has been scanned, as usearch does. Handing turbovec the ids directly waits for the contract to carry them (MemMachine#1602). Like the engines beside it since MemMachine#1612, it holds no lock of its own: the store serializes access. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Purpose of the change
A property filter reached the search engine as a
Container[int]— a predicate backed by SQL, issuing one point query per candidate the search touched, memoized within a call. An HNSW traversal touches hundreds to thousands of candidates, so a filtered query paid that many round trips.It also closed off a capability. A container that only answers
incannot be handed to an engine that has a native allowlist, so no engine could restrict its scan to the allowed set — every one of them had to search unrestricted and discard.Description
The filter resolves before the search. A LIMIT probe fetches one row past
selective_filter_limit— the extra row is what separates "at most this many" from "more than this many":row_id, so it goes to the engine as an allowlist. One SQL query for the whole query batch.limit * max_overfetch_factor, then returning what survived rather than widening without end.allowed_keys: Container[int]becomesallowlist: Collection[int], and hnswlib and usearch answer an allowlist by gathering those vectors and scoring them directly.That change is about correctness, not only cost. Overfetch-and-post-filter is approximate: allowed vectors the unrestricted search never reaches are simply missed. Gather-and-score reaches every one. hnswlib also stops threading a filter callback through its
knn_queryfallbacks, and turbovec can now be handed the allowlist its native search already accepts (#1599).One engine instead of two.
_KeyFilterwas the only consumer of the store's synchronous SQLAlchemy engine — a secondpysqliteengine built from the async URL, with its ownconnecthook registering the foreign-keys pragma. Both go with it.Scoring is an inner product now
Scoring a gathered vector by cosine meant dividing by its norm, and that division was most of the work — the row-norm pass is unvectorized where the matmul is BLAS:
85% of the time, guarding the 15%. It was also recomputed per query vector, over a matrix gathered once.
Vectors are scaled to unit length on the way in instead — usearch on
add, where its scale-invariant metric is indifferent to the change; hnswlib already does it underspace="cosine"— and queries at the top of the search, before the allowlist branch. Scoring is then a plain inner product, which for unit vectors is the cosine similarity, and the helpers say so:cosine_similarities→inner_products,unit_vectorsis the one place scaling happens, andtop_k_matchesstates the precondition it ranks under.Scores are clamped onto
[-1, 1]: usearch's f16 storage returns gathered vectors at 0.999–1.0009, which the old division hid and a raw dot does not. That is O(k), not O(N·D).New knobs
selective_filter_limitmax_overfetch_factorlimitTests
Neither engine had a single allowlist test, and neither knob was exercised. This adds 21, and no test passed a vector that was not already unit. Eight new ones pin that magnitudes never reach the score, on both the engine's own path and the allowlist path. The other 13:
test_allowlist_restricts_results,test_allowlist_excludes_best_match,test_empty_allowlist_returns_nothingandtest_missing_allowlist_keys_ignoredfor hnswlib and usearch, plus five store-level regime tests covering ranking, sparse filters, widening, the widening cap, and that the two regimes agree.I checked they discriminate rather than trusting a green run. Ablating the engine allowlist path fails exactly five —
restricts_resultsandexcludes_best_matchon both engines, plusselective_and_broad_agree. The eight survivors are correctly independent of it: the empty-allowlist tests are caught by the guard insearch()before_sync_search, the missing-key tests hold one-key indexes, and the broad-path tests setselective_filter_limit=0to force the store-side post-filter. Ablating the widening loop failstest_broad_path_widens_until_filledalone.Verification
pytest packages/server/server_tests: 1919 passed, 3 skipped, 1 failed.ty check --project packages/server: clean.ruff check/ruff format --check: clean.The one failure is
test_get_version, pre-existing onspeedkick: it rejects the.postsegmentgit describeputs into the derived version. Nothing here touches versioning or tags.🤖 Generated with Claude Code
https://claude.ai/code/session_01NKmF9xNph9QH3ozNw3ZnJL