AI 工程基础体系 · 第 18/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

AI 向量检索基础:Embedding、切块、ANN、过滤和召回评测

1. 向量检索在 RAG 中解决什么问题

向量检索(vector retrieval)是把查询和文档转换为向量,再按照向量之间的相似度找到相关内容的检索方法。它通常是 RAG(Retrieval-Augmented Generation,检索增强生成)流水线中的“证据获取”阶段:系统先从外部知识库检索候选片段,再把候选片段放入生成模型的上下文中。

RAG 的基本因果链是:

flowchart LR
    A[原始文件] --> B[解析与规范化]
    B --> C[切块]
    C --> D[Embedding]
    D --> E[向量索引]
    Q[用户问题] --> F[查询 Embedding]
    F --> G[ANN 候选检索]
    G --> H[权限与元数据过滤]
    H --> I[精排/重排]
    I --> J[上下文拼接]
    J --> K[生成与引用]

离线摄取流程和在线查询流程并不对称:

  • 离线阶段处理文件、解析结构、切块、生成文档向量并建立索引。
  • 在线阶段处理查询、生成查询向量、检索候选、执行过滤和重排。
  • 任何一个阶段的错误都会影响最终回答。例如,生成模型回答错误不一定是模型推理失败,也可能是切块时丢失了表头,或者权限过滤后没有留下真正相关的片段。

原始 RAG 论文将外部非参数记忆与生成模型结合起来;工程上常见的向量检索、元数据过滤和重排,都是为了让“送给生成模型的上下文”更相关、更安全、更可控。可以参见 Retrieval-Augmented GenerationOpenAI Retrieval Guide


2. Embedding:把文本映射到语义空间

2.1 定义与基本形式

Embedding 是一个把离散对象映射到连续向量空间的函数。对文本来说,可以写成:

f:XRdf: \mathcal{X} \rightarrow \mathbb{R}^{d}

其中:

  • X\mathcal{X} 是文本集合;
  • dd 是向量维度;
  • f(x)f(x) 是文本 xx 的 embedding 向量。

例如:

“退款多久到账” -> [0.12, -0.04, ..., 0.31]
“退款处理时间” -> [0.10, -0.03, ..., 0.29]

这两个向量通常应当比较接近,因为它们表达的意图相似。这里的“接近”不是字面相似,而是由 embedding 模型在训练数据中学习到的语义关系。

向量空间不是人工预先定义的“词义坐标系”。第 17 维不一定代表“时间”,第 42 维也不一定代表“退款”。单个维度通常难以解释,工程上关注的是整体向量之间的相对几何关系。

2.2 Tokenization、Transformer 和池化

文本并不会直接进入 embedding 模型,而是先经过 Tokenization。Tokenizer 把文本转换为 token ID:

“退款多久到账”
    -> ["退", "款", "多久", "到", "账"]   # 仅为示意
    -> [t1, t2, t3, t4, t5]

实际 token 不一定对应汉字或完整单词,也可能是子词、字节片段或其他词表单元。不同模型的 tokenizer、词表和最大上下文长度可能不同,因此“字符数”和“token 数”不能互换。

典型的 Transformer 编码过程可以抽象为:

H=Transformer(T+P)H = \operatorname{Transformer}(T + P)

其中:

  • TRn×hT \in \mathbb{R}^{n \times h}nn 个 token 的初始表示;
  • PP 是位置编码;
  • hh 是隐藏层维度;
  • HH 是每个 token 经过多层注意力和前馈网络后的表示。

要得到一个固定长度的句向量,需要进行池化(pooling)。常见方式包括:

  1. CLS 池化:取特殊 [CLS] token 的隐藏状态;
  2. Mean pooling:对有效 token 的隐藏状态求平均;
  3. 加权平均:对不同 token 赋予不同权重;
  4. 模型专用池化头:由模型训练时定义的投影层输出最终 embedding。

Mean pooling 的形式为:

e=1imii=1nmihie = \frac{1}{\sum_i m_i}\sum_{i=1}^{n}m_i h_i

其中:

  • hih_i 是第 ii 个 token 的隐藏状态;
  • mi{0,1}m_i \in \{0,1\} 是 attention mask;
  • padding token 的 mi=0m_i=0

如果错误地把 padding 也纳入平均,短文本的向量会被大量无意义的零填充或特殊表示污染。更重要的是,不能把“任意语言模型的隐藏状态取平均”直接当成质量可靠的 embedding;embedding 模型通常针对语义相似度、检索或对比学习目标进行过专门训练。

2.3 语义空间为何能支持检索

Embedding 模型通常通过对比学习、双塔训练或相似度学习,使相关的查询—文档对靠近,不相关的对远离。一个简化的对比损失可以写为:

L=logexp(s(q,d+)/τ)j=1Bexp(s(q,dj)/τ)L=-\log \frac{\exp(s(q,d^+)/\tau)} {\sum_{j=1}^{B}\exp(s(q,d_j)/\tau)}

