从 embedding 到语义检索:向量相似度、召回与独立评测
用户问“电子票据在哪儿拿”,文档写“进入订单详情下载发票”。两者词面不完全一致,却可能讨论同一件事。语义检索希望把这种联系表示成可比较的向量关系,再找到有用的文本。
但“变成向量”不是成功的终点:编码空间、相似度、文本分块和评测目标只要有一个不一致,最近的向量也可能不是最有用的证据。这篇沿着 文本怎样变成向量 → 怎样比较 → 怎样检索 → 怎样证明有用 展开。
1. 接在 Transformer 和 RAG 之间
先读 Transformer 入门,会更容易理解编码器的内部计算;想知道训练如何改变向量,可补 反向传播与优化器。本文重点是检索这一层,之后再接 RAGFlow 实战 与 RAG 评测。
| 层次 | 输入与输出 | 本篇需要回答的问题 |
|---|---|---|
| 表示 | 文本 → 向量 | 为什么相关文本会靠近,而不是随便给一句话编号 |
| 检索 | 查询向量 → 候选文本 | 用什么分数,如何处理过滤与并列 |
| 重排 | 查询和候选 → 新顺序 | 怎样在小候选集上比较更细的相关性 |
| 生成 | 查询和证据 → 回答 | 本文不实现;检索正确也不能代替回答核验 |
本文的可运行代码只依赖 Python 标准库。为了让每个数都能手算,示例向量由人工指定,只实现检索端,不训练或调用语义编码模型。它验证的是数值计算、过滤顺序和评测逻辑,不是中文语义理解能力。接真实编码器时,需要另做标注集评测。
2. token 向量不等于可直接搜索的句向量
Transformer 会为一串 token 产生一组上下文表示。检索通常需要一个固定长度的表示来对应一段文本,因此还需要与训练方式匹配的聚合或输出步骤。
| 表示 | 一般形状 | 不应混淆的事情 |
|---|---|---|
| token embedding | 长度 × 隐藏维度 |
输入查表向量不等于最终文本语义 |
| 上下文隐藏状态 | 长度 × 隐藏维度 |
每个位置受上下文影响,但仍是一组向量 |
| 文本 embedding | 输出维度 |
经过模型规定的聚合、投影等步骤,可供检索比较 |
| 批量文本 embedding | 文本数 × 输出维度 |
不同行必须使用兼容的编码约定 |
flowchart TD A["文本与角色前缀"] --> B["分词与截断"] B --> C["Transformer 上下文表示"] C --> D["按模型约定聚合或投影"] D --> E["固定维度文本向量"] E --> F["按检索度量决定是否归一化"]
平均池化是一种方法,但不是所有模型的通用规定。采用平均池化时,padding 位置通常应由掩码排除;换成取特殊位置、最后一个有效 token 或投影层输出,必须有相应模型说明支持。随便把生成模型的一层平均,就假设得到优秀检索向量,是跳过了训练目标这一环。
Sentence-BERT 的关键思路之一,是通过适合句子比较的训练方式,使文本能够分别编码后再比较,而不是每一对文本都重新一起送入模型。Sentence-BERT 原始论文
靠近不是坐标轴自带的含义
为了画图,我们可能把二维坐标叫“开票”“网络”;真实向量的某一维通常不能直接解释成一个稳定主题。向量在某个训练目标下的相对位置,比单个坐标的名称更重要。
常见训练思路是:让查询与正例的分数相对更高,与负例的分数相对更低。例如“如何下载发票”的正例是对应操作说明,困难负例则可能是“如何修改发票抬头”——词面相近,却回答了另一个问题。假负例同样危险:未标注的另一个正确入口,不应该仅因为未被选中就被当成错误答案。
flowchart TD Q["查询:怎样下载发票"] --> E["编码与配对打分"] P["正例:下载入口说明"] --> E N["困难负例:修改抬头说明"] --> E E --> L["训练目标比较正负分数"] L --> U["反向传播更新编码器参数"] U --> E
这个示意图描述一类训练组织方式,不代表所有 embedding 模型使用相同损失或相同负例策略。训练数据强调的关系,也会影响“靠近”究竟更像主题相似、问题等价,还是能回答查询。
3. 余弦、点积和距离:把一组数算明白
对于非零向量 q 和 d:点积为对应元素乘积之和;欧氏范数为平方和的平方根;余弦相似度为 dot(q,d) / (norm(q) × norm(d))。余弦比较方向,范围为 [-1,1],不是相关概率。
下面所有向量都是手工构造的教学数据。查询 q=(1,0),它的范数为 1。
| ID | 示例文本 | 向量 d | 范数 | 点积 | 余弦(约) |
|---|---|---|---|---|---|
| A | 发票入口 | (3,4) |
5 | 3 | 0.600000 |
| B | 电子发票下载 | (10,0) |
10 | 10 | 1.000000 |
| C | 网络日志与开票排查合集 | (100,100) |
100√2 |
100 | 0.707107 |
| D | 无线网络故障 | (0,2) |
2 | 0 | 0.000000 |
点积排序是 C → B → A → D,余弦排序是 B → C → A → D。C 的长度很大,使原始点积变高;这并不自动说明 C 更相关。反过来,也不能断言点积总是错误:如果模型训练与索引就是为点积设计,范数可能承载有用信号,应遵循模型约定。
为什么单位向量可以直接用点积
把非零向量除以自己的范数,得到单位向量。两个单位向量的范数都为 1,所以点积就是余弦。进一步展开平方距离:
1 | ||q - d||² = ||q||² + ||d||² - 2 dot(q,d) |
因此,对同一组单位向量,按点积从大到小与按平方 L2 距离从小到大排序等价。Faiss 官方明确区分内积与余弦,并说明其 L2 返回值是平方距离。Faiss:度量与距离
| 使用的分数 | 排序方向 | 必须先确认 |
|---|---|---|
| 原始点积 | 大的靠前 | 模型是否按这种评分方式训练或推荐 |
| 余弦相似度 | 大的靠前 | 两侧向量均非零,范数计算正确 |
| 单位向量点积 | 大的靠前 | 查询与文档均按相同规则归一化 |
| L2 或平方 L2 | 小的靠前 | API 返回的是距离还是平方距离 |
零向量没有定义良好的余弦方向。本文选择明确报错,不偷偷把它当成“相似度为零”;NaN、无穷大和维度不同也都在边界处拒绝。
4. 从分块到索引:离线和在线要对得上
长文通常会切成较小的检索单元。切得太小,条件、表头或步骤可能断开;切得太大,不相关内容会占据表示与上下文预算。固定字数只是基线,标题、段落、表格边界也值得参与切分。
flowchart TD D["离线:原文与元数据"] --> C["按结构切块,保留来源与版本"] C --> E["文档编码器与归一化约定"] E --> I["向量索引与文本映射"] Q["在线:查询与用户上下文"] --> QE["兼容的查询编码器"] QE --> S["在允许的候选集合内检索"] I --> S S --> R["重排与证据检查"] R --> O["返回带来源的文本片段"]
查询编码器与文档编码器需要处在兼容空间;它们可以是同一模型的不同输入角色,也可能是经过联合训练的两个分支。维度相同不代表空间兼容:不能拿一个模型的查询去搜另一个模型的历史向量。
| 应随索引记录的配置 | 为什么影响复现 |
|---|---|
| 模型标识与具体版本 | 同名模型更新或换模型可能改变向量空间 |
| 查询/文档前缀或任务角色 | 同一句话以不同角色编码可能得到不同表示 |
| 分词器、截断长度与池化 | 超长内容是否被丢弃、padding 是否参与聚合 |
| 向量维度、归一化与距离度量 | 决定分数含义和排序方向 |
| 分块策略、原文版本与块 ID | 结果能否准确回到当时的原始证据 |
| 数据范围与访问策略版本 | 查询应在哪些文档中进行 |
模型或分块策略发生影响表示的变化时,应重新生成受影响的向量并验证新索引,不把新旧表示无说明地混用。保留可回滚的旧版本,在评测通过后再切换查询流量。
当前接口与模型约定怎样核对
截至 2026-09-29,Sentence Transformers 的语义搜索文档建议在非对称检索中使用 encode_query 与 encode_document,让模型配置中的角色、提示或路由得以应用;没有这些差异的模型可能与普通 encode 得到相同结果。方法名并不保证替你补上所有模型专属前缀,仍要检查具体模型配置。Sentence Transformers 官方语义搜索文档
例如 intfloat/multilingual-e5-small 的官方说明要求检索查询加 query: 、文档加 passage: ,非英文文本同样如此,并说明长文本最多截断到 512 tokens。这是该模型的约定,不是所有 embedding 模型的统一规范;也不要同时手动添加前缀、又让配置重复添加。E5 官方模型说明
本文不下载模型、不提供未经本机运行的框架示例。接入时应固定自己的依赖和模型版本,先测输入约定,再运行下一节的检索逻辑。
5. 纯 Python:一个可以独立核对的检索端
下面程序完成文档向量归一化、查询评分、过滤后取 top-k,以及 Recall@k 和首个命中倒数排名。它不调用网络,不需要凭据,也不实现语义编码或完整权限系统。
示例在前面的 A—D 之外增加 E:向量 (20,0),范围为 internal。公开查询只允许 public;E 虽然与查询同向,也不会进入公开结果。这个 scope 是教学用数据标签,真实系统必须从可信的服务端身份与策略得到可见范围,不能让用户随意填写一个 scope 来获得权限。
1 | import json |
把代码保存为 retrieval_demo.py,执行 python retrieval_demo.py。结果中的 ID 顺序应为 B,C,A,分数约为 1,0.707107,0.6。E 在评分前已经被过滤。
| 验算项 | 预期结果 | 为什么 |
|---|---|---|
| public 的 top-3 | B、C、A | 按余弦降序,排除 E |
相关集合 {A,B} 的 Recall@2 |
0.5 | 前两名只找回 B,分母是 2 个相关文档 |
| 同一集合的 Recall@3 | 1.0 | A 与 B 都进入前三名 |
| 两次的 RR@k | 1.0 | 第一名已经相关,不会因 A 靠后而变化 |
| k=0 或允许范围为空 | 空列表 | 不返回候选,不扩大可见范围 |
建索引做 O(Nd) 次量级的数值处理并存储 O(Nd) 数据。查询先扫描 N 个 scope,给其中 M 个允许的文档评分,再完整排序,代价为 O(N + Md + M log M),额外候选空间 O(M)。正式系统可以批量矩阵运算、用大小为 k 的堆或专用向量索引;这个版本优先保留易读、可对拍的基线。
过滤必须参与候选生成。先在所有文档中取两条、再删掉无权限结果,可能只剩一条,而本来有两条可见的好结果。真实 ANN 系统的预过滤/后过滤策略各有约束,应在权限隔离、召回率与性能之间明确设计,而不是把这个小示例当作安全产品。
6. 精确扫描、近似检索、混合与重排
| 方法 | 解决的主要问题 | 不能代替的事情 |
|---|---|---|
| 精确向量扫描 | 在给定向量和度量下找到真实最近邻 | 最近邻未必符合业务相关性 |
| ANN 近似索引 | 降低大规模查询成本 | 可能漏掉精确近邻,需要测速度与召回折中 |
| 关键词/稀疏检索 | 捕捉术语、编号和字面证据 | 单靠词面未必识别改写后的查询 |
| 混合检索 | 合并不同检索方式的候选 | 不同原始分数的尺度未必可以直接相加 |
| Cross-Encoder 重排 | 在较小候选集上联合比较查询与文本 | 未被召回的文档无法靠重排凭空找回 |
双编码器可以提前存好文档向量,在线只编码查询。Cross-Encoder 则把查询与候选一起处理,通常适合重排较小候选集,而不是对整个库逐条做昂贵配对。Sentence Transformers:检索与重排
对于“错误码 XQ-107”“合同第 4.2 条”等精确标识,保留关键词基线尤其重要。对复杂系统,可以先合并多路候选并按文档或块 ID 去重,再统一重排;如果合并的是原始分数,要验证尺度兼容性。不要直接把一个余弦分数与另一个无界相关性分数相加,就假定权重有同样含义。
flowchart TD Q["查询"] --> V["向量召回"] Q --> K["关键词召回"] V --> M["合并候选与去重"] K --> M M --> R["小候选集重排"] R --> E["证据与权限复核"] E --> A["进入回答或展示结果"]
检索分数不是“答案正确概率”,重排器的输出也未必天然校准。固定阈值必须针对具体模型、领域与查询分布验证;换模型之后,旧阈值不能自动继承。
7. 两种召回率不能混为一谈
第一种问题是“近似索引有没有找到精确算法会找到的近邻”;第二种是“系统有没有找到人工判定相关的资料”。它们的参考集合不同。
| 指标 | 参考集合或定义 | 能发现什么 |
|---|---|---|
| ANN 邻居召回率 | ANN top-k 与精确 top-k 的交集大小除以 k(候选不少于 k) | 索引近似带来的遗漏 |
| 相关性 Recall@k | 前 k 个唯一结果中的相关 ID 数 / 所有已标注相关 ID 数 | 实际相关资料有没有召回 |
| RR@k | 前 k 内首个相关结果排名的倒数;无命中为 0 | 第一条有用结果出现得有多早 |
| MRR@k | 多个查询的 RR@k 平均 | 查询集上的首个命中表现 |
在第五节的例子里,精确排序毫无近邻误差,但 Recall@2 只有 0.5。它已经说明:数值代码正确,不等于向量空间达到了我们的相关性目标。
代码中的 reciprocal_rank 是一个查询的 RR,不是单独一次运行就得到整个数据集的 MRR。相关标签为空时,本例返回 None,不把“没有标注”当成“系统一定检索失败”;汇总时应明确纳入哪些查询。无答案查询应单独测试误报与拒答,不能因为不计入 Recall 就忽略它们。
| 评测集应该覆盖 | 示例问题 | 标签注意事项 |
|---|---|---|
| 同义改写 | “票据在哪拿”与“下载发票” | 可有多篇正确文档,不能只认一条 |
| 词面近但意图不同 | 下载发票与修改抬头 | 构造困难负例,检查模型是否只认主题 |
| 编号与数字 | 错误码、版本号、条款号 | 注意近似文字匹配造成的错误 |
| 不同语言与领域表达 | 简称、口语、专业术语 | 按实际用户分布抽样 |
| 无答案与权限边界 | 库中不存在的问题或不可见资料 | 相关集合以该用户可见语料为准 |
先保留一组小而可人工检查的问题,再扩大覆盖范围。把调参集和最终测试集分开,记录延迟、成本与相关性;切块调整后还要重新核对块级标签,不能把旧块 ID 原封不动当成新索引真值。BEIR 的跨数据集评测也提醒我们,某一种检索方法在一个任务上的表现不保证能迁移到所有领域。BEIR 原始论文
8. 错误清单与独立验证
| 现象 | 优先检查 | 不要立刻下的结论 |
|---|---|---|
| 换了编码器后结果突然失真 | 查询与文档模型、版本、维度和输入角色 | “向量数据库坏了” |
| 文档越长分数越大 | 原始点积、范数与模型约定 | “内容越多越相关” |
| 相关句在长文末尾却搜不到 | 截断与分块边界 | “模型不懂中文” |
| 编号查询总找错 | 关键词基线、混合召回与负例 | “再换一个更大模型就好” |
| ANN 与精确结果差异大 | 索引参数与邻居召回 | “必须重新训练 embedding” |
| 精确近邻正确但业务答案错 | 相关性标签、模型任务适配与重排 | “只要提高 ANN recall 就能解决” |
| 过滤后结果数量不足 | 过滤发生在哪一层、候选预算 | “允许返回越权文档补齐” |
| 分数高却没有支持答案的句子 | 证据内容与可回答性 | “高相似度就是可信回答” |
专项验证器 tests/verify-embedding-retrieval-article.cjs 从本文提取 Python 代码直接运行,不维护另一份可能漂移的示例。参考答案由独立 JavaScript 实现计算原始余弦,并用单位向量的平方距离复核排序。
验证覆盖随机向量、正比例缩放不变性、分数范围、确定性并列、过滤后 top-k、维度错误、零向量、极大极小有限值、重复 ID、空相关集合,以及本文的 B—C—A 手算。归一化与近邻测试不使用真实语义标签,因此不会把这些测试次数报告成模型检索准确率。
本次验证通过 362 组检索案例、120 组指标案例、20 项非法输入拒绝检查,共完成 32,607 项数值核验。随机部分使用固定种子,可以重复运行得到同样结果。
1 | node tests/verify-embedding-retrieval-article.cjs |
读完后可以做三个小练习:把 C 的向量整体乘以 10,比较点积和余弦;把 k 从 2 改为 3,观察 Recall 与 RR 的不同变化;在允许范围中加入 internal,解释为什么结果变化来自候选集合而不是编码器变聪明了。
下一步回到 AI 工程学习专题,继续 RAGFlow 搭建 或 RAG 评测与故障定位。此时“检索”不再是一个黑盒按钮,而是几层可以分别检查的假设。
资料入口
- Sentence-BERT 论文:文本分别编码与句子比较的训练思路。
- Sentence Transformers 语义搜索文档:查询与文档角色、编码接口。
- Faiss 度量说明:余弦、内积和平方 L2 的关系。
- E5 官方模型卡:特定模型的前缀、池化及长度约定。
- Retrieve & Re-Rank 官方文档:两阶段检索架构。
- BEIR 论文:跨领域检索评测。
接口与模型资料于 2026-09-29 核对;本文手算、数据、代码与学习路线为本站重新组织。