生命不息,折腾不止。前三课把「召回 + 精排」做得能查能测了,但用户拿到手的是一段没有出处、不知道靠不靠谱的文字——这课给答案装上「引用出处」和「生成层打分」两样东西,让回答不光有据可依,还能摊开给你看它凭啥这么说。

上一课收尾时留了个口子:检索端你已经能做「召回 → 精排」,但那套 recall / MRR / hit 指标只证明「该捞的捞回来了」,没回答用户真正关心的两件事:

  1. 这答案哪来的? 大模型吐出的话,用户想问一句「依据呢?」,你答不上来,信任就崩了。
  2. 这答案编没编? recall 再高,生成那一步照样可能满嘴跑火车——而且 RAG 的失败往往「不报错」,只会给你一段流畅、自信、但错得离谱的文字,没有任何异常日志,你连它错了都不知道。

这就是这一课要补的最后一块:生成层。干两件活——让答案带内联引用 [1][2][3],再用 faithfulness(忠实度)、答案正确性这些指标给生成这一步打分。做完,「召回 → 精排 → 生成」这条链路才算真正闭环。

一、先分清:检索层指标 vs 生成层指标,测的不是一回事

很多人评测 RAG 会犯一个错——拿一个「端到端正确率」的分数,拍脑袋说系统好还是坏。错了。检索和生成是两段,得分段测,否则你根本定位不了 bug 在哪:

测的是 指标 依赖什么
检索层 该捞的捞回来没有 recall、MRR、hit、context precision 标准 chunk 标注
生成层 编没编、答没答到点上 faithfulness、answer relevancy、answer correctness 上下文 + (部分指标要)参考答案

一句话记住分工:检索指标查「检索器」的账,生成指标查「生成器」的账。上一课的 recall 低 = 检索没捞到,这课的 faithfulness 高但答案错 = 检索到的上下文本身就是错的。混在一起测,只会误导你朝错误的方向调参。

生成层三个最常用的指标,先给它们一句话定性(后面逐个展开):

  • Faithfulness(忠实度):答案里的每个论断,是不是都能从「喂给它的那份上下文」里推出来。不需要参考答案,有「问题 + 答案 + 实际检索到的上下文」就能算。
  • Answer Relevancy(答案相关性):答案有没有正面回答用户的问题,是不是顾左右而言他。
  • Answer Correctness(答案正确性):答案跟「标准参考答案」比,事实对不对。这个必须要有 ground truth(参考答案)

二、让答案带内联引用:[1][2][3] 出处可回查

先解决「依据呢」。最省事、也最成熟的方案是内联引用(inline citation):让大模型在正文里用 [n] 标注每条结论来自第几条资料,引用列表不在 prompt 里让模型生成,而是由你的代码动态拼出来(这样来源可控、能加链接、能折叠)。

第一步:检索结果要带元数据。 光有正文没用,每个 chunk 得带上来源信息(文档标题、chunk 编号、URL 等),否则引用了也没法回查:

1
2
3
4
5
6
# 检索结果统一成三元组: (chunk_id, title, text)
chunks = [
("err-500-001", "《服务端排查手册》", "ERR_500 通常是后端进程崩溃……"),
("err-500-002", "《服务端排查手册》", "先看 nginx-error.log 的最后 20 行……"),
("deploy-001", "《部署规范》", "回滚命令:git revert HEAD && git push……"),
]

第二步:prompt 里要求标引用,但不许它自己生成引用列表。

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
from openai import OpenAI

client = OpenAI(api_key="你的key", base_url="https://api.openai.com/v1") # 任意 OpenAI 兼容接口

def build_context(chunks):
parts = []
for i, (cid, title, text) in enumerate(chunks, start=1):
parts.append(f"[{i}] 来源《{title}》片段 {cid}:\n{text}")
return "\n\n".join(parts)

def generate_with_citations(question, chunks):
context = build_context(chunks)
system = (
"你是知识库助手,只能依据下面给出的「参考资料」回答,资料里没有就直接说不知道。\n"
"回答中,每个关键结论后面用 [n] 标注它来自第几条资料(n 是资料编号)。\n"
"只负责在正文里标 [n],不要额外生成「引用来源」列表。"
)
user = f"参考资料:\n{context}\n\n问题:{question}"
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "system", "content": system},
{"role": "user", "content": user}],
temperature=0.2,
)
return resp.choices[0].message.content

这里有两个要点:一是让模型只标号、别自己列来源;二是 temperature 调低(0~0.2),引用标注这种结构性输出要它收着点,别即兴发挥。

第三步:用代码解析引用号,动态生成来源列表。 模型正文只回来 [1][3] 这种标号,你拿正则把号抠出来,再回头映射到你手里的 metadata:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import re

def build_reference_list(answer, chunks):
cited = sorted({int(n) for n in re.findall(r"\[(\d+)\]", answer)})
refs = []
for n in cited:
_, title, _ = chunks[n - 1]
refs.append(f"[{n}] 《{title}》")
return answer, refs

answer, refs = build_reference_list(
generate_with_citations("ERR_500 怎么处理?", chunks), chunks
)
print(answer)
print("\n".join(refs))

