Table of contents Show Hide
The fixed-vector exercise verifies scores, filters, and reopening. This article examines Vectra 0.15.0 storage and update internals: after canceling an update, which states disappear and which files can remain? Those boundaries affect ChatCrystal’s recovery policy.
The current query implementation does not use the described HNSW graph
ChatCrystal’s lockfile pins Vectra 0.15.0. The upstream repository provides implementation and documentation. These observations come from src/LocalIndex.ts, ItemSelector.ts, and default storage shipped in that npm version, without inferring other versions’ behavior.
LocalIndex.queryItems loads the committed items array, applies the supplied metadata filter, traverses candidates to calculate cosine similarity, retains leading results in a bounded min-heap of at most K, and sorts them. Candidate traversal and heap selection are approximately O(N log K). This path does not use the original article’s binary graph/ HNSW hierarchy, m, or efConstruction parameters.
Similarity measures vector direction without proving semantic equivalence; this implementation returns NaN for a zero norm. Model identity, vector space, dimensions, and zero vectors need separate checks. This article has no measurement supporting guaranteed millisecond queries over thousands of items. Requiring no separate database service does not make the npm package dependency-free.
Default JSON layout depends on metadata configuration
Default JsonCodec serializes version, metadata_config, and items into index.json. Items include vectors, norms, and metadata, without always creating the described items/ and graph/ directories. With indexed metadata keys configured, the main index retains only those keys. When other keys exist, it writes a UUID-named metadata file and records metadataFile on the item.
Queries filter using metadata in the main index before loading full external metadata for selected results in parallel. A field in the returned result was not necessarily available for the earlier filter. ChatCrystal’s getIndex calls default createIndex without the exercise’s indexed configuration. The exercise exposes that library branch.
Run a canceled update that leaves a metadata file
Install npm install --ignore-scripts --save-exact vectra@0.15.0 in an isolated directory, save lesson.mjs, and run with Node 24+. All files are in a fresh temporary directory cleaned in finally. No Embedding API or actual ChatCrystal data is used.
import assert from 'node:assert/strict';
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { LocalIndex } from 'vectra';
const folder = await mkdtemp(join(tmpdir(), 'vectra-internals-lesson-'));
try {
const index = new LocalIndex(folder);
await index.createIndex({ version: 1, metadata_config: { indexed: ['noteId'] } });
await index.insertItem({
id: 'kept',
vector: [1, 0],
metadata: { noteId: 1, title: 'kept title' },
});
const stored = JSON.parse(await readFile(join(folder, 'index.json'), 'utf8'));
assert.equal(stored.items.length, 1);
assert.deepEqual(stored.items[0].metadata, { noteId: 1 });
assert.ok(stored.items[0].metadataFile);
const result = await index.queryItems([1, 0], 'unused without BM25', 1, { noteId: 1 });
assert.equal(result[0].item.metadata.title, 'kept title');
assert.equal(result[0].score, 1);
const before = new Set(await readdir(folder));
await index.beginUpdate();
const staged = await index.insertItem({
id: 'canceled',
vector: [0, 1],
metadata: { noteId: 2, title: 'staged title' },
});
assert.ok(staged.metadataFile);
assert.ok((await readdir(folder)).includes(staged.metadataFile));
index.cancelUpdate();
assert.equal(await index.getItem('canceled'), undefined);
assert.equal((await new LocalIndex(folder).getIndexStats()).items, 1);
const leftovers = (await readdir(folder)).filter((name) => !before.has(name));
assert.deepEqual(leftovers, [staged.metadataFile]);
const writer = new LocalIndex(folder);
await writer.insertItem({ id: 'later', vector: [0, 1], metadata: { noteId: 3 } });
assert.equal((await index.getIndexStats()).items, 1);
assert.equal((await new LocalIndex(folder).getIndexStats()).items, 2);
console.log('metadata files, cancel boundaries, and instance cache pass');
} finally {
await rm(folder, { recursive: true, force: true });
}
The committed item stores only noteId in the main file; queryItems restores title in its result. The staged item’s external metadata file is written before endUpdate. After cancelUpdate, neither the committed snapshot nor a reopened index sees canceled, while that file remains. Expect an empty leftovers array and the assertion fails; restore it to pass.
After another instance writes later, the old instance’s cache still reports one item while a new instance reads two. Expect two from the old cache and that assertion fails too. This does not reproduce real product concurrency, damaged files, or a crash. Instance cache and disk state are observed separately.
An update snapshot is not a disk transaction
BeginUpdate loads current data and copies the items array. Item objects remain shared references, so upsert replaces objects to avoid mutating the committed snapshot in place. A repeated beginUpdate throws. CancelUpdate clears the staged snapshot without uniformly deleting already written metadata files.
EndUpdate passes staged data to storage.upsertFile, then changes the committed memory snapshot. Version 0.15.0’s default LocalFileStorage uses fs.writeFile directly, without a transaction spanning every related file. Custom storage/codec implementations can differ. Method names do not establish crash guarantees across all backends.
How ChatCrystal limits visibility of cross-store mismatches
GenerateEmbeddings prepares all vectors before opening an index update. A SQL transaction replaces embeddings mappings and marks syncing; it saves the database, deletes old vectors, ends the index update, then marks done and saves again. Failure attempts to cancel an unfinished index update; after a SQL swap, it marks failed. SQLite and Vectra do not become one atomic transaction.
MaterializeDirectSearchHits accepts only notes with database embedding_status=done and keeps the highest-scoring chunk per noteId. Insufficient candidates can expand the retrieval count; lexical matching and score filtering also apply. Relation expansion selects neighbors with confidence≥0.5 and discounts the direct score by 0.7 times confidence. An already added neighbor is not upgraded by a later higher-scoring path, and the final count can exceed direct topK.
These results are processed by the application, beyond raw Vectra scores. When reading index recovery, record the main index, metadata files, SQL mappings, and note state separately. Rebuild decisions require those actual states and failure samples, without assuming cancelUpdate rolls back every disk write.