Skip to content

Add VectorSearchEngine backed by turbovec, and take vector read-back out of the vector store contract (speedkick) - #1591

Closed
edwinyyyu wants to merge 6 commits into
MemMachine:speedkickfrom
edwinyyyu:feat/turbovec-engine-speedkick
Closed

edwinyyyu wants to merge 6 commits into
MemMachine:speedkickfrom
edwinyyyu:feat/turbovec-engine-speedkick

Conversation

@edwinyyyu

Copy link
Copy Markdown
Contributor

Copy of #1499 onto speedkick, stacked on #1588 (the speedkick copy of #1460) — its five commits are the first five here, so review only the last six. Cherry-picked cleanly; no conflicts.

Beyond the copy, this carries the change asked for on the port: get is gone from the vector store contract and get_vectors from the engine.

Taking vector read-back out of the contract

VectorStoreCollection.get had exactly one production caller. VectorStoreSemanticStorage.update_feature read a feature's stored embedding back so it could write that same embedding again alongside fresh properties, because upsert demands a whole record and the vector record's properties mirror the relational row. return_vector=True was passed at that one call site and nowhere else, and VectorSearchEngine.get_vectors existed to serve it.

It is replaced by set_properties, which says what the caller actually wanted — replace a record's properties, leave its vector alone:

  • SQLite / sqlite-vec: a plain UPDATE on the records table. Properties live in SQL and vectors live in the index, so this never touches the index — no pending operation, no index save, nothing for a crash to lose.
  • Qdrant: overwrite_payload selected by filter, not by id. An id list raises on an id the collection does not hold, and would reach a point another logical collection owns in the same native collection; the filter form is scoped to the partition and matches nothing when the record is absent.
  • Milvus: Milvus has no partial update, so the read-back survives — inside the one backend that needs it, rather than in the contract every backend has to satisfy.

With no caller left, get, return_vector and get_vectors all go. Worth noting for this PR in particular: turbovec could not implement get_vectors at all — it stores only TurboQuant-compressed vectors, so the method it contributed was raise NotImplementedError. Removing it retires the one contract method a conforming engine was allowed to refuse.

On the semantic memory question: where does the update_feature embedding come from?

SemanticMemory.add_feature computes it as embedder.ingest_embed([value]) and SemanticMemory.update_feature recomputes it the same way, but only when value is not None. So the embedding is a function of value alone, on both paths — reusing the stored vector when value did not change was already correct, and the other two backends (sqlalchemy_pgvector_semantic, neo4j_semantic_storage) express it as a partial UPDATE that simply leaves the embedding column alone.

What looked weird was not the reuse, it was that this one backend expressed it as a read-modify-write through the search index — the least reliable place in the store to route a value that never needed to move. That is what is gone.

Two consequences worth calling out

  1. A record whose vector the index lost is now invisible through the contract. get was the only read that could still see it; query goes through the index. The row survives, and test_a_reverted_publication_costs_search_not_the_record_row reads the row directly to say so. This also makes [sqlite store fixes 1/7] Publish vector index files atomically (but not durably) #1460's last commit ("Report a lost embedding as lost, not as a missing feature") moot — the code it added is deleted here, since there is no longer a read-back that can encounter the case.

  2. get was load-bearing for tests, not for the server. About 40 test call sites used it as their read-by-UUID observation point. They now read through query via a _fetch_records helper per suite, which lists the collection with an arbitrary probe vector and no score threshold. If that trade is not wanted, the alternative is to keep a properties-only get in the contract — get_vectors, return_vector and the read-modify-write all still go, and the tests keep their affordance.

Verification

  • pytest packages/server/server_tests: 2013 passed, 3 skipped. The one failure, test_get_version, is a git-describe version-string artifact of the local checkout and fails identically on the untouched base branch (0.3.9.post2.dev10+g4105a720f).
  • ty check --project packages/server: 17 diagnostics, the same 17 as the base branch.
  • ruff check / ruff format --check / uv lock --check: clean.

Below: the original description of #1499.


Depends on #1460 and is branched from it, so everything here except
Add VectorSearchEngine backed by turbovec and Update turbovec to 1.0.0 and let it publish the index is either that PR's or a merge of main (taken to
resolve uv.lock against the Milvus backend that landed since). Supersedes
#1448.

Purpose of the change

SQLiteVectorStore backed by turbovec is smaller and faster than
SQLiteVecVectorStore backed by sqlite-vec. Both are linear full scans, but
turbovec keeps TurboQuant-compressed vectors in RAM, so its scaling constant is
much smaller and an index is a fraction of the size of the f32 engines'.

Description

Adds TurboVecVectorSearchEngine, a VectorSearchEngine backed by turbovec,
behind a new turbovec optional extra floored at 1.0.0. turbovec indexes a
dimensionality that is a multiple of 8, so any other width is zero-padded up to
one -- exact for both metrics here, since a zero coordinate adds nothing to an
inner product or to an L2 norm -- and every width the other engines accept
works.

