Skip to content

Embedding 与向量检索底层

RAG 做不好,很多团队第一反应是"模型答得不行"。但企业里更常见的真实情况是:用户问得没问题,模型也不差,问题出在检索链路没有把对的材料按对的顺序拿回来。这一页就是把"RAG 错误"往下拆,拆到 embedding、索引、召回、排序和切分策略,从数学推导到源码实现到完整测试流水线。

教学导读

**定位:**这一章把 RAG 中最容易被黑盒化的部分拆开,重点不是背名词,而是能分层解释"为什么没召回、为什么排序不准、为什么上下文一多就乱"。本篇从向量空间的数学基础出发,覆盖 Embedding 模型的演进历史、ANN 索引算法的完整推导、FAISS 源码剖析,以及端到端的评测体系。 **前置依赖:**建议已经学习过 RAG 基础流程、检索评测和基本向量库概念。对线性代数有基本认知(矩阵乘法、向量内积)。 **适用场景:**知识库问答、企业制度检索、FAQ、文档检索增强生成、长文档 QA、多模态检索。 **学完产出:**你应该能独立设计 retrieval-only 评测,手推 HNSW 搜索复杂度,理解 IVF-PQ 的量化误差来源,并把 chunk、索引、rerank、embedding drift 的影响拆出来分析。

先说结论。

测试人员如果只盯最终回答,就会把"检索没拿到""拿到了但排在后面""片段切坏了""生成阶段没用上"这些完全不同的问题都混成一个"回答错误"。RAG 真正的进阶,不是多学一个框架名,而是学会分层诊断。而分层诊断的基础,是你必须理解每一层到底在做什么数学运算、为什么会失败、失败的边界条件是什么。

01. 向量空间的数学基础

在讨论 Embedding 之前,我们需要先建立严格的数学语言。Embedding 的本质是将离散的符号(文字、Token)映射到一个连续的向量空间中,这个空间具有精确的数学结构。

1.1 向量空间与内积空间

一个实数向量空间 V 是定义在实数域 ℝ 上的集合,满足向量加法和标量乘法的八条公理(封闭性、结合律、交换律、分配律等)。Embedding 空间通常是 ℝd,其中 d 是嵌入维度,例如 OpenAI text-embedding-3-large 的 d = 3072。 在向量空间的基础上,内积空间(Inner Product Space)额外定义了内积运算 ⟨·,·⟩,满足以下三个性质:

  1. 对称性:⟨u, v⟩ = ⟨v, u⟩
  2. 线性性:⟨au + bw, v⟩ = a⟨u, v⟩ + b⟨w, v⟩
  3. 正定性:⟨v, v⟩ ≥ 0,等号当且仅当 v = 0 在 ℝd 中,标准内积就是点积:⟨u, v⟩ = Σᵢ uᵢvᵢ。这是余弦相似度和欧氏距离的数学根基。

1.2 范数:衡量向量的"大小"

范数是向量空间上的一个函数 ‖·‖,满足非负性、齐次性和三角不等式。Embedding 领域最常见的范数是:

范数定义几何含义在 Embedding 中的用途
L1 范数‖v‖₁ = Σᵢ |vᵢ|曼哈顿距离稀疏性度量,较少用于 dense embedding
L2 范数‖v‖₂ = √(Σᵢ vᵢ²)欧几里得长度归一化、欧氏距离计算
L∞ 范数‖v‖∞ = maxᵢ |vᵢ|切比雪夫距离异常值检测

一个关键事实:当向量被 L2 归一化后(即 ‖v‖₂ = 1),余弦相似度、内积和欧氏距离三者之间存在严格的数学等价关系。这一点对理解向量数据库的索引选择至关重要。

1.3 度量空间与距离函数

度量空间 (X, d) 是一个集合 X 配上距离函数 d: X × X → ℝ,满足:

  1. 非负性:d(x, y) ≥ 0,d(x, y) = 0 ⟺ x = y
  2. 对称性:d(x, y) = d(y, x)
  3. 三角不等式:d(x, z) ≤ d(x, y) + d(y, z) 三角不等式是 ANN 索引能够剪枝的数学基础。HNSW 在搜索时之所以能跳过大量节点,就是因为如果某个中间节点离 query 已经很远,那么通过它能到达的节点在三角不等式约束下不可能是最近邻。

为什么测试人员需要知道这些。

当你在向量数据库中选择 metric_type="COSINE" 还是 "IP" 还是 "L2" 时,你实际上在选择度量空间的几何结构。不同的选择对排序结果有直接影响——如果 embedding 模型输出的向量没有做 L2 归一化,那么 COSINE 和 IP 的排序结果就会不同。这是一类经常出现但很少被正确诊断的 RAG bug。

02. 相似度度量的数学推导与几何解释

2.1 余弦相似度的推导

余弦相似度衡量两个向量夹角的余弦值,完全忽略向量长度: cos(θ) = ⟨q, d⟩ / (‖q‖₂ · ‖d‖₂) = Σᵢ qᵢdᵢ / (√(Σᵢ qᵢ²) · √(Σᵢ dᵢ²)) 值域为 [-1, 1]。cos(θ) = 1 表示方向完全相同,cos(θ) = 0 表示正交(语义无关),cos(θ) = -1 表示方向完全相反。 几何解释:在二维平面上,两个向量 q 和 d 之间的夹角 θ 完全决定了它们的余弦相似度。向量的长度被归一化消除了,所以一个很短的向量和一个很长但方向相同的向量,余弦相似度仍然是 1。这意味着余弦相似度关注的是"语义方向"而非"语义强度"。

2.2 欧氏距离(L2 距离)的推导

L2(q, d) = ‖q - d‖₂ = √(Σᵢ (qᵢ - dᵢ)²) 欧氏距离衡量的是两个点在空间中的绝对几何距离。与余弦不同,它受向量长度的影响。两个方向相同但长度不同的向量,欧氏距离可能很大。

2.3 三者的等价关系(归一化条件下)

这是最关键的推导。当 ‖q‖₂ = ‖d‖₂ = 1(即向量已做 L2 归一化)时: L2²(q, d) = ‖q - d‖₂² = ⟨q - d, q - d⟩ = ⟨q, q⟩ - 2⟨q, d⟩ + ⟨d, d⟩ = ‖q‖² - 2⟨q, d⟩ + ‖d‖² = 1 - 2⟨q, d⟩ + 1 = 2(1 - ⟨q, d⟩) = 2(1 - cos(θ))

∴ cos(θ) = 1 - L2²/2 ∴ IP(q, d) = ⟨q, d⟩ = cos(θ) (因为 ‖q‖=‖d‖=1) 这个推导的实际意义是:如果你的 embedding 模型输出的向量已经做了归一化(如 OpenAI 的 text-embedding-3 系列默认输出归一化向量),那么在向量数据库中选 COSINE、IP 还是 L2 得到的排序结果完全一致。但如果没有归一化,三者的排序就会不同。

2.4 手算完整示例

query q = [1, 2, 1]
doc_a d1 = [1, 2, 1]
doc_b d2 = [2, 0, 1]