这样前端就能「正文高亮标号 + 底部折叠来源列表」双栏展示,用户点一下直接对到原文。引用的价值不在装样子,而在可回查——这是把「AI 瞎编」跟「有据可查」分开的那道线。

小提示:这一步的生成模型只要 OpenAI 兼容接口就行。多模型、按量付费想省心的话,可以统一接中转站(比如本博客常说的 ai.aklibk.com)一个 key 调多个模型,judge 和生成分开用不同档位的模型。不接也不影响本文跑通。

三、Faithfulness 忠实度:把「编没编」量化成一个数

引用标出来了,不代表它没编。faithfulness 是生成层最有用的一把尺子,专门回答那个要命的问题:模型是不是在它被喂的材料之外,自己发挥(幻觉)了?

它的定义很干净,一句话:faithfulness = 被上下文支持的论断数 ÷ 总论断数。满分 1.0 = 每句话都有据;0.6 = 四成内容是模型自己补的。关键是它不依赖参考答案(reference-free),所以你线上随便一个真实问题都能算,不用人肉写标准答案。

原理分两步,用「LLM 当裁判」(LLM-as-judge)来做:

  1. 拆 claim:让 judge 把答案拆成一条条独立、可单独验证的陈述;
  2. 逐条验证:让 judge 判每条陈述「是否被上下文支持」,支持的算 yes,不支持的算 no。

手搓版代码(延续前几课「不靠框架」的思路,逻辑就上面两行):

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
def faithfulness(answer, context, judge_client):
# 第一步:拆 claim
r = judge_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "把下面答案拆成一条条独立、可验证的陈述,每行一条,只输出陈述本身,不要编号、不要解释。"},
{"role": "user", "content": answer},
],
)
claims = [c.strip() for c in r.choices[0].message.content.split("\n") if c.strip()]

# 第二步:逐条判「是否被上下文支持」
supported = 0
for c in claims:
r2 = judge_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "判断「陈述」是否被下面「上下文」所支持。只回答 yes 或 no。"},
{"role": "user", "content": f"上下文:\n{context}\n\n陈述: {c}"},
],
)
if r2.choices[0].message.content.strip().lower().startswith("yes"):
supported += 1

return supported / len(claims) if claims else 0.0

这里有个很值钱的细节:judge 用二选一(yes/no)比 1~5 打分靠谱得多。打分制里「3 分和 4 分差在哪」永远吵不清;二选一强制你定义清楚「什么算支持」,聚合出来的数才有明确含义。

跑出来可能是这样:

1
2
3
4
context: ……ERR_500 是后端进程崩溃,查 nginx-error.log 最后 20 行……
answer: ERR_500 是后端进程崩溃[1],建议先看 nginx-error.log 最后 20 行[2],并重启上次更新过的服务。
^^^有据 ^^^有据 ^^^没出处 → unsupported
faithfulness = 2 / 3 ≈ 0.67

第三条「重启上次更新过的服务」上下文里压根没有,模型凭记忆补的——这就是被 faithfulness 逮住的幻觉。它可能现实里碰巧是对的,但不在你喂的材料里,就不该出现在 RAG 的答案里,这正是 faithful 要卡的行为。

不想手搓的话,RAGAS 这个开源评测库把这套东西打包好了(真实可用,API 我对着官方文档核过):

1
pip install ragas
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from openai import AsyncOpenAI
from ragas.llms import llm_factory
from ragas.metrics.collections import Faithfulness

client = AsyncOpenAI()
llm = llm_factory("gpt-4o-mini", client=client)
scorer = Faithfulness(llm=llm)

result = await scorer.ascore(
user_input="When was the first super bowl?",
response="The first superbowl was held on Jan 15, 1967",
retrieved_contexts=["The First AFL–NFL World Championship Game ... January 15, 1967 ..."],
)
print(result.value) # 1.0

两个版本的本质一样,都是「拆 claim + 逐条验」。上了规模、要持续跑 CI 回归,用 RAGAS 省心;想搞清楚原理、要完全可控,手搓 30 行也够了。

四、答案正确性 / 相关性:faithfulness 测不了的,它俩来补

别把 faithfulness 当万金油。 它只管「模型有没有老实照抄上下文」,不管上下文本身对不对。一个更直白的说法:

  • Faithfulness:这段话有没有超出它手里的材料(没有 = 高);
  • Answer Correctness:这段话到底对不对(跟参考答案比)。

一个极端例子:知识库里存着一条过时的内部记录说「价格是 100 元」,模型照实回答「100 元」,faithfulness = 1.0 满分——但它错了,因为真正价格早改了。faithfulness 高一不等于答案对,所以还得有 answer correctness

Answer Correctness 必须要 ground truth(标准答案),这也是它和 faithfulness 最本质的区别。它的计算是「事实正确率」和「语义相似度」的加权和,其中事实正确率用 F1 的套路:

1
2
3
4
5
TP = 参考答案和生成答案都有的论断
FP = 生成答案里多出来、参考答案没有的论断(可能多编了)
FN = 参考答案里有、生成答案漏掉的论断(漏答了)

