Skip to content

feat(v2.10): Float32 BLOB vectors — 5x smaller, lossless, migrated in bounded slices - #39

Merged
MXAntian merged 1 commit into
mainfrom
feat/vector-blob
Sep 3, 2026
Merged

feat(v2.10): Float32 BLOB vectors — 5x smaller, lossless, migrated in bounded slices#39
MXAntian merged 1 commit into
mainfrom
feat/vector-blob

Conversation

@MXAntian

@MXAntian MXAntian commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • memories.content_vector moves 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.
  • No schema change: the column keeps TEXT affinity; SQLite stores BLOBs verbatim and typeof() separates the eras. Existing IS NOT NULL AND != '' checks keep matching.
  • Migration 013 converts in place in 200-row transactions, as a background loop in the long-lived MCP server (yielding between batches) — never in hook children or ordinary CLI calls, which run under a spawn timeout. Resumable; both UPDATEs re-check typeof so a concurrent converter/re-embedder is never overwritten. PRAGMA user_version = 13 marks 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-vectors finishes 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).
  • Readers decode both formats; a half-migrated library is fully functional.

On the retired "JSON for cross-tool visibility" comment

A project-wide grep for content_vector found 10 files outside this checkout:

  • ariel-workspace/memory-engram/index.mjs — a sibling engine copy for another agent; opens its own engram-ariel.db, never this file.
  • 6 ad-hoc backfill / NULL-check scripts in chinatsu-workspace/.tmp/ and skill-audit/ — reference the column name, none JSON.parse it.
  • 3 files in a git worktree of this same repo — not external.

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)

finding fix
P1 trailing COUNT(*) full scan on every call removed; remaining is 0 when drained, null when cut short, exact only with count: true (tested)
P1 migration ran inside hook children (spawnSync kills at ~2.8 s) removed from initMemory(); runs in the MCP server's background loop + CLI only
P1 encodeVector accepted NaN/Infinity/float32 overflow rejects non-finite, symmetric with decode (tested)
P1 --migrate-vectors had no try/catch file-standard try/catch, exit 1 with a re-run hint on SQLITE_BUSY
P1 write-gate ranker untested on mixed formats rankNearDuplicateCandidates extracted from findNearDuplicates; tested with JSON + BLOB + junk + orthogonal candidates
P2 UPDATE by rowid could overwrite a concurrent re-embed both UPDATEs carry AND typeof(content_vector) = 'text'
P2 endianness implicit documented in the codec header
P2 schema comment implied sqlite-vec reads this column reworded: only application-layer cosine reads it
P2 one-way marker unexplained README callout + CLI ignores marker (tested)
P2 conversations.content_vector still JSON writer now encodes too (no reader exists, confirmed by the review)

Test plan

  • vector-blob.integration.test.mjs49 checks: codec (both directions reject non-finite; misaligned Buffer; every column state); migration bounded, remaining=null without count, resume, junk→NULL, marker only when drained, idempotent, skipIfComplete honoured 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 locally unchanged, including hooks/hooks.test.mjs which boots the modified server
  • server smoke: 450 seeded legacy rows → 450 BLOBs + user_version=13 within 4 s of boot; runner log line present
  • CLI smoke: 5 legacy + 1 junk with marker pre-set → {"converted":5,"skipped":1,"drained":true}, exit 0
  • CI on the amended head
  • after merge (2026-09-04): runtime fork rebased (6 private commits, 2 expected conflicts); server restarted, up in 105 ms; background runner converted 10,925 / 10,925 rows, 0 unparseable, in 2.4 s; VACUUM locked 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: simple where libsimple is present).

🤖 Generated with Claude Code

…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
MXAntian marked this pull request as ready for review September 3, 2026 16:18
@MXAntian
MXAntian merged commit 400f004 into main Sep 3, 2026
2 checks passed
@MXAntian
MXAntian deleted the feat/vector-blob branch September 3, 2026 16:18
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>
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