# ====== 余弦相似度 ======
q · d1 = 1*1 + 2*2 + 1*1 = 6
‖q‖ = √(1² + 2² + 1²) = √6
‖d1‖ = √6
cos(q, d1) = 6 / 6 = 1.000      ← 完全相同方向

q · d2 = 1*2 + 2*0 + 1*1 = 3
‖d2‖ = √(4 + 0 + 1) = √5
cos(q, d2) = 3 / (√6·√5) = 3/√30 ≈ 0.548

# ====== 欧氏距离 ======
L2(q, d1) = √((1-1)² + (2-2)² + (1-1)²) = 0
L2(q, d2) = √((1-2)² + (2-0)² + (1-1)²) = √5 ≈ 2.236

# ====== 内积 ======
IP(q, d1) = 6
IP(q, d2) = 3

# 结论:三种度量对 d1 vs d2 的排序一致
# 但如果向量未归一化,排序可能不一致

一个极简 top-k 检索示意 候选文档 d1 d2 d3 d4 余弦分数 0.95 0.72 0.61 0.40 排序 top1 top2 top3 top4

一个真实的 bug 案例。

某企业将 embedding 模型从 text-embedding-ada-002 迁移到 text-embedding-3-small。旧模型输出的向量不是单位向量,他们使用 IP 作为度量并且建立了基线。新模型输出的是归一化向量。迁移后索引度量没改,导致 IP 和 cosine 结果一致了,但与旧基线对比出现了大面积"回退"——其实不是新模型差了,而是度量空间变了。教训:换模型时必须同时检查向量归一化状态和索引度量类型。

03. Embedding 到底在表达什么

Embedding 可以精确定义为:一个函数 f: T → ℝd,将文本序列 T 映射到 d 维实数向量空间。两段文本在这个空间中越近(按照选定的度量),通常代表语义越接近。它不是字符串匹配,也不是关键词匹配,而是通过大规模语料上的统计学习得到的语义位置关系。

传统关键词检索(BM25)

基于词频-逆文档频率的稀疏匹配。更擅长找到字面上相似的内容,漏召回语义改写表达。在术语精确匹配场景(如产品型号、法条编号)仍然不可替代。

Dense Embedding 检索

将 query 和文档都编码为稠密向量,通过向量相似度排序。更擅长找到意思接近但写法不同的内容,但也更容易引入"看起来相关其实不够准"的语义噪声。

测试启发

你需要同时准备"同义表达"(如"退货"vs"退回商品")和"近义误导表达"(如"退款"vs"退货"语义接近但业务逻辑不同)两类 case。混合检索(BM25 + Dense)的测试还需覆盖融合权重的影响。

企业启发

embedding 模型一换、文档切分一改、向量库索引一调,整体检索质量就可能整体漂移。必须建立版本化的 retrieval eval 基线。

3.1 Embedding 的分布假设

一个训练良好的 embedding 模型会让语义相关的文本在向量空间中形成聚类。从信息论的角度,embedding 实际上是在做有损压缩:把变长的自然语言文本压缩成固定长度的向量,在压缩过程中保留对检索任务最有价值的语义信息,丢弃冗余的表面形式信息。 这意味着:(1)不同的 embedding 模型在不同的语义维度上压缩效率不同;(2)没有一个模型对所有领域都最优;(3)你选的模型训练语料分布和你的业务数据分布越接近,检索效果通常越好。这是为什么需要在自己的数据上评测而不能只看 MTEB 排行榜。

04. 从 Word2Vec 到 OpenAI Embeddings 的演进

理解 embedding 的历史演进,有助于理解当前模型的设计选择和局限性。

4.1 Word2Vec(2013):分布式假设的胜利

Mikolov 等人提出的 Word2Vec 基于分布式假设:"一个词的含义由它周围出现的词决定"。两种架构:

CBOW(连续词袋)

用上下文窗口内的周围词预测中心词。目标函数是最大化 P(wₜ | wₜ₋ₖ, ..., wₜ₊ₖ)。训练更快,适合频繁词。

Skip-gram

用中心词预测周围词。目标函数是最大化 P(wₜ₊ⱼ | wₜ)。更擅长捕捉稀有词的语义。配合负采样(Negative Sampling)避免 softmax 对全词表的计算。 Skip-gram 的损失函数经过负采样近似后: L = -log σ(v'ᵀwₒ · vwᵢ) - Σₖ₌₁ᴷ 𝔼[log σ(-v'ᵀwₖ · vwᵢ)]

其中 σ(x) = 1/(1+e⁻ˣ),K 是负样本数 v'wₒ 是输出词向量,vwᵢ 是输入词向量 Word2Vec 的局限:每个词只有一个向量,无法处理多义词(如"苹果"作为水果 vs 公司)。

4.2 GloVe(2014):全局统计 + 局部上下文

GloVe 的核心洞察是:词共现矩阵的对数包含了丰富的语义信息。它直接对共现矩阵进行分解: J = Σᵢ,ⱼ f(Xᵢⱼ) · (wᵢᵀw̃ⱼ + bᵢ + b̃ⱼ - log Xᵢⱼ)²

其中 Xᵢⱼ 是词 i 和词 j 的共现频次 f(x) 是权重函数,对高频共现做截断以避免主导损失 GloVe 的优势是训练更稳定,且能直接利用全局统计信息。但和 Word2Vec 一样,仍然是静态词向量。

4.3 ELMo → BERT(2018):上下文感知的动态 Embedding

ELMo 首先通过双向 LSTM 生成上下文相关的词表示。BERT 进一步用 Transformer 的 self-attention 机制取代了 RNN,通过 Masked Language Model(MLM)和 Next Sentence Prediction(NSP)两个预训练任务获得深层双向上下文表示。 BERT 生成句子 embedding 的方式通常有三种:取 [CLS] token 的输出、对所有 token 做 mean pooling、对所有 token 做 max pooling。但直接使用 BERT 做语义检索有一个致命问题:它需要对 query-document pair 做 cross-attention,计算复杂度是 O(n²),无法预先计算文档向量做索引。

4.4 Sentence-BERT(2019):双塔架构的突破

Reimers 和 Gurevych 提出了 Sentence-BERT(SBERT),使用孪生网络(Siamese Network)架构:query 和 document 分别通过同一个 BERT 编码器,独立生成向量,然后通过余弦相似度计算相关性。这使得文档向量可以预计算并建索引,将在线推理从 O(n²) 降到 O(d)。

Word2Vec (2013) → GloVe (2014) → BERT (2018) → SBERT (2019) → OpenAI Embeddings

4.5 OpenAI Embeddings 与现代 Embedding 模型

OpenAI 的 text-embedding-3 系列代表了当前商业 embedding 模型的水准。它的关键特性包括:

  • **维度可选:**text-embedding-3-large 支持 256 到 3072 维的维度截断(Matryoshka),这背后是 MRL 训练技术。
  • **输出归一化:**默认输出 L2 归一化向量,简化了度量选择。
  • **指令感知:**后续模型如 Instructor、GTE、BGE 等引入了 instruction prefix,让同一个模型能区分"检索"和"分类"等不同下游任务。

开源替代方案中,BGE(BAAI)、GTE(Alibaba)、Jina Embeddings 在 MTEB 排行榜上表现优异。对中文场景,BGE 和 GTE 通常比通用英文模型更适合。

