固定向量练习已验证分数、过滤和重开。这里继续看 Vectra 0.15.0 的存储与更新内部:一个更新被取消后,哪些状态消失,哪些文件仍可能存在?这个问题直接关系到 ChatCrystal 的恢复策略。
当前查询实现没有原稿中的 HNSW 图
ChatCrystal 的锁文件固定 Vectra 0.15.0。上游仓库提供实现与说明;本篇结论来自该 npm 版本随包交付的 src/LocalIndex.ts、ItemSelector.ts 和默认存储实现,而不反推其他版本。
LocalIndex.queryItems 加载已提交的 items 数组,先按传入的 metadata filter 筛选,再遍历候选计算余弦相似度,以大小不超过 K 的最小堆保留领先结果,最后排序。该路径的候选遍历与堆选择约为 O(N log K),没有使用原稿所述的 graph/ 二进制 HNSW 分层图、m 或 efConstruction 参数。
相似度衡量向量方向,不证明语义完全相同;零范数在这个实现返回 NaN。模型、向量空间、维度和零向量仍需单独检查。没有本篇实测就不能写“万级必定毫秒返回”。没有独立数据库服务也不等于 npm 包零依赖。
默认 JSON 布局取决于元数据配置
默认 JsonCodec 将 version、metadata_config 和 items 序列化到 index.json;每个 item 包含向量、范数和元数据,未统一创建原稿所述的 items/ 与 graph/ 目录。若配置 indexed 元数据键,主索引只保留这些键;其余键存在时会写一个 UUID 命名的元数据文件,并在 item 中记录 metadataFile。
查询先使用主索引中的元数据过滤;选出结果后,再并行加载外部完整元数据。所以“某个字段在返回结果中出现”不代表它也参与了此前的过滤。ChatCrystal 的getIndex调用默认 createIndex,没有设置本篇练习的 indexed 配置;练习用于暴露库的这条分支。
运行一个会留下元数据文件的取消更新
独立目录安装 npm install --ignore-scripts --save-exact vectra@0.15.0,保存 lesson.mjs,以 Node 24+ 运行。全部文件在新建临时目录中,finally 清理;没有 Embedding API 或实际 ChatCrystal 数据。
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 });
}
提交的 item 在主文件里只有 noteId,queryItems 的返回值补回 title。随后 staged item 的外部元数据文件在 endUpdate 前已经写出。cancelUpdate 之后,已提交快照与重开索引都看不到 canceled,但这个文件仍存在。将 leftovers 的期望改成空数组,断言失败;恢复后通过。
另一个实例写入 later 后,旧实例的缓存仍报告一条,新实例重开报告两条。把旧缓存的期望改成两条,断言也失败。这里没有构造真实产品的并发写入、损坏文件或崩溃;实例缓存和磁盘数据被分别观察。
更新快照与磁盘事务不能画等号
beginUpdate 加载当前数据,复制 items 数组;item 对象仍共享引用,upsert 路径通过替换对象避免原地修改影响已提交快照。重复 beginUpdate 会报错。cancelUpdate 清空暂存快照,不统一删除之前写出的元数据文件。
endUpdate 将暂存数据交给 storage.upsertFile,再切换已提交的内存快照。0.15.0 的默认 LocalFileStorage 直接使用 fs.writeFile;这条实现没有把全部相关文件组成跨文件原子事务。自定义 storage/codec 可能有不同实现,不能从方法名字推断全部后端的崩溃保证。
ChatCrystal 如何限制跨存储不一致的可见性
generateEmbeddings先生成全部向量,再开启索引更新;SQL 事务替换 embeddings 映射并标记 syncing,保存数据库,删除旧向量,完成索引更新,最后标记 done 再保存。失败会尝试取消未完成索引更新;若 SQL 已交换,会标记 failed。这没有把 SQLite 和 Vectra 变成一个原子事务。
搜索 materializeDirectSearchHits 只接收数据库中 embedding_status=done 的笔记,按 noteId 保留最高分 chunk;候选不足时可以扩展检索数。当前还应用词法匹配与分数过滤。关系扩展选 confidence≥0.5 的邻居,使用直接分数×0.7×置信度;已加入的邻居不会因后续另一条路径再换成更高分,最终数量也可能超过直接 topK。
这套搜索结果是应用加工后的结果,而不是纯 Vectra 分数。继续读索引恢复时,分别记录主索引、元数据文件、SQL 映射与笔记状态。选择是否重建应来自这些实际状态和失败样本,而不是假定 cancelUpdate 回滚了所有磁盘写入。