前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
返回 AI 情报前线
All News · 全部资讯10183
  • 让编程助手接入浏览器完成调试闭环
  • 先验证分类器,再让 AI 排查运行轨迹
  • 录制模型响应,隔离评测代码与模型变动
  • 审查 AI 生成 PR,先核对任务边界
  • 用有效吞吐压测确定模型接口并发上限
  • Agent 改一个字段,如何证明操作正确
  • 用多任务决策模型自动分流 CVE 漏洞
  • 逐步核算 Token,定位 Agent 的结构性浪费
  • Agent 生成 SQL,如何控制重跑漂移
  • 审核超时后的任务释放与计费隔离
  • DeepSeek 开源大规模推理框架 DeepInfer
  • DeepInfer 开源,公开大规模推理部署能力
  • Cloudflare预览低价结构化抽取模型
  • Headroom 在本地压缩智能体上下文
  • 智能体改记忆,如何验证没有误伤
  • llama-server 工具控制与停止条件现兼容问题
  • 模型快十倍,智能体为何只快一点
  • 用决策模型做评测,先调分类阈值
  • 编码 Agent 实测:成本之外看交付细节
  • Mercury 3 发布:主打高速生成与百万上下文
  • 用 Node 拆解向量池化与检索质量
  • 补齐 Oracle 方言约束,轻量模型首答率跃升
  • 拆解 Claude Code 的完整智能体循环
  • 邮件 Agent 重试前先校验退信
  • 用信号队列约束 Agent 等待邮件
  • 用 Cloudflare 搭建 Agent 收信中枢
  • 一次字符串替换如何让线上文章翻倍
  • AI 搭项目失败,先确认代码是否拉取成功
  • 检索匹配再高,也救不了过期知识库
  • 本地模型帮你给常用命令起快捷名
  • MCP 无状态改版涉及哪些迁移工作
  • 检查 Supabase 服务端鉴权的信任来源
  • 流式 AI 接口延迟该如何拆分测量
  • 模型故障切换如何追踪实际调用路径
  • 百灵开放五款语音模型供开发者组合
  • Drex Max 为电脑操作智能体输出决策概率
  • 无人值守的 Claude 循环如何耗尽额度
  • Bedrock为Claude提供严格结构化输出
  • 错误码搜不准?给向量检索补上 BM25
  • 让 Agent 崩溃后续跑,并处理重复副作用
  • 用 TypeScript 搭建带人工审批的客服智能体
  • Copilot 模型策略支持 REST API 管理
  • Copilot多种使用场景启动模型弃用
  • 把框架说明随包交付,让编程智能体可用
  • Nx 恶意包如何借编程智能体窃取秘密
  • 用分层防护限制 Agent 越权与误操作
  • 在表格中用自然语言分析完整BigQuery数据
  • 亚马逊推 TorchNeuron,拓宽 PyTorch 芯片选择
  • OpenCode平台遭入侵,密钥被盗刷
  • Cloudflare如何将AI审查成本降68%
  • 将Copilot任务无缝接续到VS Code
  • 已加载 51 / 10183
8.0
热点
AI SCORE
技术实践2026-10-20 11:01

用 Node 拆解向量池化与检索质量

dev.to · AI#向量检索#池化#Node.js
Editor brief · 编辑速览

文章用 Transformers.js 展示文本如何从逐词元张量变成句向量,并比较均值池化与 CLS 的选择。索引与查询端若采用不同的池化或归一化设置,检索质量可能下降且不报错。

文章思维导图
Knowledge map
拖拽缩放
Full translation

完整中文译文

系列丛书: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

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 改变的是点积,不是余弦相似度

余弦相似度会除以两个向量各自的长度,所以先做 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 就是模型的一部分。

把 embedding 配置与向量一起固定下来

把生成向量的所有设置视为一个整体,并将它与索引一起存储。

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 That Reads — AI in TypeScript

这是 AI in TypeScript 系列的第 2 本。全系列共五本,从第一次调用 LLM,一路讲到将 Agent 投入生产:完整系列在这里。

如需采取进一步措施,可以考虑屏蔽此人和/或举报滥用行为。

Original source

本文由 AI 翻译整理自 dev.to · AI,原文版权归原作者所有。

阅读英文原文
上一篇
Mercury 3 发布:主打高速生成与百万上下文
下一篇
补齐 Oracle 方言约束,轻量模型首答率跃升