05. 对比学习损失函数的完整推导

现代 embedding 模型几乎都采用对比学习(Contrastive Learning)进行训练。核心思想是:拉近正样本对(语义相关的 query-document)的向量距离,推远负样本对的距离。

5.1 InfoNCE 损失

InfoNCE 由 van den Oord 等人在 CPC(Contrastive Predictive Coding)中提出,它实际上是对互信息 I(x; c) 的下界估计。给定一个 query q、一个正样本 d⁺ 和 N-1 个负样本 {d⁻₁, ..., d⁻ₙ₋₁}: L_InfoNCE = -log [ exp(sim(q, d⁺)/τ) / Σᵢ₌₀ᴺ⁻¹ exp(sim(q, dᵢ)/τ) ]

其中:

  • sim(·,·) 通常是余弦相似度
  • τ 是温度参数(temperature),控制分布的"尖锐程度"
  • 分母对正样本 d⁺ 和所有负样本 d⁻ 求和 推导温度参数的作用:当 τ → 0 时,softmax 退化为 argmax,只有最大值的梯度非零,训练变得极不稳定;当 τ → ∞ 时,softmax 趋向均匀分布,所有样本贡献相同梯度,模型无法区分正负样本。实践中 τ 通常设为 0.05~0.1,或作为可学习参数。

5.2 NT-Xent(Normalized Temperature-scaled Cross Entropy)

SimCLR 使用的 NT-Xent 是 InfoNCE 的对称版本。在一个 batch 中,每个样本 xᵢ 经过两种增强得到 x̃₂ᵢ₋₁ 和 x̃₂ᵢ,构成 2N 个样本: ℓ(i, j) = -log [ exp(sim(zᵢ, zⱼ)/τ) / Σₖ₌₁²ᴺ 𝟙[k≠i] exp(sim(zᵢ, zₖ)/τ) ]

总损失 L = (1/2N) Σₖ₌₁ᴺ [ℓ(2k-1, 2k) + ℓ(2k, 2k-1)] NT-Xent 相比 InfoNCE 的区别:(1)使用同一 batch 内的其他样本作为负样本(in-batch negatives),不需要额外维护负样本队列;(2)对称计算,即 (i,j) 和 (j,i) 都计算损失。

5.3 Hard Negative Mining 的重要性

对比学习的效果高度依赖负样本的质量。如果负样本太"简单"(与 query 语义完全无关),模型很容易就能区分正负,梯度信号弱,训练效率低。Hard Negative 是指与 query 语义接近但不是正确答案的文档——比如对于"如何退货"这个 query,"如何退款"就是一个 hard negative。 ANCE(Approximate Nearest Neighbor Negative Contrastive Estimation)使用 ANN 索引从语料库中动态挖掘 hard negatives,是当前效果最好的策略之一。

测试启发:负样本质量决定模型边界。

如果你在评测 embedding 模型时发现它对明显不相关的文档排序很好,但对近义文档区分度很差,很可能是训练时 hard negative 不够。测试用例设计中,"相似但不正确"的 case 比"完全无关"的 case 更有诊断价值。

06. Matryoshka Representation Learning(MRL)

传统 embedding 模型输出固定维度的向量(如 768 维或 1536 维)。在资源受限场景(边缘设备、超大规模索引),高维向量的存储和计算成本可能不可接受。MRL 提供了一种优雅的解决方案。

6.1 核心思想

Kusupati 等人(2022)提出的 MRL 在训练时同时在多个维度截断点上施加对比损失: L_MRL = Σₘ∈M αₘ · L_InfoNCE(f_m(x), f_m(y))

其中:

  • M = {d₁, d₂, ..., dₖ} 是一组递增的维度,如
  • f_m(x) = x[:m],即取向量的前 m 个维度
  • αₘ 是每个维度的损失权重,通常设为均匀权重 关键洞察:MRL 迫使模型将最重要的语义信息编码在前面的维度中,形成类似"俄罗斯套娃"的嵌套结构。前 256 维已经包含了大部分语义信息,后续维度提供越来越细粒度的补充。

6.2 实际应用:渐进式检索

MRL 使得一个有趣的检索策略成为可能:先用低维向量(如 256 维)做粗筛,得到一个较大的候选集;再用全维向量(如 3072 维)做精排。这类似于 ANN 的思想,但在维度空间而不是图空间上做层次化检索。

python
import numpy as np

def matryoshka_search(query_vec, index_256, index_3072, top_k=5, pre_k=100):
    """两阶段 Matryoshka 检索"""
    # 阶段1:用前256维做粗筛
    query_256 = query_vec[:256]
    query_256 = query_256 / np.linalg.norm(query_256)
    candidates = index_256.search(query_256, k=pre_k)

    # 阶段2:用全3072维对候选做精排
    candidate_vecs = np.array([index_3072.get_vector(doc_id) for doc_id in candidates])
    query_full = query_vec / np.linalg.norm(query_vec)
    scores = candidate_vecs @ query_full
    top_indices = np.argsort(-scores)[:top_k]

    return [candidates[i] for i in top_indices]

07. Chunk 怎么切,决定了你能不能召回对的内容

chunking 是 RAG 最容易被低估的一层。切得太大,片段主题过混,query 和关键句之间会被大量无关信息稀释;切得太小,语义上下文断裂,模型拿到的是碎片化事实,难以复原完整答案。

切分策略优点风险适合场景
固定大小 chunk实现简单,行为可预测切断语义单元快速原型验证
小 chunk(128-256 token)更精准,减少噪声丢失上下文FAQ、结构化知识片段
大 chunk(512-1024 token)上下文完整噪声多、语义被稀释规章制度、长文档综述
带 overlap降低边界断裂索引体积和冗余上升连续叙述类文本
按结构切分语义边界更自然实现成本更高文档标题清晰、版式稳定的知识库
语义分割(Semantic Chunking)基于 embedding 相似度动态决定切分点依赖 embedding 质量,计算成本高段落边界不清晰的非结构化文本

7.1 一个切分失败案例

原文中"试用期员工不享受年假,转正后从转正日开始计算资格"被切成两个 chunk,一个只有"试用期员工不享受年假",另一个只有"转正后开始计算资格"。如果 query 是"试用期员工什么时候可以请年假",top-k 只召回第一个 chunk,就会导致回答不完整甚至误导。

7.2 Parent Document Retrieval 策略

LangChain 引入的 Parent Document Retrieval 是一种优雅的折中:用小 chunk 做检索以提高精度,但返回小 chunk 所属的大 chunk(parent)给 LLM,以保证上下文完整。

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 小 chunk 用于检索(精准匹配)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=50)
# 大 chunk 用于返回给 LLM(上下文完整)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)

store = InMemoryStore()
retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=store,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)
retriever.add_documents(documents)

测试启发。

RAG 用例不要只写"答案对不对",还要写出期望命中的文档 id、期望覆盖的证据句,以及这个问题最容易在哪种切分策略下漏掉关键事实。建议对每个 chunking 策略单独跑 retrieval eval,形成策略×指标的交叉对比表。

08. ANN 与 HNSW 算法的完整推导

