Skip to content

DEFERRED (until lifecycle/DDL changes): Route filtered queries by selectivity, and score allowlists exactly (speedkick) - #1602

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

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

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

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 in cannot 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":

  • Selective (at or under the threshold): the probe already holds every matching row_id, so it goes to the engine as an allowlist. One SQL query for the whole query batch.
  • Broad (over it): the search runs unrestricted and survivors are post-filtered with bounded widening — 4× per round up to limit * max_overfetch_factor, then returning what survived rather than widening without end.

allowed_keys: Container[int] becomes allowlist: 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_query fallbacks, and turbovec can now be handed the allowlist its native search already accepts (#1599).

One engine instead of two. _KeyFilter was the only consumer of the store's synchronous SQLAlchemy engine — a second pysqlite engine built from the async URL, with its own connect hook 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:

np.linalg.norm(m, axis=1)   1.78 ms     # 10_000 x 768
m @ q  (BLAS gemv)          0.28 ms

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 under space="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_vectors is the one place scaling happens, and top_k_matches states the precondition it ranks under.

Q=1:  1.89 ms -> 0.31 ms   (6.1x)
Q=8: 15.50 ms -> 2.59 ms   (6.0x)

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

default meaning
selective_filter_limit 10 000 at or under this many matching records, pre-filter and score directly
max_overfetch_factor 64 cap on post-filter widening; a broad query may return fewer than limit

Tests

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_nothing and test_missing_allowlist_keys_ignored for 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_results and excludes_best_match on both engines, plus selective_and_broad_agree. The eight survivors are correctly independent of it: the empty-allowlist tests are caught by the guard in search() before _sync_search, the missing-key tests hold one-key indexes, and the broad-path tests set selective_filter_limit=0 to force the store-side post-filter. Ablating the widening loop fails test_broad_path_widens_until_filled alone.

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 on speedkick: it rejects the .post segment git describe puts into the derived version. Nothing here touches versioning or tags.


🤖 Generated with Claude Code

https://claude.ai/code/session_01NKmF9xNph9QH3ozNw3ZnJL

@edwinyyyu
edwinyyyu force-pushed the feat/vector-store-selective-filter-speedkick branch 2 times, most recently from aacaaed to 3f62a90 Compare September 10, 2026 17:13
@edwinyyyu
edwinyyyu force-pushed the feat/vector-store-selective-filter-speedkick branch 7 times, most recently from 374d247 to f7bd59f Compare September 10, 2026 18:03
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
edwinyyyu force-pushed the feat/vector-store-selective-filter-speedkick branch from f7bd59f to 7365cf3 Compare September 10, 2026 18:08
@edwinyyyu

edwinyyyu commented Sep 10, 2026 •

Copy link
Copy Markdown
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
edwinyyyu marked this pull request as draft September 10, 2026 18:42
@edwinyyyu edwinyyyu changed the title Route filtered queries by selectivity, and score allowlists exactly (speedkick) DEFERRED (until lifecycle/DDL changes): Route filtered queries by selectivity, and score allowlists exactly (speedkick) Sep 10, 2026
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

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