文章用 Transformers.js 展示文本如何从逐词元张量变成句向量,并比较均值池化与 CLS 的选择。索引与查询端若采用不同的池化或归一化设置,检索质量可能下降且不报错。
系列丛书:AI in TypeScript——共 5 本,从第一次调用 LLM 到将 Agent 投入生产——五本均在这里。
我的项目:Hermes IDE | GitHub——一款面向使用 Claude Code 和其他 AI 编程工具交付软件的开发者的 IDE。
关于我:xgabriel.com | GitHub
你的 RAG 代码里,大概有这样一行:const vec = await embed(text)。它返回一个包含 384 个浮点数的列表,你把它存起来,之后就再也不看里面是什么了。
这个函数隐藏了一个选择。在 Transformers.js 中,feature-extraction pipeline 有两个选项,决定这些浮点数究竟代表什么。根据 pipeline API 文档,它们默认都“不做处理”:pooling 默认为 "none",normalize 默认为 false。文档展示,如果不传任何选项,直接用它处理 "This is a simple test.",得到的是一个形状为 [1, 8, 384] 的 tensor。也就是说,返回的是八个向量,每个 token 对应一个。
每个 embedding 库都会在某个地方替你做这个选择。如果构建索引的任务和查询流程做出了不同的选择,并不会抛出任何异常,检索效果只会悄悄变差。接下来,我们在 Node 中一步步打开这个黑盒,然后故意把它弄坏。
先安装这个库(npm 上的当前版本是 4.3.1,在 Node 中,它通过 ONNX Runtime 运行 ONNX 模型),再用 tsx 运行脚本。首次运行时,模型会从 Hugging Face Hub 下载。不需要 API key。
npm i @huggingface/transformers
npx tsx embed.mts
这里使用的是 all-MiniLM-L6-v2 的 ONNX 导出版本,也是 Transformers.js 文档使用的模型。它的 model card 说明,这个模型会把文本映射到一个 384 维空间,使用 mean pooling,并截断超过 256 个 word piece 的输入。
// embed.mts
import {
AutoModel,
AutoTokenizer,
type Tensor,
} from "@huggingface/transformers";
const ID = "onnx-community/all-MiniLM-L6-v2-ONNX";
const tokenizer = await AutoTokenizer.from_pretrained(ID);
const model = await AutoModel.from_pretrained(ID, {
dtype: "fp32",
});
const query = "I can't log in. How do I change my password?";
const docs = [
"Reset your password from the account settings page.",
"Our office is closed on public holidays.",
"Two-factor codes expire after 30 seconds.",
"Passwords must be at least 12 characters long.",
"Contact billing to update your credit card.",
];
const all = [query, ...docs];
console.log(tokenizer.tokenize(query));
const enc = await tokenizer(all, {
padding: true,
truncation: true,
});
console.log(enc.input_ids.tolist()[0]);
console.log(enc.attention_mask.tolist()[1]);
const out = (await model(enc)) as Record<string, Tensor>;
const hidden = out.last_hidden_state ?? out.token_embeddings;
console.log(hidden.dims); // [batch, tokens, 384]
这段输出里,有三件事值得看。
tokenize 展示的是 WordPiece 拆分结果。词表里没有的单词会被拆成子词片段,所以 token 数量不等于单词数量。
input_ids 的元素数量比 tokenize 打印出来的更多。这是因为 tokenizer 默认会在两端添加特殊 token(add_special_tokens 默认为 true)。位置 0 上的 token 是 [CLS]。记住这个位置。
对于 batch 中任何一个短于最长句子的句子,attention_mask 都是一串 1,后面跟着若干个 0。设置 padding: true 后,每一行都会被补齐到相同长度,mask 用来标记哪些位置是真实文本。tokenizer 文档将其描述为:“参与 attention 的位置(1)与 padding 位置(0)”。
模型输出的形状是 [6, N, 384]:六段文本,每段 N 个位置,每个位置有 384 个数字。这里回退读取 token_embeddings 的写法,与 pipeline 源码中的处理方式一致:根据 ONNX 文件的导出方式,它依次尝试读取 last_hidden_state、logits,最后是 token_embeddings。
Pooling 会把 N 个 token 向量合并成一个。常见的两种选择是 mean pooling(对真实 token 取平均)和 CLS pooling(取位置 0,忽略其余位置)。这两种 pooling 加上 L2 normalization,用几行普通的 TypeScript 就能实现。把下面这个代码块,以及后续的每个代码块,都追加到 embed.mts,然后重新运行脚本。
const H = hidden.tolist() as number[][][];
const M = (enc.attention_mask.tolist() as bigint[][])
.map((row) => row.map(Number));
function meanPool(tokens: number[][], mask: number[]) {
const sum = new Array(tokens[0].length).fill(0);
let count = 0;
tokens.forEach((vec, t) => {
if (mask[t] === 0) return; // skip padding
count++;
vec.forEach((x, d) => (sum[d] += x));
});
return sum.map((x) => x / count);
}
const clsPool = (tokens: number[][]) => tokens[0];
function l2(v: number[]) {
const norm = Math.hypot(...v);
return v.map((x) => x / norm);
}
const dot = (a: number[], b: number[]) =>
a.reduce((s, x, i) => s + x * b[i], 0);
const cosine = (a: number[], b: number[]) =>
dot(a, b) / (Math.hypot(...a) * Math.hypot(...b));
if (mask[t] === 0) return 这一行,就是 mask 存在的全部意义。模型仍然会为 padding 位置输出 hidden state。如果把这些位置也算进平均值,短句的向量就会受到同一个 batch 中其他句子长度的影响。正因如此,sentence-transformers 的 model card 在自己的 mean_pooling 辅助函数上专门写了一条注释:“将 attention mask 纳入计算,才能正确求平均。”
把你自己的实现与库的实现对照一下。pipeline 内部执行的也是这些步骤:使用 padding: true, truncation: true 做 tokenization;当选项为 "mean" 时,将 attention mask 传入 mean_pooling;当选项为 "cls" 时,切出位置 0;如果要求 normalization,就调用 normalize(2, -1)。这些都可以在源码中看到。
import { pipeline } from "@huggingface/transformers";
const extractor = await pipeline("feature-extraction", ID, {
dtype: "fp32",
});
const ref = (
await extractor(all, { pooling: "mean", normalize: true })
).tolist() as number[][];
const mine = all.map((_, i) => l2(meanPool(H[i], M[i])));
const diffs = mine.flatMap((v, i) =>
v.map((x, d) => Math.abs(x - ref[i][d])),
);
console.log("max abs difference:", Math.max(...diffs));
你希望看到的是一个处于浮点舍入误差量级的数字。如果差值很大,就说明你的 pooling 与库的实现不一致。在建立任何索引之前,值得先把这个问题弄清楚。试着删掉检查 mask 的那一行,再运行一次:短句向量产生的偏移,就是 padding 混入计算造成的。
余弦相似度会除以两个向量各自的长度,所以先做 normalization,并不会改变余弦相似度分数。任选两个 pooling 后的向量,同时打印 cosine(a, b) 和 cosine(l2(a), l2(b)),除去舍入误差,两者会一致。
Normalization 改变的是点积。对于单位长度的向量,点积和余弦相似度是同一个数。对于原始向量,点积还会偏向长度更大的向量。因此,一篇文档即使没有更相关,也可能仅仅因为 pooling 后的向量恰好更长,就在排名中上升。
这一点很重要,因为向量数据库通常会让你选择运算符。pgvector 的 README 列出了表示负内积的 <#> 和表示余弦距离的 <=>,并建议:“如果向量已经归一化到长度为 1(例如 OpenAI embeddings),使用内积可以获得最佳性能。”如果你对 normalize: false 的向量照搬这条建议,那么即使一行检索代码都没改,实际使用的排名函数也已经变了。
这个实验只对相同的六个字符串执行一次前向传播,然后用四种不同的方式,把 hidden state 转换成检索排名。
function rank(name: string, q: number[], d: number[][]) {
console.log(`\n${name}`);
d.map((v, i) => ({ i, s: dot(q, v) }))
.sort((a, b) => b.s - a.s)
.forEach(({ i, s }) =>
console.log(` ${s.toFixed(4)} ${docs[i]}`));
}
const mean = all.map((_, i) => meanPool(H[i], M[i]));
const cls = all.map((_, i) => clsPool(H[i]));
const meanL2 = mean.map(l2);
const clsL2 = cls.map(l2);
rank("A. mean + L2 (model card)", meanL2[0], meanL2.slice(1));
rank("B. cls + L2", clsL2[0], clsL2.slice(1));
rank("C. mean, raw dot", mean[0], mean.slice(1));
rank("D. cls query vs mean index", clsL2[0], meanL2.slice(1));
实验 A 使用的是 model card 描述的配置,因此将它作为基准,其他实验都与它比较。
实验 B 在查询端和文档端都换了一种 pooling。分数会变化,因为得到的句子向量已经不同了。第一名是否变化,取决于你使用的句子。model card 使用 mean pooling,因此在微调时,位置 0 并不是 pooling 所针对的目标。可以预期,它提供的信号会更弱。
实验 C 保留 mean pooling,但跳过 normalization,直接按原始点积排名。把它的顺序与 A 比较。你看到的任何排名变化,都只来自向量长度。
实验 D 才是会在生产环境中造成麻烦的情况:索引使用 mean pooling 构建,查询流程却使用 CLS。两边的向量都是 384 个浮点数,长度也都为 1。数据库会接受它们,查询会返回五行结果,分数看起来也像正常的分数。pipeline 中没有任何机制会提醒你,两端的向量是由不同函数生成的。
换成你自己的查询,再放入几十篇自己的文档,观察每一步如何改变输出。
BGE 模型的训练方式恰好相反。BAAI/bge-small-en-v1.5 随附的 sentence-transformers pooling 配置将 "pooling_mode_cls_token" 设为 true,将 "pooling_mode_mean_tokens" 设为 false;model card 中的 Transformers 示例则通过 model_output[0][:, 0] 完成 pooling。BGE 教程说明,BGE“经过训练,会使用 [CLS] 的最后一层 hidden state 作为句子 embedding”,并警告:“如果使用 mean pooling,性能会显著下降。”
同一模型适用于 Transformers.js 的移植版本 Xenova/bge-small-en-v1.5,却在使用示例中给出了 { pooling: 'mean', normalize: true }。这个示例自身是一致的,因为索引和查询都使用 mean。但如果你把它复制进一个 Node 索引构建程序,再用一个 Python 服务查询同一个集合,而这个服务通过 sentence-transformers 加载 BAAI/bge-small-en-v1.5(其 modules.json 接入了上面提到的 CLS pooling 配置),你就把实验 D 搬进了生产环境。
Cloudflare Workers AI 的文档也记录了其托管模型 bge-base-en-v1.5 存在的同类差异。文档说,pooling 参数默认为 mean,“是为了避免引入破坏性变更”;同时建议使用 cls 来提高准确性,并明确指出:“通过 cls pooling 创建的 embeddings,与通过 mean pooling 生成的 embeddings 不兼容。”
最后这句话就是规则。对于你的索引来说,pooling 就是模型的一部分。
把生成向量的所有设置视为一个整体,并将它与索引一起存储。
export const EMBED = {
model: "onnx-community/all-MiniLM-L6-v2-ONNX",
dtype: "fp32",
pooling: "mean",
normalize: true,
queryPrefix: "",
} as const;
export const EMBED_KEY = JSON.stringify(EMBED);
export function assertSameSpace(indexKey: string) {
if (indexKey !== EMBED_KEY) {
throw new Error(
`index built with ${indexKey}, querying with ${EMBED_KEY}`,
);
}
}
构建索引时,把 EMBED_KEY 写入一条元数据记录。查询服务启动时,调用 assertSameSpace。如果还有另一个使用其他语言编写的服务读取同一个索引,也给它相同的 key,并执行相同的检查。
把 dtype 放进来是有原因的:根据 dtypes 指南,Transformers.js 允许加载量化变体,包括 q8、q4、fp16 等。量化模型使用的是另一套权重,因此也要像其他设置一样固定下来。放入 queryPrefix,则是因为 BGE 的 model card 建议为较短的检索查询添加指令前缀,而文档段落不需要。这又是一个很容易在某一端遗漏的非对称设置。
如果你改用采用 last_token 或 eos pooling 的 decoder 风格 embedding 模型,还有一个陷阱:pipeline 会直接取序列轴上的最后一个位置,不参考 attention mask,源码中就是这样实现的。在进行 batch 处理之前,检查 tokenizer 在哪一侧添加 padding,否则短句的“最后一个 token”可能只是 padding。
这篇文章拆开讲的是一个函数的内部。AI That Reads 则是围绕它展开的一整本书:使用面向 Node 的 TypeScript,讲解 embeddings、chunking、向量搜索,以及如何以你自己的文档为依据生成回答。如果你喜欢弄清楚这 384 个浮点数从哪里来,这本书会从这里继续,讲解如何存储它们、搜索它们,以及如何判断检索是否正在悄悄失效。

这是 AI in TypeScript 系列的第 2 本。全系列共五本,从第一次调用 LLM,一路讲到将 Agent 投入生产:完整系列在这里。
如需采取进一步措施,可以考虑屏蔽此人和/或举报滥用行为。