真实企业库往往是十万、百万级别向量,做全量精确搜索(brute-force)的时间复杂度是 O(nd),其中 n 是向量数量、d 是维度。当 n 达到百万、d 达到千级别时,延迟不可接受。ANN(Approximate Nearest Neighbor)用牺牲少量精确性换取数量级的速度提升。

8.1 从 Skip List 到 Navigable Small World Graph

理解 HNSW 需要先理解两个前置概念:

Skip List(跳表)

在有序链表上建立多层索引。底层包含所有元素,每上一层随机保留约一半元素。搜索时从最高层开始,利用高层的稀疏性快速定位到目标附近的区域,然后逐层下降做精细搜索。期望搜索复杂度 O(log n)。 HNSW 借鉴了 Skip List 的多层结构思想,但将一维有序链表替换为高维空间中的导航图。

NSW 是一种在向量空间上构建的图,每个节点连接到若干"邻居"。搜索从任意入口点出发,贪心地沿着距离递减的方向移动,直到找到局部最近邻。 NSW 的关键性质:短边用于精细搜索,长边(long-range links)用于跨越空间、避免陷入局部最优。这类似于"六度分隔理论"。

8.2 HNSW 的建图算法

HNSW(Hierarchical NSW)由 Malkov 和 Yashunin(2018)提出,核心是将 NSW 图组织成多层结构: 建图算法 INSERT(q, M, M_max, ef_construction, m_L):

  1. 为新节点 q 随机选择最高层 l = ⌊-ln(uniform(0,1)) · m_L⌋

    • m_L 通常设为 1/ln(M),这使得每层节点数量呈指数递减
  2. 从图的入口点 ep 开始,在第 L 层(最高层)到第 l+1 层:

    • 贪心搜索找到每层最近的 1 个节点
    • 将该节点作为下一层的入口点
  3. 在第 l 层到第 0 层:

    • 用 ef_construction 大小的动态候选列表搜索 M 个最近邻
    • 与这 M 个最近邻建立双向边
    • 如果某个邻居的连接数超过 M_max,执行收缩: 保留最近的 M_max 个连接(用启发式选择以保持图的多样性) 关键参数解析:
  • M:每个节点在每层的最大连接数。M 越大,图越密,召回越高,但内存消耗 O(nM) 线性增长。推荐值 12-48。
  • ef_construction:建图时的候选列表大小。越大建图越慢但质量越高。推荐值 100-500。
  • m_L:层数分配参数。m_L = 1/ln(M) 时,期望层数约 log_M(n)。

8.3 HNSW 的搜索算法

SEARCH(q, ep, ef, K):
  # ep: 入口点, ef: 搜索时的候选列表大小, K: 返回 top-K

  # 阶段1: 从最高层到第1层,每层贪心找最近的1个点
  for layer L downto 1:
      ep = greedy_search(q, ep, layer=L)  # 返回该层距 q 最近的点

  # 阶段2: 在第0层用 ef 大小的候选列表做精细搜索
  candidates = priority_queue(maxsize=ef)  # 按距离从小到大
  visited = set()
  candidates.push(ep)
  visited.add(ep)

  while candidates is not empty:
      c = candidates.pop_nearest()         # 取当前最近的候选
      f = result.get_farthest()            # 结果集中最远的

      if dist(c, q) > dist(f, q):
          break                             # 所有候选都比当前最差结果更远,停止

      for neighbor in c.connections[layer=0]:
          if neighbor not in visited:
              visited.add(neighbor)
              if dist(neighbor, q) < dist(f, q) or len(result) < ef:
                  candidates.push(neighbor)
                  result.push(neighbor)
                  if len(result) > ef:
                      result.pop_farthest()

  return result.top_K(K)

8.4 复杂度分析

操作时间复杂度解释
建图(单点插入)O(M · ef_construction · log n)每层搜索 O(ef_construction · M),共 O(log n) 层
搜索O(ef · M · log n)高层贪心 O(M · log n),底层精搜 O(ef · M)
内存O(n · M · d)n 个节点,每个 M 条边,加上 d 维向量存储

与 brute-force 的 O(nd) 相比,HNSW 的搜索复杂度只有 O(ef · M · log n),当 ef 和 M 都是常数时近似 O(log n)。这就是为什么百万级向量库的搜索延迟可以控制在毫秒级。

理解 efSearch 对召回的影响。

efSearch(搜索时的 ef 参数)是运行时可调的。它直接控制搜索的"广度":ef=K 等价于纯贪心搜索(最快但最可能漏),ef=n 等价于暴力搜索(最准但最慢)。企业中的常见做法是:先用 ef=500 跑基线得到接近精确的 Recall@K,然后逐步降低 ef 观察召回下降曲线,找到延迟和召回的甜蜜点。

09. IVF-PQ:倒排文件 + 乘积量化

HNSW 是基于图的 ANN 方法,IVF-PQ 是另一类主流方法,基于空间划分 + 向量压缩,更适合超大规模(亿级)且内存受限的场景。

9.1 IVF(Inverted File Index):空间粗划分

IVF 的思想很直观:先用 K-Means 将整个向量空间划分为 nlist 个 Voronoi 单元(聚类),每个单元有一个质心 cᵢ。查询时只搜索距 query 最近的 nprobe 个单元内的向量,而非全部。 训练阶段: 对所有向量 {x₁, ..., xₙ} 运行 K-Means,得到 nlist 个质心 {c₁, ..., c_nlist} 每个向量 xⱼ 被分配到最近的质心:assign(xⱼ) = argminᵢ ‖xⱼ - cᵢ‖² 建立倒排索引:每个质心 cᵢ 维护一个列表,存储所有被分配到它的向量

查询阶段:

  1. 计算 query q 到所有 nlist 个质心的距离
  2. 选择距离最近的 nprobe 个质心
  3. 只在这 nprobe 个列表中搜索最近邻

搜索的向量数量期望为 n · nprobe / nlist,远小于 n

9.2 PQ(Product Quantization):向量压缩

IVF 解决了搜索范围的问题,但每个向量仍然是完整的 d 维浮点数(4d 字节)。PQ 通过将向量切分为子空间并量化来大幅压缩存储。 PQ 编码过程:

  1. 将 d 维向量切分为 m 个子向量,每个 d/m 维 x = [x⁽¹⁾, x⁽²⁾, ..., x⁽ᵐ⁾]

  2. 在每个子空间中独立训练一个 K-Means(通常 K=256=2⁸) 得到 m 组码本 C⁽¹⁾, C⁽²⁾, ..., C⁽ᵐ⁾,每组 256 个质心

  3. 每个子向量用最近的质心编号(1字节)替代 PQ(x) = [q⁽¹⁾(x⁽¹⁾), q⁽²⁾(x⁽²⁾), ..., qₘ(x⁽ᵐ⁾)]

存储:原始 4d 字节 → m 字节(压缩比 4d/m) 例:d=768, m=48 → 压缩比 = 3072/48 = 64x

9.3 距离计算:ADC(Asymmetric Distance Computation)

PQ 的距离近似是理解其误差来源的关键: 精确距离:‖q - x‖² = Σⱼ₌₁ᵐ ‖q⁽ʲ⁾ - x⁽ʲ⁾‖²

PQ 近似距离:d̃(q, x) = Σⱼ₌₁ᵐ ‖q⁽ʲ⁾ - c⁽ʲ⁾_{qⱼ(x⁽ʲ⁾)}‖²

