---
title: Vectra 索引内部：元数据文件、取消更新与实例缓存
description: 核对 Vectra 0.15.0 的真实查询和存储路径，在临时索引中观察外部元数据、取消后的文件残留，以及不同实例的缓存状态。
image: "https://zengliangyi.com/og/blog/vectra-search-internals.png"
---

[固定向量练习](/blog/vectra-local-search/)已验证分数、过滤和重开。这里继续看 Vectra 0.15.0 的存储与更新内部：一个更新被取消后，哪些状态消失，哪些文件仍可能存在？这个问题直接关系到 ChatCrystal 的恢复策略。

## 当前查询实现没有原稿中的 HNSW 图

ChatCrystal 的[锁文件](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/pnpm-lock.yaml)固定 Vectra 0.15.0。[上游仓库](https://github.com/Stevenic/vectra)提供实现与说明；本篇结论来自该 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](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/vector-index.ts)调用默认 createIndex，没有设置本篇练习的 indexed 配置；练习用于暴露库的这条分支。

## 运行一个会留下元数据文件的取消更新

独立目录安装 `npm install --ignore-scripts --save-exact vectra@0.15.0`，保存 lesson.mjs，以 Node 24+ 运行。全部文件在新建临时目录中，finally 清理；没有 Embedding API 或实际 ChatCrystal 数据。



```js
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](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/embedding.ts)先生成全部向量，再开启索引更新；SQL 事务替换 embeddings 映射并标记 syncing，保存数据库，删除旧向量，完成索引更新，最后标记 done 再保存。失败会尝试取消未完成索引更新；若 SQL 已交换，会标记 failed。这没有把 SQLite 和 Vectra 变成一个原子事务。

搜索 materializeDirectSearchHits 只接收数据库中 embedding_status=done 的笔记，按 noteId 保留最高分 chunk；候选不足时可以扩展检索数。当前还应用词法匹配与分数过滤。关系扩展选 confidence≥0.5 的邻居，使用直接分数×0.7×置信度；已加入的邻居不会因后续另一条路径再换成更高分，最终数量也可能超过直接 topK。

这套搜索结果是应用加工后的结果，而不是纯 Vectra 分数。继续读[索引恢复](/blog/vectra-index-recovery/)时，分别记录主索引、元数据文件、SQL 映射与笔记状态。选择是否重建应来自这些实际状态和失败样本，而不是假定 cancelUpdate 回滚了所有磁盘写入。

分享：

##  相关文章

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

对照五个固定适配器，区分 JSONL 容器、事件语义与 SQLite 键布局，用两个内存数据库观察消息索引、孤立数据和未知版本。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-format-divergence)    [##  AI 对话管理方案：四种存储路径与迁移验收

比较 Claude Code、Cursor、Copilot、Trae 的固定适配器输入，区分原始副本、可读导出与知识库，并用合成清单验证身份和信息保留。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-management-options)    [##  sql.js WASM 持久化：内存、导出、外键与保存点

对照 ChatCrystal 的数据库生命周期，运行 sql.js 1.14.2 样本，区分 SQL 提交、二进制导出、文件写入、外键恢复和嵌套回滚。          曾亮懿       2026年10月7日      阅读约 5 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/sqljs-wasm-persistence)

##  从文章到代码

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

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Vectra 索引内部：元数据文件、取消更新与实例缓存",
  "description": "核对 Vectra 0.15.0 的真实查询和存储路径，在临时索引中观察外部元数据、取消后的文件残留，以及不同实例的缓存状态。",
  "url": "https://zengliangyi.com/blog/vectra-search-internals/",
  "image": "https://zengliangyi.com/images/blog/vectra-search-internals.svg",
  "datePublished": "2026-10-07T00:00:00.000Z",
  "dateModified": "2026-10-07T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/vectra-search-internals/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "Vectra 索引内部：元数据文件、取消更新与实例缓存",
      "item": "https://zengliangyi.com/blog/vectra-search-internals/"
    }
  ]
}
```