其中:

  • qq 是查询;
  • d+d^+ 是正样本文档;
  • djd_j 是一个 batch 中的候选文档;
  • s(q,d)s(q,d) 是相似度函数;
  • BB 是 batch 大小;
  • τ\tau 是温度参数。

训练目标不是让所有语义相近的文本拥有相同向量,而是让正样本相对于负样本具有更高相似度。因此 embedding 质量依赖于:

  • 训练语料是否覆盖目标领域;
  • 正负样本是否能反映真实检索任务;
  • 查询和文档是否使用正确的输入格式;
  • 语言、术语、代码、表格和长文本是否在模型能力范围内。

2.4 相似度:余弦、点积与欧氏距离

最常见的余弦相似度为:

cos(x,y)=xyx2y2\operatorname{cos}(x,y)= \frac{x\cdot y}{\|x\|_2\|y\|_2}

它只关注方向,不关注向量长度。将向量归一化为:

x^=xx2\hat{x}=\frac{x}{\|x\|_2}

之后:

cos(x,y)=x^y^\operatorname{cos}(x,y)=\hat{x}\cdot\hat{y}

此时,最大化余弦相似度等价于最大化归一化向量的点积;同时还有:

x^y^22=22(x^y^)\|\hat{x}-\hat{y}\|_2^2 =2-2(\hat{x}\cdot\hat{y})

所以在向量已归一化时,余弦、点积和欧氏距离可以通过单调变换互相转换。

但这不表示三种度量永远等价。若向量没有归一化:

  • 点积同时受方向和长度影响;
  • 余弦只受方向影响;
  • 欧氏距离受绝对位置和长度影响。

因此,索引配置中的 metric 必须与 embedding 模型的训练方式、归一化方式保持一致。错误示例是:模型和离线评测使用 cosine,生产索引却使用未归一化向量的 inner product,导致排序分布发生变化。

2.5 文档向量与查询向量必须匹配

一个检索系统至少包含两个输入集合:

  • 文档 chunk:被写入索引;
  • 用户 query:在线检索时输入。

最安全的原则是使用同一个 embedding 模型、同一版本、同一输入规范和同一相似度定义。若模型提供 query/document 不同的编码模式,则必须按文档说明分别编码;不能把查询模式当作文档模式,也不能混用不同版本后直接比较。

Embedding 版本需要进入索引元数据。例如:

{
  "embedding_model": "example-embed-v3",
  "embedding_dimension": 1536,
  "metric": "cosine",
  "normalized": true,
  "created_at": "2025-01-15T10:00:00Z"
}

更换模型、维度或归一化策略后,通常需要重建索引。直接把新旧向量放入同一个空间会产生不可解释的相似度。


3. 切块:决定检索单元的粒度

3.1 为什么不能直接把整篇文档做成一个向量

假设一份 80 页的手册包含安装、权限、计费和故障排查。把整篇手册编码成一个向量,会产生两个问题:

  1. 查询“如何重置 API 密钥”时,向量表示被其他主题稀释;
  2. 检索到整篇文档后,上下文过大,生成模型需要从大量无关内容中寻找答案。

因此,RAG 通常先把文档切成 chunk,再为每个 chunk 建立向量。

切块的目标不是简单地“每 N 个字符截断”,而是让每个检索单元同时满足:

  • 包含足够上下文,使其独立表达一个事实;
  • 尽可能聚焦一个主题;
  • 长度适合 embedding 模型和生成模型;
  • 能在检索后被准确引用和定位。

3.2 结构化切块优先于固定长度切块

固定长度切块可以定义为:

ci=x[i(so):(i(so)+s)]c_i = x[i\cdot(s-o):(i\cdot(s-o)+s)]

其中:

  • ss 是 chunk 长度;
  • oo 是相邻 chunk 的重叠长度;
  • 步长是 sos-o

例如,以 100 个 token 为窗口、20 个 token 为重叠:

chunk 1: token 0   - 99
chunk 2: token 80  - 179
chunk 3: token 160 - 259

重叠可以减少边界截断造成的信息损失,但会带来更多 chunk、更多 embedding 成本和更多重复上下文。

更稳妥的顺序通常是:

  1. 先识别文档结构:标题、章节、段落、列表、表格、代码块;
  2. 以语义单元为基本边界;
  3. 若单元过长,再按句子或 token 窗口递归拆分;
  4. 对相邻片段保留适量上下文,而不是无条件复制大量文本。

例如,下面的内容不应简单按字符截断:

## 退款条件

退款仅适用于购买后 30 天内的订单。

| 状态 | 是否可退款 |
|---|---|
| 未使用 | 是 |
| 已使用 | 否 |

如果表头被切到另一个 chunk,后续片段中的“是”和“否”将失去列语义。表格通常应被序列化成带表头的文本,或作为完整结构单元处理:

退款条件:
购买后 30 天内的订单可以申请退款。
状态“未使用”:可退款。
状态“已使用”:不可退款。

代码、配置和法律条款也有类似问题:一个函数签名、一个 YAML 层级或一个条款编号被拆开后,局部文本可能产生错误含义。

3.3 Chunk 的语义独立性与父子关系