其中 c⁽ʲ⁾_{qⱼ(x⁽ʲ⁾))} 是子空间 j 中 x⁽ʲ⁾ 的量化质心

ADC 技巧:预计算 query 的每个子向量到每组 256 个质心的距离 → m × 256 个距离值(查找表) → 每个向量的近似距离只需 m 次查表 + m 次加法 → 无需解压原始向量 量化误差来源于两点:(1)子空间独立性假设——实际上不同子空间的维度之间有相关性;(2)量化粒度——256 个质心对每个子空间的覆盖有限。OPQ(Optimized Product Quantization)通过正交旋转矩阵减少子空间相关性来缓解第一个问题。

IVF-PQ 的误差叠加。

IVF-PQ 的总误差 = IVF 的划分误差(query 的真正最近邻可能不在被搜索的 nprobe 个单元中)+ PQ 的量化误差(距离近似不精确导致排序错误)。测试时需要分别评估:用 IVF-Flat(无 PQ)隔离 IVF 误差,用 PQ-only(无 IVF)隔离 PQ 误差。

10. ScaNN 与 DiskANN 的核心思想

10.1 ScaNN(Google, 2020)

ScaNN(Scalable Nearest Neighbors)的核心创新是各向异性量化(Anisotropic Quantization)。传统 PQ 最小化的是量化误差 ‖x - q(x)‖²,但这不等价于最小化检索排序误差。ScaNN 的洞察是: 传统 PQ 目标:min ‖x - q(x)‖² (最小化重构误差)

ScaNN 目标:min ‖⟨query, x⟩ - ⟨query, q(x)⟩‖ (最小化内积误差)

关键区别:后者对"平行于 query 方向"的误差惩罚更大 因为平行方向的误差直接影响排序,垂直方向的误差不影响 直觉上,如果量化误差方向垂直于 query,那么内积值几乎不受影响,排序也不会变。所以应该把量化精度预算集中在影响排序的方向上。

10.2 DiskANN(Microsoft, 2019)

DiskANN 解决的是另一个问题:当向量数据集太大、无法全部放入内存时怎么办。核心思想是将 Vamana 图索引存储在 SSD 上,结合以下技术:

  • **Vamana 图:**一种改进的 NSW 图,通过贪心搜索 + 修剪策略构建,同时保持图的有界度数(每个节点连接数有上限)和低直径(任意两点之间的最短路径长度有上限)。
  • **PQ 压缩向量常驻内存:**用 PQ 将所有向量压缩后放入内存,用于粗筛。只在需要精确计算时从 SSD 读取原始向量。
  • **Beam Search + 预取:**搜索过程中提前预取下几步可能需要的磁盘块,隐藏 SSD 读取延迟。

DiskANN 的关键指标:在 10 亿级向量上实现 95%+ 的 Recall@1,查询延迟 < 5ms,内存占用仅为原始数据的约 1/20。这使得在单台服务器上处理十亿级向量成为可能。

11. 索引参数、召回率和延迟的三角关系

企业里最真实的问题不是"能不能更快",而是"快多少、丢多少、值不值"。下面这个三角关系建议你直接记住:

更高召回

通常意味着更大的搜索范围(HNSW 的 efSearch↑、IVF 的 nprobe↑)、更高延迟、更高资源消耗。

更低延迟

通常意味着近似更强(efSearch↓、nprobe↓、PQ 子空间数 m↓)、漏召回风险更高。

更低成本

通常意味着向量维度↓(MRL 截断)、PQ 压缩更激进、或放弃 rerank。

一个最常见的误判:
"线上延迟高,所以把 efSearch 调低一点。"

如果不重跑 retrieval eval,
你可能会得到:
延迟下降 20%
但 Recall@5 下降 8%
而业务方只会看到"最近答非所问多了"

正确做法:
1. 建立 Recall@K vs 延迟的 Pareto 曲线
2. 明确业务可接受的最低召回率
3. 在约束内找延迟最优点
4. 变更参数后必须重跑 retrieval eval

精确搜索 O(nd) → HNSW O(ef·M·logn) → IVF-PQ O(nprobe·n/nlist) → DiskANN(SSD加速)

12. FAISS 关键代码路径剖析

FAISS(Facebook AI Similarity Search)是业界使用最广泛的向量搜索库。理解它的关键代码路径有助于你在排查性能问题或精度问题时不至于两眼一黑。

12.1 IndexFlatIP:暴力搜索的基线实现

# FAISS IndexFlatIP 的核心搜索路径(简化版)
# 源码位置:faiss/IndexFlat.cpp → search()

"""
IndexFlatIP::search(n, x, k, distances, labels):
  1. 计算查询矩阵 X (n × d) 与数据库矩阵 D (ntotal × d) 的内积
     → 实际调用 BLAS 的 sgemm: C = X × D^T
     → 结果是 n × ntotal 的得分矩阵

  2. 对每一行(每个 query)取 top-k
     → 使用堆排序 partial_sort

关键性能点:
  - sgemm 利用 SIMD/AVX 指令集,单核吞吐可达 ~100 GFLOPS
  - 当 ntotal < 10万 且 d < 1024 时,暴力搜索可能比 HNSW 更快
    (因为避免了图遍历的 cache miss)
"""

import faiss
import numpy as np

d = 768
n_db = 100000
n_query = 10

# 创建随机数据
db_vectors = np.random.randn(n_db, d).astype('float32')
query_vectors = np.random.randn(n_query, d).astype('float32')

# L2 归一化(使 IP 等价于 cosine)
faiss.normalize_L2(db_vectors)
faiss.normalize_L2(query_vectors)

# IndexFlatIP: 暴力内积搜索
index = faiss.IndexFlatIP(d)
index.add(db_vectors)
scores, ids = index.search(query_vectors, k=5)
print(f"Top-5 scores for query 0: {scores[0]}")
print(f"Top-5 doc ids for query 0: {ids[0]}")

12.2 IndexIVFPQ:生产级索引

# IndexIVFPQ 的关键代码路径
"""
faiss/IndexIVFPQ.cpp 中的搜索路径:

1. quantizer->search(query, nprobe)
   → 找到距 query 最近的 nprobe 个 IVF 质心

2. 对每个选中的倒排列表:
   a. 预计算 query 到每个子空间的 256 个码本质心的距离
      → 查找表 dis_table[m][256],共 m × 256 个浮点数

   b. 遍历倒排列表中的每个 PQ 编码:
      code = [c1, c2, ..., cm]  (m 个字节)
      distance = Σⱼ dis_table[j][code[j]]
      → m 次查表 + m 次加法

   c. 用堆维护 top-k

总计算量 = nprobe × (列表平均长度) × m 次加法
"""

nlist = 1024   # IVF 聚类数
m = 48         # PQ 子空间数
nbits = 8      # 每个子空间 2^8 = 256 个质心

quantizer = faiss.IndexFlatL2(d)
index_ivfpq = faiss.IndexIVFPQ(quantizer, d, nlist, m, nbits)

# 训练阶段:需要一定量的训练数据来学习 IVF 质心和 PQ 码本
index_ivfpq.train(db_vectors)
index_ivfpq.add(db_vectors)

