vectra 本地向量搜索的实现原理

本文面向:想了解纯文件型本地向量库 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 源码,它的检索是最朴素的方式:

  1. loadIndexData() 把整个 index.jsonitems 数组读进内存
  2. 每一个 item 计算与查询向量的余弦相似度
  3. 用一个大小为 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 在两个场景中使用元数据过滤:

  1. 笔记更新时 :先用 listItemsByMetadata({ noteId }) 找到旧 chunk,删除后再插入新 chunk
  2. 搜索去重:同一个笔记可能有多个 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() 函数中严格使用这个模式。更新一个笔记的向量分三步:

  1. beginUpdate() 开启事务
  2. 插入新 chunk 的向量,写入 SQLite 的 embeddings 表
  3. 删除旧 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 不是万能的。它的设计定位是轻量级本地索引,有几个明确的限制:

  1. 单进程写入:不支持并发写入,必须通过 beginUpdate/endUpdate 串行化
  2. 全量加载 :搜索时需要将整个 index.json 加载到内存,万级以上向量会占用可观内存
  3. 无持久化过滤索引:元数据过滤是内存操作,不像数据库有索引加速
  4. 无近似检索:全程精确线性扫描,没有 HNSW / IVF 这类加速结构,检索成本随向量数线性增长

对于 ChatCrystal 的使用场景(个人知识库,通常几千到几万条向量),这些限制完全可接受。但如果要扩展到团队共享或百万级向量,就需要迁移到 Qdrant、Milvus 这类专业方案了。

小结

vectra 的设计哲学是够用就好。它用文件系统替代数据库,用内存内线性扫描替代复杂的索引结构,用事务式 API 替代复杂的并发控制。对于 ChatCrystal 这种"一个用户、一台机器、几千条知识"的场景,它提供了最佳的复杂度/性能平衡。

理解 vectra 的内部机制,有助于你在使用 ChatCrystal 时做出更好的决策:chunk 大小影响索引粒度,元数据设计影响过滤能力,向量规模直接影响检索耗时。这些都不是黑盒------打开 vectra-index/index.json,一切都在文件里。


项目地址:github.com/ZengLiangYi/ChatCrystal

如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。

相关推荐
夜雪闻竹13 天前
vectra 向量索引文件损坏怎么办
ai编程·向量·vectra
逆境不可逃13 天前
Hello-Agents 第二部分-第八章总结:记忆与检索
人工智能·向量·rag
染指111014 天前
7.相似度计算(本地模型下载和使用,在线模型的使用)-RAG基础1
人工智能·机器学习·阿里云·向量·rag
weisian1511 个月前
基础篇--概念原理-3-向量是什么?——从原理到实战,一篇讲透
面试·职场和发展·向量
alwaysrun1 个月前
Zig之标量、向量与SIMD
vector·向量·simd·zig
深念Y1 个月前
哈希与向量:计算机理解现实的两座桥梁
人工智能·数学·机器学习·向量·hash·哈希·空间
深念Y1 个月前
图数据库 vs 向量数据库:AI时代的两个“最强大脑”
数据库·人工智能·neo4j·图论··向量·rag
深念Y2 个月前
从字典到向量:索引技术的演进
向量·es·索引·倒排索引·向量数据库·字典·倒排文件索引