•
阅读时长 3 分钟

从文本到向量,用 Node.js 实现一个语义检索

目录

搜索资料时,问题里的用词和文章里的用词不一定相同。比如想查“怎么停止正在返回的内容”,相关文档可能写的是“取消流式请求”,直接匹配关键词可能找不到。

语义检索的一种做法,是用 embedding 模型把问题和文档转换成向量,再比较向量的相似度,返回比较接近的文档。下面用三条英文文档做一个 Node.js 示例,把生成向量和检索过程接起来。

文本向量是什么

模型把一段文字转换成一组数字。相同模型下,语义相近的文本通常会得到相对接近的向量,但这不是每个问题都能命中的保证。

查询和文档必须使用相同的模型及处理方式。两个模型即使输出维度一样,数字代表的空间也不一定相同,不能直接混用。换模型时,已有文档向量通常需要重新生成。

余弦相似度

余弦相似度比较两个向量的方向:

相似度 = 点积 /(向量 A 的长度 × 向量 B 的长度)

查询向量分别和每条文档向量计算分数,按分数从高到低排列,取前 k 条。分数不是答案正确的概率,也没有对所有模型、所有语料都合适的固定阈值。

下面实现会检查维度和数值。零向量返回 0,避免除以零:

export function cosine(a, b) {
  if (!a.length || a.length !== b.length ||
      a.some(value => !Number.isFinite(value)) ||
      b.some(value => !Number.isFinite(value))) {
    throw new Error('向量维度或数值不合法');
  }
  const dot = a.reduce((sum, value, index) => sum + value * b[index], 0);
  const norm = Math.hypot(...a) * Math.hypot(...b);
  return norm ? dot / norm : 0;
}

export function rank(query, documents, k = 3) {
  return documents.map(doc => ({ ...doc, score: cosine(query, doc.vector) }))
    .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
    .slice(0, k);
}

把代码保存为 retrieval.mjs。这里只对少量向量逐条计算,没有引入向量数据库。

使用模型生成向量

在一个新目录中安装依赖:

npm init -y
npm install @huggingface/transformers@3.4.0

模型使用 Xenova/all-MiniLM-L6-v2,通过 Transformers.js 在本地推理,首次运行需要下载模型文件。这个例子使用英文,是为了让示例语料与模型的常见用途一致,并不拿它证明中文知识库的检索效果。

创建 search.mjs:

import { pipeline } from '@huggingface/transformers';
import { rank } from './retrieval.mjs';

const extractor = await pipeline(
  'feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' }
);

async function embed(text) {
  const output = await extractor(text, {
    pooling: 'mean', normalize: true,
  });
  return Array.from(output.data);
}

const corpus = [
  { id: 'cookie', text: 'The browser sends session cookies to authenticate a user.' },
  { id: 'stream', text: 'Read streaming HTTP data and cancel unfinished requests.' },
  { id: 'canvas', text: 'Draw a rectangle on a canvas and handle pointer movement.' },
];

const documents = [];
for (const doc of corpus) {
  documents.push({ ...doc, vector: await embed(doc.text) });
}
const query = 'How do I stop an ongoing response?';
const results = rank(await embed(query), documents);
console.table(results.map(({ id, score }) => ({ id, score })));

执行 node search.mjs,会生成三条文档的向量和问题向量,再输出排序。pooling: 'mean' 把 token 的表示汇总为文本表示,normalize: true 做归一化。接口用法可以参考 Transformers.js 文档,模型文件和用途见 模型页面。

在这组三条文档中,结果如下,保留三位小数:

文档相似度
stream0.373
canvas0.060
cookie0.036

查询里没有直接写 streaming 或 cancel,流式请求条目仍然排在前面。这个结果只说明这组样本的排序,不能当成准确率评测,也没有比较它和关键词搜索在大量数据中的表现。

不下载模型时,能检查什么

排序函数可以用固定向量检查,保存为 check.mjs:

import assert from 'node:assert/strict';
import { cosine, rank } from './retrieval.mjs';

const result = rank([1, 0], [
  { id: 'a', vector: [0, 1] },
  { id: 'b', vector: [1, 0] },
]);
assert.equal(result[0].id, 'b');
assert.equal(cosine([0, 0], [1, 0]), 0);
assert.throws(() => cosine([1], [1, 0]));
console.log('排序检查通过');

node check.mjs 不需要模型下载。它能检查计算和排序代码,不能评价模型理解文字的能力。模型下载失败也应显示为下载失败,不能偷偷换成随机向量后继续报告语义检索成功。

如果已有完整模型文件,pipeline 的模型参数也可以使用本地目录;目录需要包含 tokenizer、配置以及所选 dtype 对应的 ONNX 文件。是否走本地模型,应在运行配置中明确指定。

文档增加后还需要处理什么

长文章通常需要切成多个片段,保留片段到原文的映射。切块大小、重叠长度、是否把标题一起编码,都会影响检索结果,需要用实际问题检查。

有权限的数据还要过滤访问范围,不能因为向量分数高就返回用户没有权限看的文档。文档修改或删除后,索引也要同步更新。

评测可以从一组问题开始,人工标记应该找到的文章,再观察 top-k 命中情况和找不到的原因。这些工作完成之后,才有依据判断模型是否适合自己的语料。当前示例只做检索,没有调用模型生成答案,因此也不是完整的 RAG 应用。