一个 chunk 可能需要继承父级标题:

产品手册 > API > 身份认证 > 刷新令牌

刷新令牌需要在 access token 过期前调用……

如果只保留正文,“刷新令牌”在向量空间中可能仍有一定语义,但在生成阶段缺少产品和章节范围,引用也不完整。常见做法是把路径标题拼接到 chunk 前面,同时保留原文偏移:

{
  "chunk_id": "doc-17#chunk-04",
  "text": "产品手册 > API > 身份认证 > 刷新令牌\n刷新令牌需要……",
  "source_document": "doc-17",
  "section_path": ["API", "身份认证", "刷新令牌"],
  "char_start": 4210,
  "char_end": 4478
}

对于长文档,可以采用父子检索:

  • 子 chunk 用于精确召回;
  • 父 chunk 或完整段落用于向生成模型提供上下文。

这样可以减少向量检索单元的长度,又避免把生成上下文压缩到一句孤立的文本。

3.4 切块的完整算例

原文:

## 密钥轮换

管理员可以在控制台创建新密钥。创建新密钥后,旧密钥仍可使用 24 小时。
完成客户端配置更新后,管理员应撤销旧密钥。
撤销操作不可逆,撤销前必须确认所有客户端已经完成切换。

若按句子切成三个 chunk:

C1: 管理员可以在控制台创建新密钥。
C2: 创建新密钥后,旧密钥仍可使用 24 小时。
C3: 完成客户端配置更新后,管理员应撤销旧密钥。
C4: 撤销操作不可逆,撤销前必须确认所有客户端已经完成切换。

查询“创建新密钥后旧密钥还能用多久”时,C2 足够回答。查询“什么时候可以撤销旧密钥”时,C3 和 C4 都重要。如果只取 top-1,C3 可能被召回,但“撤销前必须确认所有客户端”这一安全条件会丢失。

因此,切块评测不能只问“是否召回了某个 chunk”,还要标注答案所需的最小证据集合。例如该问题的相关集合是:

R(q)={C3,C4}R(q)=\{C3,C4\}

只有同时召回 C3 和 C4,生成模型才有机会得到完整答案。


4. 索引前的元数据与数据状态

向量不是检索记录的全部。生产中的一个 chunk 至少应包含:

{
  "chunk_id": "doc-17#chunk-04",
  "document_id": "doc-17",
  "tenant_id": "tenant-a",
  "text": "……",
  "embedding": [0.01, -0.02],
  "embedding_model": "example-embed-v3",
  "source_uri": "s3://bucket/manual.pdf",
  "section_path": ["API", "身份认证"],
  "language": "zh",
  "acl": ["group:admins", "user:u123"],
  "version": 7,
  "content_hash": "sha256:...",
  "valid_from": "2025-01-01T00:00:00Z",
  "valid_to": null
}

这些字段承担不同职责:

  • document_idchunk_id 用于去重、引用和删除;
  • tenant_id 用于租户隔离;
  • acl 或等价权限字段用于授权过滤;
  • versioncontent_hash 用于增量更新与幂等;
  • embedding_model 用于防止向量空间混用;
  • source_uri 和偏移量用于生成引用;
  • 有效时间字段用于处理版本和时态知识。

摄取过程应有明确状态,而不是“写入向量数据库即完成”:

DISCOVERED
  -> PARSED
  -> CHUNKED
  -> EMBEDDED
  -> INDEXED
  -> ACTIVE

失败路径也必须可恢复:

EMBEDDED -> INDEX_FAILED -> RETRYING -> INDEXED
PARSED   -> CHUNK_FAILED -> DEAD_LETTER
ACTIVE   -> DELETED      -> TOMBSTONED

常见的生产错误是文档删除成功,但旧 chunk 仍留在索引中;或者新版本写入后旧版本仍被检索。这些问题不是相似度算法能解决的,需要通过版本字段、软删除、物理删除和查询时过滤共同处理。


5. ANN:近似最近邻检索

5.1 精确最近邻是什么

给定查询向量 qq 和向量集合:

X={x1,x2,,xN}X=\{x_1,x_2,\ldots,x_N\}

精确 top-kk 检索会计算 qq 与所有 xix_i 的相似度,再排序取前 kk 个。复杂度大致是:

O(Nd)O(Nd)

其中 NN 是向量数量,dd 是维度。数据量较大时,逐个比较会增加延迟和计算成本。

精确搜索仍然非常重要,因为它是 ANN 的评测基线。没有精确结果,就无法知道近似索引漏掉了多少真正的邻居。

5.2 ANN 的基本目标

ANN(Approximate Nearest Neighbor,近似最近邻)不保证总能返回数学意义上的真实 top-kk,而是在可接受的延迟和内存下,尽量返回真实近邻。

它优化的是三者之间的平衡:

  • 召回率:真实 top-kk 中有多少被找回;
  • 延迟:一次查询需要多久;
  • 资源:索引占用多少内存、构建需要多少 CPU 和时间。

ANN 不是一种单一算法,而是一类算法和索引结构。

5.3 HNSW:图上的逐步搜索

