feat(v2.10): Float32 BLOB vectors — 5x smaller, lossless, migrated in bounded slices - #39
Merged
Conversation
…igrated in the background
memories.content_vector held a JSON array. On a 10,920-row library that was
226 MB — 21,723 bytes per 1024-dim vector — for values that are all
float32-exact: 2,048,000 of 2,048,000 sampled stored numbers satisfy
Math.fround(x) === x. The embedding API returns float32; JSON was spending 5x
the bytes to write the same 32-bit values out in decimal. The same vectors as
Float32 are 43 MB. sqlite-vec's KNN index has always been Float32, so recall
precision does not move.
The column keeps TEXT affinity. SQLite stores BLOBs verbatim in a TEXT column
and typeof() reports 'blob' vs 'text', so there is no schema change and the
existing `IS NOT NULL AND != ''` coverage checks keep matching (verified: a
BLOB compares != '' and counts as covered).
vector-codec.mjs
encodeVector / decodeVector / vectorStorageKind. decode accepts BLOB and
legacy JSON so no reader cares which era a row is from. Copies unaligned
driver Buffers before viewing as Float32Array (a subarray at byteOffset 1
would otherwise throw). Both directions reject non-finite values: a NaN or
a float32-overflowed double, if written, would count as "covered" for every
!= '' check while decoding to null forever. Byte order is the host's
(little-endian on every supported target, same assumption as sqlite-vec).
Migration 013 (index.mjs migrateVectorsToBlob + mcp-server.mjs runner)
Converts legacy rows in place in 200-row transactions. Resumable by
construction: converted rows leave the WHERE, so each batch makes progress
and re-running is a no-op. Both UPDATEs re-check typeof inside the
transaction, so a row another process converted or re-embedded between our
SELECT and our UPDATE is left alone.
It runs as a background loop in the long-lived MCP server, yielding between
batches — NOT in initMemory(). Hook children are spawnSync-killed at ~2.8 s
and every CLI call pays initMemory(); bulk work there gets SIGTERM'd
half-done and taxes every turn until it drains. A 10k-row backlog drains in
seconds after one server start. Never runs COUNT(*) unless asked
(`count: true`): typeof() cannot use an index, so counting is a full scan —
the exact cost this code exists to bound. Whether it drained is known from
the loop (a short batch).
PRAGMA user_version = 13 marks completion; the server passes
skipIfComplete so every later start is a single pragma read. The CLI does
not, so `node index.mjs --migrate-vectors` converts legacy rows that
reappear after completion (restored backup, out-of-band import).
Text that does not decode is set to NULL. It was never a usable vector, and
leaving it makes the != '' checks count it as vectorised forever while the
self-heal sweep skips it forever. NULL lets embedMissingVectors re-embed it.
VACUUM is deliberately left to the operator (exclusive lock; 37 s on the
554 MB library measured today). README says so.
Writers (storeMemoryAsync, embedMissingVectors, recordConversationAsync,
backfill-embeddings) now encode; readers (findNearDuplicates via the
extracted rankNearDuplicateCandidates, memory-health near-dup scan) decode.
On the retired "JSON for cross-tool visibility" comment: a project-wide grep
found 10 files outside this checkout that mention the column — a sibling
engine copy for another agent (opens its own engram-ariel.db, never this
file), six ad-hoc backfill/NULL-check scripts that reference the name but
never parse it, and a worktree of this same repo. So the accurate claim is
that no other process reads this database's column, not that no reader
exists anywhere.
Review fixes (fanout on the draft PR, all five P1s taken):
- the trailing COUNT(*) that ran on every call is gone (opt-in only)
- migration moved out of initMemory() into the server's background loop
- encodeVector rejects non-finite, symmetric with decodeVector
- --migrate-vectors has the file's standard try/catch and exit codes
- rankNearDuplicateCandidates extracted so the write-gate's dual-format
read is tested without sqlite-vec
Tests: vector-blob.integration.test.mjs, 49 checks — codec round-trip
bit-identical on float32 input; every column state a reader can meet (BLOB,
JSON, '', NULL, junk, misaligned, wrong length, NaN, non-finite on encode);
migration bounded (limit=2), reports remaining=null when cut short without
count, resumes, clears junk, sets the marker only when drained, is
idempotent, honours skipIfComplete only when asked; memory-health and the
write-gate ranker each pair a legacy JSON row with a BLOB row. All 18
existing suites pass unchanged, including the hooks e2e that boots the
modified server. Two smokes outside the suite: the server runner converted
450 seeded legacy rows and set the marker within 4 s of boot; the CLI
converted 5 legacy + cleared 1 junk row with the marker pre-set, exit 0.
Co-Authored-By: 千夏 <qianxia@clawgamers.com>
MXAntian
force-pushed
the
feat/vector-blob
branch
from
September 3, 2026 16:15
beac907 to
18c110a
Compare
MXAntian
marked this pull request as ready for review
September 3, 2026 16:18
2 tasks
MXAntian
added a commit
that referenced
this pull request
Sep 3, 2026
initMemory() builds memories_fts with the libsimple Chinese tokenizer when the extension is present — true in the deployed runtime, false in CI. Two suites open a second raw better-sqlite3 connection to seed rows directly, and on INSERT the FTS trigger needs the tokenizer on THAT connection too. Without it: SqliteError: no such tokenizer: simple so vector-blob.integration.test.mjs (new in #39) and memory-health.test.mjs (pre-existing) both crashed before printing a verdict when run from the runtime checkout, while passing in CI. A test that only passes where the extension is absent is not testing the environment it ships to. cold-pool-gate.test.mjs already carried the fix (tryLoadSimple); this copies that helper into the other two. No-op where the extension is absent, so CI is unchanged. Also observed while verifying: hooks/hooks.test.mjs case 10 ("pinned DB answers from that DB") is load-sensitive in the libsimple environment — a fresh pinned DB per hook call pays the jieba dictionary load plus an FTS unicode61→simple migration inside the hook's 2.8 s spawn budget, and it failed 2 of 4 runs while other suites ran concurrently, passing when quiet. Not changed here; noted so the next person does not bisect it against unrelated code as I did. Co-authored-by: 千夏 <qianxia@clawgamers.com>
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.
Summary
memories.content_vectormoves from a JSON array to a Float32 BLOB (host byte order, same assumption as sqlite-vec). Measured on a 10,920-row library: 226 MB → 43 MB, and lossless — 2,048,000 / 2,048,000 sampled stored values are float32-exact (Math.fround(x) === x), i.e. the API already returns float32.typeof()separates the eras. ExistingIS NOT NULL AND != ''checks keep matching.typeofso a concurrent converter/re-embedder is never overwritten.PRAGMA user_version = 13marks completion; the server honours it (skipIfComplete) so later starts are one pragma read. Unparseable text → NULL so the self-heal sweep re-embeds it.node index.mjs --migrate-vectorsfinishes it in one go and deliberately ignores the marker, so legacy rows that reappear (restored backup, out-of-band import) get converted. VACUUM is left to the operator (exclusive lock).On the retired "JSON for cross-tool visibility" comment
A project-wide grep for
content_vectorfound 10 files outside this checkout:ariel-workspace/memory-engram/index.mjs— a sibling engine copy for another agent; opens its ownengram-ariel.db, never this file.chinatsu-workspace/.tmp/andskill-audit/— reference the column name, noneJSON.parseit.So the accurate claim is no other process reads this database's column, not "no reader anywhere". The commit message and the code comment say that.
Review fixes (fanout on the draft — all five P1s taken, plus five P2s)
COUNT(*)full scan on every callremainingis0when drained,nullwhen cut short, exact only withcount: true(tested)spawnSynckills at ~2.8 s)initMemory(); runs in the MCP server's background loop + CLI onlyencodeVectoraccepted NaN/Infinity/float32 overflow--migrate-vectorshad no try/catchSQLITE_BUSYrankNearDuplicateCandidatesextracted fromfindNearDuplicates; tested with JSON + BLOB + junk + orthogonal candidatesAND typeof(content_vector) = 'text'conversations.content_vectorstill JSONTest plan
vector-blob.integration.test.mjs— 49 checks: codec (both directions reject non-finite; misaligned Buffer; every column state); migration bounded,remaining=nullwithoutcount, resume, junk→NULL, marker only when drained, idempotent,skipIfCompletehonoured only when asked;memory-healthand the write-gate ranker each pair a legacy JSON row with a BLOB rowhooks/hooks.test.mjswhich boots the modified serveruser_version=13within 4 s of boot; runner log line present{"converted":5,"skipped":1,"drained":true}, exit 0VACUUMlocked 2.2 s, 412 → 223 MB (vector bytes 226 → 42.7 MB); integrity ok. Follow-up test: load the libsimple tokenizer on every raw test connection #40 fixes a test-harness portability gap found while verifying (no such tokenizer: simplewhere libsimple is present).🤖 Generated with Claude Code