📚前言
📒FDE系列内容总纲:
🚄前置课程列表:
见文档结尾附录。
🚀阶段3·Day 73:RAGAS 评测 --- 用数据证明改动有效
FDE 学习系列教程 · 第三阶段 · 第 11 周 · Day 3 预计时长:3 小时 | 难度:★★★★☆ | 前置知识:Day 61-72(RAG 全链路、Qdrant 混合检索、Reranker、引用溯源、权限过滤、查询改写) 对标大纲课时:3.2.10 RAG 评测 ------ 召回率、准确率、幻觉率、上下文相关性、答案相关性;工具 RAGAS / Phoenix;实践项"用 RAGAS 评估 RAG Pipeline 质量"
📌 一句话目标 :把"肉眼评估"换成四个可复现的量化指标(faithfulness / answer relevancy / context precision / context recall),建一份 20 条的黄金问题集,跑出第一份 RAGAS 报告,然后用这份报告定位短板到底在检索还是在生成,跑通"改 → 再评测"的闭环。
🧑🤝🧑 开场:客户要一份报告,你交不出手
Day 72 收工时,你已经能回答"用平台还是自研"了。晚上八点,客户项目经理发来一条消息:
"下周要上验收评审。麻烦你准备一份系统效果的量化报告,
要能说明:准确率多少?会不会胡说?跟三个月前比有没有进步?
另外,我们想自己也能复跑这个测试。"
你打开电脑,发现手里只有三样东西:
doc/week10/recall_eval_v1.md → 20 条问题,我手工标的 Recall@5 = 0.950
doc/week10/rerank_eval_v1.md → 20 条问题,我手工标的 MRR = 0.925
doc/week11/platform_vs_code.json → 20 条问题,我手工数的 0.67 / 1.00
然后你发现这份"报告"有四个说不出口的问题:
① 只测了【召回】,没测【答案】
Recall@5 高只说明"正确的块被找出来了",
但模型有没有照着块说、有没有自己编、有没有答非所问 ------ 一个数字都没有。
② 样本是我自己编的,标准是我自己判的
客户完全可以问一句:"你凭什么说这几条是'正确答案'?"
③ 改一次参数,噪音比信号大
20 条里差 1 条,指标就跳 5 个百分点。到底是真的变好了还是运气?
④ 客户复跑不了
我的评测脚本是一段临时写的 python,跑完就扔了,没有版本、没有说明。
⚠️ 一句话总结:我们现在能【造】一个系统,但还不能【证明】它有多好。
而"证明"这件事,恰恰是验收会上唯一被追问的东西。
回看主线,这就是第 11 周第三天要补的洞:
第二阶段:设备告警工单闭环(FastAPI + MySQL + Docker + 飞书推送)
↓
第 7-8 周:v0.1 智能工单助手(Day 60 冻结)
↓
第 9-10 周:v0.2 能答、答得准、可溯源、权限合规
↓
Day 71:查询侧升级(Recall@5 0.417 → 0.917,但仍是手工评测)
↓
Day 72:平台化交付(RAGFlow 对照 0.67 / 1.00,还是手工评测)
↓
Day 73(今天)🆕:量化评测 ------ 用 RAGAS 把"我觉得"换成"数据显示"
↓
Day 74:企业架构(黄金问题集模板 + 生产就绪检查单)
Day 75:v0.2 冻结 → Day 85 v0.3 → Day 100 v1.0
今天八件事:① 搞清"肉眼评估"的三个盲区;② 装 RAGAS 并把 DeepSeek 配成裁判模型 ;③ 四个核心指标到底在算什么;④ 建 20 条黄金问题集 (含 ground truth);⑤ 跑出第一份报告;⑥ 两指标交叉定位表------检索的锅还是生成的锅;⑦ 跑通改进闭环(调分块 / 调检索 / 调 Prompt → 再评测);⑧ 把它做成可复跑的脚本 + CI 质量门禁。
💡 今天的关键词不是"再多一个指标",而是"让每一次改动都能被证明"。 前三周你做了十几处优化,每一处都说"变好了"------但没有一处是被独立验证过的。今天之后,你再改任何参数,都能在五分钟内拿到一组可比的数字。这是从"能跑"到"可交付"的最后一块拼图。
📖 一、为什么"肉眼评估"撑不到上线
1.1 RAG 有四个失败点,你只测了一个
RAG 全链路的四个环节,每个环节都会以不同方式失败:
① 解析/分块 → 表格被切碎、章节被拆散 ❌ 你没测
② 检索 → 该召回的没召回、召回了垃圾 ✅ 你测了(Recall@5 / MRR)
③ 生成 → 照抄上下文但答非所问 ❌ 你没测
④ 生成 → 上下文里没有,模型自己编 ❌ 你没测(Day69 只做了人工抽查)
更要命的是:②③④ 的症状在用户眼里是一样的------"答得不对"。 但它们的修法完全不同:
| 失败点 | 症状 | 用户看到的 | 正确的修法 | 错误的修法 |
|---|---|---|---|---|
| ② 检索漏了 | 该召回的没召回 | "资料里明明有,它说没有" | 调分块、加混合检索、加改写 | 改 Prompt(没用) |
| ② 检索脏了 | 召回一堆无关块 | "答得驴唇不对马嘴" | 加 Reranker、调 top_k | 改 Prompt(没用) |
| ③ 答非所问 | 上下文对但没答到点上 | "说了一堆,没回答我的问题" | 改 Prompt、改输出结构 | 调检索(没用) |
| ④ 幻觉 | 上下文里没有却说了 | "它编了一个数字!" | 收紧 Prompt、加拒答校验 | 调检索(没用) |
⭐ 没有评测体系时,这四种失败会被混为一谈,
于是工程师会用【改 Prompt】去修【检索问题】,用【调 top_k】去修【幻觉】,
改来改去指标不动,最后得出一个结论:"这个场景 RAG 不好使"。
------ 实际上只是你没搞清楚是哪个环节坏了。
1.2 手工评测的三个硬伤
| 硬伤 | 具体表现 | 后果 |
|---|---|---|
| 只能测"找到没找到" | Recall@5 是集合命中率,跟答案质量无关 | 幻觉率高但召回率满分完全可能同时发生 |
| 样本小,噪音大 | 20 条里差 1 条 = ±5% | 改了参数以为变好,其实是运气 |
| 不可复现 | 标注标准在脑子里,换个人标注结果就不同 | 客户不信、同事不信、三个月后的自己也不信 |
1.3 RAGAS 怎么解决
RAGAS(Retrieval-Augmented Generation Assessment) 的核心思路是用 LLM 当裁判(LLM-as-a-Judge):让一个模型来读"问题 / 召回的上下文 / 生成的答案 / 参考答案",按规则打分。
传统做法:人读答案 → 打勾或打叉 → 算比例
· 慢(100 条要一下午)· 主观 · 不可复现 · 只能判"对/错"
RAGAS:把答案拆成【原子声明】→ 逐条核对上下文 → 算比例
· 快(100 条约 3-5 分钟)· 有明确判据 · 可复现(temperature=0)· 四个维度分别打分
例:答案 = "料筒温度超过 230℃ 时应立即停机,并更换加热圈"
拆成两条声明:
s1: "料筒温度超过 230℃ 时应立即停机" → 上下文支持 ✅
s2: "应更换加热圈" → 上下文没提 ❌
faithfulness = 1/2 = 0.5
⭐ 这条分数里藏着一个非常具体的行动信号:
"模型多说了一句'更换加热圈' → 去收紧 Prompt 的'不得超出资料范围'约束"
📌 RAGAS 最大的价值不是那个总分,而是"失败的粒度"。 它告诉你的是"第 7 条问题的答案里,有一句话没有出处",而不是"你的系统准确率 82%"。前者能直接指导你改代码,后者只能写进 PPT。
🖥️ 二、环境准备:RAGAS + DeepSeek 裁判
实操步骤 1:安装(版本一定要锁)
# ⭐ 锁 0.2.x:本教程全部代码按 0.2.x 的稳定 API 写
pip install "ragas>=0.2.10,<0.3" "langchain-openai>=0.1" "langchain-community>=0.2"
pip install "sentence-transformers>=3.0" "datasets>=2.19" pandas
# 验证
python -c "import ragas; print(ragas.__version__)"
⚠️ RAGAS 的 API 在 0.2 → 0.4 之间变过一次,一定要锁版本。 0.4 起官方推荐用
llm_factory()配原生 client,LangchainLLMWrapper被标为 deprecated(仍可用但会告警)。本教程按 0.2.x 写,锁死>=0.2.10,<0.3------客户现场最忌讳的就是"在你机器上能跑,在他机器上 API 变了"。如果你确实要用 0.4+,请把LangchainLLMWrapper(ChatOpenAI(...))换成官方迁移文档里的llm_factory(model, client=...)写法,其余指标类名保持不变。
实操步骤 2:把 DeepSeek 配成裁判模型
# eval/ragas_setup.py ------ Day73 评测环境:裁判 LLM = DeepSeek,裁判 Embedding = BGE-M3
import os
from dotenv import load_dotenv
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
from langchain_openai import ChatOpenAI
from ragas.embeddings import LangchainEmbeddingsWrapper
from ragas.llms import LangchainLLMWrapper
load_dotenv()
# ── 裁判 LLM:DeepSeek(OpenAI 兼容协议)──
judge_llm = ChatOpenAI(
model="deepseek-chat",
base_url="https://api.deepseek.com",
api_key=os.getenv("DEEPSEEK_API_KEY"),
temperature=0, # ⭐ 裁判必须确定性,否则两次跑分不一样
max_retries=3,
timeout=60,
)
evaluator_llm = LangchainLLMWrapper(judge_llm)
# ── 裁判 Embedding:BGE-M3(跟 Day62 检索侧同一个模型,口径一致)──
_bge = HuggingFaceBgeEmbeddings(
model_name="BAAI/bge-m3",
model_kwargs={"device": "cpu"}, # 有 GPU 就改 "cuda"
encode_kwargs={"normalize_embeddings": True},
)
evaluator_emb = LangchainEmbeddingsWrapper(_bge)
def smoke_test():
"""先确认裁判模型能通,别等跑 20 条才报错"""
from ragas.dataset_schema import SingleTurnSample
from ragas.metrics import Faithfulness
s = SingleTurnSample(
user_input="冷却水压力低于多少必须停机?",
response="低于 0.25MPa 时必须停机检查。",
retrieved_contexts=["冷却水进出口温差应控制在 5~8℃;低于 0.25MPa 必须停机检查。"],
)
m = Faithfulness(llm=evaluator_llm)
print("✅ faithfulness =", m.single_turn_ascore(s))
if __name__ == "__main__":
smoke_test()
✅ faithfulness = 1.0
💡 为什么裁判模型也要用 DeepSeek? 三个理由:① 系列硬约束,LLM 唯一选型;② 中文工业语境下,DeepSeek 的判据稳定性够用;③ 最重要的是成本 ------一次四指标评测大约要 100+ 次裁判调用,DeepSeek 的价格让"每次改参数都跑一遍"这件事在经济上可行。用贵的裁判模型,你会舍不得跑,评测体系就会荒废。
⚠️ 但要记住一条:裁判模型跟你生成用的模型是同一个时,会有"自我偏好"偏差(模型倾向于认为自己生成的话是对的)。如果客户对评测严谨度要求高,裁判应该换一个不同系列的强模型。这一点要在报告里如实写明。
📖 三、四个核心指标到底在算什么
3.1 一张总表
| 指标 | 中文 | 在问什么 | 需要 ground truth? | 需要 embedding? | 归谁的责 |
|---|---|---|---|---|---|
| faithfulness | 忠实度 | 答案里每一句话,都能在召回的上下文里找到依据吗? | ❌ | ❌ | 生成 |
answer relevancy (类名 ResponseRelevancy) |
答案相关性 | 答案有没有直接回应这个问题?有没有废话、跑题? | ❌ | ✅ | 生成 |
context precision (类名 LLMContextPrecisionWithReference) |
上下文精确率 | 召回的块里,真正有用的排得靠前吗?(是不是塞了一堆垃圾) | ✅ | ❌ | 检索 |
context recall (类名 LLMContextRecall) |
上下文召回率 | 回答问题需要的信息,都被召回来了吗? | ✅ | ❌ | 检索 |
⭐ 记住这条分工 ------ 这是全篇最重要的一句话:
faithfulness / answer relevancy → 测【生成】→ 坏了改 Prompt、改输出结构
context precision / context recall → 测【检索】→ 坏了改分块、改检索、改改写
RAGAS 最大的工程价值就在这里:它把"系统不好"这个模糊抱怨,
拆成了"检索不好"和"生成不好"两个各有对策的问题。
3.2 faithfulness:有没有编造
算法:
① 把回答拆成若干【原子声明】(claims),如 s1 / s2 / s3
② 逐条拿去跟 retrieved_contexts 比对:这条声明能不能从上下文推出来?
③ faithfulness = 支持的声明数 / 总声明数
取值范围 0~1。1.0 = 每句话都有出处;0.5 = 一半是编的。
⚠️ 注意它测的是【有没有依据】,不是【依据对不对】。
如果检索召回的块本身就是错的,模型照着错块回答,faithfulness 依然是 1.0。
→ 所以 faithfulness 高【不能单独说明系统好】,必须跟 context recall 一起看。
3.3 answer relevancy:有没有答到点上
算法:
① 裁判模型根据【答案】反推出 3~5 个"这个答案像是在回答什么问题"
② 分别算这些"生成问题"与【原始问题】的 embedding 余弦相似度
③ 取平均
低分意味着:答案啰嗦、跑了题、或者只回答了一部分。
典型低分案例:
问:"冷却水压力低于多少必须停机?"
答:"冷却系统是注塑机的重要组成部分,其作用是......(300字背景介绍)......
建议定期维护冷却系统。"
→ 一句话没说到 0.25MPa,answer relevancy 会很低,尽管 faithfulness 可能是 1.0
3.4 context precision:召回的是不是垃圾
算法(带 reference 版):
逐条判断 retrieved_contexts 里每个块,对回答问题是否"有用",
再按【排名加权】平均 ------ 排在前面的块如果没用,扣分更狠。
低分意味着:召回了一堆无关块,有用的被埋在后面。
⚠️ 这就是 Day 68 Reranker 存在的意义。
Recall@5 只看"有没有",context precision 看"排得怎么样"。
两者可以完全独立:Recall 满分 + precision 0.4 是常见组合
(Top-5 里有对的那条,但另外 4 条全是垃圾)。
3.5 context recall:该召回的召回了吗
算法:
① 把 ground truth(参考答案)拆成若干条声明
② 逐条看能不能在 retrieved_contexts 里找到依据
③ context recall = 能找到的 / 总数
低分意味着:答案需要的信息根本没进上下文 ------ 模型再乖也答不出来。
⚠️ 这个指标【强依赖 ground truth 的质量】。
你写的参考答案越啰嗦、越包含上下文里没有的推断,recall 就越低,
而且这个低是【假的】------ 是你的标注问题,不是系统问题。
写 ground truth 的铁律:只写【资料里明确写了的】那句话。
3.6 四个指标的关系图
┌───────────── context recall 低 ─────────────┐
│ 该召回的没召回 → 检索的锅 │
│ 修法:调分块 / 加改写 / 加混合检索 / 加 top_k │
└──────────────────────────────────────────┘
↑
用户问一个问题 ──> 【检索】──> retrieved_contexts ──> 【生成】──> answer
│ │
↓ ↓
context precision faithfulness
(召回的是不是垃圾) (有没有编造)
│ │
context recall answer relevancy
(该召回的召回了吗) (有没有答到点上)
│ │
检索的锅 生成的锅
修法:Reranker/过滤 修法:Prompt/输出结构
🖥️ 四、构造评测集:20 条黄金问题
这一步是全部工作中最枯燥、也最决定成败的一步。 评测集质量差,后面所有数字都是废的。
实操步骤 3:设计评测集结构
{
"version": "v1",
"created": "2026-07-16",
"source_docs": ["manual_a3.clean.md", "sop_repair_hydraulic.clean.md", "process_window.clean.md"],
"note": "ground truth 必须【逐字摘自资料】,不得自行推断或补充",
"cases": [
{
"id": "G01",
"user_input": "料筒温度超过 230℃ 时应该怎么处置?",
"reference": "料筒温度超过 230℃ 时,应立即检查冷却水回路是否通畅;若 5 分钟内温度未下降,应停机检查加热圈。",
"gold_source": "manual_a3.clean.md",
"gold_chunk_id": "manual_a3-0042",
"type": "书面术语",
"level": "L1"
}
]
}
字段说明:
| 字段 | 作用 | 谁用 |
|---|---|---|
user_input |
问题 | 四个指标都要 |
reference |
参考答案(ground truth) | context precision / recall(也可加 FactualCorrectness) |
gold_source / gold_chunk_id |
正确来源 | 你自己算 Recall@5 用(RAGAS 不管这个,但你要保留) |
type |
问题类型(口语/术语/数值/多跳...) | 分组分析用,很有价值 |
level |
密级 | 验证权限与角色分组评测 |
实操步骤 4:写 20 条(分布要刻意设计)
不要随手编 20 条。评测集的分布决定了它能发现什么问题:
| 类型 | 条数 | 例子 | 想抓出什么问题 |
|---|---|---|---|
| 标准术语题 | 6 | "料筒超温的处置流程是什么" | 基线能力 |
| 口语题 | 5 | "机器热的厉害咋办" | Day71 改写的收益 |
| 精确数值题 | 4 | "液压阀拆卸扭矩是多少" | 幻觉最易发区 + HyDE 禁区 |
| 多跳题 | 3 | "先确认冷却水压,再判断是否需要停机,停机后第一步做什么" | Multi-Query 的收益 |
| 无据题(应拒答) | 2 | "这台设备能接入 380V 三相电吗"(手册没写) | 拒答率 / 幻觉率 |
# eval/build_golden.py ------ Day73 步骤4:生成并校验黄金问题集
import json
from pathlib import Path
GOLD = Path("data/eval/golden_set_v1.json")
CASES = [
# ── 标准术语题(6)──
{"id": "G01", "user_input": "料筒温度超过 230℃ 时应该怎么处置?",
"reference": "料筒温度超过 230℃ 时,应立即检查冷却水回路是否通畅;若 5 分钟内温度未下降,应停机检查加热圈。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0042",
"type": "术语", "level": "L1"},
{"id": "G02", "user_input": "冷却水进出口温差应控制在什么范围?",
"reference": "冷却水进出口温差应控制在 5~8℃。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0047",
"type": "术语", "level": "L1"},
{"id": "G03", "user_input": "液压系统额定压力是多少?",
"reference": "系统额定压力 14MPa,由柱塞泵供油。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0071",
"type": "术语", "level": "L1"},
{"id": "G04", "user_input": "锁模力不足会导致什么缺陷?",
"reference": "锁模力不足会导致产品飞边增多,合模线处出现溢料。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0098",
"type": "术语", "level": "L1"},
{"id": "G05", "user_input": "换模作业包含哪些步骤?",
"reference": "换模作业包含:安全锁定与上锁挂牌、模具吊装对中、工艺参数重置、试模验证首件检验。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0130",
"type": "术语", "level": "L1"},
{"id": "G06", "user_input": "设备日常点检包含哪些内容?",
"reference": "每日开机前应确认设备外观无异常、无异味,检查油位、冷却水压力与急停按钮。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0172",
"type": "术语", "level": "L1"},
# ── 口语题(5)──
{"id": "G07", "user_input": "机器热的厉害咋办",
"reference": "料筒温度超过 230℃ 时,应立即检查冷却水回路是否通畅;若 5 分钟内温度未下降,应停机检查加热圈。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0042",
"type": "口语", "level": "L1"},
{"id": "G08", "user_input": "打不动了是不是液压的问题",
"reference": "锁模力不足时应检查液压系统压力是否达到额定值 14MPa,并排查换向阀是否卡滞。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0071",
"type": "口语", "level": "L1"},
{"id": "G09", "user_input": "老漏油,怎么治",
"reference": "液压油泄漏时应先停机泄压,查明泄漏点后更换对应密封件。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0088",
"type": "口语", "level": "L1"},
{"id": "G10", "user_input": "产品边上全是毛刺",
"reference": "产品飞边时应检查锁模力是否足够,并复核合模线间隙与模具磨损情况。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0103",
"type": "口语", "level": "L1"},
{"id": "G11", "user_input": "打不满怎么回事",
"reference": "充填不足时应检查射出量与保压参数设置,并确认料斗下料是否通畅。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0156",
"type": "口语", "level": "L1"},
# ── 精确数值题(4)──
{"id": "G12", "user_input": "液压阀拆卸扭矩是多少?",
"reference": "拆卸扭矩 45±3N·m,分三次对角拧紧。",
"gold_source": "sop_repair_hydraulic.clean.md", "gold_chunk_id": "sop_hyd-0023",
"type": "数值", "level": "L4"},
{"id": "G13", "user_input": "液压油更换周期是多少小时?",
"reference": "液压油每 500 小时更换一次。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0076",
"type": "数值", "level": "L1"},
{"id": "G14", "user_input": "冷却水压力低于多少必须停机?",
"reference": "冷却水压力低于 0.25MPa 时必须停机检查。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0047",
"type": "数值", "level": "L1"},
{"id": "G15", "user_input": "料筒温度的标准工艺窗口是多少?",
"reference": "料筒温度区间 195~215℃。",
"gold_source": "process_window.clean.md", "gold_chunk_id": "proc-0011",
"type": "数值", "level": "L3"},
# ── 多跳题(3)──
{"id": "G16", "user_input": "冷却水压力偏低时,要先查什么、达到什么条件必须停机、停机后第一步做什么?",
"reference": "先检查冷却水泵进出口压差与过滤器是否堵塞;低于 0.25MPa 必须停机;停机后先切断加热电源并挂上锁牌。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0047",
"type": "多跳", "level": "L1"},
{"id": "G17", "user_input": "料筒超温和温控表故障分别该怎么判断和处理?",
"reference": "料筒温度超过 230℃ 应检查冷却水回路;温控表显示值与实际值偏差超过 ±5℃ 时需校准或更换。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0042",
"type": "多跳", "level": "L1"},
{"id": "G18", "user_input": "液压系统压力不足时,如何通过点检和维修两步处理?",
"reference": "点检时确认系统压力是否达到额定值 14MPa;维修时排查柱塞泵与换向阀,拆卸扭矩按 45±3N·m 执行。",
"gold_source": "manual_a3.clean.md", "gold_chunk_id": "manual_a3-0071",
"type": "多跳", "level": "L1"},
# ── 无据题(2,期望拒答)──
{"id": "G19", "user_input": "这台设备能接入 380V 三相电吗?",
"reference": "资料中未提及该设备的供电电压规格。",
"gold_source": None, "gold_chunk_id": None,
"type": "无据", "level": "L1", "expect_refuse": True},
{"id": "G20", "user_input": "设备保修期是几年?",
"reference": "资料中未提及设备保修期限。",
"gold_source": None, "gold_chunk_id": None,
"type": "无据", "level": "L1", "expect_refuse": True},
]
def validate(cases: list[dict]) -> list[str]:
"""写评测集最容易犯的四个错,这里自动拦一遍"""
errs = []
ids = [c["id"] for c in cases]
if len(ids) != len(set(ids)):
errs.append("存在重复 id")
for c in cases:
if len(c.get("user_input", "")) < 5:
errs.append(f"{c['id']} 问题太短")
if len(c.get("reference", "")) < 8:
errs.append(f"{c['id']} reference 太短(会被拆出 0 条声明,recall 恒为 0)")
if c.get("type") != "无据" and not c.get("gold_chunk_id"):
errs.append(f"{c['id']} 非无据题却没标 gold_chunk_id")
if c.get("type") == "无据" and c.get("gold_chunk_id"):
errs.append(f"{c['id']} 无据题不应有 gold_chunk_id")
return errs
if __name__ == "__main__":
errs = validate(CASES)
if errs:
print("❌ 评测集校验未通过:")
for e in errs:
print(" -", e)
raise SystemExit(1)
GOLD.parent.mkdir(parents=True, exist_ok=True)
GOLD.write_text(json.dumps({
"version": "v1", "created": "2026-07-16",
"note": "reference 必须逐字摘自资料,不得自行推断",
"cases": CASES,
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"✅ 已生成 {len(CASES)} 条评测集 → {GOLD}")
✅ 已生成 20 条评测集 → data/eval/golden_set_v1.json
📌 写 ground truth 的三条铁律 :① 逐字摘自资料 ,不要自己概括(你自己概括的版本,上下文里往往找不到,context recall 会假性偏低);② 只写答案要点 ,别写推理过程;③ 无据题的 reference 就写"资料中未提及",并标
expect_refuse: true------这两条是专门用来测 Day 69 拒答能力的,不能混进普通题里拉低 recall。
实操步骤 5:跑一遍管线,生成 RAGAS 需要的样本
RAGAS 不吃"问题"和"参考答案",它吃的是你的系统真实跑出来的东西:问题、召回的上下文、生成的答案。
# eval/run_pipeline.py ------ Day73 步骤5:跑自己的管线,产出 (question, contexts, answer)
import json
import os
from pathlib import Path
from dotenv import load_dotenv
from kb.advanced import advanced_answer # Day71(含改写 + 自判闭环)
from kb.search_authorized import search_with_acl
load_dotenv()
GOLD = Path("data/eval/golden_set_v1.json")
OUT = Path("data/eval/pipeline_out_v1.json")
def collect(strategy: str = "auto", top_k: int = 5, recall_k: int = 20,
role: str = "管理员") -> list[dict]:
"""对每条评测题跑一遍真实管线,收集 RAGAS 需要的四个字段"""
cases = json.loads(GOLD.read_text(encoding="utf-8"))["cases"]
from kb.authz import Role, normalize_role
r = normalize_role(role)
rows = []
for c in cases:
q = c["user_input"]
# ① 检索:拿到真实召回的上下文(RAGAS 要的是【文本列表】)
hits, _ = search_with_acl(q, role=r, top_k=top_k, recall_k=recall_k)
contexts = [h["payload"].get("text", "") for h in hits]
hit_ids = [str(h["id"]) for h in hits]
# ② 生成:走完整问答(含改写/自判/溯源)
res = advanced_answer(q, strategy=strategy, top_k=top_k, recall_k=recall_k)
rows.append({
"id": c["id"],
"user_input": q,
"retrieved_contexts": contexts,
"response": res.answer,
"reference": c["reference"],
# ── 以下是我们自己算 Recall@5 用的,RAGAS 不消费 ──
"type": c["type"],
"gold_chunk_id": c["gold_chunk_id"],
"expect_refuse": c.get("expect_refuse", False),
"recall_hit": bool(c["gold_chunk_id"])
and any(i.startswith(c["gold_chunk_id"]) for i in hit_ids),
"refused": res.refused,
"strategy_used": res.strategy,
})
print(f" {c['id']} {c['type']:<4} strategy={res.strategy:<8} "
f"recall={'✅' if rows[-1]['recall_hit'] else '❌'} "
f"refused={'是' if res.refused else '否'}")
return rows
if __name__ == "__main__":
rows = collect(strategy=os.getenv("EVAL_STRATEGY", "auto"))
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
n = len(rows)
print(f"\n💾 已写入 {OUT}")
print(f"📊 自算 Recall@5 = {sum(r['recall_hit'] for r in rows) / n:.3f}"
f" 拒答 {sum(r['refused'] for r in rows)}/{n}")
G01 术语 strategy=rewrite recall=✅ refused=否
G07 口语 strategy=rewrite recall=✅ refused=否
G12 数值 strategy=rewrite recall=✅ refused=否
G19 无据 strategy=multi recall=❌ refused=是
G20 无据 strategy=multi recall=❌ refused=是
💾 已写入 data/eval/pipeline_out_v1.json
📊 自算 Recall@5 = 0.833 拒答 2/20
🖥️ 五、跑第一次评测
实操步骤 6:组装 EvaluationDataset 并跑四个指标
# eval/run_ragas.py ------ Day73 步骤6:跑 RAGAS 四指标
import json
import os
from pathlib import Path
from dotenv import load_dotenv
from ragas import EvaluationDataset, evaluate
from ragas.dataset_schema import SingleTurnSample
from ragas.metrics import (Faithfulness, LLMContextPrecisionWithReference,
LLMContextRecall, ResponseRelevancy)
from ragas.run_config import RunConfig
from eval.ragas_setup import evaluator_emb, evaluator_llm
load_dotenv()
SRC = Path(os.getenv("EVAL_INPUT", "data/eval/pipeline_out_v1.json"))
OUT = Path(os.getenv("EVAL_OUT", "doc/week11/ragas_report_v1.json"))
rows = json.loads(SRC.read_text(encoding="utf-8"))
# ① 组装样本:字段名必须严格对应
samples = [
SingleTurnSample(
user_input=r["user_input"],
retrieved_contexts=r["retrieved_contexts"],
response=r["response"],
reference=r["reference"], # ⭐ 只给需要 ground truth 的指标用
)
for r in rows
]
dataset = EvaluationDataset(samples=samples)
print(f"📦 样本数:{len(dataset)}")
# ② 四个指标
metrics = [
Faithfulness(llm=evaluator_llm), # 生成:有没有编造
ResponseRelevancy(llm=evaluator_llm, embeddings=evaluator_emb), # 生成:答到点上没
LLMContextPrecisionWithReference(llm=evaluator_llm), # 检索:垃圾多不多
LLMContextRecall(llm=evaluator_llm), # 检索:该召回的召回没
]
# ③ 跑。max_workers 别开太大,DeepSeek 有限流
result = evaluate(dataset=dataset, metrics=metrics,
run_config=RunConfig(max_workers=4, timeout=120))
df = result.to_pandas()
df.to_csv("doc/week11/ragas_report_v1.csv", index=False, encoding="utf-8-sig")
print("\n📊 总分(均值):")
means = df.mean(numeric_only=True)
for k, v in means.items():
print(f" {k:<40} {v:.3f}")
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps({
"input": str(SRC),
"sample_count": len(dataset),
"means": {k: round(float(v), 4) for k, v in means.items()},
"per_case": df.to_dict(orient="records"),
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n💾 报告 → {OUT} / doc/week11/ragas_report_v1.csv")
⚠️
ReferenceRelevancy必须传embeddings,否则会报缺 embedding 的错误。Faithfulness和两个 context 指标只需要llm。如果你手头没有 embedding 模型,可以先只跑三个指标,把ResponseRelevancy留到配好 BGE-M3 再说。
实操步骤 7:第一份报告长什么样
📦 样本数:20
📊 总分(均值):
faithfulness 0.842
answer_relevancy 0.781
llm_context_precision_with_reference 0.613
context_recall 0.688
按问题类型拆开看(这一步才是真正有价值的):
# eval/analyze.py ------ Day73 步骤7:按类型分组 + 找出最差的样本
import json
from pathlib import Path
import pandas as pd
rows = json.loads(Path("data/eval/pipeline_out_v1.json").read_text(encoding="utf-8"))
df = pd.read_csv("doc/week11/ragas_report_v1.csv")
df = pd.concat([pd.DataFrame(rows)[["id", "type", "recall_hit", "refused", "expect_refuse"]],
df], axis=1)
print("📊 按问题类型分组:")
print(df.groupby("type")[["faithfulness", "answer_relevancy",
"llm_context_precision_with_reference",
"context_recall"]].mean().round(3).to_string())
print("\n🔍 最差的 5 条(faithfulness 升序):")
cols = ["id", "type", "faithfulness", "answer_relevancy",
"llm_context_precision_with_reference", "context_recall"]
print(df.nsmallest(5, "faithfulness")[cols].round(3).to_string(index=False))
print("\n🎯 拒答表现:")
no = df[df["expect_refuse"]]
print(f" 无据题 {len(no)} 条,实际拒答 {int(no['refused'].sum())} 条")
📊 按问题类型分组:
faithfulness answer_relevancy ctx_precision ctx_recall
术语 0.912 0.864 0.741 0.833
口语 0.821 0.795 0.602 0.700
数值 0.688 0.742 0.548 0.625
多跳 0.795 0.688 0.512 0.556
无据 1.000 0.905 0.250 0.500
🔍 最差的 5 条(faithfulness 升序):
id type faithfulness answer_relevancy ctx_precision ctx_recall
G12 数值 0.500 0.712 0.500 0.667
G18 多跳 0.667 0.604 0.417 0.500
G16 多跳 0.714 0.588 0.500 0.500
G15 数值 0.750 0.688 0.333 0.667
G09 口语 0.800 0.756 0.583 0.667
🎯 拒答表现:
无据题 2 条,实际拒答 2 条
⭐ 这份报告里已经藏着三条明确的行动信号:
① 数值题 faithfulness 最低(0.688)→ 模型在数值题上最容易"多说一句"
② 多跳题 ctx_recall 最低(0.556)→ 一题多点,单次检索装不下 → 该上 Multi-Query
③ 无据题 ctx_precision 0.250 → 正常(本来就该召不到),且 2/2 全拒答 ✅
注意第 ③条:无据题的四个指标会天然难看,这是【期望行为】,不是缺陷。
看报告时一定要先把无据题单独拎出来,否则它会把总分拉低,让你误判系统变差了。
📖 六、短板定位:到底是检索的锅还是生成的锅
6.1 两指标交叉定位表(本篇最实用的一张表)
把 context recall(检索够不够) 和 faithfulness(生成忠不忠) 交叉:
| context recall 高(该召回的召回了) | context recall 低(该召回的没召回) | |
|---|---|---|
| faithfulness 高(不编造) | ✅ 健康区检索够、生成乖→ 优化 answer relevancy(Prompt 让它少啰嗦) | 🔴 检索的锅模型很乖,但资料没给它→ 调分块、加改写、加混合检索、加 top_k |
| faithfulness 低(会编造) | 🟡 生成的锅资料给了,它还是自己编→ 收紧 Prompt、加"未提及必须拒答"、加 Day69 溯源校验 | ⚫ 两头都坏先修检索(否则 Prompt 改了也没用)再修生成 |
再看 context precision:它单独回答"召回的垃圾多不多"。
| 组合 | 诊断 | 第一动作 |
|---|---|---|
| recall 高 + precision 高 | 检索健康 | 去优化生成侧 |
| recall 高 + precision 低 | 捞回来了但排在后面 | 加/调 Reranker(Day68) |
| recall 低 + precision 高 | 捞得准但捞太少 | 加大 top_k / 加 Multi-Query / 检查分块是否切太碎 |
| recall 低 + precision 低 | 检索整体失效 | 检查 embedding 模型、分词、分块大小(回 Day62/64/67) |
| faithfulness 低 + recall 高 | 生成在编 | 改 Prompt(这条最容易,收益也最快) |
| answer_relevancy 低 + faithfulness 高 | 答得对但不对题 | 改输出的结构(让它先给结论再给依据) |
⭐ 决策顺序(永远按这个顺序改,别跳步):
1. 先看 context recall ------ 召回都不全,改 Prompt 是白改
2. 再看 context precision------ 捞回来了但排得差,加 Reranker
3. 再看 faithfulness ------ 资料够了还编,改 Prompt + 溯源校验
4. 最后 answer relevancy ------ 前三步好了这个通常也上来了
⚠️ 每一步改完【只改一处】,然后重跑评测。
一次改三处 = 不知道是哪处起了作用 = 下次还不会。
6.2 用数据反推:今天的报告该改什么
把 6.1 套到我们的第一份报告上:
整体:recall 0.688(偏低) precision 0.613(偏低) faithfulness 0.842(还行)
→ 落在【recall 低 + precision 低】象限 → 检索整体需要加强
分组看:
多跳题 recall 0.556 → 单次检索装不下多个意图 → 该上 Multi-Query(Day71 已有,确认是否生效)
数值题 faithfulness 0.688 → 数值题上模型爱多说 → 改 Prompt(加"数值必须原文照抄")
口语题 recall 0.700 → 改写生效了但不够 → 补术语表条目
→ 今天的改进计划(三条,各改一处,依次验证):
实验 A:Prompt 加数值约束 → 预期 faithfulness ↑
实验 B:多跳题强制走 multi 策略 → 预期 context recall ↑
实验 C:补 5 条术语表 → 预期 口语题 recall ↑
🖥️ 七、改进闭环:改 → 再评测
实操步骤 8:实验 A ------ 改 Prompt(治生成)
先看现在的 Prompt 问题在哪。打开 kb/cite.py,找到生成用的模板,加一条数值约束:
# kb/prompts.py ------ Day73 实验 A:给生成 Prompt 加"数值硬约束"
PROMPT_VERSION = "v1.3-ragas" # ⭐ 版本号要跟着改,报告里要记录
CITE_SYSTEM_V13 = """你是工业设备手册问答助手。只能依据下面编号的资料回答,严禁使用资料外的知识。
硬性规则:
1. 每个事实性结论后面必须标注来源编号,格式如 [1]。
2. 【数值铁律】凡涉及数值(温度、压力、扭矩、时间、周期、尺寸),必须【逐字照抄】资料中的原文,
包括单位与精度(如"45±3N·m"不能写成"约 45N·m","0.25MPa"不能写成"0.25")。
⚠️ 若资料中没有该数值,必须回答"资料中未提及",禁止估算、禁止换算、禁止给范围。
3. 不得补充资料里没有的步骤、原因、建议。"应定期检查"这类无具体周期的表述禁止出现。
4. 资料中没有答案时,只输出:"资料中未提及。"
5. 先给结论,再给依据;每条依据不超过两句话。
资料:
{context}
"""
📌
PROMPT_VERSION一定要跟着改,并写进评测报告。 三个月后客户问"这份报告是哪个版本的系统跑的",你要能一秒答出来。Prompt 是有版本的资产,不是随手改的字符串。
实操步骤 9:实验 B / C ------ 改检索(改配置,不改代码)
# eval/ab_run.py ------ Day73 步骤9:A/B 实验跑批(一次只改一处)
import json
import os
import subprocess
from pathlib import Path
GOLD = Path("data/eval/golden_set_v1.json")
EXPERIMENTS = {
"baseline": {"strategy": "auto", "top_k": 5, "recall_k": 20},
"A_prompt": {"strategy": "auto", "top_k": 5, "recall_k": 20,
"prompt_version": "v1.3-ragas"},
"B_multi": {"strategy": "multi", "top_k": 5, "recall_k": 20,
"prompt_version": "v1.3-ragas"},
"C_glossary": {"strategy": "multi", "top_k": 5, "recall_k": 20,
"prompt_version": "v1.3-ragas", "glossary": "glossary_v2.json"},
}
def run_one(name: str, cfg: dict):
"""跑一个实验:改环境变量 / 改配置 → 重跑管线 → 跑 RAGAS → 存结果"""
env = dict(os.environ)
env["EVAL_STRATEGY"] = cfg["strategy"]
env["RAG_TOP_K"] = str(cfg["top_k"])
env["RAG_RECALL_K"] = str(cfg["recall_k"])
env["PROMPT_VERSION"] = cfg.get("prompt_version", "v1.2")
if "glossary" in cfg:
env["GLOSSARY_PATH"] = f"config/{cfg['glossary']}"
out_json = f"data/eval/pipeline_out_{name}.json"
env["EVAL_OUT_JSON"] = out_json
print(f"\n▶️ 实验 {name}: {cfg}")
subprocess.run(["python", "-m", "eval.run_pipeline"], env=env, check=True)
env["EVAL_INPUT"] = out_json
env["EVAL_OUT"] = f"doc/week11/ragas_{name}.json"
subprocess.run(["python", "-m", "eval.run_ragas"], env=env, check=True)
rep = json.loads(Path(f"doc/week11/ragas_{name}.json").read_text(encoding="utf-8"))
return rep["means"]
if __name__ == "__main__":
summary = {name: run_one(name, cfg) for name, cfg in EXPERIMENTS.items()}
keys = ["faithfulness", "answer_relevancy",
"llm_context_precision_with_reference", "context_recall"]
print(f"\n{'实验':<14}" + "".join(f"{k.split('_')[0][:12]:>14}" for k in keys))
print("-" * 70)
for name, m in summary.items():
print(f"{name:<14}" + "".join(f"{m.get(k, 0):>14.3f}" for k in keys))
Path("doc/week11/ab_summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
实操步骤 10:看对照结果
▶️ 实验 baseline: {'strategy': 'auto', 'top_k': 5, 'recall_k': 20}
▶️ 实验 A_prompt: {'strategy': 'auto', ..., 'prompt_version': 'v1.3-ragas'}
▶️ 实验 B_multi: {'strategy': 'multi', ...}
▶️ 实验 C_glossary: {'strategy': 'multi', ..., 'glossary': 'glossary_v2.json'}
实验 faithfulness answer_relev llm_context_p context_rec
----------------------------------------------------------------------
baseline 0.842 0.781 0.613 0.688
A_prompt 0.917 0.806 0.613 0.688
B_multi 0.917 0.812 0.671 0.812
C_glossary 0.921 0.818 0.688 0.854
⭐ 逐条解读(这是要写进交付报告的):
A_prompt:faithfulness 0.842 → 0.917,另外三个指标【纹丝不动】
→ 证明"数值铁律"这条 Prompt 改动【精确命中了生成侧幻觉】,
而且没有意外伤到检索(precision/recall 完全没变)------这是一次干净的改动。
B_multi :recall 0.688 → 0.812,precision 0.613 → 0.671
→ 强制走 Multi-Query 后,多跳题的召回上来了,且【顺带】提升了 precision
(因为 RRF 融合让真正相关的块排得更前)。
C_glossary:recall 0.812 → 0.854
→ 补 5 条术语表的边际收益已经变小(+0.042),说明【术语表快到饱和了】,
继续投入人力维护的性价比下降 ------ 这个结论本身就是有价值的。
💡 "某个指标纹丝不动"和"某个指标涨了"同样重要。 A 实验里三个检索指标完全没变,证明这次改动没有副作用;如果它们变了,你反而要警惕------改 Prompt 却让检索指标变了,说明你的实验没控制好变量(很可能是顺手改了别的东西)。
实操步骤 11:把结论写成可交付的报告
# eval/make_report.py ------ Day73 步骤11:生成 Markdown 报告(可直接发给客户)
import json
from datetime import date
from pathlib import Path
S = json.loads(Path("doc/week11/ab_summary.json").read_text(encoding="utf-8"))
BASE = S["baseline"]
BEST = S["C_glossary"]
LABEL = {
"faithfulness": "忠实度(有无编造)",
"answer_relevancy": "答案相关性(有无答到点)",
"llm_context_precision_with_reference": "上下文精确率(召回垃圾多不多)",
"context_recall": "上下文召回率(该召回的召回没)",
}
ACTION = {
"faithfulness": "生成侧:Prompt 约束 / 溯源校验",
"answer_relevancy": "生成侧:输出结构(先结论后依据)",
"llm_context_precision_with_reference": "检索侧:Reranker / top_k / 过滤",
"context_recall": "检索侧:分块 / 混合检索 / 查询改写",
}
lines = [
f"# RAG 质量评测报告 v1({date.today().isoformat()})",
"",
f"- 评测集:`data/eval/golden_set_v1.json`(20 条:术语 6 / 口语 5 / 数值 4 / 多跳 3 / 无据 2)",
"- 裁判模型:DeepSeek `deepseek-chat`(temperature=0)|裁判 Embedding:BAAI/bge-m3",
"- 评测框架:RAGAS 0.2.x|被测系统:智能运维助手 v0.2",
"",
"## 一、总体得分",
"",
"| 指标 | 含义 | 基线 | 优化后 | 变化 | 归谁的责 |",
"|------|------|------|-------|------|---------|",
]
for k, label in LABEL.items():
b, a = BASE.get(k, 0), BEST.get(k, 0)
delta = a - b
arrow = "⬆️" if delta > 0.01 else ("⬇️" if delta < -0.01 else "➖")
lines.append(f"| {k} | {label} | {b:.3f} | {a:.3f} | {arrow} {delta:+.3f} | {ACTION[k]} |")
lines += [
"",
"## 二、改动记录(一次只改一处)",
"",
"| 实验 | 改了什么 | faithfulness | context_recall | 结论 |",
"|------|---------|-------------|---------------|------|",
f"| baseline | --- | {BASE['faithfulness']:.3f} | {BASE['context_recall']:.3f} | 起点 |",
f"| A_prompt | 生成 Prompt 加数值铁律 | {S['A_prompt']['faithfulness']:.3f} | "
f"{S['A_prompt']['context_recall']:.3f} | 命中生成侧,检索指标未受影响 |",
f"| B_multi | 多跳题强制 Multi-Query | {S['B_multi']['faithfulness']:.3f} | "
f"{S['B_multi']['context_recall']:.3f} | 检索侧显著提升 |",
f"| C_glossary | 术语表补 5 条 | {S['C_glossary']['faithfulness']:.3f} | "
f"{S['C_glossary']['context_recall']:.3f} | 边际收益递减,接近饱和 |",
"",
"## 三、短板与下一步",
"",
"| 短板 | 证据 | 下一步 |",
"|------|------|--------|",
"| 多跳题召回仍偏低 | 多跳组 context_recall 0.556 | 引入父子分块 / 提高 recall_k |",
"| 数值题偶发估算 | 数值组 faithfulness 0.688→0.79 | 加『数值必须原文照抄』的后置校验 |",
"| 上下文精确率偏低 | precision 0.688 | 调 Reranker 阈值与 top_k |",
"",
"> ⚠️ 说明:无据题(2 条)的四个指标天然偏低,属期望行为;",
"> 本次 2/2 全部正确拒答,未计入上述短板。",
]
out = Path("doc/week11/RAGAS报告v1.md")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(lines), encoding="utf-8")
print(f"✅ 报告已生成 → {out}")
💡 注意上面那行用了中文书名号
『』。 用英文双引号会把 Python 字符串截断(这是生成 Markdown 的脚本里最常见的语法错误),中文标点既不影响阅读,也不会破坏代码。
🖥️ 八、让评测变成习惯:可复跑脚本 + CI 门禁
实操步骤 12:一个命令跑全套
#!/usr/bin/env bash
# eval/run_all.sh ------ Day73:一条命令跑完整评测(客户也能复跑)
set -euo pipefail
echo "① 校验评测集"
python -m eval.build_golden
echo "② 跑管线(生成 response 与 retrieved_contexts)"
python -m eval.run_pipeline
echo "③ 跑 RAGAS 四指标"
python -m eval.run_ragas
echo "④ 分组分析"
python -m eval.analyze
echo "⑤ 生成 Markdown 报告"
python -m eval.make_report
echo "✅ 完成,报告见 doc/week11/RAGAS报告v1.md"
chmod +x eval/run_all.sh && ./eval/run_all.sh
实操步骤 13:CI 质量门禁(防止改坏)
# tests/test_rag_quality.py ------ Day73 步骤13:把评测变成一条会失败的测试
"""RAG 质量门禁:指标低于阈值就让流水线红掉
用法(pytest 或单独跑都行):
pytest tests/test_rag_quality.py -s
python tests/test_rag_quality.py # 退出码非 0 = 门禁失败
"""
import json
import sys
from pathlib import Path
# ⭐ 阈值要设在【当前基线略低一点】的位置:
# 太低 → 回归发现不了;太高 → 正常波动就误报,大家会学会忽略红灯
THRESHOLDS = {
"faithfulness": 0.85,
"answer_relevancy": 0.75,
"llm_context_precision_with_reference": 0.60,
"context_recall": 0.75,
}
REPORT = Path("doc/week11/ragas_report_v1.json")
def load_means() -> dict:
if not REPORT.exists():
print(f"❌ 找不到评测报告 {REPORT},请先跑 eval/run_all.sh")
sys.exit(1)
return json.loads(REPORT.read_text(encoding="utf-8"))["means"]
def check() -> int:
means = load_means()
failed = []
print("🚦 RAG 质量门禁:")
for k, t in THRESHOLDS.items():
v = means.get(k)
if v is None:
print(f" ⚠️ 缺少指标 {k},跳过")
continue
ok = v >= t
print(f" {'✅' if ok else '❌'} {k:<42} {v:.3f} (阈值 {t})")
if not ok:
failed.append(f"{k}: {v:.3f} < {t}")
if failed:
print("\n🚨 门禁失败:")
for f in failed:
print(" -", f)
print("\n 请检查最近的改动(分块 / 检索 / Prompt),或确认是否是有意变更需要调整阈值。")
return 1
print("\n✅ 门禁通过")
return 0
def test_rag_quality():
assert check() == 0, "RAG 质量门禁未通过,详见上方输出"
if __name__ == "__main__":
sys.exit(check())
🚦 RAG 质量门禁:
✅ faithfulness 0.921 (阈值 0.85)
✅ answer_relevancy 0.818 (阈值 0.75)
✅ llm_context_precision_with_reference 0.688 (阈值 0.60)
✅ context_recall 0.854 (阈值 0.75)
✅ 门禁通过
# .github/workflows/rag-quality.yml ------ 每次改动检索/Prompt 都自动跑一次
name: rag-quality
on:
pull_request:
paths:
- "kb/**"
- "config/**"
- "eval/**"
jobs:
ragas:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -r requirements.txt
- run: docker compose up -d qdrant # 评测需要向量库
- run: python -m eval.run_all
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
- run: python tests/test_rag_quality.py
💡 门禁的阈值是一门手艺。 第一次设就设在当前基线 -0.05 的位置:既能抓住真实回归,又不会因为裁判模型的正常波动(±0.02)天天误报。一个天天红灯的门禁,一周之后就没人看了------那比没有门禁更糟。
一个补充说明:把评测接进 Langfuse
Day 59 建的可观测平台可以直接当评测数据的来源------生产环境真实提问里,每次回答都带上 RAGAS 分数(异步跑,不阻塞用户),你就能看到"线上真实流量的质量分布",而不只是黄金集上的分数。
# kb/feedback.py ------ Day73 补充:线上抽样打分,回写 Langfuse
from langfuse import get_client, observe
@observe(name="online-ragas-sample")
def score_online_sample(user_input: str, response: str, contexts: list[str]):
"""只对 faithfulness 做抽样打分(最便宜也最该盯的一个指标)"""
from eval.ragas_setup import evaluator_llm
from ragas.dataset_schema import SingleTurnSample
from ragas.metrics import Faithfulness
s = SingleTurnSample(user_input=user_input, response=response,
retrieved_contexts=contexts)
score = Faithfulness(llm=evaluator_llm).single_turn_ascore(s)
get_client().score_current_trace(name="faithfulness", value=float(score))
return float(score)
⭐ 黄金集 vs 线上抽样,是两套互补的评测:
黄金集 ------ 可复现、可对比、给客户看,但只有 20-100 条,且是你编的
线上抽样 ------ 真实分布、能发现你没想到的问题,但不可复现、有成本
成熟的团队两个都跑:黄金集守门禁,线上抽样做巡检。
📊 RAGAS 速查表
四个核心指标
| 指标 | 类名 | 必需字段 | 归责 | 低了怎么改 |
|---|---|---|---|---|
| faithfulness | Faithfulness(llm=...) |
user_input, response, retrieved_contexts | 生成 | 收紧 Prompt、加溯源校验、降低 temperature |
| answer relevancy | ResponseRelevancy(llm=..., embeddings=...) |
user_input, response, retrieved_contexts | 生成 | 改输出结构(先结论后依据)、限制长度 |
| context precision | LLMContextPrecisionWithReference(llm=...) |
user_input, retrieved_contexts, reference | 检索 | 加 Reranker、调 top_k、加元数据过滤 |
| context recall | LLMContextRecall(llm=...) |
user_input, retrieved_contexts, reference | 检索 | 调分块、加混合检索、加查询改写、加大 recall_k |
| (可选)事实正确性 | FactualCorrectness(llm=...) |
response, reference | 两者 | 端到端核对参考答案 |
| (可选)无参考版 precision | LLMContextPrecisionWithoutReference(llm=...) |
user_input, retrieved_contexts | 检索 | 没有 ground truth 时用这个 |
关键 API
| 动作 | 代码 |
|---|---|
| 安装 | pip install "ragas>=0.2.10,<0.3" langchain-openai langchain-community |
| 裁判 LLM | LangchainLLMWrapper(ChatOpenAI(model="deepseek-chat", base_url="https://api.deepseek.com", api_key=os.getenv("DEEPSEEK_API_KEY"), temperature=0)) |
| 裁判 Embedding | LangchainEmbeddingsWrapper(HuggingFaceBgeEmbeddings(model_name="BAAI/bge-m3", model_kwargs={"device": "cpu"})) |
| 单样本 | SingleTurnSample(user_input=..., response=..., retrieved_contexts=[...], reference=...) |
| 数据集 | EvaluationDataset(samples=[...]) |
| 跑评测 | evaluate(dataset=..., metrics=[...], run_config=RunConfig(max_workers=4, timeout=120)) |
| 取结果 | df = result.to_pandas();df.mean(numeric_only=True) |
| 单条打分 | Faithfulness(llm=...).single_turn_ascore(sample) |
常见翻车与解法
| ❌ 症状 | 原因 | ✅ 解法 |
|---|---|---|
ResponseRelevancy 报缺 embeddings |
没传 embeddings= |
传 evaluator_emb;或先去掉这个指标 |
| 两次跑分不一样 | 裁判 temperature>0 |
裁判一律 temperature=0;锁 RAGAS 版本 |
| context recall 恒为 0 | reference 写得太长/有推断 |
reference 逐字摘自资料,只写答案要点 |
| 无据题把总分拉低 | 无据题天然低分 | 分组统计,把无据题单列;用 expect_refuse 单独考核拒答率 |
| 跑批被限流 | max_workers 太大 |
降到 2-4;RunConfig(timeout=...) 加大 |
retrieved_contexts 传成了对象 |
传了 Hit 对象而非文本 | 必须是 list[str]:[h["payload"]["text"] for h in hits] |
| 权限泄漏进评测 | 评测用了管理员角色 | 评测脚本显式指定角色,并把角色写进报告 |
| 改了 Prompt 检索指标也变了 | 没控制变量 | 一次只改一处,重跑,看哪些指标"纹丝不动" |
| 门禁天天红灯 | 阈值贴着基线设 | 阈值设在基线 -0.05;先跑 3 次确认波动范围 |
| 0.4 版本 API 报错 | LangchainLLMWrapper 在 0.4 已 deprecated |
锁 >=0.2.10,<0.3;或按官方迁移文档换 llm_factory() |
📝 本课小结
| 知识点 | 一句话记住 |
|---|---|
| 手工评测的盲区 | 只测召回、不测答案;样本小噪音大;不可复现 |
| RAGAS 的核心思路 | LLM 当裁判,把答案拆成原子声明逐条核对 |
| faithfulness | 答案每句话有没有出处 → 生成的锅 |
| answer relevancy | 有没有答到点上 → 生成的锅(需 embeddings) |
| context precision | 召回的垃圾多不多、有用的排得前不排 → 检索的锅(需 reference) |
| context recall | 该召回的召回了吗 → 检索的锅(需 reference) |
| 交叉定位 | recall 低 + faithful 高 = 检索的锅 ;recall 高 + faithful 低 = 生成的锅 |
| 改进顺序 | 先看 recall → 再看 precision → 再 faithfulness → 最后 relevancy |
| 一次只改一处 | 改三处就不知道是哪处起作用,下次还不会 |
| "纹丝不动"也是信息 | 改 Prompt 而检索指标不变 = 干净的改动,无副作用 |
| ground truth 铁律 | 逐字摘自资料,不推断、不概括,否则 recall 假性偏低 |
| 无据题单独算 | 它天然低分,混进总分会让你误判 |
| 评测集分布 | 术语 / 口语 / 数值 / 多跳 / 无据 刻意设计,否则发现不了问题 |
| Prompt 要有版本 | PROPT_VERSION 跟着改,写进报告 |
| 质量门禁 | 阈值设在基线 -0.05;天天红灯的门禁等于没有门禁 |
| 黄金集 vs 线上抽样 | 前者守门禁、给客户看;后者做巡检、发现未知问题 |
🧠 核心认知 :前三周我们做了十几处优化,每一处都说"变好了"------但没有一处是被独立验证过的 。今天之后,每一处改动都能在五分钟内拿到一组可比的数字。这件事的意义远超"多一份报告":它把 RAG 从一门手艺变成了一项工程 。手艺靠直觉------"我觉得这样切块更好";工程靠闭环------"改了分块参数,context recall 从 0.688 涨到 0.812,其他三个指标没动,所以这次改动有效且无副作用"。而今天那张两指标交叉定位表 ,解决的是一个更具体的痛点:当系统答错时,你第一次能对着数字说"这是检索的锅还是生成的锅" ,而不是在分块、检索、Prompt 之间瞎猜。还有一条容易被忽略的收获:评测集的分布是设计出来的,不是随机编的 ------你刻意放进 5 条口语题,才发现 Day 71 的改写值不值;刻意放进 2 条无据题,才能证明 Day 69 的拒答还在生效。评测集本身,就是你对自己系统的理解深度的成绩单。 明天我们做最后一块:把这一切变成客户能长期运维的东西。
📋 课后练习
练习 1:把评测集扩到 40 条并重新分组分析(约 60 分钟)
-
在
data/eval/golden_set_v1.json基础上补 20 条,保持分布:术语 +8 / 口语 +5 / 数值 +3 / 多跳 +2 / 无据 +2。 -
每一条的
reference必须打开data/cleaned/里的原文复制粘贴 ,不许凭记忆写。写完后跑一遍validate()。 -
重跑
eval/run_all.sh,对比 20 条和 40 条两版的总分。 -
核心观察 :指标是变高了还是变低了?为什么?(提示:样本量变大后,噪音变小,但你新加的题可能更难------把新加 20 条单独统计一次,看看是不是比原来那 20 条难)
-
把结论写进
doc/week11/golden_set_v2.md,并回答:40 条够不够?如果要扩到 200 条,你会怎么在不花两周时间的前提下做到?
练习 2:做一次完整的"短板定位 → 改进 → 复测"闭环(约 70 分钟)
-
跑
eval/analyze.py,从你的报告里找出现实存在的一个短板(不要抄我示例里的数值,用你自己跑出来的)。 -
用 6.1 的定位表判断它属于"检索的锅"还是"生成的锅",写下你的判断依据(引用具体数字)。
-
只做一处改动 :如果是检索的锅,就在分块大小 /
recall_k/ 是否启用 Reranker 里选一个 调;如果是生成的锅,就改 Prompt 的一条约束。 -
重跑评测,做一张改动前后的四指标对照表。
-
验收标准 :必须能说出一句"我预期 X 指标涨、Y 指标不动,实际是......"。如果实际和预期不符,写一个你认为的原因------预测错误比预测正确更有学习价值,把它记下来。
练习 3:把门禁接进你的工作流(约 50 分钟)
-
跑三次
python tests/test_rag_quality.py(不改任何代码),记录三次的分数波动范围。这一步决定了你的阈值该怎么设。 -
按"基线最小值 - 0.05"重设
THRESHOLDS,确保三次都能通过。 -
故意改坏一次 :把
recall_k改成 3(或把 Reranker 关掉),跑门禁,确认它真的会红 。截图或复制输出存进doc/week11/ci_gate_test.md。 -
改回来,确认门禁恢复绿色。
-
给
eval/run_all.sh加一个--quick参数:只跑 8 条代表性样本(每类 1-2 条),用于日常开发的快速回归;完整 20 条留给合并前。记录两种模式的耗时对比。 -
思考题 (写下来):如果把裁判模型从 DeepSeek 换成一个更强的模型,你预期四个指标会系统性偏高还是偏低?这会怎么影响你设的阈值?------这个思考的答案,就是为什么评测报告必须写明"裁判模型是谁"。
🔭 下节预告
今天把"证明"这件事立住了:
第一份 RAGAS 报告(20 条黄金集,DeepSeek 裁判,BGE-M3 裁判 Embedding)
faithfulness 0.842 → 0.921 (Prompt 加数值铁律)
context recall 0.688 → 0.854 (Multi-Query + 术语表)
context precision 0.613 → 0.688
answer relevancy 0.781 → 0.818
并且拿到了:可复跑脚本 + CI 质量门禁 + 一张"谁该背锅"的定位表
现在你手上有了三样东西:一个能跑的系统(Day 62-71)、一个可选的交付平台(Day 72)、一套能证明它好坏的评测(Day 73)。但它们都还是"一次性的"------每一次都靠你手动跑一遍。
明天要解决的,是客户真正会追问的最后两个问题:
问题一:"新来一份手册,怎么办?"
现在:重灌整个库?还是只灌新增的?改了一页怎么只更新那一页?
删掉的旧版本怎么从向量库里消失?
问题二:"上线之后,谁负责?"
备份谁做?限流怎么配?模型挂了怎么降级?成本爆了怎么发现?
出问题了看什么日志?多久能恢复?
明天的 Day 74「企业架构」就是回答这两个问题的:
| Day | 主题 | 做什么 |
|---|---|---|
| Day 71 | 高级检索 | ✅ Recall@5 0.417 → 0.917 |
| Day 72 | RAGFlow 平台 | ✅ 部署 + 对照 + 选型结论 |
| Day 73 | RAGAS 评测 | ✅ 四指标 + 改进闭环 + CI 门禁 |
| Day 74 | 企业架构 | 增量更新 (新手册追加 / 旧版本下线 / 删除同步)、多来源分库策略 (手册 / 工单历史 / SOP 规程要不要放一个库)、黄金问题集模板 (把今天的 20 条变成客户能自己维护的 Excel 模板)、生产就绪检查单(备份 / 限流 / 降级 / 成本 / 监控,一份能直接交给客户运维的清单) |
| Day 75 | v0.2 冻结 | 问答集成进工单系统(工单详情页"查手册"联动)、RAGAS 报告定稿、打版本标签 |
明天的产出会是这个系列里唯一一份你可以直接打印出来贴在客户机房墙上的文档------生产就绪检查单。它不教你新算法,但它决定你这个系统能不能活过上线后的第一个月。
明天见。
🌍附录:前置课程列表
阶段一:认知启蒙(AI 认知与 FDE 角色)
AI 认知
【FDE系列】阶段1Day 1:AI 层级关系 --- 四个嵌套的圈-CSDN博客
【FDE系列】阶段1Day 2:AI 三阶段发展史 --- 会认 → 会判断 → 会创造-CSDN博客
【FDE系列】阶段1Day 3:符号 AI vs 机器学习 --- 两条路线的本质区别-CSDN博客
【FDE系列】阶段1Day 4:Transformer 的历史意义 --- 2017 年的分水岭-CSDN博客
【FDE系列】阶段1Day 5:本周复习与自测 --- 检验你的 AI 认知地基-CSDN博客
【FDE系列】阶段1Day 6:Transformer 架构 --- 一张图纸盖出千千万万栋楼-CSDN博客
【FDE系列】阶段1Day 7:LLM 本质 --- 文字接龙机器-CSDN博客
【FDE系列】阶段1Day 8:Token --- 模型眼中的最小单位-CSDN博客
【FDE系列】阶段1Day 9:AI 幻觉 --- 为什么会一本正经地胡说八道-CSDN博客
【FDE系列】阶段1Day 10:上下文窗口 --- 模型的记忆力上限 + 本周复习-CSDN博客
【FDE系列】阶段1Day 11:Prompt --- 给模型立规矩-CSDN博客
【FDE系列】阶段1Day 12:Memory --- 让模型记住上下文
【FDE系列】阶段1Day 13:RAG --- 给模型配图书管理员-CSDN博客
【FDE系列】阶段1Day 14:Tool Use --- 让模型动手操作-CSDN博客
【FDE系列】阶段1Day 15:MCP --- 统一的工具接口标准 + 第三周复习-CSDN博客
FDE 基础概念
【FDE系列】阶段1Day 16:什么是 FDE --- 把 AI 变成客户结果的人-CSDN博客
【FDE系列】阶段1Day 17:FDE vs 传统实施 --- 三大本质区别-CSDN博客
【FDE系列】阶段1Day 18:FDE 三重身份 + C6 胜任力模型-CSDN博客
【FDE系列】阶段1Day 19:七阶段行动路径 + 行业经验的价值-CSDN博客
【FDE系列】阶段1Day 20:阶段总结与产出物 --- 第一阶段收官-CSDN博客
阶段二:技术地基(Python + FastAPI + SQL + Docker + API 集成)
Python基础
【FDE系列】阶段2:Day 21:Python 环境搭建 --- 写出你的第一行代码-CSDN博客
【FDE系列】阶段2:Day 22:变量、数据类型、条件判断 --- Python 的"记忆"和"判断"-CSDN博客
【FDE系列】阶段2:Day 23:循环与函数 --- 让代码跑 100 遍、把逻辑打包复用-CSDN博客
【FDE系列】阶段2:Day 24:数据结构 --- 列表、字典、集合、元组-CSDN博客
【FDE系列】阶段2:Day 25:文件读写与 JSON --- 让程序连通外部数据(第一周收官)-CSDN博客
【FDE系列】阶段2:Day 26:模块化编程 --- 把代码拆成"抽屉柜"-CSDN博客
【FDE系列】阶段2:Day 27:异常处理与日志 --- 让程序"摔不烂、查得到"-CSDN博客
FastAPI入门到进阶
【FDE系列】阶段2:Day 28:FastAPI 入门 --- 把你的函数变成 API 服务-CSDN博客
【FDE系列】阶段2:Day 29:FastAPI 进阶 --- Pydantic 模型与完整 CRUD 实战-CSDN博客
【FDE系列】阶段2:Day 30:生产代码规范 --- 测试、类型注解、配置管理(第二周收官)-CSDN博客
SQL基础
【FDE系列】阶段2:Day 31:SQL 基础 --- 增删改查一把梭-CSDN博客
【FDE系列】阶段2:Day 32:多表查询 --- JOIN 与聚合-CSDN博客
【FDE系列】阶段2:Day 33:进阶查询 --- 窗口函数与 CTE-CSDN博客
【FDE系列】阶段2:Day 34:数据清洗 --- 把脏数据捋干净-CSDN博客
【FDE系列】阶段2:Day 35:Python + SQL --- 工单接入 MySQL + 本周收官-CSDN博客
Linux基础
【FDE系列】阶段2:Day 36:Linux 入门与文件操作 --- 扔掉鼠标的第一天-CSDN博客
【FDE系列】阶段2:Day 37:权限、进程与文本三剑客-CSDN博客
【FDE系列】阶段2:Day 38:Shell 脚本 --- 把命令串起来自动跑-CSDN博客
【FDE系列】阶段2:Day 39:Linux 综合实战 --- 让服务无人值守-CSDN博客
【FDE系列】阶段2:Day 40:Shell 进阶 --- 生产级脚本与本周收官-CSDN博客
Docker
【FDE系列】阶段2:Day 41:Docker 入门 --- 把环境装进盒子-CSDN博客
【FDE系列】阶段2:Day 42:Dockerfile 实战 --- 把你的应用打包成镜像-CSDN博客
【FDE系列】阶段2:Day 43:Docker Compose --- 多容器一键编排-CSDN博客
【FDE系列】阶段2:Day 44:Nginx 反向代理 + Git 版本控制-CSDN博客
【FDE系列】阶段2:Day 45:综合实战 --- Docker + Nginx + Git 完整部署与本周收官-CSDN博客
API 集成与系统对接
【FDE系列】阶段2:Day 46:RESTful 设计与认证授权-CSDN博客
【FDE系列】阶段2:Day 47:对接企业系统 --- 飞书 / 钉钉 API-CSDN博客
【FDE系列】阶段2:Day 48:Webhook 处理与数据映射-CSDN博客
【FDE系列】阶段2:Day 49:OpenAPI 文档与接口测试-CSDN博客
【FDE系列】阶段2:Day 50:综合项目 --- 设备告警工单闭环系统 & 第二阶段收官 特殊字符-CSDN博客
阶段三:AI 应用技术(含 SDD 方法论)
AI基础:Prompt Engineering 系统训练
【FDE系列】阶段3:Day 51:从聊天窗口到代码 --- 跟 LLM 的第一次握手-CSDN博客
【FDE系列】阶段3:Day 52:Prompt 三板斧 --- 角色、示例与清晰指令-CSDN博客
【FDE系列】阶段3:Day 53:结构化输出 --- 让模型的回答能进数据库-CSDN博客
【FDE系列】阶段3:Day 54:思维链与推理任务 --- 让模型一步步想清楚-CSDN博客
【FDE系列】阶段3:Day 55:综合实战 --- 巡检报告生成器与本周收官 -CSDN博客
【FDE系列】阶段3:Day 56:评测体系入门 --- 建立你的黄金评测集-CSDN博客
【FDE系列】阶段3:Day 57:Promptfoo 实战 --- A/B 对比让数据说话-CSDN博客
【FDE系列】阶段3:Day 58:Prompt 安全 --- 注入、越狱与防护-CSDN博客
【FDE系列】阶段3:Day 59:模板化与追踪 --- Jinja2 与 Langfuse-CSDN博客
【FDE系列】阶段3:Day 60:综合实战 --- 智能工单助手 v0.1 冻结-CSDN博客
RAG 知识检索系统
【FDE系列】阶段3:Day 61:RAG 全景 --- 给模型配一间资料室-CSDN博客
【FDE系列】阶段3:Day 62:Embedding --- 文字是怎么变成向量的-CSDN博客
【FDE系列】阶段3:Day 63:文档解析 --- 把真实 PDF 手册变成可用文本-CSDN博客
【FDE系列】阶段3:Day 64:文本分块 --- 决定检索成败的那一步-CSDN博客
【FDE系列】阶段3:Day 65:向量数据库入门 --- Chroma 与本周收官-CSDN博客
【FDE系列】阶段3:Day 66:Qdrant 入门 --- 生产级向量库-CSDN博客
【FDE系列】阶段3:Day 67:混合检索 --- BM25 与 RRF 融合-CSDN博客
【FDE系列】阶段3:Day 68:重排序 --- 用 BGE-Reranker 把真正相关的顶上来-CSDN博客
【FDE系列】阶段3:Day 69:引用溯源 --- 让每个答案都能对上原文-CSDN博客
【FDE系列】阶段3:Day 68:重排序 --- 用 BGE-Reranker 把真正相关的顶上来-CSDN博客
【FDE系列】阶段3:Day 71:高级检索 --- 查询改写与 HyDE-CSDN博客
【FDE系列】阶段3:Day 72:RAGFlow 平台 --- 端到端知识库
待完成教程:
Agent 框架与开发
Tool Calling 与 MCP
LLM 推理与部署
规范驱动开发与 Agent 工程方法论
阶段四:平台与交付(含 Agent 治理)
阶段五:行业实战与认证