HNSW(Hierarchical Navigable Small World)将向量组织成多层近邻图。高层节点较少,用于快速跨区域跳转;低层节点较多,用于局部精细搜索。

查询过程可以抽象为:

  1. 从最高层入口点开始;
  2. 比较当前节点和其邻居;
  3. 若某个邻居更接近查询,则移动到该邻居;
  4. 在当前层无法继续改进时,下降到下一层;
  5. 在底层保留一组候选节点,扩展并取 top-kk

关键参数通常包括:

  • M:每个节点的最大连接数,影响图的连通性、内存和构建成本;
  • ef_construction:建图时搜索候选规模,较大通常有利于图质量,但构建更慢;
  • ef_search:查询时搜索候选规模,较大通常提高召回,但增加延迟。

HNSW 的失败表现包括:

  • ef_search 太小,召回率低但延迟很好看;
  • 图构建质量不足,某些区域难以到达;
  • 过滤条件很严格时,候选图节点大多不符合条件;
  • 删除大量节点后,逻辑状态与物理图结构不一致。

HNSW 通常适合需要低延迟、内存允许、更新相对频繁的场景,但具体表现取决于数据分布、维度、过滤方式和实现。

5.4 IVF:先分桶,再搜索部分桶

IVF(Inverted File)先通过聚类把向量分成若干个桶(list):

XL1,L2,,LmX \rightarrow L_1,L_2,\ldots,L_m

查询时先找距离查询最近的若干个聚类中心,再只搜索这些桶。nprobe 表示查询时探测的桶数量。

nprobe=1 时,查询只搜索最接近的一个桶,速度较快但可能漏掉边界附近的真实邻居;增加 nprobe 会搜索更多桶,通常提高召回并增加延迟。

IVF 的两个阶段分别有风险:

  • 聚类中心不合理,相关向量被分散到不易访问的桶;
  • nprobe 太小,正确结果位于未探测的桶;
  • 数据分布变化后,旧聚类不再适合新数据;
  • 过滤条件集中在少数桶时,探测策略可能不够。

5.5 PQ:用压缩向量降低成本

PQ(Product Quantization)把向量分成多个子空间,并为每个子空间学习有限个码字。原向量不再完整保存,而是保存每个子空间对应的码字编号。

假设一个 d=8d=8 的向量被分成 4 个二维子空间,每个子空间有 256 个码字,则每个向量只需保存 4 个字节编号,而不是 8 个浮点数。实际系统中的参数和压缩比例由实现决定。

PQ 降低内存和带宽,但会引入量化误差:

xx~x \rightarrow \tilde{x}

检索使用近似向量 x~\tilde{x} 后,排序可能改变。常见补偿方式是:

  • 用压缩向量做粗排;
  • 对候选的原始向量进行精确重算;
  • 调大候选集,再交给重排模型。

PQ 适合数据量大、内存成本敏感的场景,但不能只看索引大小,还要测量压缩后 Recall@k 的变化。


6. 一个可运行的精确检索基线

在调 ANN 之前,先建立一个小规模精确搜索程序。下面的代码只依赖 NumPy,可直接运行。

import numpy as np

documents = [
    ("c1", "退款申请需要在购买后 30 天内提交。"),
    ("c2", "退款通常会在审核通过后的 3 到 5 个工作日到账。"),
    ("c3", "API 密钥可以在控制台的安全设置中轮换。"),
    ("c4", "轮换密钥后,旧密钥仍可使用 24 小时。"),
]

# 仅用于演示。真实系统中的向量应由 embedding 模型生成。
doc_vectors = np.array([
    [0.90, 0.10, 0.00],
    [0.82, 0.18, 0.02],
    [0.05, 0.10, 0.99],
    [0.10, 0.20, 0.95],
], dtype=np.float32)

query_vector = np.array([0.85, 0.15, 0.01], dtype=np.float32)

def normalize(x):
    norm = np.linalg.norm(x, axis=-1, keepdims=True)
    return x / np.maximum(norm, 1e-12)

doc_vectors = normalize(doc_vectors)
query_vector = normalize(query_vector)

scores = doc_vectors @ query_vector
order = np.argsort(-scores)

for rank, index in enumerate(order[:3], start=1):
    doc_id, text = documents[index]
    print(f"{rank}. {doc_id} score={scores[index]:.4f} {text}")

在这个例子中,查询向量被人为设置为接近退款相关向量,因此预期前两项是 c1c2。这个结果不能证明向量模型有效,因为向量是手工构造的;它只验证了余弦相似度实现、排序方向和 top-kk 逻辑。

这段代码还说明了一个重要边界:精确搜索返回的是“按照当前向量和 metric 定义的最近邻”,并不保证它们就是人类认为的相关文档。若 c3 排名很高,问题可能在 embedding 模型、数据表达或查询改写,而不是索引。


7. 过滤:相关性之外的约束

7.1 过滤的定义

过滤(filtering)是根据结构化条件限制可检索文档集合。例如:

tenant_id = "tenant-a"
AND language = "zh"
AND status = "published"
AND user_id ∈ allowed_users