factual F1 = |TP| / (|TP| + 0.5 × (|FP| + |FN|))

还是爱因斯坦那个经典例子:参考答案「爱因斯坦 1879 年生于德国」,生成答案「爱因斯坦 1879 年生于西班牙」。

1
2
3
4
5
TP: [爱因斯坦 1879 年生]   ← 两边都有
FP: [生于西班牙] ← 生成答案多出来的、错的
FN: [生于德国] ← 参考答案有、生成答案漏了

factual F1 = 1 / (1 + 0.5 × (1 + 1)) = 0.5 → 正确性打折

这套逻辑同样能用 LLM-as-judge 手搓:让 judge 输出「参考答案 vs 生成答案」的 TP / FP / FN 三张清单,套公式即可。

Answer Relevancy(答案相关性) 则更轻量,它测的是「答案有没有正面回答问题」,和上下文、参考答案都无关——同一批检索结果,模型是「答非所问」还是「直击要害」,这一项就能筛出来。

三者合起来,才构成生成层的完整体检表:

指标 需要什么 能抓到的问题
faithfulness 问题 + 答案 + 上下文 幻觉、超出材料发挥
answer relevancy 问题 + 答案 答非所问、跑题
answer correctness 问题 + 答案 + 参考答案 答案事实错误(哪怕有据)

五、把生成层评测接进上一课的脚本,闭环跑起来

前面单独讲指标容易飘,实际你要的是「同一份脚本,改完参数一键出全套分」。把上一课的检索层评测和这课的生成层评测串起来,就是一条完整链路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def full_evaluate(test_set, build_index, generator):
"""test_set: [(query, gold_chunk_ids, ground_truth_answer), ...]"""
metrics = {"recall@5": [], "mrr": [], "faithfulness": [], "correctness": []}

for q, gold, gt_answer in test_set:
candidates = hybrid_search(q, k=50) # 上一课的宽召回
top = rerank(q, candidates, top_k=8) # 上一课的精排
context = build_context(top) # 拼上下文

answer = generate_with_citations(q, top) # 这课的生成
metrics["faithfulness"].append(faithfulness(answer, context, judge))
metrics["correctness"].append(answer_correctness(answer, gt_answer, judge))
# recall / mrr 复用上一课的检索层计算……

return {k: sum(v) / len(v) for k, v in metrics.items()}

拿这套脚本做对比实验,你就能看懂「问题出在哪一层」:

1
2
3
改 embedding 前:  recall@5 0.71 / faithfulness 0.85 / correctness 0.60
改 embedding 后: recall@5 0.83 / faithfulness 0.84 / correctness 0.82
^上下文更准了 → 答案更对了(生成没变)

看这组数:faithfulness 没怎么动(生成器没换),correctness 涨了——说明这次改进是检索端带动的,上下文更准,答案自然更对。反过来,如果 faithfulness 低而 recall 高,那就是生成器爱加戏,得去压生成 prompt、加「不知道就说不知道」的硬约束。分层指标的真正价值就在这:能定位,而不是一句「系统变差了」。

六、小结 + 这几个坑别踩

到这,「召回 → 精排 → 生成」三段全部闭环了。回顾一下四课走过的路:第一课手搓最短链路,第二课装 FAISS + 混合检索,第三课加 reranker + 检索层评测,今天把生成层的引用和打分补上。一条能生产用的 RAG,骨架不过如此。

收尾前把今天几个坑点一点:

  • 引用号是代码发出去的,别让模型自报家门。prompt 里只让它标 [n],来源列表由你的代码从 metadata 映射,才能保证出处可控、可加链接、可折叠。
  • faithfulness 一定要拿「实际喂给模型的上下文」去验,而不是检索器返回的原始排序列表——中间可能截断、拼接、去重过,「模型真看见什么」和「检索器返回什么」未必一致。
  • judge 用 yes/no 二选一,别用 1~5 打分。二选一强制你定义「什么算支持」,聚合分数才有意义;打分制里「3 分和 4 分」永远扯不清。
  • 别用 faithfulness 替代 correctness。faithfulness 高一 ≠ 答案对,知识库内容错了,它照抄也是满分。要抓事实错误,得上需要 ground truth 的 answer correctness。
  • ground truth 别偷懒只写一问一答。answer correctness 靠 TP/FP/FN 拆论断,参考答案写得越完整(关键事实都列上),算出来的分越可信。
  • judge 和生成别用同一个劣质模型。让一个爱幻觉的小模型去判断另一个模型有没有幻觉,等于让瞎子带路;judge 至少换个靠谱且在角色上稳的模型。

生命不息,折腾不止。四课下来,你的 RAG 已经「能查、能测、能信」,但有一个更贴近生产的高频场景还没碰:多轮对话里的 RAG——用户追问「那它的增长率呢」,你得让系统记得上一轮说的是哪家公司的哪份数据,还要在改写 query 时不把话题带偏。下一篇 RAG 实战第五课:给链路接上多轮记忆 + query 改写(单轮变多轮、追问能接住),让知识库从「一问一答的问答机」升级成「记得住上下文的对话助手」。回见。