多向量检索(MultiVectorRetrieval)是指对同一份文档生成多个向量表示,再在检索阶段对这些向量进行综合打分的检索方式。相比单向量检索只能把整篇文档压缩成一个向量,多向量检索能保留文档内部更细粒度的语义信息,特别适合长文本、多主题文档和问答场景。本文将以Node.js为开发语言,从原理到代码完整实现一个多向量检索系统。

一、为什么需要多向量检索
传统的单向量检索流程是把一段文本送入Embedding模型,得到一个固定长度的向量,然后把文档向量存入数据库,查询时用查询向量与所有文档向量计算相似度。这种做法在短文本场景效果不错,但一旦文档变长,问题就暴露出来了:一篇几千字的技术文档可能同时讨论了数据库设计、缓存策略和部署方案,而单个向量在高维空间中只能占据一个点,它会被迫成为多个主题的“平均值”,导致无论用什么关键词查询,相似度都不高也不低,检索精度明显下降。
多向量检索的核心思路是把文档拆成多个语义单元,每个单元单独生成向量。例如按段落切分,或者按句子切分,一篇文档就对应一组向量。检索时拿查询向量与这组向量分别计算相似度,再通过取最大值、求和平均等方式聚合成文档级别的得分。这样一来,只要文档中某一段与查询高度相关,整篇文档就能被准确召回,语义细节不会被平均掉。
聚合策略的选择直接影响检索效果。取最大值(max)适合“命中即相关”的场景,比如FAQ匹配;求平均(mean)适合需要整体语义接近的场景;而加权求和则可以兼顾两者。后面代码实现中我们会把这几种策略都做出来,方便按需切换。
二、整体架构与向量生成
在Node.js中实现多向量检索,整体架构可以分成三层:第一层是文档预处理层,负责把原始文本切分成语义单元;第二层是向量化层,调用Embedding服务把每个单元转成向量;第三层是存储与检索层,负责保存多向量并提供查询能力。目录结构可以简单组织为chunker(切分器)、embedder(向量化器)和retriever(检索器)三个模块,职责清晰,便于单独测试和替换。
文档切分是第一个关键环节。切得太细(比如逐句切)会丢失上下文,切得太粗则回到单向量的老问题。实践中比较稳妥的做法是按段落切分后再做滑动窗口合并,保证每个chunk既有完整语义又不过长。下面是一个简单的切分实现:
function chunkDocument(text, maxLen = 500, overlap = 100) {
const paragraphs = text.split(/\n{2,}/).filter(Boolean);
const chunks = [];
let buffer = '';
for (const p of paragraphs) {
if ((buffer + p).length > maxLen && buffer) {
chunks.push(buffer.trim());
buffer = buffer.slice(Math.max(0, buffer.length - overlap));
}
buffer += p + '\n\n';
}
if (buffer.trim()) chunks.push(buffer.trim());
return chunks;
}向量化层需要调用Embedding模型。如果使用OpenAI的接口,可以通过官方SDK批量生成向量;如果追求本地化和零成本,也可以用sentence-transformers类的本地模型,通过Python服务或onnxruntime-node在Node.js中直接推理。无论哪种方式,建议对chunk做批量请求,一次生成多个向量,能显著减少网络开销。假设我们用OpenAI接口,生成函数大致如下:
const OpenAI = require('openai');
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function embedTexts(texts) {
const res = await client.embeddings.create({
model: 'text-embedding-3-small',
input: texts,
});
return res.data.map(item => item.embedding);
}三、存储结构与检索实现
多向量检索的存储结构需要在文档级别和chunk级别各建一份索引。文档级别记录文档ID、标题、原始内容等元信息;chunk级别记录每个向量所属的文档ID、chunk文本和向量本身。在数据量不大的场景(十万级以内的chunk),完全可以用内存数组加上暴力计算的方式实现,Node.js的性能足够应付,实现也最简单。数据量更大时再考虑pgvector、Milvus这类专用向量数据库,把多向量检索下推到数据库层。
先看内存版本的实现。核心是相似度计算和聚合打分两个函数。余弦相似度的计算公式是两个向量的点积除以模长的乘积,为了减少重复计算,可以在入库时就把每个向量归一化,这样查询时只需点积即可:
function dotProduct(a, b) {
let sum = 0;
for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
return sum;
}
function retrieve(queryVec, chunkStore, topK = 5, strategy = 'max') {
const docScores = new Map();
for (const chunk of chunkStore) {
// chunk.vector已归一化,点积即余弦相似度
const score = dotProduct(queryVec, chunk.vector);
if (!docScores.has(chunk.docId)) docScores.set(chunk.docId, []);
docScores.get(chunk.docId).push(score);
}
const results = [];
for (const [docId, scores] of docScores) {
let final;
if (strategy === 'max') final = Math.max(...scores);
else if (strategy === 'mean') final = scores.reduce((a, b) => a + b, 0) / scores.length;
else final = scores.reduce((a, b) => a + b, 0);
results.push({ docId, score: final });
}
return results.sort((a, b) => b.score - a.score).slice(0, topK);
}这套实现里有几个细节值得注意。第一,向量归一化一定要在入库时完成,查询向量也要归一化,否则点积结果不等于余弦相似度。第二,Math.max(...scores)在数组很大时可能触发栈溢出,chunk数量极多时应改用循环求最大值。第三,暴力遍历的时间复杂度是O(N×D),N是chunk总数,D是向量维度,当N超过五十万时单次查询可能需要几百毫秒,这时候就该考虑下推到数据库了。
如果选择pgvector作为存储后端,建表时需要两张表:documents存文档元信息,document_chunks存向量,其中embedding列声明为vector类型。查询时用SQL先计算每个chunk的相似度,再按文档聚合,例如取每个文档下所有chunk的最大相似度作为文档得分。这样聚合逻辑交给SQL的GROUP BY和MAX完成,Node.js层只负责拼装参数和处理结果,整体吞吐会好很多。
四、性能优化与实践建议
实际项目中,多向量检索最大的性能瓶颈往往不在检索本身,而在向量化阶段。批量调用Embedding接口时,可以把chunk按每批几十条打包,配合并发控制库(比如p-limit)限制同时在途的请求数,既快又不会触发接口限流。另外,向量结果应该做持久化缓存,文档没有变更时绝不重复生成向量,这一条能省下大量成本。
检索质量方面,有几个经验值得参考。其一,切分时保留overlap能避免语义被硬切断,overlap取chunk长度的百分之十到二十比较常见。其二,查询扩展对多向量检索帮助明显,可以先用查询生成两三个改写版本,分别检索后合并结果,相当于在查询侧也做了一次“多向量”。其三,聚合策略不要拍脑袋定,最好准备一批标注好的查询-文档对,分别用max、mean、sum跑一遍,看哪个召回率最高。
最后是工程化方面的建议。把检索服务封装成独立的HTTP接口,用Express或Fastify暴露一个/search端点,内部完成查询向量化、聚合打分和结果组装,这样上游应用无需关心向量细节。同时加上简单的监控日志,记录每次查询的耗时和topK得分,得分普遍偏低往往意味着Embedding模型与业务语料不匹配,需要考虑换模型或做微调。多向量检索不是银弹,但在长文档、多主题场景下,它带来的精度提升通常远超实现成本,值得作为RAG系统的标配能力。