过滤与向量相似度解决的是不同问题:

  • 向量相似度回答“哪些内容语义上接近查询”;
  • 过滤回答“哪些内容允许进入本次候选集合”。

权限过滤不能被向量相似度替代。把“机密”作为文本写进 chunk,并期待 embedding 自动把它排到后面,既不可靠也不安全。

7.2 预过滤、后过滤和过滤后的 top-k

设全集为 XX,过滤后的集合为:

XF={xXF(x)=true}X_F=\{x\in X\mid F(x)=\text{true}\}

严格的过滤检索应当计算:

TopK(q,XF)\operatorname{TopK}(q,X_F)

而不是先在全集中取 kk 个,再删除不符合条件的结果。后者的流程是:

Filter(TopK(q,X),F)\operatorname{Filter}(\operatorname{TopK}(q,X),F)

两者不等价。

完整算例:

候选按相似度排序:
A: 0.99,允许
B: 0.98,不允许
C: 0.97,不允许
D: 0.96,允许
E: 0.95,允许

k=3k=3 时:

  • 先取 top-3 再过滤:只剩 A;
  • 先过滤再取 top-3:得到 A、D、E。

如果后过滤后不足 kk,生成模型可能拿到很少的证据,或者系统错误地认为“没有更多相关内容”。

后过滤有时作为工程折中,例如 ANN 索引不支持高效过滤。但对于 ACL、租户隔离、删除状态等安全条件,必须确保未授权内容不会暴露给应用层或生成模型。仅靠“返回后再过滤”还可能造成侧信道:返回数量、分数分布和延迟可能泄露受保护数据的存在。

7.3 过滤选择性与候选集大小

定义过滤选择性:

sF=XFXs_F=\frac{|X_F|}{|X|}

sFs_F 较低时,普通 ANN 的 top-kk 候选中可能大部分都被过滤掉。一个常见补救方式是先取更大的候选数 KK',再过滤:

ANN top-100
    -> ACL 过滤
    -> 保留 8 个
    -> 重排后取 top-5

但增大 KK' 不是严格保证。若真正相关且有权限的向量未进入 ANN 候选集,后续过滤无法找回它。

过滤性能还取决于实现方式:

  • 预过滤:搜索时只遍历符合条件的向量;
  • 分区或分片:按 tenant、时间或语言拆分索引;
  • 位图/倒排结构:先计算结构化条件对应的候选集合;
  • 后过滤:先做向量检索,再在应用层筛选。

按 tenant 分片可以降低跨租户搜索风险,但租户数量很多且数据量不均衡时,会增加索引管理和热点问题。将 ACL 作为数组存储便于表达权限,但高基数用户权限可能造成过滤索引膨胀。具体取舍依赖数据库实现,不能仅凭“支持 metadata filter”判断其性能或安全语义。


8. ANN 召回与检索召回不是同一个概念

“召回率”在向量检索中至少有两层含义。

8.1 ANN 召回率

给定精确检索结果集合 Gk(q)G_k(q),ANN 返回结果集合 Ak(q)A_k(q),ANN Recall@k 为:

Recall@kANN=Gk(q)Ak(q)k\operatorname{Recall@k}_{ANN} = \frac{|G_k(q)\cap A_k(q)|}{k}

例如精确 top-5 是:

G5 = {a, b, c, d, e}

ANN 返回:

A5 = {a, b, c, x, y}

则:

Recall@5ANN=35=0.6\operatorname{Recall@5}_{ANN}=\frac{3}{5}=0.6

这只衡量 ANN 是否接近精确向量搜索,不判断文档是否真正回答了问题。

8.2 任务相关召回率

对一个查询 qq,人工或业务标注得到相关文档集合 R(q)R(q)。检索结果为 Sk(q)S_k(q),则:

Recall@k=R(q)Sk(q)R(q)\operatorname{Recall@k} = \frac{|R(q)\cap S_k(q)|}{|R(q)|}

若问题需要 C3 和 C4 两个证据,标注集合为:

R(q) = {C3, C4}

检索 top-5 只返回 C3,则任务 Recall@5 是:

12=0.5\frac{1}{2}=0.5

即使 ANN Recall@5 达到 1,也可能因为 embedding 模型把 C4 排在后面而导致任务召回不足。

这两个指标的诊断意义不同:

  • ANN Recall 低:索引参数、压缩、过滤执行或 ANN 算法有问题;
  • ANN Recall 高但任务 Recall 低:embedding、切块、查询表达或标注有问题。

9. 检索评测:从集合指标到排序指标

9.1 Precision@k、Recall@k 和 F1

设 top-kk 结果中相关文档数量为 rkr_k,真实相关文档总数为 RR

Precision@k=rkk\operatorname{Precision@k}=\frac{r_k}{k}

Recall@k=rkR\operatorname{Recall@k}=\frac{r_k}{R}

Precision@k 关注返回结果是否“干净”,Recall@k 关注相关证据是否“找全”。

例如:

真实相关集合:{A, B, C, D}
top-3:{A, X, B}

