跳到正文
曾亮懿 ZengLiangYi Blog

Vectra 索引内部:元数据文件、取消更新与实例缓存

核对 Vectra 0.15.0 的真实查询和存储路径,在临时索引中观察外部元数据、取消后的文件残留,以及不同实例的缓存状态。

曾亮懿

曾亮懿

阅读约 4 分钟
目录 展开 收起

固定向量练习已验证分数、过滤和重开。这里继续看 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 回滚了所有磁盘写入。

分享:

相关文章

AI 对话格式差异:事件、快照与跨库消息定位

对照五个固定适配器,区分 JSONL 容器、事件语义与 SQLite 键布局,用两个内存数据库观察消息索引、孤立数据和未知版本。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #SQLite

AI 对话管理方案:四种存储路径与迁移验收

比较 Claude Code、Cursor、Copilot、Trae 的固定适配器输入,区分原始副本、可读导出与知识库,并用合成清单验证身份和信息保留。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #SQLite

sql.js WASM 持久化:内存、导出、外键与保存点

对照 ChatCrystal 的数据库生命周期,运行 sql.js 1.14.2 样本,区分 SQL 提交、二进制导出、文件写入、外键恢复和嵌套回滚。

曾亮懿 曾亮懿
阅读约 5 分钟
#ChatCrystal #Node.js #SQLite

从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录,欢迎交流实现细节。