# 搜索阶段
index_ivfpq.nprobe = 32   # 运行时可调
scores, ids = index_ivfpq.search(query_vectors, k=5)

12.3 FAISS 索引选择决策树

数据规模内存约束推荐索引关键参数
< 10万IndexFlatIP/L2无需调参
10万-100万宽裕IndexHNSWFlatM=32, efConstruction=200
100万-1000万有限IndexIVFPQnlist=√n, m=d/8, nprobe=32-128
> 1000万紧张IndexIVFPQ + OPQOPQ 旋转矩阵减少量化误差
> 1亿SSD 可用DiskANN / SPANNPQ 内存 + SSD 存储

13. Rerank 模型原理:Cross-Encoder vs Bi-Encoder

Embedding 检索擅长粗召回,Rerank 擅长精排序。前者更像先从大海里捞出 20-100 个可能相关的片段,后者再把这些候选重新按 query 的精细语义重新排一遍。理解 Rerank 的原理需要从架构对比开始。

13.1 Bi-Encoder(双塔模型)

Bi-Encoder 是 Embedding 检索使用的架构。Query 和 Document 分别独立编码,不存在交互: score_bi(q, d) = sim(E_q(q), E_d(d))

其中 E_q 和 E_d 可以是同一个编码器(共享权重) 关键特性:Document 向量可以预计算并离线索引 在线延迟 = 编码 query + ANN 搜索 ≈ O(d + log n)

13.2 Cross-Encoder(交叉编码器)

Cross-Encoder 将 Query 和 Document 拼接后一起输入 Transformer,让 self-attention 在两者之间充分交互: score_cross(q, d) = MLP(BERT([CLS] q [SEP] d [SEP]))

关键区别:q 和 d 的每个 token 都能 attend to 对方的每个 token → 能捕捉更细粒度的语义匹配(如否定词、条件句、指代关系) 代价:无法预计算 document 表示,每个 (q, d) 对都要完整前向传播 在线延迟 = O(k · (|q| + |d|)²),其中 k 是候选数量

13.3 为什么需要两阶段

维度Bi-Encoder(检索)Cross-Encoder(重排)
语义交互无(独立编码)深度交互(full attention)
精度较低(粗召回)较高(精排序)
速度极快(ANN)慢(每对都要推理)
可索引性可以(预计算向量)不可以
适合规模百万-亿级全库几十-几百个候选

13.4 完整的 retrieve-then-rerank 实现

python
from sentence_transformers import SentenceTransformer, CrossEncoder
import numpy as np

# 阶段 1: Bi-Encoder 粗召回
bi_encoder = SentenceTransformer('BAAI/bge-base-zh-v1.5')
cross_encoder = CrossEncoder('BAAI/bge-reranker-base')

def retrieve_then_rerank(query, documents, top_k=20, final_k=5):
    # 编码所有文档(实际生产中这一步是离线完成的)
    doc_embeddings = bi_encoder.encode(documents, normalize_embeddings=True)
    query_embedding = bi_encoder.encode(query, normalize_embeddings=True)

    # 余弦相似度粗排
    scores = doc_embeddings @ query_embedding
    top_indices = np.argsort(-scores)[:top_k]
    candidates = [(documents[i], scores[i]) for i in top_indices]

    # 阶段 2: Cross-Encoder 精排
    pairs = [(query, doc) for doc, _ in candidates]
    rerank_scores = cross_encoder.predict(pairs)

    reranked = sorted(
        zip(candidates, rerank_scores),
        key=lambda x: x[1],
        reverse=True
    )[:final_k]

    return [{
        'document': doc,
        'bi_score': float(bi_s),
        'cross_score': float(cr_s)
    } for (doc, bi_s), cr_s in reranked]

# 测试要点:
# 1. bi_score 和 cross_score 的排序是否一致
# 2. cross_encoder 是否成功将关键文档从 top-20 提到 top-5
# 3. 对比 rerank 前后的 MRR 和 NDCG 变化
# 4. rerank 的延迟开销是否在业务可接受范围内

什么时候 Rerank 价值最大。

  • 文档库主题相近、候选之间差异很细时(如法律法规、医学指南)。
  • 用户 query 很短(如"怎么退"),但真正意图要靠细粒度语义辨别时。
  • 知识库噪声较多,靠 embedding 相似度难以压掉干扰片段时。
  • 需要处理否定逻辑时("不支持退货"vs"支持退货",Bi-Encoder 可能给出相近分数)。

14. Recall@K、MRR、NDCG 的完整公式推导

检索测试的核心是分层指标。最终答案正确不代表检索好,答案错误也不一定全是检索差。你需要有单独的 retrieval eval,而理解指标的数学定义是正确使用它们的前提。

14.1 Recall@K

Recall@K = |{检索返回的前 K 个文档} ∩ {所有相关文档}| / |{所有相关文档}|

对于单个 query 只有一个正确答案(binary relevance)的场景: Recall@K = 1 如果正确文档在前 K 中,否则 = 0

对多个 query 取平均: Mean Recall@K = (1/Q) · Σᵢ₌₁ᵠ Recall@K(queryᵢ) Recall@K 不关心排序——正确文档在第 1 位还是第 K 位,Recall 都一样。这是它的局限,也是为什么需要 MRR 和 NDCG。

14.2 MRR(Mean Reciprocal Rank)

对单个 query,Reciprocal Rank 定义为第一个相关文档的排名倒数: RR(queryᵢ) = 1/rankᵢ (rankᵢ 是第一个相关文档的排名位置) 如果没有命中:RR = 0

MRR = (1/Q) · Σᵢ₌₁ᵠ RR(queryᵢ)

手算示例: case_1: 正确文档排第1 → RR = 1/1 = 1.000 case_2: 正确文档排第3 → RR = 1/3 = 0.333 case_3: 没命中 → RR = 0 case_4: 正确文档排第2 → RR = 1/2 = 0.500

MRR = (1 + 0.333 + 0 + 0.5) / 4 = 0.458 MRR 只关注第一个相关文档的位置。当每个 query 只有一个正确答案时很合适,但当一个 query 有多个相关文档时(如"列举所有退货政策"),MRR 不够用。

14.3 NDCG(Normalized Discounted Cumulative Gain)

NDCG 是最通用的排序指标,支持多级别相关性(如 0/1/2/3 分),并对排在后面的结果施加对数折扣。 DCG@K = Σᵢ₌₁ᴷ (2^relᵢ - 1) / log₂(i + 1)

其中 relᵢ 是第 i 个返回文档的相关性等级

IDCG@K = 按理想排序计算的 DCG@K(把所有相关文档排在最前面)

NDCG@K = DCG@K / IDCG@K ∈ [0, 1]

手算示例: 返回结果的相关性:[3, 2, 0, 1, 0] DCG@5 = (2³-1)/log₂(2) + (2²-1)/log₂(3) + (2⁰-1)/log₂(4) + (2¹-1)/log₂(5) + 0 = 7/1 + 3/1.585 + 0/2 + 1/2.322 + 0 = 7 + 1.893 + 0 + 0.431 + 0 = 9.324