则:

Precision@3 = 2/3
Recall@3    = 2/4

二者不能互相替代。RAG 常常更怕遗漏关键事实,因此需要较高的候选召回;但候选过多又会增加上下文成本和干扰,最终还需要重排和截断。

9.2 MRR:第一个正确结果有多早

MRR(Mean Reciprocal Rank)适合“只要找到一个能回答问题的结果”的场景:

MRR=1QqQ1rankq\operatorname{MRR} = \frac{1}{|Q|} \sum_{q\in Q}\frac{1}{\operatorname{rank}_q}

其中 rankq\operatorname{rank}_q 是第一个相关结果的排名;若没有相关结果,贡献为 0。

如果三个查询的第一个相关结果分别位于第 1、2、4 位,则:

MRR=1+1/2+1/43=1.7530.5833\operatorname{MRR} = \frac{1+1/2+1/4}{3} =\frac{1.75}{3}\approx0.5833

MRR 不关心第二个、第三个相关结果,因此不适合需要多个证据共同完成答案的问题。

9.3 nDCG:考虑等级和位置

当相关性不只是“相关/不相关”,而是有 0、1、2、3 等等级时,可以使用 nDCG。

DCG@k=i=1k2reli1log2(i+1)DCG@k=\sum_{i=1}^{k} \frac{2^{rel_i}-1}{\log_2(i+1)}

其中 relirel_i 是第 ii 位结果的相关性等级。再用理想排序的 DCG 归一化:

nDCG@k=DCG@kIDCG@knDCG@k=\frac{DCG@k}{IDCG@k}

完整算例:三个结果的相关等级按排序为:

[3, 0, 2]

则:

DCG@3=231log2(2)+201log2(3)+221log2(4)=7+0+32=8.5DCG@3= \frac{2^3-1}{\log_2(2)} + \frac{2^0-1}{\log_2(3)} + \frac{2^2-1}{\log_2(4)} =7+0+\frac{3}{2}=8.5

理想排序为 [3, 2, 0]

IDCG@3=7+3log2(3)8.8928IDCG@3=7+\frac{3}{\log_2(3)}\approx8.8928

所以:

nDCG@38.58.89280.956nDCG@3\approx\frac{8.5}{8.8928}\approx0.956

nDCG 能表达“高度相关的结果应该排在前面”,比单纯集合召回更适合评估排序质量。

9.4 端到端 RAG 还需要评估什么

检索指标无法直接证明最终答案正确。至少还要分开记录:

  1. 检索相关性:相关证据是否进入候选;
  2. 上下文精度:送入模型的内容中有多少真正有用;
  3. 上下文完整性:回答所需证据是否齐全;
  4. 答案正确性:生成内容是否符合证据;
  5. 引用正确性:引用是否确实支持对应陈述;
  6. 拒答正确性:无证据或无权限时是否拒绝编造。

一个典型的错误链是:

Recall@20 很高
-> 重排丢掉关键的第二个证据
-> 生成模型只看到局部条件
-> 答案看似流畅但结论不完整

因此,检索评测应保存每个查询的中间结果:原始 ANN top-N、过滤后的结果、重排分数、最终上下文、引用和生成答案,而不是只保存一个最终准确率。


10. 一个简单的评测程序

下面的代码计算二值相关性下的 Recall@k、Precision@k 和 MRR。它不依赖向量数据库,适合先验证评测定义。

def recall_at_k(retrieved, relevant, k):
    retrieved_k = retrieved[:k]
    return len(set(retrieved_k) & set(relevant)) / max(len(relevant), 1)

def precision_at_k(retrieved, relevant, k):
    retrieved_k = retrieved[:k]
    return len(set(retrieved_k) & set(relevant)) / max(k, 1)

def reciprocal_rank(retrieved, relevant):
    relevant = set(relevant)
    for rank, doc_id in enumerate(retrieved, start=1):
        if doc_id in relevant:
            return 1.0 / rank
    return 0.0

retrieved = ["A", "X", "B", "Y", "C"]
relevant = ["A", "B", "C"]

print("Recall@3:", recall_at_k(retrieved, relevant, 3))
print("Precision@3:", precision_at_k(retrieved, relevant, 3))
print("RR:", reciprocal_rank(retrieved, relevant))

预期输出:

Recall@3: 0.6666666666666666
Precision@3: 0.6666666666666666
RR: 1.0

MRR 为 1,是因为第一个结果 A 就是相关结果;但 Recall@3 只有 2/32/3,因为 C 还没有出现。这个例子说明“第一个结果正确”和“证据已经找全”是不同目标。

评测集至少应包含:

  • 真实用户查询;
  • 查询所属租户和权限身份;
  • 相关文档或 chunk;
  • 相关性等级;
  • 需要多个证据的组合问题;
  • 无答案问题;
  • 同义词、缩写、拼写错误和中英文混合查询;
  • 时效性、版本和权限边界案例。

如果所有测试问题都是从文档标题直接改写出来,指标会过于乐观,不能代表真实用户查询。


11. 查询流程中的候选、过滤与重排