Publication is turbovec's own. 1.0.0 is upstream's first stable release, and
what it commits to is the on-disk format: v7 is the only container turbovec
reads or writes, and a file written by 1.0.0 stays readable by later releases.
v7 is also what makes sync possible, and save calls it -- a checkpoint
appends what changed since the last one rather than restating the whole index,
and commits it durably, so a crash at any byte leaves the previous commit
intact and the publication survives a power failure. The engine therefore does
not route through the shared atomic_index_write helper that the hnswlib and
usearch engines use on #1460: wrapping sync would restate the index on every
checkpoint and publish it through the weaker of the two protocols, a rename the
helper's own docstring declines to make durable.

That is what supersedes #1448, which added the same engine against turbovec
0.x, whose write had no publication protocol of its own and wrote straight to
the final path -- an interrupted save left a truncated file where the store
expects a loadable index, and because index_saved makes a published index a
durable contract, that is a hard IndexLoadError rather than a silent rebuild.

Known limitations, both consequences of storing only compressed vectors:

  • get_vectors raises NotImplementedError, since the original vectors are not
    recoverable. That makes the engine usable by EventMemory but not by semantic
    memory, whose feature updates read stored embeddings back.
  • Search is approximate. A self-match lands near the exact score rather than on
    it, so the tests assert ranking and membership rather than magnitudes. The
    quantized inner product also overshoots the cosine range -- 40 of 50
    self-matches at 8 dimensions, and every dimension measured at bit width 2 --
    so SearchMatch scores are clamped to [-1, 1] under a cosine metric, which
    is what its docstring promises and what score_threshold compares against.
    A dot product is unbounded by contract and is not clamped.

Removal is a strong point by comparison: turbovec drops the id from its map
rather than tombstoning it, so deletions stay cheap and a removed key cannot
resurface in results.

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested?

  • Unit Test

test_turbovec_engine.py covers construction, add, remove, cosine and dot
search, filtered search, get_vectors raising, score range (clamped under
cosine, untouched under dot), unaligned widths (search, save/load, and a
wrong-width vector refused), and persistence -- a save/load
round-trip, a load replacing a live index, a save leaving nothing but the index
behind, and what a later checkpoint carries, reading a bulk removal and an
append back through a fresh engine. The module is importorskip-guarded, so the
suite still runs without the extra installed.

Test Results: uv run pytest packages/server/server_tests/memmachine_server/common/vector_store
-> 346 passed. ruff check and ruff format --check clean. The ty jobs are
red on main itself (nebulagraph_python.client lost the members the graph
store imports), fixed by #1519 rather than here; nothing ty reports is in
this diff.

