用户问“电子票据在哪儿拿”,文档写“进入订单详情下载发票”。两者词面不完全一致,却可能讨论同一件事。语义检索希望把这种联系表示成可比较的向量关系,再找到有用的文本。

但“变成向量”不是成功的终点:编码空间、相似度、文本分块和评测目标只要有一个不一致,最近的向量也可能不是最有用的证据。这篇沿着 文本怎样变成向量 → 怎样比较 → 怎样检索 → 怎样证明有用 展开。

1. 接在 Transformer 和 RAG 之间

先读 Transformer 入门,会更容易理解编码器的内部计算;想知道训练如何改变向量,可补 反向传播与优化器。本文重点是检索这一层,之后再接 RAGFlow 实战 与 RAG 评测。

层次 输入与输出 本篇需要回答的问题
表示 文本 → 向量 为什么相关文本会靠近,而不是随便给一句话编号
检索 查询向量 → 候选文本 用什么分数,如何处理过滤与并列
重排 查询和候选 → 新顺序 怎样在小候选集上比较更细的相关性
生成 查询和证据 → 回答 本文不实现;检索正确也不能代替回答核验

本文的可运行代码只依赖 Python 标准库。为了让每个数都能手算,示例向量由人工指定,只实现检索端,不训练或调用语义编码模型。它验证的是数值计算、过滤顺序和评测逻辑,不是中文语义理解能力。接真实编码器时,需要另做标注集评测。

2. token 向量不等于可直接搜索的句向量

Transformer 会为一串 token 产生一组上下文表示。检索通常需要一个固定长度的表示来对应一段文本,因此还需要与训练方式匹配的聚合或输出步骤。

表示 一般形状 不应混淆的事情
token embedding 长度 × 隐藏维度 输入查表向量不等于最终文本语义
上下文隐藏状态 长度 × 隐藏维度 每个位置受上下文影响,但仍是一组向量
文本 embedding 输出维度 经过模型规定的聚合、投影等步骤,可供检索比较
批量文本 embedding 文本数 × 输出维度 不同行必须使用兼容的编码约定

平均池化是一种方法,但不是所有模型的通用规定。采用平均池化时,padding 位置通常应由掩码排除;换成取特殊位置、最后一个有效 token 或投影层输出,必须有相应模型说明支持。随便把生成模型的一层平均,就假设得到优秀检索向量,是跳过了训练目标这一环。

Sentence-BERT 的关键思路之一,是通过适合句子比较的训练方式,使文本能够分别编码后再比较,而不是每一对文本都重新一起送入模型。Sentence-BERT 原始论文

靠近不是坐标轴自带的含义

为了画图,我们可能把二维坐标叫“开票”“网络”;真实向量的某一维通常不能直接解释成一个稳定主题。向量在某个训练目标下的相对位置,比单个坐标的名称更重要。

常见训练思路是:让查询与正例的分数相对更高,与负例的分数相对更低。例如“如何下载发票”的正例是对应操作说明,困难负例则可能是“如何修改发票抬头”——词面相近,却回答了另一个问题。假负例同样危险:未标注的另一个正确入口,不应该仅因为未被选中就被当成错误答案。

这个示意图描述一类训练组织方式,不代表所有 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
2
||q - d||² = ||q||² + ||d||² - 2 dot(q,d)
单位向量时:||q - d||² = 2 - 2 dot(q,d)

因此,对同一组单位向量,按点积从大到小与按平方 L2 距离从小到大排序等价。Faiss 官方明确区分内积与余弦,并说明其 L2 返回值是平方距离。Faiss:度量与距离

使用的分数 排序方向 必须先确认
原始点积 大的靠前 模型是否按这种评分方式训练或推荐
余弦相似度 大的靠前 两侧向量均非零,范数计算正确
单位向量点积 大的靠前 查询与文档均按相同规则归一化
L2 或平方 L2 小的靠前 API 返回的是距离还是平方距离

零向量没有定义良好的余弦方向。本文选择明确报错,不偷偷把它当成“相似度为零”;NaN、无穷大和维度不同也都在边界处拒绝。

4. 从分块到索引:离线和在线要对得上

长文通常会切成较小的检索单元。切得太小,条件、表头或步骤可能断开;切得太大,不相关内容会占据表示与上下文预算。固定字数只是基线,标题、段落、表格边界也值得参与切分。