理想排序:[3, 2, 1, 0, 0] IDCG@5 = 7/1 + 3/1.585 + 1/2 + 0 + 0 = 7 + 1.893 + 0.5 + 0 + 0 = 9.393

NDCG@5 = 9.324 / 9.393 = 0.993(接近完美排序)

14.4 指标选择指南

场景推荐指标理由
每个 query 只有一个正确文档Recall@K + MRRRecall 看"有没有找到",MRR 看"排第几"
每个 query 有多个相关文档(二元)Recall@K + MAPMAP 是 MRR 的多文档版本
文档有多级相关性标注NDCG@K唯一支持多级相关性的指标
粗召回阶段评测Recall@K(K=20~100)只关心"能不能捞到"
精排阶段评测NDCG@K(K=5~10)关心排序质量

测试建议。

企业里至少要有两层报告:一层是 retrieval 指标(Recall@K、MRR/NDCG),一层是 answer 质量指标(Accuracy、F1、Faithfulness)。只看最终回答,会让检索问题和生成问题相互遮蔽。建议在 CI 中自动跑 retrieval eval,将指标下降超过阈值设为 blocking。

15. 向量数据库选型对比

选择向量数据库不只是"哪个跑分高"的问题,还涉及运维成本、生态集成、扩展方式和数据一致性保障。

维度PineconeMilvusQdrantWeaviate
部署模式全托管 SaaS自托管 / Zilliz Cloud自托管 / Qdrant Cloud自托管 / WCS
开源是(Apache 2.0)是(Apache 2.0)是(BSD-3)
索引算法自研HNSW / IVF_FLAT / IVF_PQ / DiskANNHNSWHNSW
标量过滤元数据过滤丰富的表达式过滤Payload 过滤GraphQL 式过滤
多租户NamespacePartition / CollectionPayload + CollectionTenant
水平扩展自动Segment + ShardShard + ReplicaShard
混合检索稀疏+稠密支持支持BM25 + Dense 原生
适合场景快速上线、不想运维大规模、索引类型多Rust 性能、易部署模块化、内置向量化

15.1 选型决策要点

关注运维成本

如果团队没有专门的基础设施工程师,Pinecone 或 Qdrant Cloud 的全托管方案能显著降低运维负担。自建 Milvus 集群需要管理 etcd、MinIO、Pulsar 等多个组件。

关注索引灵活性

Milvus 支持最多种类的索引(HNSW/IVF_FLAT/IVF_PQ/IVF_SQ8/DiskANN),适合需要在不同数据集上使用不同索引策略的场景。

关注过滤性能

当查询需要同时做向量相似度搜索 + 标量条件过滤(如"只搜索 2024 年的文档")时,过滤的实现方式(pre-filter vs post-filter)对性能影响巨大。Pre-filter 在搜索前缩小范围,Post-filter 搜索后再过滤。

关注数据一致性

大多数向量数据库是最终一致(eventually consistent),即写入后不一定立即可搜索。如果业务需要写后即读(read-after-write consistency),需要特别确认数据库的一致性保障级别。

16. Embedding Drift:为什么系统会"慢慢变差"

最难排查的不是明显坏掉,而是"过去一个月指标慢慢掉"。造成 drift 的常见原因有:

  1. **模型版本混用:**更换 embedding 模型后没有重建全部索引,导致新旧向量在不同的语义空间中,余弦相似度失去意义。
  2. **数据分布偏移:**知识库新增内容的写作风格、用词习惯明显变了(如从正式文档变成 FAQ 口语体),embedding 分布发生偏移。
  3. **切分策略变更:**chunk_size 或 overlap 的改变导致相同文档的向量表示变了,历史基线失效。
  4. **索引参数变更:**HNSW 的 M 或 efSearch、IVF 的 nprobe 被修改,ANN 召回率下降。
  5. **Query 分布变化:**用户行为从简单 FAQ 转向复杂多跳问题,原本工作良好的检索策略不再适用。
  6. **Tokenizer 变化:**模型更新带来的 tokenizer 变化可能让相同文本产生不同的 token 序列,从而改变 embedding。

16.1 Drift 检测策略

python
import numpy as np
from scipy import stats

def detect_embedding_drift(
    baseline_embeddings,   # 基线时期的向量集合
    current_embeddings,    # 当前时期的向量集合
    threshold=0.05
):
    """检测 embedding 分布是否发生显著偏移"""

    # 方法1: 平均余弦相似度对比
    baseline_centroid = np.mean(baseline_embeddings, axis=0)
    current_centroid = np.mean(current_embeddings, axis=0)
    centroid_sim = np.dot(baseline_centroid, current_centroid) / (
        np.linalg.norm(baseline_centroid) * np.linalg.norm(current_centroid)
    )

    # 方法2: 维度级 KS 检验
    drift_dims = 0
    for dim in range(baseline_embeddings.shape[1]):
        stat, p_value = stats.ks_2samp(
            baseline_embeddings[:, dim],
            current_embeddings[:, dim]
        )
        if p_value < threshold:
            drift_dims += 1

    drift_ratio = drift_dims / baseline_embeddings.shape[1]

    # 方法3: 内部一致性
    baseline_self_sim = np.mean(baseline_embeddings @ baseline_embeddings.T)
    current_self_sim = np.mean(current_embeddings @ current_embeddings.T)
    consistency_shift = abs(baseline_self_sim - current_self_sim)

    return {
        'centroid_similarity': centroid_sim,
        'drifted_dimensions_ratio': drift_ratio,
        'consistency_shift': consistency_shift,
        'alert': drift_ratio > 0.3 or centroid_sim < 0.9
    }

所以企业不只要评估模型,还要评估 embedding 版本、索引版本、chunk 版本。否则你很难知道"退化是从哪一层开始的"。建议在每次变更时打 tag,保存变更前后的 retrieval eval 结果用于回溯。

17. 完整的 Embedding 质量测试 Pipeline

以下是一个端到端的 Embedding 质量测试流水线,覆盖从数据准备到指标报告的全过程。

17.1 测试设计清单

  1. 为每个 query 明确期望文档 id,不要只写标准答案。
  2. 准备同义 query、模糊 query、误导 query 三类检索样本。
  3. 对 chunk size、overlap、top-k、rerank 开关做 A/B 评测。
  4. 建立 retrieval-only 基线,独立于 generation 指标。
  5. 变更 embedding 模型或索引参数时,必须复跑检索回归集。
  6. 对 top-k 增加的收益和噪声同时做分析,不要默认越多越好。
  7. 设计 hard negative case:语义接近但答案不同的文档对。
  8. 测试多语言、多方言场景下的 embedding 质量(如简体 vs 繁体)。

17.2 完整评测脚本

python
import json
import numpy as np
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class RetrievalTestCase:
    query: str
    expected_doc_ids: list[str]
    relevance_grades: Optional[dict[str, int]] = None  # doc_id → grade (0-3)
    category: str = "general"  # synonymy / ambiguous / adversarial

@dataclass
class RetrievalMetrics:
    recall_at_k: dict[int, float] = field(default_factory=dict)
    mrr: float = 0.0
    ndcg_at_k: dict[int, float] = field(default_factory=dict)
    avg_latency_ms: float = 0.0

