本文面向:想了解纯文件型本地向量库 vectra 内部机制的开发者。
预计阅读时间:10 分钟
最终效果:理解 vectra 的单文件存储、内存内线性扫描 + 余弦相似度、事务式写入,以及它与 SQLite 协同的搜索流程。
为什么选 vectra
ChatCrystal 需要一个本地运行、无需外部服务的向量数据库。候选方案有几个:
- FAISS:Meta 开源,性能极好,但 C++ 绑定在 Node.js 环境下部署复杂
- HNSWLib:纯 JS 实现可用,但维护活跃度下降
- vectra:Steven Ickman(Microsoft)开发,纯 TypeScript,零原生依赖,API 简洁
vectra 的核心优势是零依赖部署 。它不需要启动额外的数据库进程,不需要 Docker,不需要编译原生模块。一个 LocalIndex 实例指向磁盘上的一个目录,所有数据以 JSON 和二进制文件的形式存储在那里。对于 ChatCrystal 这种桌面应用场景------用户本地安装、不想折腾基础设施------这是决定性优势。
文件结构:索引就是一个目录
当你调用 new LocalIndex('./vectra-index') 时,vectra 会在指定路径创建一个目录。但和很多向量库不同,它的数据几乎全部集中在一个文件里:
vectra-index/
└── index.json # 索引的全部:配置 + 所有向量 + 元数据
vectra 的 LocalIndex 源码里直接把这个文件命名为 index.json。它既存全局配置,也把每一个向量及其元数据 inline 存在 items 数组里:
json
{
"version": 1,
"metadata_config": {},
"items": [
{
"id": "abc-123",
"vector": [0.023, -0.156, 0.089, ...],
"norm": 1.0,
"metadata": {
"noteId": 42,
"chunkIndex": 0,
"title": "Fastify 生命周期钩子"
}
}
]
}
只有当你显式声明要为某些元数据字段建独立索引时,vectra 才会额外生成一批 metadata 文件;否则一切都在 index.json 里。ChatCrystal 调用 createIndex() 时不带任何参数(见 vector-index.ts),所以没有额外文件------整个索引就是这一个 JSON。
这种设计的好处是透明可调试 。你可以直接用文本编辑器打开 index.json 看索引状态,数里面有多少 item。出了问题,不需要专用工具就能排查。代价是 vectra 必须把整个文件读进内存才能查询。
检索机制:全量加载 + 内存内线性扫描
很多人(包括早期的我)会以为 vectra 用了 HNSW 这类近似最近邻(ANN)算法。实际不是。翻开 vectra 的 LocalIndex.queryItems 源码,它的检索是最朴素的方式:
loadIndexData()把整个index.json的items数组读进内存- 对每一个 item 计算与查询向量的余弦相似度
- 用一个大小为 K 的小顶堆维护当前 top-K,边扫边淘汰
源码的核心就是一个遍历所有 item 的 for 循环,注释自陈复杂度是 O(N log K)------N 是向量总数,K 是要返回的条数。换句话说,这是暴力线性扫描,不是 O(log N) 的图检索:
typescript
// vectra LocalIndex.queryItems 的核心逻辑(简化)
let items = this._data.items;
if (filter) items = items.filter((i) => ItemSelector.select(i.metadata, filter));
for (let i = 0; i < items.length; i++) {
const distance = ItemSelector.normalizedCosineSimilarity(
vector, norm, items[i].vector, items[i].norm,
);
// distance 比堆顶大就替换,维护大小为 K 的 top-K 堆
}
这意味着 vectra 的检索成本随向量总数线性增长。官方定位是"small indexes 有 sub-millisecond 延迟"------快是因为规模小,不是因为算法有跳跃加速结构。当 ChatCrystal 积累了上千条笔记、每条切成多个 chunk、总共上万个向量时,这种线性扫描在桌面端依然是毫秒级,足够用;但它没有任何"跳到目标区域"的近似索引,规模真正上去后只能靠换库解决。
余弦相似度:排序依据
vectra 用**余弦相似度(Cosine Similarity)**作为向量距离度量。余弦相似度衡量两个向量方向的一致性:
cosine_similarity(A, B) = (A · B) / (|A| × |B|)
- 值为 1:方向完全相同(语义完全一致)
- 值为 0:正交(语义无关)
- 值为 -1:方向完全相反
实际使用中,Embedding 模型输出的向量通常是归一化的(模为 1),此时余弦相似度退化为简单的点积运算。
在搜索时,vectra 对索引中的每个向量计算余弦相似度,再按得分排序返回 Top-K 结果。
元数据过滤:不只是向量搜索
vectra 支持在搜索时附加元数据过滤条件。这在 ChatCrystal 中非常实用------你可能想搜索"某个项目下"或"某个标签下"的知识。
typescript
// 按 noteId 查找某个笔记的所有 chunk
const items = await index.listItemsByMetadata({ noteId: 42 });
// 按项目名过滤(filter 是 queryItems 的第 4 个参数,直接传元数据条件)
const results = await index.queryItems(queryVector, 'search query', 10, {
projectName: 'chatcrystal',
});
元数据存储在每个 item 文件中,过滤在内存中完成。这意味着过滤不会加速搜索(仍需遍历候选集),但能精确缩小结果范围。
ChatCrystal 在两个场景中使用元数据过滤:
- 笔记更新时 :先用
listItemsByMetadata({ noteId })找到旧 chunk,删除后再插入新 chunk - 搜索去重:同一个笔记可能有多个 chunk 命中,保留得分最高的那个
beginUpdate/endUpdate:事务式写入
向量索引的写入不是原子的------插入一个 chunk 涉及修改图结构、写入 item 文件、更新 index.json。如果写到一半进程崩溃,索引可能处于不一致状态。
vectra 通过 beginUpdate() / endUpdate() / cancelUpdate() 提供了事务式写入:
typescript
await index.beginUpdate();
try {
// 所有写入操作在 endUpdate 之前不会持久化到磁盘
for (const chunk of chunks) {
await index.insertItem({
vector: chunk.embedding,
metadata: { noteId: 42, chunkIndex: chunk.index }
});
}
for (const oldId of oldVectraIds) {
await index.deleteItem(oldId);
}
// endUpdate 原子性地提交所有变更
await index.endUpdate();
} catch (error) {
// 取消所有未提交的变更
await index.cancelUpdate();
throw error;
}
ChatCrystal 在 generateEmbeddings() 函数中严格使用这个模式。更新一个笔记的向量分三步:
beginUpdate()开启事务- 插入新 chunk 的向量,写入 SQLite 的 embeddings 表
- 删除旧 chunk 的向量,
endUpdate()提交
如果任何步骤失败,cancelUpdate() 会回滚 vectra 的变更,SQLite 侧也通过 withTransaction() 保证一致性。这确保了 vectra 索引和 SQLite 数据库始终保持同步。
搜索流程:从查询到结果
完整的语义搜索流程:
用户输入 "Fastify 插件注册"
↓
Embedding API → 查询向量 [0.023, -0.156, ...]
↓
vectra 线性扫描 → 返回 Top-K 个 chunk(含 noteId、chunkIndex、score)
↓
SQLite 查询 → 补充 chunk 原始文本(embeddings 表)
↓
去重 → 同一笔记保留最高分 chunk
↓
关系扩展(可选)→ 沿 note_relations 表的边找到关联笔记
↓
按得分排序 → 返回结果
关系扩展是 ChatCrystal 的特色功能。当 expandRelations=true 时,搜索不仅返回直接命中的笔记,还会沿知识图谱的边找到关联笔记,并以原始得分 × 0.7 × 关系置信度的折扣分数加入结果。这让用户能发现"间接相关"的知识。
vectra 的局限
vectra 不是万能的。它的设计定位是轻量级本地索引,有几个明确的限制:
- 单进程写入:不支持并发写入,必须通过 beginUpdate/endUpdate 串行化
- 全量加载 :搜索时需要将整个
index.json加载到内存,万级以上向量会占用可观内存 - 无持久化过滤索引:元数据过滤是内存操作,不像数据库有索引加速
- 无近似检索:全程精确线性扫描,没有 HNSW / IVF 这类加速结构,检索成本随向量数线性增长
对于 ChatCrystal 的使用场景(个人知识库,通常几千到几万条向量),这些限制完全可接受。但如果要扩展到团队共享或百万级向量,就需要迁移到 Qdrant、Milvus 这类专业方案了。
小结
vectra 的设计哲学是够用就好。它用文件系统替代数据库,用内存内线性扫描替代复杂的索引结构,用事务式 API 替代复杂的并发控制。对于 ChatCrystal 这种"一个用户、一台机器、几千条知识"的场景,它提供了最佳的复杂度/性能平衡。
理解 vectra 的内部机制,有助于你在使用 ChatCrystal 时做出更好的决策:chunk 大小影响索引粒度,元数据设计影响过滤能力,向量规模直接影响检索耗时。这些都不是黑盒------打开 vectra-index/index.json,一切都在文件里。
项目地址:github.com/ZengLiangYi/ChatCrystal
如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。