查询编码器与文档编码器需要处在兼容空间;它们可以是同一模型的不同输入角色,也可能是经过联合训练的两个分支。维度相同不代表空间兼容:不能拿一个模型的查询去搜另一个模型的历史向量。

应随索引记录的配置 为什么影响复现
模型标识与具体版本 同名模型更新或换模型可能改变向量空间
查询/文档前缀或任务角色 同一句话以不同角色编码可能得到不同表示
分词器、截断长度与池化 超长内容是否被丢弃、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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import json
import math


def unit(vector):
values = tuple(float(x) for x in vector)
if not values or not all(math.isfinite(x) for x in values):
raise ValueError("vector must be nonempty and finite")
scale = max(abs(x) for x in values)
if scale == 0:
raise ValueError("zero vector has no cosine direction")
# Scale first so finite but very large/small inputs remain usable.
scaled = tuple(x / scale for x in values)
length = math.sqrt(math.fsum(x * x for x in scaled))
return tuple(x / length for x in scaled)


def build_index(rows):
if not rows:
raise ValueError("index must contain at least one document")
entries = []
seen = set()
dimension = None
for row in rows:
ident = row["id"]
if not isinstance(ident, str) or not ident or ident in seen:
raise ValueError("document IDs must be unique nonempty strings")
if not isinstance(row["scope"], str):
raise ValueError("scope must be a string")
vector = unit(row["vector"])
if dimension is None:
dimension = len(vector)
if len(vector) != dimension:
raise ValueError("document dimension mismatch")
seen.add(ident)
entries.append((ident, vector, row["scope"]))
return tuple(entries)


def search(index, query, k, allowed_scopes):
if type(k) is not int or k < 0:
raise ValueError("k must be a nonnegative integer")
if not index:
raise ValueError("empty index")
q = unit(query)
if len(q) != len(index[0][1]):
raise ValueError("query dimension mismatch")
if isinstance(allowed_scopes, str):
raise ValueError("allowed_scopes must be a collection, not a string")
allowed = set(allowed_scopes)
hits = []
for ident, vector, scope in index:
if scope not in allowed:
continue
score = math.fsum(x * y for x, y in zip(q, vector))
hits.append({"id": ident, "score": score})
# Deterministic order for exactly equal computed scores.
hits.sort(key=lambda hit: (-hit["score"], hit["id"]))
return hits[:k]


def metrics(ranked_ids, relevant_ids, k):
if type(k) is not int or k < 0:
raise ValueError("k must be a nonnegative integer")
if len(ranked_ids) != len(set(ranked_ids)):
raise ValueError("ranked IDs must not repeat")
relevant = set(relevant_ids)
if not relevant:
return {"recall": None, "reciprocal_rank": None}
top = ranked_ids[:k]
found = len(set(top) & relevant)
first = next((rank for rank, ident in enumerate(top, 1)
if ident in relevant), None)
return {"recall": found / len(relevant),
"reciprocal_rank": 0.0 if first is None else 1.0 / first}


def demo():
rows = [
{"id": "A", "vector": [3, 4], "scope": "public"},
{"id": "B", "vector": [10, 0], "scope": "public"},
{"id": "C", "vector": [100, 100], "scope": "public"},
{"id": "D", "vector": [0, 2], "scope": "public"},
{"id": "E", "vector": [20, 0], "scope": "internal"},
]
hits = search(build_index(rows), [1, 0], 3, {"public"})
ids = [hit["id"] for hit in hits]
return {"hits": hits, "at_2": metrics(ids, {"A", "B"}, 2),
"at_3": metrics(ids, {"A", "B"}, 3)}


if __name__ == "__main__":
print(json.dumps(demo(), ensure_ascii=False, indent=2))

把代码保存为 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 去重,再统一重排;如果合并的是原始分数,要验证尺度兼容性。不要直接把一个余弦分数与另一个无界相关性分数相加,就假定权重有同样含义。

检索分数不是“答案正确概率”,重排器的输出也未必天然校准。固定阈值必须针对具体模型、领域与查询分布验证;换模型之后,旧阈值不能自动继承。

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 评测与故障定位。此时“检索”不再是一个黑盒按钮,而是几层可以分别检查的假设。

资料入口

接口与模型资料于 2026-09-29 核对;本文手算、数据、代码与学习路线为本站重新组织。