def dcg_at_k(relevances: list[int], k: int) -> float:
    """计算 DCG@K"""
    relevances = relevances[:k]
    return sum(
        (2**rel - 1) / np.log2(i + 2)
        for i, rel in enumerate(relevances)
    )

def ndcg_at_k(relevances: list[int], k: int) -> float:
    """计算 NDCG@K"""
    dcg = dcg_at_k(relevances, k)
    ideal = dcg_at_k(sorted(relevances, reverse=True), k)
    return dcg / ideal if ideal > 0 else 0.0

def evaluate_retrieval(
    test_cases: list[RetrievalTestCase],
    retriever,
    k_values: list[int] = [1, 3, 5, 10, 20]
) -> RetrievalMetrics:
    """完整的检索评测函数"""
    import time

    recall_hits = {k: 0 for k in k_values}
    rr_sum = 0.0
    ndcg_sums = {k: 0.0 for k in k_values}
    total_time = 0.0
    max_k = max(k_values)

    for case in test_cases:
        start = time.perf_counter()
        results = retriever.search(case.query, top_k=max_k)
        elapsed = (time.perf_counter() - start) * 1000
        total_time += elapsed

        result_ids = [r["id"] for r in results]
        expected_set = set(case.expected_doc_ids)

        # Recall@K
        for k in k_values:
            if expected_set & set(result_ids[:k]):
                recall_hits[k] += 1

        # MRR
        for i, doc_id in enumerate(result_ids):
            if doc_id in expected_set:
                rr_sum += 1.0 / (i + 1)
                break

        # NDCG@K(需要相关性等级标注)
        if case.relevance_grades:
            relevances = [
                case.relevance_grades.get(doc_id, 0)
                for doc_id in result_ids
            ]
            for k in k_values:
                ndcg_sums[k] += ndcg_at_k(relevances, k)

    n = len(test_cases)
    return RetrievalMetrics(
        recall_at_k={k: recall_hits[k] / n for k in k_values},
        mrr=rr_sum / n,
        ndcg_at_k={k: ndcg_sums[k] / n for k in k_values},
        avg_latency_ms=total_time / n,
    )

def compare_configs(test_cases, configs: dict):
    """对比多组配置的检索效果"""
    results = {}
    for name, retriever in configs.items():
        metrics = evaluate_retrieval(test_cases, retriever)
        results[name] = {
            "Recall@5": f"{metrics.recall_at_k.get(5, 0):.3f}",
            "Recall@10": f"{metrics.recall_at_k.get(10, 0):.3f}",
            "MRR": f"{metrics.mrr:.3f}",
            "NDCG@5": f"{metrics.ndcg_at_k.get(5, 0):.3f}",
            "Latency(ms)": f"{metrics.avg_latency_ms:.1f}",
        }

    # 打印对比表
    print(f"{'Config':<25} {'Recall@5':>10} {'Recall@10':>10} {'MRR':>8} {'NDCG@5':>8} {'Latency':>10}")
    print("-" * 75)
    for name, m in results.items():
        print(f"{name:<25} {m['Recall@5']:>10} {m['Recall@10']:>10} {m['MRR']:>8} {m['NDCG@5']:>8} {m['Latency(ms)']:>10}")

    return results

17.3 测试用例设计模板

# 测试用例集示例
test_suite = [
    # 同义 query 测试
    RetrievalTestCase(
        query="怎么把买的东西退回去",
        expected_doc_ids=["doc_return_policy_001"],
        relevance_grades={"doc_return_policy_001": 3, "doc_refund_001": 1},
        category="synonymy"
    ),
    # 模糊 query 测试
    RetrievalTestCase(
        query="退",
        expected_doc_ids=["doc_return_policy_001", "doc_refund_001"],
        category="ambiguous"
    ),
    # Hard Negative 测试
    RetrievalTestCase(
        query="试用期员工可以请年假吗",
        expected_doc_ids=["doc_leave_policy_003"],
        relevance_grades={
            "doc_leave_policy_003": 3,  # 正确答案
            "doc_leave_policy_001": 1,  # 年假通用政策(不完整)
            "doc_probation_001": 0,     # 试用期通用说明(不含年假信息)
        },
        category="adversarial"
    ),
    # 否定逻辑测试
    RetrievalTestCase(
        query="哪些商品不支持退货",
        expected_doc_ids=["doc_no_return_items"],
        relevance_grades={
            "doc_no_return_items": 3,
            "doc_return_policy_001": 1,  # 相关但不是最佳答案
        },
        category="adversarial"
    ),
]

# 多配置对比
configs = {
    "bge-base + HNSW(ef=64)": retriever_a,
    "bge-base + HNSW(ef=256)": retriever_b,
    "bge-base + rerank": retriever_c,
    "gte-large + HNSW(ef=128)": retriever_d,
}

results = compare_configs(test_suite, configs)

17.4 CI/CD 集成建议

将 retrieval eval 嵌入持续集成。

  • 在 CI 中维护一份 golden test set(至少 200 条标注好的 query-doc pair)。
  • 每次变更 embedding 模型、chunk 策略、索引参数或 rerank 模型时,自动触发 retrieval eval。
  • 设置 blocking 阈值:如 Recall@10 下降 > 2% 或 MRR 下降 > 3% 时阻止部署。
  • 保存每次评测的完整结果(包括每条 case 的返回文档列表),以便后续做 diff 分析。
  • 定期(如每周)对线上 query 日志采样做回归分析,检测 drift。

17.5 自测标准

学完这一页后,你应该能:

  • 从内积空间的公理出发推导余弦相似度,并解释归一化条件下三种度量的等价关系。
  • 描述 Word2Vec 到 OpenAI Embeddings 的演进,说清楚 Bi-Encoder 和 Cross-Encoder 的架构差异。
  • 手写 InfoNCE 损失函数,解释温度参数的作用。
  • 画出 HNSW 的多层图结构,说清楚建图和搜索算法的复杂度。
  • 解释 IVF-PQ 的两层近似(空间划分 + 量化)各自的误差来源。
  • 用 FAISS 构建 IndexFlatIP 和 IndexIVFPQ 索引并运行搜索。
  • 手算 MRR 和 NDCG,知道什么场景用哪个指标。
  • 设计包含同义、模糊、对抗三类 case 的检索评测集。
  • 解释 Embedding Drift 的六种常见原因,并实现 drift 检测代码。
  • 把 RAG 错误拆成召回、排序、拼接、生成四层,独立定位问题所在层。

补充练习与参考答案

补充练习

  1. 解释余弦相似度为什么适合 Embedding 检索,并说明它和向量长度之间的关系。
  2. 给出一个 hard negative 样本例子,并说明它为什么比随机负样本更有训练价值。
  3. 如果线上 Recall@10 持续下降,你会优先从哪几层定位:Embedding、索引、召回参数还是数据分布?

参考答案要点

  • 余弦相似度衡量的是向量方向而不是绝对长度,所以更适合判断语义接近程度;归一化后它和内积在排序上常常等价。
  • hard negative 指“看起来很像但其实不对”的样本,例如“如何退款”和“如何退货”在语义上接近,但答案不完全相同。
  • Recall 下降通常先查数据分布变化和 Embedding/索引配置是否变更,再看召回参数、chunk 策略和 rerank 是否引入退化。