生产查询通常不会“向量 top-k 后直接交给生成模型”,而是分成多个阶段:

query
  -> 查询规范化/改写
  -> query embedding
  -> ANN top-K
  -> 过滤 tenant、ACL、版本、时间和状态
  -> 去重与相邻 chunk 合并
  -> 可选 BM25/关键词混合检索
  -> cross-encoder 或其他重排
  -> 截断到上下文预算
  -> 生成与引用

查询改写可能把用户问题转换为更适合检索的形式,但也可能改变原意。尤其在权限和实体名称场景,不应让模型自由改写出新的租户、项目或用户范围。

混合检索把稀疏检索和向量检索结合起来:

  • 向量检索擅长同义表达和语义匹配;
  • BM25、倒排索引或关键词检索擅长精确匹配产品名、错误码、版本号和 API 参数。

一个常见的融合方式是 Reciprocal Rank Fusion:

RRF(d)=lL1k0+rankl(d)RRF(d)=\sum_{l\in L}\frac{1}{k_0+\operatorname{rank}_l(d)}

其中:

  • LL 是不同检索列表;
  • rankl(d)\operatorname{rank}_l(d) 是文档 dd 在列表 ll 中的名次;
  • k0k_0 是平滑常数。

重排模型通常读取 query 和候选文本的交互,而不是分别编码后只比较两个向量。因此它可能比双塔 embedding 更准确,但计算成本更高,适合对较小候选集进行精排。


12. 切块、Embedding 和 ANN 的故障诊断

12.1 关键词命中但向量检索找不到

可能原因包括:

  • 查询中的错误码或专有名词被 embedding 弱化;
  • chunk 被切断,术语与解释分离;
  • 查询和文档编码模式不一致;
  • 文档语言或领域超出模型能力;
  • ANN 参数导致近邻漏召回。

诊断顺序应先用精确搜索验证:

  1. 关键词能否在原始文本中找到;
  2. 目标 chunk 是否正确生成;
  3. query 和目标 chunk 的相似度是多少;
  4. 精确 top-k 是否包含目标;
  5. ANN top-k 是否丢失目标;
  6. 过滤前后目标在哪一步消失。

若精确搜索也找不到,问题不在 ANN;若精确搜索找到而 ANN 找不到,才应调索引参数或更换索引策略。

12.2 检索结果相关但答案缺少关键条件

常见原因是:

  • chunk 太短,条件位于邻近 chunk;
  • top-k 太小;
  • 去重逻辑只保留同一文档的一个 chunk;
  • 重排偏好主题相似的片段,却丢掉补充限制;
  • 上下文预算截断了后半段。

这类问题不能只通过“提高 embedding 维度”解决。应检查问题对应的最小证据集合,并在评测中明确要求多证据完整性。

12.3 结果很多但内容重复

chunk 重叠过大、同一文档多个版本并存,或相邻 chunk 都进入 top-k,都会造成重复。可以在重排前后做:

  • document_idsection_path 的多样性约束;
  • 相邻 chunk 合并;
  • MMR(Maximal Marginal Relevance)平衡相关性和新颖性;
  • 只保留最新有效版本。

但去重不能简单按文本字符串完全相等,因为同一事实可能在不同章节以不同形式出现。

12.4 权限过滤后的答案不稳定或不安全

典型错误包括:

  • 先把跨租户结果交给应用层,再尝试删除;
  • 过滤字段缺失时默认允许;
  • 文档更新后旧 chunk 的 ACL 未同步;
  • 缓存 key 没有包含 tenant 或用户权限;
  • 生成上下文缓存被不同用户复用。

权限判断应采用默认拒绝原则:缺失 ACL、租户不匹配、版本失效或状态不明确时,不应进入候选上下文。缓存也必须把权限范围、租户和数据版本纳入键或做等价隔离。


13. 成本、延迟和一致性

13.1 Embedding 成本

离线 embedding 成本大致与输入 token 数成正比:

CembedNtokenspembedC_{\text{embed}} \approx N_{\text{tokens}}\cdot p_{\text{embed}}

切块重叠会增加总 token 数。若 chunk 长度为 ss,重叠为 oo,文档总长度为 TT,粗略 chunk 数为:

nToson\approx \left\lceil\frac{T-o}{s-o}\right\rceil

s=500,o=100s=500,o=100 时,步长为 400;若把重叠提高到 250,步长变成 250,chunk 数和 embedding 成本会显著增加。

成本还包括:

  • 解析和 OCR;
  • 向量数据库存储;
  • ANN 索引构建与重建;
  • 查询时 ANN、过滤和重排 CPU;
  • 生成模型输入上下文 token;
  • 评测和日志存储。

增大 top-K 往往同时增加过滤、重排和生成上下文成本,因此不能单独追求 Recall@100。

13.2 延迟预算

在线延迟可分解为:

Ttotal=Trewrite+Tembed+TANN+Tfilter+Trerank+TgenerationT_{\text{total}}= T_{\text{rewrite}}+ T_{\text{embed}}+ T_{\text{ANN}}+ T_{\text{filter}}+ T_{\text{rerank}}+ T_{\text{generation}}

