
最近折腾 RAG 系统,被 Embedding 评估坑了几次,这篇把问题说清楚。
向量数据库 15 毫秒返回 20 个文档,RAG 系统照样给你一个烂答案。
检索的核心难点从来不是生成向量,而是判断向量有没有把对的文档排在查询附近。
从这个角度看,Embedding 评估本质上是个信息检索问题。一旦想通这点,三个指标就变得特别有用:
然后还有第二个坑。
用户用印地语搜索英文文档怎么办?或者在印地语句子里用拉丁字母输入产品名?或者用了模型训练数据里根本不存在的内部术语?
最后还有个灵魂拷问:什么时候该微调 Embedding 模型,而不是改别的东西?
咱们从头捋一遍。
很多人把 Embedding 理解成语义指纹——编码一段文本,得到一个向量:
"How do I rotate an API key?"
编码后得到:
[0.18, -0.07, 0.42, ...]
然后把语料库里每个文档块都编码存起来,检索时找最相似的向量。
相似度通常用余弦相似度:
cos(q, d) = (q . d) / (||q|| * ||d||)
向量归一化后,余弦相似度就等价于点积:
cos(q, d) = q . d
整个系统做的事用一句话就能说清:
query | v
embedding | v
rank documents by similarity | v
top-k documents
关键在 rank 这个词。
你的应用根本不在乎文档 A 的余弦相似度是 0.83 还是文档 B 的 0.79。
它只在乎 A 是不是应该排在 B 前面。
这个区别在信息检索领域早就被研究透了。
2002 年,Kalervo Järvelin 和 Jaana Kekäläinen 提出了用分级相关性评估检索系统:一篇文档可以是无关的、有点用的、很有用的、直接回答问题的。动机很实际:检索系统返回的结果太多了,评估必须奖励那些把高度相关内容排在前面靠前的系统。
现代 Embedding 搜索遇到的是同一道题。
向量数据库只是实现手段。
你的系统真正在做的事是:
semantic query -> ranking -> useful context
所以你需要一个测试集。
假设你的文档系统有 10 万个文本块。
收集 500 条真实用户查询:
"how to rotate api keys"
"why does websocket authentication fail"
"can I run the agent without public ports"
"delete an organization"
...
对每条查询,标出哪些文档真正能回答它。
比如:
Query:
"how to rotate api keys" Relevant:
doc_1842
doc_7119
doc_9320
这样你就有了一个小规模检索 Benchmark。
现在可以用完全相同的查询来对比不同的 Embedding 模型了。
这比拍脑袋说"模型 A 看起来更语义化"有用一万倍。
一个能打的 Benchmark 应该包含各种刁钻情况:
还要留一部分测试集,专门用于调参时不动它。
一个简单的评估数据集长这样:
query_id: 17
query: "rotate api key"
relevant_docs: - doc_1842 - doc_7119 query_id: 18
query: "websocket auth failure"
relevant_docs: - doc_921
这样 Embedding 问题就变成可测量的了。
假设某条查询有 5 个相关文档:
Relevant = {A, B, C, D, E}
Embedding 检索返回:
Top 5 = [X, A, Y, Z, B]
找到了 5 个中的 2 个。
所以:
Recall@5 = 2 / 5 = 0.40
通用公式:
Recall@k = relevant documents retrieved in top-k ------------------------------------- total relevant documents
直觉含义:
Recall@k 衡量你把多少答案空间纳入了视野。
想一下:如果你的 RAG 系统检索 10 个块,但答案所在的那个块从来没出现在 top 10 里,LLM 就是巧妇难为无米之炊。
更强的 LLM 不会神奇地修复缺失的上下文。
假设有 1000 条评估查询,平均每条 4 个相关块。
大约有:
1,000 * 4 = 4,000 个相关块
如果 Recall@20 是 0.80,检索器大约召回了:
4,000 * 0.80 = 3,200 个相关块
大约 800 个相关块漏掉了。
这是一个具体的失败预算。
它把所有相关文档一视同仁。
假设相关集合是:
[A, B, C]
两个系统返回:
System 1: [A, X, Y, Z, B]
System 2: [X, A, B, Y, Z]
在 k=5 时,两者的 Recall 完全一样。
但 System 1 把最强结果排在了第一位。
Recall 告诉你有没有把有用材料纳入候选集。
它对排序质量几乎说不出什么。
这就是 MRR 和 NDCG 登场的地方。
MRR 是 Mean Reciprocal Rank(平均倒数排名)。
单条查询:
reciprocal rank = 1 / rank_of_first_relevant_result
所以如果第一个相关结果出现在:
rank 1 -> 1.00
rank 2 -> 0.50
rank 3 -> 0.33
rank 10 -> 0.10
多条查询取平均。
例子:
Query 1 -> first relevant at rank 1 -> 1.00
Query 2 -> first relevant at rank 2 -> 0.50
Query 3 -> first relevant at rank 4 -> 0.25
Query 4 -> no relevant result -> 0.00
那么:
MRR = (1.00 + 0.50 + 0.25 + 0.00) / 4 = 0.4375
MRR 特别适合那种通常只有一个正确答案的查询。
比如:
"what is the default timeout?"
"where is the config file?"
"how do I reset my password?"
"what command starts the server?"
这类问题,把正确答案排在第 1 位远比排到第 15 位好得多。
但 MRR 完全忽略第一个相关文档之后的所有内容。
考虑:
System A: [A, X, X, X, X]
System B: [A, B, C, D, E]
只要 A 是相关的,两个系统的:
RR = 1.0
MRR 觉得它们完全一样。
但对于很多 RAG 应用来说这显然不对——如果你的答案需要多个证据片段,你肯定关心整个排序。
这正是 NDCG 的用武之地。
NDCG 是 Normalized Discounted Cumulative Gain(归一化折损累计增益)。
核心思想比名字简单得多。
给每个检索结果打一个相关性分:
比如:
0 = irrelevant
1 = somewhat useful
2 = useful
3 = directly answers the question
假设你的排序是:
rank 1 -> relevance 3
rank 2 -> relevance 2
rank 3 -> relevance 0
rank 4 -> relevance 1
rank 5 -> relevance 0
NDCG 给排在前面靠前的高相关文档更多分数。
底层 DCG 公式:
DCG@k = sum from i=1 to k of (2^rel_i - 1) / log2(i + 1)
各部分含义:
2^rel_i - 1
让相关性 3 比相关性 1 价值高很多。
而:
log2(i + 1)
对排名靠后的结果打折。
所以排名第 1 位的结果比排名第 10 位的权重高很多。
然后用理想排序做归一化:
NDCG@k = DCG@k / ideal_DCG@k
因此:
NDCG@k = 1.0
意味着你的排序和理想排序完全一致。
假设理想相关性评分是:
[3, 2, 2, 1, 0]
你的系统返回:
[1, 0, 3, 2, 0]
系统仍然召回了相关文档。
Recall 看起来可能还行。
MRR 可能也还行,因为一个相关文档出现得很早。
但 NDCG 崩了,因为相关性 3 的答案被推到了第 3 位。
这往往正是 RAG Pipeline 中你想惩罚的情况。
把它们想象成三个不同的问题:
Recall@k -> 我们检索到足够多的答案了吗? MRR -> 我们快速找到答案了吗? NDCG -> 我们把有用的答案排对了吗?
一个靠谱的检索 Benchmark 通常三个都要用。
简单 Python 实现把区别说得很清楚:
import math def recall_at_k(ranked_ids, relevant_ids, k): relevant_ids = set(relevant_ids) retrieved = set(ranked_ids[:k]) return len(retrieved & relevant_ids) / len(relevant_ids) def reciprocal_rank(ranked_ids, relevant_ids): relevant_ids = set(relevant_ids) for rank, doc_id in enumerate(ranked_ids, start=1): if doc_id in relevant_ids: return 1.0 / rank return 0.0 def ndcg_at_k(relevances, k): def dcg(values): return sum( (2 ** rel - 1) / math.log2(i + 2) for i, rel in enumerate(values[:k]) ) actual = dcg(relevances) ideal = dcg(sorted(relevances, reverse=True)) return actual / ideal if ideal else 0.0
注意一个重要细节:
NDCG 需要分级评判。
如果每个文档只标记"相关"或"不相关",NDCG 也能跑,但你扔掉了有用信息。
很多生产系统的实用评判方案:
0 = 不能回答查询
1 = 相关但不够充分
2 = 有用的证据
3 = 直接回答查询
这样通常就足够让排序问题暴露出来了。
现在假设你的文档是多语言的。
用户问:
"How do I reset my password?"
相关文档是中文的:
"忘记密码时,您可以通过以下步骤重置密码..."
一个真正支持多语言的 Embedding 模型应该在向量空间里把这两段文本放得很近。
这比普通单语言相似度计算难多了,因为模型要学习语言无关的语义表示。
这在 LLM 时代之前就是个重要的研究方向了。
比如 Mikel Artetxe 和 Holger Schwenk 开发的 LASER,支持 93 种语言的多语言句子嵌入。他们的工作明确评估了跨语言相似度,证明了共享嵌入空间可以支持跨语言语义搜索。
但"多语言"不等于"在所有语言对上表现一样好"。
你自己得测。
比如你的评估矩阵可能是:
| Query | Document | Recall@10 |
|---|---|---|
| 英文 | 英文 | 0.91 |
| 中文 | 中文 | 0.87 |
| 英文 | 中文 | 0.64 |
| 中文 | 英文 | 0.59 |
| 泰语 | 英文 | 0.52 |
| 中英混合 | 英文 | 0.47 |
这些数字是举例,但测试结构本身很重要。
聚合分数可能掩盖严重问题。
假设:
英文-英文: 0.92
英文-中文: 0.61
中文-英文: 0.58
而 90% 的 Benchmark 查询都是英文-英文。
你的整体 Recall 可能看起来很漂亮。
你的中文用户实际体验完全不同。
有几个反复出现的坑:
不同文字体系
英文: "insurance claim"
中文: "保险理赔"
音译
"保险"
"baoxian"
"insurance"
代码混合
"UPI 的交易失败了"
专有名词
公司名、API 名、药品名、地名或产品名可能在各语言间保持不变。
领域词汇
你的用户可能在自然语言中混入术语:
OAuth
JWT
webhook
protobuf
nginx
这些既不是纯英文也不是纯中文。
多语言检索要按语言对、按查询类型分别评估,而不是看一个全局分数。
更宽泛的教训来自后来的 Benchmark 研究。MTEB 对 Embedding 模型在大量任务和语言上做了评估,发现没有任何单一 Embedding 方法在所有任务上称霸。Embedding 质量是任务相关的。
这个发现非常重要——当有人说:
"我们用的是最好的 Embedding 模型。"
最好追问一句:最好是哪种?
微调很诱人,因为它看起来像直接解法:
retrieval is bad | v
fine-tune embedding model | v
retrieval gets better
有时候这确实是对的。
但很多时候这是 premature(过早)的