语义搜索、推荐系统和大模型检索增强生成(RAG)都离不开向量数据库。它把文本、图像等非结构化数据编码成高维向量,查询时找出与目标向量最接近的记录。很多前端或全栈开发者习惯使用现成SDK,但对内部机制缺乏直观认识。借助Node.js实现一个小型向量数据库,不需要任何第三方依赖,就能把存储、相似度计算、持久化和索引这些关键环节串起来。本文会给出可运行的代码,并逐步分析设计取舍。

一、向量存储结构设计
向量数据库首先要解决如何存储高维浮点数。JavaScript的普通数组虽然直观,但每个元素都是独立的Number对象,内存分配不连续,批量计算时缓存命中率低。更适合的做法是使用TypedArray,例如Float32Array,它底层是一段连续的内存块,存储效率高,而且与WebAssembly或C++扩展交互时不需要额外拷贝。定义一条向量记录时,需要包含唯一ID、向量本体和元数据。元数据可以放原始文本、标签或业务字段,方便检索后回显。
下面给出一个基础存储结构。插入向量时强制校验维度,避免后续计算时出现长度不一致导致的NaN。由于查询阶段的余弦相似度依赖向量模长,可以在插入时先归一化并缓存模长,这样后续直接用点积就能得到余弦值,省去重复开方计算。
class VectorRecord {
constructor(id, vector, metadata = {}) {
this.id = id;
this.vector = new Float32Array(vector);
this.metadata = metadata;
}
}
class VectorDatabase {
constructor(dimensions) {
this.dimensions = dimensions;
this.records = new Map();
}
insert(id, vector, metadata = {}) {
if (vector.length !== this.dimensions) {
throw new Error(`Expected ${this.dimensions} dimensions, got ${vector.length}`);
}
const record = new VectorRecord(id, vector, metadata);
this.records.set(id, record);
return record;
}
get(id) {
return this.records.get(id);
}
}
这里用Map保存记录,优点是按ID查找的复杂度接近O(1),删除和更新也方便。不过Map的遍历顺序是按插入顺序,对于全量扫描没有影响。当向量维度较高时,建议在业务层控制向量精度,比如归一化后用Float32Array已经足够,不必使用Float64Array,内存能减少一半。
二、相似度计算与暴力检索
向量检索的核心是衡量两个向量之间的相似度。文本嵌入通常使用余弦相似度,它关注方向而非绝对长度,取值范围从-1到1,值越大表示方向越接近。计算时先求点积,再除以两个向量的L2范数乘积。如果查询向量和记录向量都已经归一化为单位长度,分母就是1,余弦相似度退化为点积,能显著减少计算量。下面给出未归一化情况下的通用实现。
function cosineSimilarity(a, b) {
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA === 0 || normB === 0) {
return 0;
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
有了相似度函数,就可以实现最简单的暴力检索。遍历数据库中的所有记录,逐一计算与查询向量的相似度,然后按分数降序排列并截取前K个结果。暴力检索实现直观,不依赖任何索引结构,也不受维度灾难以外的精度损失影响,是小规模数据和功能验证阶段的首选。
search(queryVector, topK = 10) {
const query = new Float32Array(queryVector);
const scored = [];
for (const record of this.records.values()) {
const score = cosineSimilarity(query, record.vector);
scored.push({
id: record.id,
score,
metadata: record.metadata
});
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, topK);
}
这段代码的时间复杂度是O(n×d),其中n是记录数,d是向量维度。当数据库里只有几万条记录、向量维度在768或1024时,单次查询可能需要几十到几百毫秒,对于原型项目可以接受。如果希望提升速度,可以先在插入阶段对向量做归一化,并把查询向量也归一化,然后用点积替代余弦相似度,省掉两次开方运算。这在大批量查询时能带来可观的性能提升。
三、持久化与数据恢复
内存中的向量数据在进程退出后会丢失,因此需要持久化机制。最简单的方案是把整个数据库导出为JSON。由于Float32Array不能直接序列化为标准JSON,需要先转成普通数组;反序列化时再把普通数组重新包装成Float32Array。这种方式可读性好,调试方便,适合数据量在几十万条以内的场景。如果对空间敏感,可以进一步改成二进制格式,例如每个向量用固定字节数的Buffer存储。
toJSON() {
return {
dimensions: this.dimensions,
records: Array.from(this.records.values()).map(record => ({
id: record.id,
vector: Array.from(record.vector),
metadata: record.metadata
}))
};
}
static fromJSON(data) {
const db = new VectorDatabase(data.dimensions);
for (const item of data.records) {
db.insert(item.id, item.vector, item.metadata);
}
return db;
}
实际落盘可以使用Node.js内置的fs/promises模块。保存时调用JSON.stringify(db.toJSON(), null, 2)写入文件,加载时读取文件内容再调用VectorDatabase.fromJSON即可。需要注意的是,如果元数据中包含函数、Date或BigInt等JSON不支持的类型,序列化会静默丢失或抛错,建议持久化前对元数据做白名单过滤。另一个常见问题是写入过程中进程崩溃导致文件损坏,生产环境可以先生成临时文件再原子重命名。
四、索引优化与生产级扩展
暴力检索在数据量超过百万级时会成为明显瓶颈。一个轻量级的优化思路是分桶索引:根据向量的某种统计特征把记录分到不同桶中,查询时只扫描最相关的若干个桶。下面这个示例用向量各维度之和的绝对值对桶数量取模,虽然精度不如真实聚类,但实现成本极低,适合作为演示。
buildBucketIndex(numBuckets = 4) {
const buckets = new Map();
for (const record of this.records.values()) {
let sum = 0;
for (let i = 0; i < record.vector.length; i++) {
sum += record.vector[i];
}
const bucket = Math.floor(Math.abs(sum) % numBuckets);
if (!buckets.has(bucket)) {
buckets.set(bucket, []);
}
buckets.get(bucket).push(record.id);
}
return buckets;
}
不过这种简单哈希分桶并不能保证相似的向量落在同一个桶里,它只是减少了平均扫描量。真正的向量数据库会采用HNSW、IVF、PQ等近似最近邻算法,在精度和速度之间做平衡。HNSW通过多层图结构快速逼近最邻近区域,IVF先对向量做聚类再在簇内搜索,PQ则把高维向量压缩成短编码以降低内存和计算开销。理解了本文的实现后,再去阅读Chroma、FAISS或LanceDB的源码,会更容易抓住这些算法的核心动机。
从工程角度看,Node.js实现向量数据库的主要短板是计算密集场景下受单线程限制。可以通过worker_threads并行处理多个查询,或者将向量计算下沉到C++ Addon、WebAssembly甚至GPU。但如果你的目标只是学习原理、构建中小规模检索服务或做本地实验,原生JavaScript的实现已经完全够用。
从零实现向量数据库并不需要很多代码,却能帮助开发者理解存储、相似度、持久化和索引这几个关键模块。先用Float32Array和Map搭建骨架,再用余弦相似度实现暴力检索,接着补上JSON持久化,最后尝试分桶优化并了解生产级近似最近邻算法,一条清晰的学习路径就完成了。你可以在这个简陋版本上继续加入归一化缓存、批量写入、过滤条件、删除更新等能力,让它更接近真实的向量数据库。
Node.js向量数据库向量检索余弦相似度修改时间:2026-09-18 17:26:17