各项不一定全部存在,但必须分别观测。平均延迟可能掩盖尾延迟问题,因此应记录 P50、P95 和 P99。

常见因果关系是:

  • 增大 HNSW ef_search:ANN 延迟上升,ANN 召回可能提升;
  • 增大 IVF nprobe:访问桶更多,延迟上升,召回可能提升;
  • 增大候选 K:过滤和重排成本上升;
  • 增加重排模型:相关性可能提升,但产生额外模型调用;
  • 增加上下文长度:生成延迟和 token 成本上升,且可能引入噪声。

13.3 更新一致性

文档更新时,至少存在三个状态:

  1. 原始文档状态;
  2. chunk 和 embedding 状态;
  3. ANN 索引可见状态。

如果文档已经在主数据库中更新,但新向量尚未完成索引,查询可能暂时读到旧内容。系统应明确采用哪种一致性模型:

  • 最终一致:接受短暂旧数据,并记录索引滞后;
  • 版本一致:查询只允许返回指定版本;
  • 强一致切换:新索引构建完成后原子切换 alias。

删除尤其需要谨慎。逻辑删除可以快速隐藏数据,但会暂时占用索引空间;物理删除释放空间,却可能需要重建图或压缩索引。对于法律删除、租户注销和权限撤销,应验证删除不仅影响主表,也影响缓存、备份、索引和异步任务。


14. ANN 参数调优的正确方法

不要直接根据经验设置 HNSW 或 IVF 参数,而应使用固定评测集进行网格实验。

例如对每个参数配置记录:

配置 A: ef_search=32
配置 B: ef_search=64
配置 C: ef_search=128

对同一批查询分别计算:

ANN Recall@10
任务 Recall@10
nDCG@10
P50/P95 延迟
CPU、内存和索引大小

调优流程是:

  1. 用精确搜索生成 ground truth;
  2. 固定 embedding、切块、过滤和查询集;
  3. 只改变一个 ANN 参数;
  4. 观察召回—延迟曲线;
  5. 再测试过滤选择性、数据规模和分布变化;
  6. 选择满足业务下限的最低成本配置。

如果没有 ground truth,单看“搜索返回了很多看起来相关的内容”无法判断 ANN 是否漏掉了更相关的结果。若 embedding 模型或切块策略发生变化,应重新生成 ground truth,因为真实 top-k 已经改变。


15. 真实边界与常见误解

误解一:向量相似就等于事实正确

相似度只表示模型认为两个输入在语义空间中接近,不表示文档内容真实、最新或适用于当前用户。需要结合来源可信度、更新时间、版本和权限。

误解二:chunk 越小,检索越精确

chunk 太小会失去条件、指代和标题上下文;chunk 太大则会稀释主题并增加上下文成本。合理粒度取决于文档结构、问题类型和下游上下文预算,不能用一个固定字符数覆盖所有数据。

误解三:top-k 越大,答案一定越好

更大的候选集可能提高召回,但也会带来更多重复和噪声。若重排模型、上下文截断或生成模型无法处理额外内容,最终答案质量可能下降。

误解四:ANN 召回率高,RAG 就一定好

ANN 只衡量近似索引是否接近精确向量搜索。若 embedding 没有表达领域术语,精确搜索本身也会返回错误内容。必须同时评估索引层和任务层。

误解五:metadata filter 是权限系统

元数据过滤可以参与授权执行,但它不是完整的身份认证、授权策略、审计和数据生命周期系统。权限信息的来源、更新、缓存隔离、默认拒绝和删除验证都需要由整个系统保证。

误解六:更换 embedding 模型只需重新写入新向量

不同模型的向量空间通常不可直接比较。更换模型后应重新编码所有有效 chunk,重建对应索引,更新评测基线,并在切换期间处理新旧索引的一致性。


16. 生产系统的最小闭环

一个可验证的向量检索系统至少应形成以下闭环:

文档采集
  -> 解析结果可追踪
  -> 结构化切块
  -> 记录模型版本和内容哈希
  -> 生成文档向量
  -> 建立 ANN 索引
  -> 在线执行权限过滤
  -> 保存候选和重排结果
  -> 通过标注集计算召回
  -> 根据失败样本修改切块、模型或索引参数

对于每个查询,应能回答:

  • 使用了哪个 query embedding 模型;
  • 查询向量是否归一化;
  • ANN 返回了哪些候选;
  • 精确搜索是否能找到更好的结果;
  • 哪些候选被 ACL、租户、版本或时间过滤掉;
  • 重排前后排名如何变化;
  • 最终上下文来自哪些 chunk;
  • 生成答案中的每个引用对应哪段原文。

当这些中间状态可观测时,Embedding、切块、ANN 和过滤就不再是一个黑盒。可以先用精确搜索建立正确性基线,再分别验证切块和 embedding 的语义质量,最后调节 ANN 的速度—召回折中;同时把权限、版本、成本和延迟作为同一生产系统的约束,而不是在检索完成后才补上的附加功能。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。