news 2026/5/27 12:38:23

vectra 实战:纯 JS 本地向量搜索引擎

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
vectra 实战:纯 JS 本地向量搜索引擎

本文面向:想在 Node.js 项目中实现本地语义搜索的开发者。
预计阅读时间:12 分钟
最终效果:掌握 vectra 的索引创建、向量插入、查询、删除、事务模式的完整用法,理解 ChatCrystal 的候选集升级和双写策略。

想在 Node.js 项目里加语义搜索,但不想装 Python、不想跑 Docker、不想申请云服务 API Key?vectra 就是为这个场景设计的——一个纯 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}from'vectra';import{resolve}from'node:path';constINDEX_PATH=resolve('./data','vectra-index');constindex=newLocalIndex(INDEX_PATH);

LocalIndex构造函数只接收一个路径参数,不创建任何文件。需要显式调用createIndex()初始化:

if(!(awaitindex.isIndexCreated())){awaitindex.createIndex();}

isIndexCreated()检查路径下是否已有索引文件。这个模式适合应用启动时调用——首次运行创建索引,后续运行直接复用。

ChatCrystal 用单例模式管理索引实例,避免重复创建:

// server/src/services/vector-index.tsconstINDEX_PATH=resolve(appConfig.dataDir,'vectra-index');let_index:LocalIndex|null=null;exportasyncfunctiongetIndex():Promise<LocalIndex>{if(_index)return_index;_index=newLocalIndex(INDEX_PATH);if(!(await_index.isIndexCreated())){await_index.createIndex();}return_index;}

插入向量

向 vectra 插入数据需要两样东西:向量(浮点数组)和元数据(任意 JSON 对象)。

先拿到一个向量。实际项目中会调用 Embedding 模型,这里用随机向量演示:

// 模拟一个 768 维的 embedding 向量constvector=Array.from({length:768},()=>Math.random()*2-1);constitem=awaitindex.insertItem({vector,metadata:{noteId:42,chunkIndex:0,title:'SQLite WAL 模式并发写入问题',projectName:'my-project',},});console.log(item.id);// vectra 自动生成的唯一 ID

insertItem返回的对象包含id字段,这是 vectra 分配的内部标识符。后续删除、查询都会用到它。ChatCrystal 把这个 id 存到 SQLite 的embeddings表中,建立 vectra 向量和业务数据之间的关联。

在 ChatCrystal 的实际代码中,一条笔记会被切分成多个 chunk,每个 chunk 独立生成向量并插入:

// server/src/services/embedding.tsconstitem=awaitindex.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 结果:

constqueryVector=Array.from({length:768},()=>Math.random()*2-1);constresults=awaitindex.queryItems(queryVector,'查询文本',10);for(constresultofresults){console.log(`笔记:${result.item.metadata.title}`);console.log(`相似度:${result.score}`);console.log(`chunk:${result.item.metadata.chunkIndex}`);}

queryItems的三个参数:

  1. queryVector— 查询向量(浮点数组)
  2. queryText— 查询文本(vectra 内部用于辅助,传空字符串也行)
  3. topK— 返回结果数量

返回的每个 result 包含item(含 metadata)和score(相似度分数,0-1 之间,越大越相似)。

ChatCrystal 在实际搜索中加入了候选集升级机制——因为一个笔记可能有多个 chunk,直接取 top-10 可能返回的 10 个 chunk 全来自同一条笔记。所以先取小批量,不够就翻倍:

