评估与指标 —— 如何证明系统"够好"
所属课程:《法律问答系统(llm-wiki)》八讲课件 · 第 8/8 讲 定位:前面 7 讲把系统造出来了,但”造出来”不等于”好用”。本讲讲怎么量化证明——检索质量用
recall@k,回答质量用 ragas,对应eval/目录。
本讲目标:
- 看懂 30 条测试用例的三种类型(single_hop / multi_hop / complex)设计意图;
- 理解自定义
recall@k的算法与 k=1/3/5/8 各自的含义; - 认识 ragas 五指标与 langchain 版本兼容红线;
- 解读实测基线,能说出系统当前的强项与薄弱项、以及改进方向。
1. 评估数据:eval/qa_test_cases.jsonl
JSONL 每行一条用例,共 30 条劳动法测试用例,覆盖试用期、竞业限制、经济补偿、加班、非全日制、劳务派遣、经济性裁员、孕期女职工保护、劳动争议处理、劳动监察等。
字段:
{
"id": "eval_001",
"type": "single_hop", // single_hop(10) / multi_hop(10) / complex(10)
"question": "…", // 用户问题
"answer": "…", // 标准答案 → 作为 ragas 的 ground_truth
"expected_knowledge": ["KP_…"],// 期望命中的知识点 id → 用于 recall@k 比对
"source_sections": ["…"], // 答案来源章节
"sub_questions": ["…"] // complex 类型的子问题拆分
}
三种类型回答不同能力:
| 类型 | 数量 | 考察什么 | 例 |
|---|---|---|---|
single_hop | 10 | 检索能否一步命中对应条文 | “竞业限制的期限法律怎么规定?” → 命中第二十四条 |
multi_hop | 10 | 图谱多跳能否把被引用的相关条文带进来 | 一条引用 39 条,要带出 39/46/47 条 |
complex | 10 | 大而泛的”责任/措施”题,需组合多法多条文 + 子问题拆分 | “拖欠工资怎么维权?” 牵涉劳动监察、责令支付、仲裁… |
expected_knowledge与知识图谱的KP_xxx一一对应(脚本逐条校验过无缺失、无重复),所以 recall@k 能精确到” 该召回的那几条有没有被召回到”。
2. 检索质量指标:自定义 recall@k
ragas 没有内置”该命中的知识点有没有被检索到”这种面向知识点的召回指标,所以自己写一个(eval/metrics.py):
def recall_at_k(retrieved_ids, expected_ids, k):
"""Top-k 检索召回率 = 期望命中的知识点里,有多少出现在 Top-k 内。"""
if not expected_ids:
return None
hit = sum(1 for eid in expected_ids if eid in set(retrieved_ids[:k]))
return hit / len(expected_ids)
def recall_report(retrieved_ids, expected_ids, ks=(1, 3, 5)):
return {f"recall@{k}": recall_at_k(retrieved_ids, expected_ids, k) for k in ks}
为什么用 1/3/5/8 四个 k?
| k | 含义 |
|---|---|
| recall@1 | 排第一的知识点是不是期望的那条——最严格,几乎决定能不能”一步答对” |
| recall@3 / @5 | 前几名内有没有期望条文——反映检索排名质量 |
| recall@8 | 检索返回 5 种子 + 3 扩展 = 8 块全用给生成器,所以 @8 = 生成器真实可见上下文里有没有期望条文——是最贴近”答得对不对”的指标 |
一个容易忽略的点:
evaluate.py用kb.search(question, top_k),检索返回 ≤8 块,生成与 recall 都用这 8 块,@8 不是随便选的大数,是生成上下文的真实容量(EXPAND_K=3 + TOP_K=5)。
3. 回答质量指标:ragas 0.3.0
回答好不好,不只”检索到没有”,还要”上下文相不相关、答案忠不忠实”。用 Ragas 0.3.0 评估框架 + DashScope:
| 指标 | 问的是什么 |
|---|---|
context_precision | 检索来的上下文里,有用的占多少(相关块排得靠前吗) |
context_recall | 标准答案需要的信息,上下文覆盖了多少 |
ContextRelevance | 检索上下文和问题的相关程度 |
answer_relevancy | 回答是否切题、没跑偏 |
faithfulness | 回答是否忠于给定上下文、有没有凭空编造 |
def build_llm():
from langchain_openai import ChatOpenAI
from ragas.llms import LangchainLLMWrapper
return LangchainLLMWrapper(ChatOpenAI(model=LLM_MODEL, base_url=DASHSCOPE_BASE_URL,
api_key=DASHSCOPE_API_KEY, temperature=0))
def build_embeddings():
from langchain_community.embeddings import DashScopeEmbeddings
from ragas.embeddings import LangchainEmbeddingsWrapper
return LangchainEmbeddingsWrapper(
DashScopeEmbeddings(model="text-embedding-v3",
dashscope_api_key=DASHSCOPE_API_KEY))
⚠️ 两个必踩的坑(都在代码注释里写明了)
- Embedding 必须用
langchain_community.embeddings.DashScopeEmbeddings(text-embedding-v3),不要用 langchain 的OpenAIEmbeddings去接 DashScope——会报input.contents is neither str nor list of str(请求格式不兼容)。 - 版本铁三角:
ragas==0.3.0+langchain-community==0.4.0+ langchain 1.x。ragas 0.4.x 会因vertexai模块被移除而与 langchain-community≥0.4.2 冲突。改这三者版本前务必验证兼容。
运行评估
uv run python -m eval.evaluate # 全量 30 条(生成 + ragas,较慢,要 API_KEY)
uv run python -m eval.evaluate --limit 3 # 快速试跑前 3 条
uv run python -m eval.evaluate --skip-ragas # 只算 recall@k(不调 LLM,可离线)
结果落盘 eval/results/evaluation_<时间戳>.json:recall@k 汇总 + ragas 各指标均值/最值/按类型分组 + 每用例明细。
4. 实测基线解读(2026-08-12,30 条,—skip-ragas)
| 指标 | 数值 | 解读 |
|---|---|---|
| recall@1 | 0.40 | 平均每条用例 Top1 就命中期望条文的比例不到一半——检索排序还有空间 |
| recall@3 | 0.69 | 前 3 名基本能框住约七成期望条文 |
| recall@5 | 0.84 | 5 个种子内已框住绝大多数 |
| recall@8 | 0.92 | 加上图谱扩展后,生成器上下文里已包含 92% 的期望条文 |
再拆一层看为什么能到 0.92:
- 单跳用例:10 条全部 recall@3=1.0(其中 7 条 recall@1=1.0)——“一步定位”对这种题几乎没短板;
- 多跳/复杂用例的依赖条文,主要靠 crossref 图谱扩展带进上下文(46→38、65→39、42→45、41→46 等),证实第 6 讲的设计是有效的。
0.40 → 0.92 的爬升曲线本身就是一张很好的”设计证据图”:单靠字面检索 top1 只能答好一半题;加上向量融合 + 图谱多跳,@8 能到九成以上——这正是混合检索与知识图谱存在的意义。
5. 已知薄弱项与改进方向(面试要会聊”不足”)
薄弱点:宽泛的”措施/责任”类问题——如 eval_026 拖欠工资维权、eval_030 维权途径、eval_025 孕期违法解除——期望条文*
未必全部落入 top-8*。成因:这类问题同时涉及两部法多个条文,检索与扩展的分数容易被”泛主题”稀释,部分期望条文掉出上下文。
缓解观察:即便如此,生成器在缺失部分条文时,仍能基于已召回的相关条文给出合法合理的回答(冒烟测试验证过)——因为 prompt 允许组合相近条文 + 如实说明。
后续方向(交接文档列了几个,供讨论):
- 查询分解:用 LLM 把宽泛问题拆成子问题分别检索再合并(complex 用例的 sub_questions 字段已为此预留);
- 实体链接 + 图传播:从 query 抽实体 → 映射到图谱节点 → 影响力传播召回,把”跨两法的责任/措施”题兜住;
- 按预期条文补 crossref 或概念映射:数据层补救,让”维权/责任”等触发词命中更多相关条文;
- 评估完善:测试集随机抽样、多版本结果对比、CI 里加评估回归。
6. 全课回顾:八讲串成一张图
第1讲 为什么不做 RAG:llm-wiki 把知识"编译"成资产
第2讲 系统总览:ingest 编译 + query 定位/综合,双阶段架构
─────── 摄入(编译知识,可读可审计)───────────────
第3讲 PDF → 205 知识点:清洗 / 康熙部首 / 章-节-条状态机
第4讲 概念增强(口语→法条) + 图谱(4类边) + Wiki 落盘
─────── 查询(只做"定位 + 综合")─────────────────
第5讲 BM25 + bge-m3 双路召回 → RRF 融合 → 5 种子
第6讲 图谱 BFS ≤3 跳扩展 → qwen-max 依据知识块综合生成
第7讲 短期记忆 / CLI / 结构化日志:能多轮、可观测、可回归
─────── 证明它够好 ─────────────────────────────
第8讲 recall@k(0.40→0.92)+ ragas:强项、薄弱、改进方向
一句话总纲:把结构规整的领域文档在摄入期”编译”成可读可审计的知识点 + 图谱(wiki + JSON),查询期用混合检索定位、图谱多跳补全、大模型综合生成,并用 recall@k 与 ragas 持续证明与改进——这就是 llm-wiki 问答系统。
企业常见面试问答
Q1:recall@1/3/5/8 各代表什么?为什么 @8 有意义?
它衡量”期望命中的知识点有没有被检索到 Top-k 内”。@1 看排第一的准不准,@3/@5 看排序质量;@8 特殊——系统检索给生成器的是 5 个种子 + 3 个图谱扩展 = 恰好 8 块,所以 recall@8 反映的是”生成器真实看到的上下文里有没有期望条文”,是最贴近答案质量的一个检索指标。基线是 0.40 / 0.69 / 0.84 / 0.92:单靠 top1 只能答对约四成,加上向量与图谱扩展到 @8 能达九成以上,恰好论证了混合检索 + 多跳扩展的设计价值。
Q2:你们的测试集是怎么设计的?三种类型分别验证什么?
30 条劳动法用例,分三类各 10 条。
single_hop验证”一步定位”——一个概念查一条条文能否命中;multi_hop验证多跳召回——一条条文引用了另一条,扩展能否把被引条文(如 39 条带出 46/47 条)带进上下文;complex覆盖大而泛的” 责任/措施/维权”题,需要组合多法多条文,每条例出子问题。每条都有expected_knowledge(对应图谱 KP 号)用于 recall 比对、有标准答案用于 ragas 的 ground_truth。类型化的好处是:指标一掉,能立刻定位是单跳、多跳还是复杂题在退化。
Q3:回答质量怎么评估?ragas 在这套里有什么坑?
用 ragas 0.3.0
的五个指标:context_precision(上下文有用率)、context_recall(上下文对答案的覆盖)、ContextRelevance、answer_relevancy、faithfulness(是否忠于给定上下文、没编)。两个坑务必记住:①
embedding 接 DashScope 必须用
langchain_community.embeddings.DashScopeEmbeddings,用OpenAIEmbeddings会因请求格式不兼容报input.contents错误;② 版本铁三角ragas==0.3.0 + langchain-community==0.4.0 + langchain 1.x,ragas 0.4.x 与 community≥0.4.2 因 vertexai 移除而冲突。
Q4:现在系统最弱的地方在哪?如果让你改进,先做哪个?
最弱是宽泛的”措施/责任”题:例如拖欠工资怎么维权、维权途径,这类同时牵涉两部法多个条文,期望条文未必全部落进 top-8(recall@8=0.92 里丢的就是它们)。改进我会先上查询分解:复杂用例已经带了 sub_questions,可以让 LLM 把大问题拆成子问题分别检索再合并,能直接缓解”泛主题稀释分数”。其次是数据层补救(给维权/责任类触发词补概念映射或 crossref),最后再考虑实体链接 + 图传播这种更重的召回升级。优先级依据很简单:哪类用例 recall 掉得最多,先补哪类。