vectra 实战:纯 JS 本地向量搜索引擎
本文面向想在 Node.js 项目中实现本地语义搜索的开发者。预计阅读时间12 分钟最终效果掌握 vectra 的索引创建、向量插入、查询、删除、事务模式的完整用法理解 ChatCrystal 的候选集升级和双写策略。想在 Node.js 项目里加语义搜索但不想装 Python、不想跑 Docker、不想申请云服务 API Keyvectra 就是为这个场景设计的——一个纯 JavaScript 实现的本地向量搜索引擎零原生依赖数据全部存在本地文件里。这篇文章从零开始带你用 vectra 完成向量索引的创建、插入、查询、删除全流程所有代码来自 ChatCrystal 的生产级实现。vectra 是什么vectra 是 Steve Bilig 开发的轻量级向量数据库核心特点纯 JS。没有任何 C 原生模块npm install即可不需要编译文件存储。索引数据存在本地文件系统不依赖 SQLite 或外部服务TypeScript 原生。类型定义完整IDE 补全友好HNSW 算法。基于 Hierarchical Navigable Small World 算法做近似最近邻搜索查询速度快vectra 不是为大规模生产环境设计的百万级以上向量应该用 Qdrant 或 Pinecone但它非常适合本地工具、CLI 应用、Electron 桌面软件这类场景。ChatCrystal 用它存储所有笔记的 embedding 向量支撑语义搜索功能。为什么选 vectra选型时考虑过几个方案方案问题chroma需要 Python 运行时Node.js 项目集成成本高hnswlib-node有 C 原生依赖跨平台编译容易出问题自己实现HNSW 算法复杂度高不适合项目初期vectra纯 JS、零配置、文件存储、API 简洁vectra 的优势在于零摩擦npm install vectra装完就能用不需要配置数据库连接、不需要启动额外进程、不需要处理原生模块编译失败的问题。对于 Electron 桌面应用这种需要跨平台分发的场景这一点尤其重要。安装和初始化npminstallvectra创建索引实例import{LocalIndex}fromvectra;import{resolve}fromnode:path;constINDEX_PATHresolve(./data,vectra-index);constindexnewLocalIndex(INDEX_PATH);LocalIndex构造函数只接收一个路径参数不创建任何文件。需要显式调用createIndex()初始化if(!(awaitindex.isIndexCreated())){awaitindex.createIndex();}isIndexCreated()检查路径下是否已有索引文件。这个模式适合应用启动时调用——首次运行创建索引后续运行直接复用。ChatCrystal 用单例模式管理索引实例避免重复创建// server/src/services/vector-index.tsconstINDEX_PATHresolve(appConfig.dataDir,vectra-index);let_index:LocalIndex|nullnull;exportasyncfunctiongetIndex():PromiseLocalIndex{if(_index)return_index;_indexnewLocalIndex(INDEX_PATH);if(!(await_index.isIndexCreated())){await_index.createIndex();}return_index;}插入向量向 vectra 插入数据需要两样东西向量浮点数组和元数据任意 JSON 对象。先拿到一个向量。实际项目中会调用 Embedding 模型这里用随机向量演示// 模拟一个 768 维的 embedding 向量constvectorArray.from({length:768},()Math.random()*2-1);constitemawaitindex.insertItem({vector,metadata:{noteId:42,chunkIndex:0,title:SQLite WAL 模式并发写入问题,projectName:my-project,},});console.log(item.id);// vectra 自动生成的唯一 IDinsertItem返回的对象包含id字段这是 vectra 分配的内部标识符。后续删除、查询都会用到它。ChatCrystal 把这个 id 存到 SQLite 的embeddings表中建立 vectra 向量和业务数据之间的关联。在 ChatCrystal 的实际代码中一条笔记会被切分成多个 chunk每个 chunk 独立生成向量并插入// server/src/services/embedding.tsconstitemawaitindex.insertItem({vector:chunk.vector,metadata:{noteId:id,chunkIndex:chunk.chunkIndex,conversationId,title,projectName,}asNoteChunkMeta,});// 保存 vectra ID 到 SQLite建立关联newItems.push({chunkIndex:chunk.chunkIndex,chunkText:chunk.chunkText,vectraId:item.id,});metadata 里的字段完全自定义。vectra 不关心你放什么进去它只负责存储和返回。但 metadata 在查询时可以用来做过滤所以合理设计 metadata 结构很重要。查询向量查询是 vectra 最核心的功能。给一个查询向量它返回余弦相似度最高的 top-K 结果constqueryVectorArray.from({length:768},()Math.random()*2-1);constresultsawaitindex.queryItems(queryVector,查询文本,10);for(constresultofresults){console.log(笔记:${result.item.metadata.title});console.log(相似度:${result.score});console.log(chunk:${result.item.metadata.chunkIndex});}queryItems的三个参数queryVector— 查询向量浮点数组queryText— 查询文本vectra 内部用于辅助传空字符串也行topK— 返回结果数量返回的每个 result 包含item含 metadata和score相似度分数0-1 之间越大越相似。ChatCrystal 在实际搜索中加入了候选集升级机制——因为一个笔记可能有多个 chunk直接取 top-10 可能返回的 10 个 chunk 全来自同一条笔记。所以先取小批量不够就翻倍letcandidateKrequestedTopK;letdirectResults:DirectSearchHit[][];while(candidateK0){constresultsawaitindex.queryItemsNoteChunkMeta(embedding,query,candidateK);directResultsawaitmaterializeDirectSearchHits(db,results);if(directResults.lengthrequestedTopK||results.lengthcandidateK)break;// 翻倍候选集但不超过索引总条数constnextCandidateKcandidateLimitundefined?candidateK*2:Math.min(candidateK*2,candidateLimit);if(nextCandidateKcandidateK)break;candidateKnextCandidateK;}materializeDirectSearchHits从 SQLite 读取 chunk 原文按noteId去重保留最高分。这样即使 vectra 返回了同一笔记的 5 个 chunk最终结果里也只出现一条。按 metadata 过滤vectra 支持按 metadata 字段过滤查询结果。比如只搜索某个项目的笔记// 获取指定笔记的所有 chunkconstitemsawaitindex.listItemsByMetadata({noteId:42});listItemsByMetadata接收一个 metadata 对象返回所有字段完全匹配的条目。ChatCrystal 用它来做两件事删除前查找先找到某条笔记在 vectra 中的所有 chunk ID然后逐个删除// server/src/services/vector-index.tsexportasyncfunctioncommittedVectraIdsForNote(index:LocalIndex,noteId:number):Promisestring[]{constitemsawaitindex.listItemsByMetadata({noteId});returnitems.map((item)item.id);}存在性检查确认 vectra 中的向量是否和 SQLite 记录一致exportasyncfunctioncurrentVectraIdsCommitted(index:LocalIndex,vectraIds:string[]):Promiseboolean{if(vectraIds.length0)returnfalse;for(constvectraIdofvectraIds){if(!(awaitindex.getItem(vectraId))){returnfalse;}}returntrue;}getItem(vectraId)按 ID 获取单个条目如果不存在返回undefined。需要注意的是vectra 的listItemsByMetadata是精确匹配不支持范围查询或模糊匹配。如果你需要复杂的过滤逻辑应该在 vectra 查询之后用业务代码二次过滤。删除向量删除单个向量awaitindex.deleteItem(vectraId);ChatCrystal 在更新笔记的 embedding 时会先删除旧向量再插入新向量// 找到旧向量constoldVectraIdsawaitcommittedVectraIdsForNote(index,noteId);// 插入新向量后删除旧的for(constvectraIdofoldVectraIds){awaitindex.deleteItem(vectraId);}如果要清空整个索引重建可以直接删除索引目录import{existsSync,rmSync}fromnode:fs;exportfunctionclearEmbeddingIndex():void{_indexnull;// 清空内存缓存if(existsSync(INDEX_PATH)){rmSync(INDEX_PATH,{recursive:true,force:true});}// 下次 getIndex() 调用会自动重建空索引}事务模式beginUpdate / endUpdate这是 vectra 最重要的设计模式。单个insertItem或deleteItem调用会立即写入磁盘但如果你要批量操作每次都写盘会很慢。vectra 提供了事务式的批量写入awaitindex.beginUpdate();// 批量插入/删除不会立即写盘awaitindex.insertItem({vector:v1,metadata:{...}});awaitindex.insertItem({vector:v2,metadata:{...}});awaitindex.deleteItem(oldId);awaitindex.endUpdate();// 这时候才一次性写入磁盘如果中途出错可以用cancelUpdate()回滚letupdateOpenfalse;try{awaitindex.beginUpdate();updateOpentrue;// ... 写入操作 ...awaitindex.endUpdate();updateOpenfalse;}catch(error){if(updateOpen){try{index.cancelUpdate();// 丢弃未提交的变更}catch{// 忽略取消失败优先抛出原始错误}}throwerror;}ChatCrystal 在所有批量写入的地方都用了这个模式。updateOpen标志位确保cancelUpdate只在事务确实开启的情况下才调用避免二次异常。实际代码删除某笔记的所有向量// server/src/services/vector-index.tsexportasyncfunctiondeleteVectraItemsForNote(index:LocalIndex,noteId:number):Promisenumber{constvectraIdsawaitcommittedVectraIdsForNote(index,noteId);if(vectraIds.length0)return0;letupdateOpenfalse;try{awaitindex.beginUpdate();updateOpentrue;for(constvectraIdofvectraIds){awaitindex.deleteItem(vectraId);}awaitindex.endUpdate();updateOpenfalse;returnvectraIds.length;}catch(error){if(updateOpen){try{index.cancelUpdate();}catch{// Ignore cancel failures}}throwerror;}}索引统计获取索引中的向量总数conststatsawaitindex.getIndexStats();console.log(索引中有${stats.items}个向量);ChatCrystal 用这个数字来决定查询时的候选集大小——如果索引里只有 5 个向量就没必要请求 top-100。文件存储结构vectra 的索引存储在你指定的目录下。ChatCrystal 的路径是{dataDir}/vectra-index/~/.chatcrystal/data/vectra-index/ ├── items/ # 向量数据文件 ├── index.json # 索引元信息 └── ...因为是纯文件存储备份只需要复制整个目录迁移也只需要把目录搬到新位置。不需要pg_dump、不需要mysqldumpcp -r搞定。这个特性对 Electron 桌面应用特别友好用户卸载重装后只要数据目录还在索引就还在。vectra vs chroma vs hnswlib维度vectrachromahnswlib-node语言纯 JS/TSPythonC binding原生依赖无有有安装难度npm installpip install server需要编译环境运行方式进程内嵌入独立服务进程内嵌入存储方式文件系统SQLite / 文件文件适合场景Node.js 本地工具Python 应用、服务端需要极致性能元数据过滤精确匹配丰富过滤表达式无内置支持最大规模万级十万级百万级vectra 的定位很清晰Node.js 生态里的轻量级本地向量存储。如果你的项目是 Python 技术栈chroma 更合适。如果你需要处理百万级向量应该上 Qdrant 或 Pinecone。但如果你是 Node.js 开发者想要一个零配置的本地语义搜索能力vectra 是目前最好的选择。下一步从零实现 Embedding 服务 — Embedding 的完整流程文本构建、分块、API 调用、存储nomic vs openai embedding 横评 — 本地与云端 Embedding 模型的性能对比大量对话导入时的内存优化 — vectra 索引一致性与内存管理vectra 源码github.com/Stevenic/vectra — 核心代码不到 2000 行值得通读有问题可以私信我项目地址github.com/ZengLiangYi/ChatCrystal