letcandidateK=requestedTopK;letdirectResults:DirectSearchHit[]=[];while(candidateK>0){constresults=awaitindex.queryItems<NoteChunkMeta>(embedding,query,candidateK);directResults=awaitmaterializeDirectSearchHits(db,results);if(directResults.length>=requestedTopK||results.length<candidateK)break;// 翻倍候选集,但不超过索引总条数constnextCandidateK=candidateLimit===undefined?candidateK*2:Math.min(candidateK*2,candidateLimit);if(nextCandidateK<=candidateK)break;candidateK=nextCandidateK;}

materializeDirectSearchHits从 SQLite 读取 chunk 原文,按noteId去重保留最高分。这样即使 vectra 返回了同一笔记的 5 个 chunk,最终结果里也只出现一条。

按 metadata 过滤

vectra 支持按 metadata 字段过滤查询结果。比如只搜索某个项目的笔记:

// 获取指定笔记的所有 chunkconstitems=awaitindex.listItemsByMetadata({noteId:42});

listItemsByMetadata接收一个 metadata 对象,返回所有字段完全匹配的条目。ChatCrystal 用它来做两件事:

删除前查找:先找到某条笔记在 vectra 中的所有 chunk ID,然后逐个删除:

// server/src/services/vector-index.tsexportasyncfunctioncommittedVectraIdsForNote(index:LocalIndex,noteId:number):Promise<string[]>{constitems=awaitindex.listItemsByMetadata({noteId});returnitems.map((item)=>item.id);}

存在性检查:确认 vectra 中的向量是否和 SQLite 记录一致:

exportasyncfunctioncurrentVectraIdsCommitted(index:LocalIndex,vectraIds:string[]):Promise<boolean>{if(vectraIds.length===0)returnfalse;for(constvectraIdofvectraIds){if(!(awaitindex.getItem(vectraId))){returnfalse;}}returntrue;}

getItem(vectraId)按 ID 获取单个条目,如果不存在返回undefined

需要注意的是,vectra 的listItemsByMetadata精确匹配,不支持范围查询或模糊匹配。如果你需要复杂的过滤逻辑,应该在 vectra 查询之后用业务代码二次过滤。

删除向量

删除单个向量:

awaitindex.deleteItem(vectraId);

ChatCrystal 在更新笔记的 embedding 时,会先删除旧向量再插入新向量:

// 找到旧向量constoldVectraIds=awaitcommittedVectraIdsForNote(index,noteId);// 插入新向量后,删除旧的for(constvectraIdofoldVectraIds){awaitindex.deleteItem(vectraId);}

如果要清空整个索引重建,可以直接删除索引目录:

import{existsSync,rmSync}from'node:fs';exportfunctionclearEmbeddingIndex():void{_index=null;// 清空内存缓存if(existsSync(INDEX_PATH)){rmSync(INDEX_PATH,{recursive:true,force:true});}// 下次 getIndex() 调用会自动重建空索引}

事务模式:beginUpdate / endUpdate

这是 vectra 最重要的设计模式。单个insertItemdeleteItem调用会立即写入磁盘,但如果你要批量操作,每次都写盘会很慢。vectra 提供了事务式的批量写入:

awaitindex.beginUpdate();// 批量插入/删除,不会立即写盘awaitindex.insertItem({vector:v1,metadata:{...}});awaitindex.insertItem({vector:v2,metadata:{...}});awaitindex.deleteItem(oldId);awaitindex.endUpdate();// 这时候才一次性写入磁盘

如果中途出错,可以用cancelUpdate()回滚:

letupdateOpen=false;try{awaitindex.beginUpdate();updateOpen=true;// ... 写入操作 ...awaitindex.endUpdate();updateOpen=false;}catch(error){if(updateOpen){try{index.cancelUpdate();// 丢弃未提交的变更}catch{// 忽略取消失败,优先抛出原始错误}}throwerror;}

ChatCrystal 在所有批量写入的地方都用了这个模式。updateOpen标志位确保cancelUpdate只在事务确实开启的情况下才调用,避免二次异常。

实际代码(删除某笔记的所有向量):

// server/src/services/vector-index.tsexportasyncfunctiondeleteVectraItemsForNote(index:LocalIndex,noteId:number):Promise<number>{constvectraIds=awaitcommittedVectraIdsForNote(index,noteId);if(vectraIds.length===0)return0;letupdateOpen=false;try{awaitindex.beginUpdate();updateOpen=true;for(constvectraIdofvectraIds){awaitindex.deleteItem(vectraId);}awaitindex.endUpdate();updateOpen=false;returnvectraIds.length;}catch(error){if(updateOpen){try{index.cancelUpdate();}catch{// Ignore cancel failures}}throwerror;}}

索引统计

获取索引中的向量总数:

conststats=awaitindex.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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/27 12:38:03

非冯·诺依曼架构与TensorFlow:大规模搜索的性能优化新范式

1. 项目概述&#xff1a;当大规模搜索遇上非冯诺依曼架构在人工智能和科学计算的深水区&#xff0c;我们常常面临一个根本性的矛盾&#xff1a;算法的胃口越来越大&#xff0c;而传统计算架构的“消化能力”却逐渐见顶。尤其是在大规模参数搜索、超参数优化、以及电磁场仿真、天…

作者头像 李华
网站建设 2026/5/27 12:36:55

2026年一键生成论文工具对比实测:5款AI神器闭眼选不翻车

写论文的焦虑&#xff0c;是每个科研人和学生绕不开的“必修课”。选题无从下手&#xff0c;文献检索耗时费力&#xff0c;逻辑结构反复推敲&#xff0c;格式排版让人抓狂&#xff0c;查重降重更是像在和系统玩“猫鼠游戏”。2026年的AI工具早已不是冷冰冰的“文字机器”&#…

作者头像 李华
网站建设 2026/5/27 12:35:53

单节点深度学习框架极致优化:从数据并行到参数调优实战

1. 项目概述&#xff1a;为什么我们需要一个“单节点”深度学习框架&#xff1f;在深度学习项目里&#xff0c;我们常常听到一个词叫“分布式训练”。当模型参数动辄上亿、数据集大到TB级别时&#xff0c;把任务拆分到几十甚至上百台机器&#xff08;节点&#xff09;上并行计算…

作者头像 李华
网站建设 2026/5/27 12:34:54

GEO实战指南:2026年如何让你的内容被AI大模型“选中“?

写了100篇文章&#xff0c;传统搜索排名还行&#xff0c;但问AI"XX领域哪家好"&#xff0c;你的品牌从来没出现过&#xff1f;问题可能不在内容质量&#xff0c;而在内容结构。前言&#xff1a;一个被忽视的流量黑洞2025年下半年开始&#xff0c;我陆续收到不少读者私…

作者头像 李华
网站建设 2026/5/27 12:34:00

如何3步完成微博PDF备份:Speechless工具的终极指南

如何3步完成微博PDF备份&#xff1a;Speechless工具的终极指南 【免费下载链接】Speechless 把新浪微博的内容&#xff0c;导出成 PDF 文件进行备份的 Chrome Extension。 项目地址: https://gitcode.com/gh_mirrors/sp/Speechless 你是否担心多年积累的微博内容会突然消…

作者头像 李华