Checklist

  • I have signed the commit(s) within this pull request
  • My code follows the style guidelines of this project (See STYLE_GUIDE.md)
  • I have performed a self-review of my own code
  • I have commented my code
  • My changes generate no new warnings
  • I have added unit tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules ([sqlite store fixes 1/7] Publish vector index files atomically (but not durably) #1460 is still open)
  • I have checked my code and corrected any misspellings

Maintainer Checklist

  • Confirmed all checks passed
  • Contributor has signed the commit(s)
  • Reviewed the code
  • Run, Tested, and Verified the change(s) work as expected

🤖 Generated with Claude Code

https://claude.ai/code/session_01NKmF9xNph9QH3ozNw3ZnJL

@edwinyyyu
edwinyyyu force-pushed the feat/turbovec-engine-speedkick branch from 77ae415 to 0b4d558 Compare September 8, 2026 18:59
@edwinyyyu
edwinyyyu force-pushed the feat/turbovec-engine-speedkick branch 8 times, most recently from f27ab80 to 7719081 Compare September 9, 2026 18:50
edwinyyyu and others added 6 commits September 9, 2026 11:52
turbovec keeps TurboQuant-compressed vectors in RAM, so an index is a
fraction of the size of the f32 engines' and a full scan has a much
smaller scaling constant than sqlite-vec's on-disk one. Search is
approximate as a result: scores land near the exact value rather than on
it, and the tests assert ranking and membership instead of magnitudes.

Two consequences of storing only compressed vectors are worth naming.
`get_vectors` raises `NotImplementedError`, since the originals are not
recoverable -- which makes this engine usable by EventMemory but not by
semantic memory, whose feature updates read stored embeddings back. And
removal is exact and cheap: turbovec drops the id from its map rather
than tombstoning, so a deleted key cannot resurface in results.

`save` publishes through the shared atomic-write helper, like the other
engines, so an interrupted save leaves the previously published index
intact rather than a truncated file the store would treat as a hard
load error. `load` clears any temp file a previous save left behind.

Signed-off-by: Edwin Yu <[email protected]>
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
1.0.0 is upstream's first stable release, and what it commits to is the
on-disk format: v7 is the only container turbovec reads or writes, and a
file written by this release stays readable by later ones. The extra
floors there rather than at 0.7.0.

v7 is also what makes `sync` possible, and that changes how this engine
saves. `save` no longer routes through the shared `atomic_index_write`
helper. turbovec publishes the index itself: a checkpoint appends what
changed since the last one rather than restating the whole index, and
commits it durably -- a crash at any byte leaves the previous commit
intact, and unlike the helper's rename the publication survives a power
failure. Wrapping that would restate the index on every checkpoint and
publish it through the weaker of the two protocols. `load` drops
`clear_stale_index_temp` with it, since the engine no longer writes the
`<path>.tmp` sibling it cleared.

The tests follow. Save leaving no temp file is now an assertion about
the whole directory, since turbovec names its own temp; the stale-temp
test goes with the protocol it tested; and a new test pins what a later
checkpoint carries, reading a bulk removal and an append back through a
fresh engine.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Edwin Yu <[email protected]>
`SearchMatch` documents a cosine score as a cosine similarity in [-1, 1],
and `SQLiteVectorStoreCollection` compares `score_threshold` against it
directly. turbovec's score is a quantized inner product, so it lands a
hair outside that range: at 8 dimensions and the default bit width, 40 of
50 self-matches score above 1.0 (max 1.0066), and at bit width 2 they
exceed it at every dimension measured (1.0038 at 768, 1.0025 at 1536).
Publishing 1.0066 as a cosine similarity is a broken promise, however
small, so the hair is clamped where the match is built.

A dot product is unbounded by contract and passes through untouched.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Edwin Yu <[email protected]>
turbovec indexes a dimensionality that is a positive multiple of 8, so the
engine refused a width every other engine in the tree accepts -- 300, the
width of a spaCy or word2vec vector, among them -- with turbovec's own
error rather than a store-level one.

The width is now rounded up and vectors are written into the leading
columns of a zeroed buffer. Padding is exact for both metrics this engine
serves: a zero coordinate adds nothing to an inner product and nothing to
an L2 norm, so the padded index answers as the unpadded one would. The
same write is what rejects a wrong-width vector, which previously reached
turbovec as a shape it would report in its own terms.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Edwin Yu <[email protected]>
`prepare()` warms turbovec's per-index caches and its lazy id-to-slot map
so the first search, contains or remove after a write does not pay a
one-time cost. Against turbovec 0.8.0 that cost was the whole SIMD-blocked
code layout, rebuilt per add and proportional to the index rather than the
batch -- 199 ms at 100k vectors, which the first unlucky search paid if
this call did not. Upstream made that re-convergence incremental in 1.0.0,
and the call now costs 12-20 us and buys nothing measurable: at 100k a
search after a one-vector add reads 0.52 ms with it and 0.51 ms without,
and in a cold process the first search reads 0.10 ms against 0.09 ms.

The remaining warm it offers -- the id-to-slot map after a load, worth
0.5 ms off the first remove at 100k -- is not on this path, and the map
materializes safely under concurrent readers regardless.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Edwin Yu <[email protected]>
`VectorStoreCollection.get` had one production caller: the semantic
storage read a feature's stored embedding back so it could write that
same embedding again alongside fresh properties, because `upsert` demands
a whole record. `return_vector=True` was passed at exactly that one call
site, and `VectorSearchEngine.get_vectors` existed to serve it.

Replace it with `set_properties`, which says what the caller wanted:
replace a record's properties and leave its vector where it is. On SQLite
and sqlite-vec that is a plain UPDATE on the records table that never
touches the index. On Qdrant it is an `overwrite_payload` selected by
filter rather than by id -- an id list raises on an id the collection
does not hold, and would reach a point another logical collection owns in
the same native one. Milvus has no partial update, so the read-back
survives there, inside the one backend that needs it.

With no caller left, `get`, `return_vector` and `get_vectors` all go.
turbovec could not implement `get_vectors` at all -- it stores only
TurboQuant-compressed vectors -- so this also retires the one contract
method a conforming engine was allowed to raise on.

Two consequences worth naming:

- A feature's embedding is a function of its `value` alone, on add and on
  update alike, so keeping the stored vector when `value` did not change
  is the behavior that was already there. What changes is that it is no
  longer expressed as a read-modify-write through the index.

- `get` was the only read that could see a record whose vector the index
  lost to a reverted publication. `query` goes through the index, so such
  a record is now invisible through the contract; the row survives, and
  the durability test reads the row directly to say so.

Tests read records back through `query` instead, via a `_fetch_records`
helper per suite.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NKmF9xNph9QH3ozNw3ZnJL
@edwinyyyu

Copy link
Copy Markdown
Contributor Author

Superseded by #1598 and #1600: resliced so the contract change lands first and the turbovec engine after it, which means the engine never ships a get_vectors that could only raise.

@edwinyyyu edwinyyyu closed this Sep 9, 2026
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