简历上写「准确率提升 30%」不难,难的是面试官问一句「怎么测出来的」你还接得住。这篇讲清楚 RAG 该测哪几个指标、评测集怎么攒、以及怎么把评测接进 CI 变成简历上能扛追问的细节。含可直接运行的 Go / Python 评测代码。
模拟面试里最常出现的一幕是这样的:一个做了两年 Go 的同学,简历第二行写着:主导企业级 RAG 知识库,回答准确率提升 30%。
我就问了一句:这 30% 是怎么测出来的?
他愣了大概五秒,说:"就是上线之后业务方反馈还行……我们自己也试了不少问题,感觉比之前准。"
这类同学前面往往答得都不错。Agent 的 Turn Loop 讲得清楚,工具调用的超时重试和熔断也提到了,甚至说出了 Milvus 不能一直用 Auto Index。就这一句,把整段项目经历的可信度拉下来一大截。
这不是个例。我这两年前后面试过 200 多个从后端转 AI 的候选人,能被追问到"你的评测集是怎么建的"还接得住话的,一只手数得过来。写到这里我自己也反思了一下,是不是我问得太刁了,但真实面试里问得更狠。
一、简历上最脆的一行,就是那个百分比
你去翻十份转 AI 方向的简历,大概能看到八份这样的表述:
- 搭建企业知识库问答系统,回答准确率提升 30%
- 优化召回策略,检索效果提升 25%
- 通过 Prompt 工程与重排,用户满意度显著提升
这几行字看着很像亮点,实际上是最先被击穿的地方。因为它自带一个承诺:你说你提升了,那你就得能证明提升了。
有意思的是,这批人里不少是工程底子相当扎实的后端,该有的工程习惯一样不缺。但一旦到了 AI 项目上,这些本能好像突然被关掉了,剩下的只有"看上去还行"。
我理解为什么会这样。模型的输出不确定,同一个问题问两次答案可能不一样,"准确率"这个概念本身就模糊,所以很多人的直觉是:这玩意儿没法量化,那就别量化了。
但面试官不这么看。他不是要你给一个多大的数字,他要确认的是:你做这件事,有没有形成闭环。
二、他追问的不是数字,是闭环
这个问题一般会连着往下追四层,每层卡掉一批人。
| 追问 | 他实际在验证什么 | 最常见的回答 |
|---|---|---|
| 这 30% 是怎么测出来的 | 有没有评测集 | 上线后我们觉得效果还行 |
| 评测集从哪来的 | 数据是不是你自己攒的 | 网上找的开源数据集 |
| 你改完切分策略,指标怎么变的 | 有没有当回归用 | 没单独对比过 |
| 线上效果掉了你怎么第一时间知道 | 有没有把评测接到流程里 | 等用户投诉 |
第一层就卡掉一大半。能走到第四层的,基本是面试官当场就想往下聊的那种。
我把这几年听到的答案分成三档,你对照看一下自己现在在哪一档。下面表格里的数字都是示例,用来示范"讲法",不是让你照抄。
| 档位 | 典型回答 | 面试官心里的判断 |
|---|---|---|
| L1 感觉派 | "效果还可以,业务方挺满意的" | 自己玩过,没做过 |
| L2 指标派 | "我们测了 Hit@5,是 0.82" | 知道要测,但不知道为什么测这个 |
| L3 闭环派 | "标注了 120 条样本,切分从固定 512 换成按标题分块后 Hit@5 从 0.71 涨到 0.86,但 Faithfulness 从 0.81 掉到 0.78(掉了 3 个点),因为块变大引入了噪声;后来加了 BGE 重排,把忠实度补到 0.91,评测脚本挂在 CI 上,指标回退就合不进去" | 这人是真干过 |
看出来区别在哪了吗?L2 和 L3 的差距根本不在技术深度,在于 L3 能讲出一次权衡。他改了一个东西,一个指标涨了另一个指标掉了,他做了取舍,然后把这个取舍固化进流程里。这恰恰是后端工程师最熟悉的工作方式,只是大部分人没意识到可以平移过来。
三、别只测一个"准确率",RAG 要拆开测
很多人卡在第二步:不知道该测什么。原因很简单,RAG 不是一个环节,它是一条链,你只测最后输出的答案对不对,出了问题根本定位不到是哪一段坏的。
我的习惯是分三段测,每段只看该它负责的事。
| 指标 | 测的是哪一段 | 怎么算 | 容易踩的坑 |
|---|---|---|---|
| Hit@K | 召回有没有找到 | Top-K 里命中至少一条标注片段记为 1 | K 取太小,等于在考重排不是考召回 |
| MRR | 命中的排得靠不靠前 | 1 / 第一条命中的位次 | 只看首条,长尾问题会失真 |
| NDCG@K | 排序整体质量 | 带位次折扣的相关性得分 | 标注只有"相关/不相关"二值时,区分度接近 Hit@K,意义打折 |
| Faithfulness | 答案有没有瞎编 | 答案里的陈述有多少条能从上下文推出 | 用 LLM 当裁判时温度必须设 0,否则两次跑出来两个分 |
| Answer Relevancy | 有没有答非所问 | 答案与问题的匹配度 | 和 Faithfulness 完全不是一回事,别混着说 |
| 工具选择正确率 | Agent 有没有调对工具 | 选对的工具数 / 该调用的总次数 | 只在 Agent 场景测,纯问答不用管 |
| P95 延迟 / 单次成本 | 工程侧 | 链路埋点统计 | 很多人忘了测,但面试官会问 |
这里我想强调一句:Faithfulness 和 Answer Relevancy 是两件事。答案完全来自上下文但答非所问,那 Faithfulness 是满分,Relevancy 是零分。反过来,答案答得很对但上下文里根本没这个信息,那是幻觉,Faithfulness 直接归零。你在简历上只写"准确率",面试官根本不知道你说的是哪个。
四、能跑起来的最小评测:100 条样本 + 3 个指标 + 一道 CI 门禁
讲道理不如给代码。下面这套我在自己的项目里就长这样,Go 写的(用到内置 min,需要 Go 1.21+),能直接 go test 跑,也能挂到 CI 上。
先是评测集的结构。记住一点,标注必须是人工确认过的,别贪图省事让模型自己觉得哪些片段相关,那就变成自己给自己判卷了。
package eval
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"testing"
)
// GoldenCase 是一条人工标注过的评测样本。
// Expected 里放的是"人工确认能回答这个问题的片段 ID"。
type GoldenCase struct {
Query string `json:"query"`
Expected []string `json:"expected_chunk_ids"`
}
func loadGolden(t *testing.T, path string) []GoldenCase {
f, err := os.Open(path)
if err != nil {
t.Fatalf("打开评测集失败: %v", err)
}
defer f.Close()
cases := make([]GoldenCase, 0, 128)
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
var c GoldenCase
if err := json.Unmarshal(sc.Bytes(), &c); err != nil {
t.Fatalf("第 %d 行解析失败: %v", len(cases)+1, err)
}
cases = append(cases, c)
}
// 样本太少的话,指标波动会大到没法做判断,直接拒绝跑
if len(cases) < 100 {
t.Fatalf("评测集只有 %d 条,至少攒到 100 条再谈结论", len(cases))
}
return cases
}
// hitAtK:Top-K 里命中任意一条标注片段,这条 query 就算召回成功。
func hitAtK(retrieved, expected []string, k int) float64 {
if k > len(retrieved) {
k = len(retrieved)
}
want := make(map[string]struct{}, len(expected))
for _, id := range expected {
want[id] = struct{}{}
}
for _, id := range retrieved[:k] {
if _, ok := want[id]; ok {
return 1
}
}
return 0
}
// mrrAtK:看第一条命中的片段排在第几位,越靠前分越高。
func mrrAtK(retrieved, expected []string, k int) float64 {
if k > len(retrieved) {
k = len(retrieved)
}
want := make(map[string]struct{}, len(expected))
for _, id := range expected {
want[id] = struct{}{}
}
for i, id := range retrieved[:k] {
if _, ok := want[id]; ok {
return 1.0 / float64(i+1)
}
}
return 0
}
// Retriever 换成你自己的实现,本地向量库、Milvus、HTTP 检索中台都行。
type Retriever interface {
Search(ctx context.Context, query string, k int) ([]string, error)
}
func TestRetrievalQuality(t *testing.T) {
cases := loadGolden(t, "testdata/golden.jsonl")
rt := newRetrieverFromEnv(t) // 从环境变量组装,避免测试里硬编码密钥
var hit5, mrr5 float64
missed := make([]string, 0, 16)
for _, c := range cases {
got, err := rt.Search(context.Background(), c.Query, 10)
if err != nil {
t.Fatalf("检索失败 query=%q: %v", c.Query, err)
}
h := hitAtK(got, c.Expected, 5)
hit5 += h
mrr5 += mrrAtK(got, c.Expected, 5)
if h == 0 {
missed = append(missed, c.Query)
}
}
n := float64(len(cases))
hit5, mrr5 = hit5/n, mrr5/n
t.Logf("样本 %d 条 | Hit@5 = %.3f | MRR@5 = %.3f", len(cases), hit5, mrr5)
if len(missed) > 0 {
limit := min(len(missed), 10)
t.Logf("召回失败的 query(前 %d 条): %v", limit, missed[:limit])
}
// 这才是评测真正值钱的地方:把基线写成断言,指标回退就合不进去
if hit5 < 0.80 {
t.Errorf("Hit@5 回退到 %.3f,低于基线 0.80,禁止合入", hit5)
}
if mrr5 < 0.60 {
t.Errorf("MRR@5 回退到 %.3f,低于基线 0.60,禁止合入", mrr5)
}
fmt.Printf("\n--- 检索评测报告 ---\nHit@5: %.3f\nMRR@5: %.3f\n", hit5, mrr5)
}
检索侧跑通之后,生成侧再加一个忠实度打分。这个用 LLM 当裁判是最省事的做法,成本也不高。
import json
from openai import OpenAI
# client 换成任意兼容 OpenAI 协议的模型服务都可以,国内能直连的也行,
# 只要改下面的 base_url 和 api_key 即可,评测代码不用动
client = OpenAI()
JUDGE_PROMPT = """你是评测员。给定【上下文】和【答案】,把答案拆成若干条独立陈述,
逐条判断该陈述是否能从上下文直接推出。只依据上下文,不要使用你自己的知识。
只输出 JSON,格式:{"supported": 2, "total": 3, "unsupported": ["陈述内容"]}
【上下文】
{context}
【答案】
{answer}
"""
def faithfulness(context: str, answer: str) -> float:
resp = client.chat.completions.create(
model="gpt-4o-mini",
temperature=0, # 评测必须关掉随机性,否则同一条样本两次跑出两个分
response_format={"type": "json_object"},
messages=[{"role": "user", "content": JUDGE_PROMPT.format(context=context, answer=answer)}],
)
r = json.loads(resp.choices[0].message.content)
if not r.get("total"):
return 0.0
return r["supported"] / r["total"]
if __name__ == "__main__":
with open("testdata/qa_pairs.jsonl", encoding="utf-8") as f:
pairs = [json.loads(line) for line in f if line.strip()]
scores = [faithfulness(p["context"], p["answer"]) for p in pairs]
mean = sum(scores) / len(scores)
low = [(p["query"], s) for p, s in zip(pairs, scores) if s < 0.5]
print(f"样本 {len(pairs)} 条 | Faithfulness 均值 = {mean:.3f}")
print(f"低分样本 {len(low)} 条,优先看这几条:")
for q, s in sorted(low, key=lambda x: x[1])[:5]:
print(f" {s:.2f} {q}")
到这里你可能会问:那 100 条样本从哪来?
这是最多人卡住的地方,其实没那么玄乎。我的做法是上线前从业务方的真实问题列表里捞一批 query,加上日志里出现频次最高的前 50 条,去重之后人工过一遍,标出每条应该命中哪几个片段。第一次做大概要花一整天,但这是整个项目里投入产出比最高的一天。之后每次改切分、换 embedding、调重排,你都有据可依,不用再靠"感觉比之前准"。
还有一样东西容易被漏掉:成本和延迟。上面那份报告里我习惯把单次调用成本和 P95 一起打出来。最典型的翻车长这样:为了把 Hit@5 再提 2 个点,把召回 K 从 10 加到 50,重排也跟着处理 50 条,答案确实准了一点点,单次成本翻了三倍,P95 从 1.2 秒变成 4 秒,业务方第二天就会来找人。指标是拿来帮你做取舍的,不是拿来攀比的。
五、同一段经历,两种写法
最后放一组对比,你感受一下差别。下面这段范文里的数字同样是示例,请换成你自己项目实测出来的值再往上写。
改之前:
负责企业知识库 RAG 系统,优化检索链路,回答准确率提升 30%。
改之后:
主导企业知识库 RAG 检索链路优化:构建 120 条人工标注评测集,覆盖 Hit@5 / MRR@5 / Faithfulness 三项指标;将固定 512 分块改为按标题层级分块,Hit@5 由 0.71 提升至 0.86,同步引入 BGE 重排将 Faithfulness 由 0.78 补到 0.91;评测脚本接入 CI,检索链路改动需指标不回退方可合入。
第二版没有一个字在吹,全是能被追问的细节。面试官顺着任何一个数字往下问,你都能接住,还能顺带把权衡讲出来。
六、说到底这是后端的主场
我做互联网技术十二年了,带过团队,也出过一本讲 Agent 工程化的书(《企业级高并发Agent实战:CloudWeGo Eino工程化落地》,人民邮电出版社)。这两年辅导了一千多个想转 AI 的学员,最大的感受是:很多人把自己原有的东西全扔了,去追那些看起来更"AI"的东西。
但你想想,评测这件事拆开是什么?是单元测试(golden set + 断言)、是压测(跑全量看指标)、是监控告警(线上效果回落能立刻发现)。这三样,哪个不是后端天天在干的活?
模型有不确定性,这不是放弃量化的理由,恰恰是更该量化的理由。越是不确定的系统,越需要一套能反复跑、能比对的尺子。我们自己的 LexAgent 项目里,光自动化测试就堆到了 665+ 条,评测 Harness 和质量门禁是和源码一起进仓库的,每次改动都得过一遍。
还有个临场的小提醒。如果你现在手里确实没有评测集,别硬编。坦白说"目前是人工抽查加业务反馈,我知道这是短板,最近正在补 golden set",面试官一般是认可的,因为他见过太多连这个意识都没有的人。真正扣分的是被追问之后还在硬撑,把"感觉还行"包装成一套听着很专业但对不上的说法。面试官一天面四五个人,这种话他分得出来。
所以下次他问"你怎么证明它有效",别再说感觉还行。把评测集有多少条、测哪几个指标、改了什么之后指标怎么变的、挂在哪个流程上,一口气讲完。这段话讲下来,比简历上任何一行百分比都有分量。
你现在手上那套 RAG,评测集有多少条?测的是哪几个指标?评论区说说,我挑典型的单独写一篇。
更多后端转 AI 的实战拆解,我持续更新在个人站:wangzhongyang.com