今天要解决的问题
过去三天你改了很多东西:加了标题前缀,换了切块大小,加了 BM25,加了 rerank,试了 query 改写。每次改完,你是怎么判断「变好了」的?
如果答案是「问了几个问题,看着回答挺对的」,那你和大部分做 RAG 的人一样。这也是大部分 RAG 项目停在 demo 阶段的原因:没有数字,就不知道改动是进步还是退步,也说服不了任何人上线。
今天建 CloudLedger RAG 的第一份评估集,然后写两个脚本:一个评检索,一个评生成。W5 整整一周都会讲评测,今天是一个针对 RAG 的提前实战。
今天结束时你应该能做到:
- 说清楚 RAG 为什么要分两层评估,以及每层看哪些指标。
- 手算 hit@k、recall@k、MRR,并知道 nDCG 大概在衡量什么。
- 建一份 30 条、覆盖五种问题类型的评估集,知道「无答案问题」为什么必须有。
- 跑通检索评估脚本,得到按问题类型拆开的分数和失败清单。
- 用大模型当裁判,评估回答的忠实度、完整度和拒答行为。
- 用评估集做一次参数实验,用数字回答一个工程问题。
预计用时 5 小时。前置:Day 16 的数据和切块,Day 18 的 BM25、RRF、rerank。
为什么要分两层
RAG 是两段式的:先检索,再生成。答错一道题,原因只有两类:
- 检索错了:正确答案所在的文档根本没进 top-k,模型手里没有正确资料。这时候换再好的大模型、改再多 prompt 也没用。
- 生成错了:正确资料已经在 top-k 里,但模型没用好,比如漏了条件、张冠李戴、或者自己编了一句。
如果只看最终回答对不对,你没法区分这两类。改了检索,最终分数没变,可能是检索确实没提升,也可能是检索提升了但被生成的问题掩盖了。所以要分开评:
检索层的好处是便宜、确定、快:只要标注好每个问题的答案在哪篇文档,算分就是纯代码,不调大模型(除了 embedding 和 rerank),跑一遍几秒钟。所以检索层的实验可以做得很频繁。生成层要调大模型生成,再调大模型打分,慢而且花钱,而且裁判本身也会犯错,适合在检索调好之后做。
检索指标:手算三个
假设一个问题的标准答案在 kb-010,检索返回的文档排名是 [kb-011, kb-010, kb-009, kb-002]。
hit@k(命中率):前 k 个里有没有正确文档,有就是 1,没有就是 0。这个例子里 hit@1 = 0(第一名是 kb-011),hit@2 = 1。对整份评估集取平均,就是「有多少比例的问题在前 k 个里找到了答案」。
recall@k(召回率):正确文档里,有多少比例进了前 k 个。只有一篇正确文档时,它和 hit@k 一样。差别出现在需要多篇文档的问题上:「发票抬头写错了,而且想退款」需要 kb-007 和 kb-003,如果前 4 个里只有 kb-007,recall@4 = 1/2 = 0.5,而 hit@4 = 1。对多跳、多问题的查询,recall 比 hit 更能反映「资料够不够」。
MRR(Mean Reciprocal Rank,平均倒数排名):第一个正确文档排第几,取倒数。排第 1 得 1,排第 2 得 0.5,排第 3 得 0.33,没找到得 0,再对所有问题取平均。这个例子里 MRR = 1/2 = 0.5。MRR 同时反映了「找没找到」和「排得多靠前」,很适合衡量 rerank 这类排序改进。
还有一个你会在论文和面试里听到的 nDCG。它考虑的是:相关文档可以有不同的相关程度(非常相关、有点相关),而且排得越靠后,贡献按对数衰减。客服场景里我们的标注是二元的(这篇能不能回答),用 MRR 和 recall 就够了。面试被问到,能说出「nDCG 支持分级相关性、对排名位置做对数折扣」就行。
有个细节:我们的检索结果是块级的(kb-010#1、kb-010#2),而标注是文档级的(kb-010)。同一篇文章的两个块都排在前面,不应该算成两个结果。所以先把块级排名「去重」成文档级排名,保留每篇文档第一次出现的位置,再算指标。
# agent-60-lab/w3/metrics.py
"""检索指标。retrieved 是按排名排好的文档 id 列表(已去重),expected 是标准答案所在的文档 id 集合。"""
def dedup_docs(chunk_doc_ids: list[str]) -> list[str]:
"""块级结果 → 文档级排名:同一篇文章的多个块只保留排名最靠前的那个。"""
seen: list[str] = []
for d in chunk_doc_ids:
if d not in seen:
seen.append(d)
return seen
def hit_at_k(retrieved: list[str], expected: set[str], k: int) -> float:
return 1.0 if expected & set(retrieved[:k]) else 0.0
def recall_at_k(retrieved: list[str], expected: set[str], k: int) -> float:
return len(expected & set(retrieved[:k])) / len(expected)
def reciprocal_rank(retrieved: list[str], expected: set[str]) -> float:
for rank, d in enumerate(retrieved, start=1):
if d in expected:
return 1.0 / rank
return 0.0
if __name__ == "__main__":
r = dedup_docs(["kb-011", "kb-011", "kb-010", "kb-009"])
print(r) # ['kb-011', 'kb-010', 'kb-009']
print(hit_at_k(r, {"kb-010"}, 1), hit_at_k(r, {"kb-010"}, 3)) # 0.0 1.0
print(recall_at_k(r, {"kb-010", "kb-003"}, 3)) # 0.5
print(reciprocal_rank(r, {"kb-010"})) # 0.5
# agent-60-lab/w3/metrics.py下面四个函数都是纯 Python,不调接口,直接uv run python w3/metrics.py就能跑,输出就是__main__里注释写的那几行(我实跑过,一致)。"""检索指标。约定写在模块 docstring 里:retrieved是已去重、按排名排好的文档 id 列表,expected是集合。后面三个指标函数都依赖这个约定,传块级 id(kb-011#1)进来就全错。def dedup_docs(块级结果转文档级排名。输入是每个块所属的文档 id,按检索排名排好,比如["kb-011", "kb-011", "kb-010", "kb-009"](前两个块都来自 kb-011)。seen: list[str] = []用 list 不用 set,因为要保持顺序:排名就是顺序,set()会把顺序打乱。: list[str]是类型标注,只给编辑器看,运行时不检查。for d in chunk_doc_ids:拿上面的例子逐个走:
第 1 个kb-011:不在 seen 里 → 加入,seen =['kb-011']
第 2 个kb-011:已经在了 → 跳过,seen 不变
第 3 个kb-010:不在 → 加入,seen =['kb-011', 'kb-010']
第 4 个kb-009:不在 → 加入,seen =['kb-011', 'kb-010', 'kb-009']if d not in seen:not in在 list 上是逐个比较,O(n)。这里每次最多十几个元素,无所谓。数据大时可以用list(dict.fromkeys(ids)),dict 从 3.7 起保持插入顺序,一行就能保序去重。return seen实测返回['kb-011', 'kb-010', 'kb-009']:4 个块变成 3 篇文档,kb-010 从「第 3 块」变成「第 2 名」。def hit_at_k(前 k 个里有没有命中任何一篇正确文档。逐行讲解和走表在代码块下面的正文里。def recall_at_k(正确文档里有几成出现在前 k 个。它和 hit@k 的区别只在多文档问题上:需要两篇,只找到一篇,hit 是 1,recall 是 0.5。return len(expected & set(retrieved[:k])) / len(expected)代入__main__的例子:retrieved[:3]=['kb-011', 'kb-010', 'kb-009'],转集合后和{"kb-010", "kb-003"}求交集 →{'kb-010'},len是 1;除以len(expected)= 2 →0.5。/在 Python 3 里永远是浮点除法,1 / 2就是0.5(整除要写//)。注意expected为空会除以 0,所以eval_retrieval.py先用if not expected: continue跳过了无答案的问题。def reciprocal_rank(第一个正确文档排第几,取倒数。逐行讲解在正文里。if __name__ == "__main__":直接运行这个文件才执行下面的自测,被eval_retrieval.pyimport 时不执行。r = dedup_docs(["kb-011", "kb-011", "kb-010", "kb-009"])模拟一次检索:前两块都是 kb-011 的。实测r = ['kb-011', 'kb-010', 'kb-009']。print(hit_at_k(r, {"kb-010"}, 1), hit_at_k(r, {"kb-010"}, 3)){"kb-010"}是集合字面量(注意空集合要写set(),{}是空字典)。k=1 只看 kb-011,没中 →0.0;k=3 包含 kb-010 →1.0。实测打印0.0 1.0。print(recall_at_k(r, {"kb-010", "kb-003"}, 3))假装这是个需要两篇文档的问题,只找到了 kb-010 →0.5。print(reciprocal_rank(r, {"kb-010"}))kb-010 排第 2 →1/2 = 0.5。
hit_at_k 那一行怎么读? retrieved[:k] 取前 k 个,set(...) 转成集合,expected & set(...) 是集合求交集(两边都有的元素,相当于 TS 的 expected.filter(x => top.has(x)))。1.0 if 交集 else 0.0 是 Python 的三元表达式,相当于 TS 的 交集.size > 0 ? 1.0 : 0.0。空集合在 if 里算假,所以不用写 len(...) > 0。返回 float 而不是 bool,是为了方便对整份评估集求平均。拿上面 __main__ 的例子走一遍:
| k | 前 k 个 | 和 {kb-010} 的交集 | 结果 |
|---|---|---|---|
| 1 | {kb-011} | 空 | 0.0 |
| 3 | {kb-011, kb-010, kb-009} | {kb-010} | 1.0 |
reciprocal_rank 怎么读? enumerate(retrieved, start=1) 在遍历时同时给出序号和元素,相当于 TS 的 retrieved.forEach((d, i) => ...)。start=1 让序号从 1 开始,因为名次从「第 1 名」算起;默认从 0 开始的话,第一名会算出 1/0 直接报错。遇到第一个正确文档就立刻 return 1.0 / rank,后面的不再看;循环走完都没遇到,就返回 0.0。同一个例子走一遍:
| rank | d | 在 expected 里吗 | 动作 |
|---|---|---|---|
| 1 | kb-011 | 否 | 继续 |
| 2 | kb-010 | 是 | 返回 1/2 = 0.5 |
用倒数是为了放大「排得靠前」的差距:第 1 名和第 2 名差 0.5,第 9 名和第 10 名只差 0.01。注意它只看第一个正确文档,问题需要两篇文档时,第二篇排第几它不管,所以多问题类要搭配 recall@k 看。
这几个函数短得可笑,但我建议你每个都手写一遍,别直接调库。面试里「手写一个 recall@k」不少见,更重要的是你得对每个数字的含义有把握,才敢拿它做决定。
建评估集:30 条,五种类型
评估集是今天最有价值的产出,比任何代码都值钱。它的质量决定了你所有后续判断的质量。几条原则:
问题要像真实用户。 用户不会说「请告诉我年付套餐的退款政策」,他们会说「买错了能把钱要回来吗」「Excel 存的 csv 传上去说格式不对」。评估集如果全是文档里的原话改写,分数会虚高,上线后一塌糊涂。最好的来源是真实的客服记录和搜索日志;没有的话,就自己扮演三种用户写:急躁的、不懂行的、描述很绕的。
覆盖不同类型。 我把 CloudLedger 的问题分成五类,每类都对应前几天发现的一个风险点:
| 类型 | 条数 | 考什么 | 例子 |
|---|---|---|---|
| policy | 10 | 基本的政策查询 | 降级以后多出来的账本会被删吗 |
| error_code | 6 | 精确编号匹配(Day 15/18 的短板) | 导入流水报 E1042 |
| colloquial | 6 | 口语和文档措辞差很远 | 老板想看所有账但不能让他改 |
| multi | 4 | 一句话两个问题,需要两篇文档 | 发票抬头写错了,而且我想退款 |
| unanswerable | 4 | 知识库里没有,该说不知道 | 客服电话是多少 |
分类型的意义在于:总分 85% 可能掩盖「口语类只有 30%」这种严重问题。改动之后也要按类型看,防止「错误码类涨了 10 分,口语类跌了 20 分」这种拆东墙补西墙。
一定要有无答案的问题。 这类问题在检索层不算分(没有正确文档),但在生成层专门考模型会不会老实说「不知道」。客服场景里,编一个不存在的电话号码或者说「支持微信支付」,比回答「我不知道」危害大得多。
标注答案要点,而不是标准答案全文。 每个问题标两三个「好回答必须包含的要点」,比如「月付的能退钱吗」的要点是「月付不支持退款」「当前周期结束后停止服务」。比对整段标准答案太死板,措辞不同就判错;比对要点,LLM 裁判容易判,人工复核也快。
# agent-60-lab/w3/make_eval_set.py
"""CloudLedger 帮助中心的第一份 RAG 评估集,30 条,写到 data/eval/rag_eval.jsonl。
字段:
question 用户问题(尽量用真实用户的说法)
expected_docs 能回答这个问题的文章 id;空列表表示帮助中心里没有答案
type policy / error_code / colloquial / multi / unanswerable
answer_points 一个好回答必须包含的要点,给 LLM 裁判对照用
"""
import json
from pathlib import Path
OUT = Path(__file__).resolve().parent.parent / "data" / "eval" / "rag_eval.jsonl"
CASES = [
# policy:直接问政策
("年付专业版用了两个月,想退款可以吗", ["kb-002"], "policy", ["按剩余整月比例退款", "已用月份和当月不退"]),
("月付的能退钱吗", ["kb-002"], "policy", ["月付不支持退款", "当前周期结束后停止服务"]),
("这个月被扣了两次钱", ["kb-002"], "policy", ["重复的那一笔全额退还", "3 个工作日内原路退回"]),
("退款多久到账", ["kb-002"], "policy", ["客服主管审批后原路退回", "支付宝 1–3 个工作日、银行卡 3–7 个工作日"]),
("怎么申请退款", ["kb-003"], "policy", ["设置 → 订阅 → 申请退款", "客服主管 1 个工作日内审批"]),
("已经开了发票还能退款吗", ["kb-003"], "policy", ["先红冲发票", "红冲完成后才能退款"]),
("自动扣款失败了会怎样", ["kb-004"], "policy", ["每 24 小时重试,最多 3 次", "7 天宽限期"]),
("降级以后多出来的账本会被删吗", ["kb-005"], "policy", ["不会删除", "变成只读"]),
("能开增值税专用发票吗", ["kb-006"], "policy", ["企业版或年付专业版可以开专票"]),
("优惠码能和别的优惠一起用吗", ["kb-008"], "policy", ["不能叠加", "每笔订单一个优惠码"]),
# error_code:带错误码
("导入流水报 E1042", ["kb-010"], "error_code", ["列名不匹配或日期格式不是 YYYY-MM-DD", "下载模板,日期列设为文本"]),
("E1043 怎么解决", ["kb-011"], "error_code", ["文件不是 UTF-8", "另存为 CSV UTF-8 或上传 XLSX"]),
("上传发票提示 E2017", ["kb-012"], "error_code", ["图片模糊、分辨率低于 1000 像素或 PDF 加密", "重新拍摄或上传原始 PDF"]),
("付款时出现 E3001", ["kb-013"], "error_code", ["支付渠道拒绝", "换卡或用支付宝"]),
("API 返回 E4029", ["kb-019"], "error_code", ["超过每分钟 60 次限制", "等待一分钟后重试"]),
("连续付款失败被锁了,多久能再付", ["kb-013"], "error_code", ["连续 5 次失败锁定 1 小时"]),
# colloquial:口语化,和文档措辞差得远
("买错了能把钱要回来吗", ["kb-002"], "colloquial", ["年付按剩余整月比例退款,当月不退", "月付不退当期"]),
("Excel 存的 csv 传上去说格式不对,好像是乱码", ["kb-011"], "colloquial", ["编码问题,需要 UTF-8", "另存为 CSV UTF-8"]),
("老板想看所有账但不能让他改", ["kb-015"], "colloquial", ["设为只读成员"]),
("换了手机号,收不到验证码登不上", ["kb-016"], "colloquial", ["旧手机号不能用可以提交人工审核", "2 个工作日"]),
("不想用了,怎么把账号删掉", ["kb-017"], "colloquial", ["设置 → 账号 → 注销账号", "30 天冷静期"]),
("外币的账怎么记", ["kb-020"], "colloquial", ["专业版及以上支持多币种", "汇率每天 8 点更新"]),
# multi:一句话两个问题,需要两篇文章
("发票抬头写错了,而且我想退款", ["kb-007", "kb-003"], "multi", ["30 天内可红冲重开一次", "退款前需先红冲发票"]),
("免费版能建几个账本,一天能导出几次", ["kb-001", "kb-014"], "multi", ["1 个账本", "每天导出 1 次"]),
("扣款失败数据会丢吗,降级后账本还能用吗", ["kb-004", "kb-005"], "multi", ["超出额度的数据保留 90 天", "超出的账本只读"]),
("导入流水支持哪些银行,文件编码有要求吗", ["kb-009", "kb-011"], "multi", ["工行、招行、建行自动识别", "需要 UTF-8 编码"]),
# unanswerable:帮助中心里没有,正确行为是说不知道或转人工
("你们支持微信支付吗", [], "unanswerable", ["没有找到相关说明或建议转人工,不能编造支持微信支付"]),
("有没有手机 App", [], "unanswerable", ["没有找到相关说明或建议转人工"]),
("有学生优惠吗", [], "unanswerable", ["没有找到相关说明或建议转人工"]),
("客服电话是多少", [], "unanswerable", ["没有找到相关说明或建议转人工,不能编造电话号码"]),
]
def main() -> None:
OUT.parent.mkdir(parents=True, exist_ok=True)
with OUT.open("w", encoding="utf-8") as f:
for i, (q, docs, typ, points) in enumerate(CASES, start=1):
row = {"id": f"q{i:02d}", "question": q, "expected_docs": docs, "type": typ, "answer_points": points}
f.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f"写入 {len(CASES)} 条到 {OUT}")
if __name__ == "__main__":
main()
main() 逐行在干什么? 把 30 条 CASES 逐条转成 JSON 写进文件,一行一条。
OUT.parent.mkdir(parents=True, exist_ok=True):先建data/eval/目录。parents=True相当于mkdir -p,上级目录不存在就一起建;exist_ok=True表示目录已存在也不报错,脚本可以反复跑。OUT.open("w", encoding="utf-8"):"w"会清空重写,所以每次都是最新的 30 条,不会越积越多。with保证块结束时自动关闭文件,相当于 TS 在try/finally里close()。for i, (q, docs, typ, points) in enumerate(CASES, start=1):enumerate给出从 1 开始的序号;每条CASES是四元组,括号里直接解包成四个变量,相当于 TS 的const [q, docs, typ, points] = c。f"q{i:02d}":02d表示整数至少 2 位、不够补 0,1 变成q01。补零后按字符串排序q02才会在q10前面。json.dumps(row, ensure_ascii=False):相当于JSON.stringify。ensure_ascii=False让中文原样写入,不加的话「月付」会变成月付。后面+ "\n"就是 JSONL 的「一行一条」。CASES用元组是为了手写方便;写进文件时转成带字段名的 JSON,后面eval_retrieval.py就能按row["question"]取值,比按位置row[0]清楚,以后加字段也不会弄乱顺序。
用 JSONL(一行一个 JSON)存评估集,是业界的习惯:方便追加、方便 git diff 看改了哪条、方便用 jq 筛选。评估集要进 git,和代码一起做版本管理。改了评估集,之前的分数就不可比了,所以每次实验记录里要写「评估集版本」。
30 条够吗?作为第一版够了,它能抓住大问题。但要知道它的局限:30 条里一条的变化就是 3.3 个百分点,两个方案差 3 分可能只是噪音。W5 讲测试集时会讨论样本量;实际项目里,我会从 30 条起步,每发现一个线上 bug 就加一条,几个月后自然长到几百条。
检索评估脚本
为了能一次试好几种切块参数,我写了一个只在内存里建索引的检索器。它和 Day 18 的 retrieve.py 逻辑一样,但不依赖 pgvector:改切块参数时不需要重建数据库。
# agent-60-lab/w3/mem_retriever.py
"""评估专用的内存检索器:给定切块参数,在内存里建向量 + BM25 索引,支持四种模式。
和 retrieve.py 的逻辑一样,但不依赖 pgvector,方便一次试好几种切块大小。
"""
import numpy as np
from bm25_index import BM25Index
from chunking import Chunk, load_chunks
from fusion import rrf
class MemRetriever:
def __init__(self, max_chars: int = 300, overlap: int = 50):
self.chunks: list[Chunk] = load_chunks(max_chars, overlap)
self.by_id = {c.id: c for c in self.chunks}
self.bm25 = BM25Index(self.chunks)
self._vecs: np.ndarray | None = None
self._qcache: dict[str, np.ndarray] = {}
def _vectors(self) -> np.ndarray:
if self._vecs is None: # 只有用到向量检索时才去调 embedding 接口
from embed import embed
self._vecs = embed([c.text for c in self.chunks])
return self._vecs
def _qvec(self, q: str) -> np.ndarray:
if q not in self._qcache:
from embed import embed_one
self._qcache[q] = embed_one(q)
return self._qcache[q]
def vector_ids(self, q: str, n: int) -> list[str]:
scores = self._vectors() @ self._qvec(q)
return [self.chunks[i].id for i in np.argsort(-scores)[:n]]
def bm25_ids(self, q: str, n: int) -> list[str]:
return [cid for cid, _ in self.bm25.search(q, n)]
def search(self, q: str, mode: str, k: int = 10, pool: int = 20) -> list[Chunk]:
if mode == "vector":
ids = self.vector_ids(q, k)
elif mode == "bm25":
ids = self.bm25_ids(q, k)
else:
fused = [cid for cid, _ in rrf([self.vector_ids(q, pool), self.bm25_ids(q, pool)])][:pool]
if mode == "hybrid":
ids = fused[:k]
else: # hybrid+rerank
from rerank import rerank
ranked = rerank(q, [self.by_id[c].text for c in fused], top_n=k)
ids = [fused[i] for i, _ in ranked]
return [self.by_id[c] for c in ids]
两个小设计:embedding 是懒加载的,只跑 BM25 模式时完全不调接口;问题向量有缓存,同一个检索器跑多种模式时,每个问题只 embed 一次。评估脚本会被反复运行,这种省钱省时间的小处理值得做。
MemRetriever 逐段在干什么? 一句话:给它一个问题和一种模式,它返回最相关的 k 个切块。评估脚本拿这个结果去和标准答案比,算分。
__init__:按max_chars/overlap把文档切成块(self.chunks);by_id是「切块 id → 切块」的字典,相当于 TS 的new Map(chunks.map(c => [c.id, c])),后面拿 id 换回原文要用;BM25 索引在本地计算,很便宜,所以创建时直接建好;向量_vecs先放None,问题向量缓存_qcache先放空字典。_vectors():第一次调用时才把所有切块送去 embedding 接口,结果存进self._vecs,以后直接返回。相当于前端带缓存的 getter。返回的是一个 N×D 的矩阵:N 个切块,每个是 D 维向量。_qvec(q):同理,把问题变成一个 D 维向量,按问题文本缓存。注意返回的是self._qcache[q](这一条向量),不是整个字典。vector_ids(q, n):矩阵 @ 向量是矩阵乘法,一次算出每个切块和问题的点积,得到长度 N 的分数数组(向量已归一化,点积就是余弦相似度)。np.argsort(-scores)按分数从高到低给出下标(取负号是因为argsort默认从小到大),取前 n 个,再换成切块 id。bm25_ids(q, n):按关键词打分取前 n 个,只要 id,丢掉分数。search(q, mode, k, pool):四种模式分别是:vector/bm25:各自直接取前 k 个。hybrid:两路各取前pool(20)个,用 RRF 按排名合并(Day 17),取前 k 个。hybrid+rerank:合并后的 20 个候选交给 rerank 模型重新打分(Day 18),取前 k 个。rerank返回的是(候选下标, 分数),所以要用fused[i]换回 id。
为什么 pool 比 k 大?融合和重排都需要候选。每路只取 4 个的话,排在第 6 名的正确答案就没机会被救上来。最后一行把 id 换回 Chunk 对象,评估脚本拿到的就是带原文和来源的切块。
# agent-60-lab/w3/eval_retrieval.py
"""检索评估:对评估集里有答案的问题,算 hit@1、recall@k、MRR,并按问题类型拆开看。
用法:
uv run python w3/eval_retrieval.py --modes bm25,vector,hybrid,hybrid+rerank --k 4 --chunk 300
"""
import argparse
import json
from collections import defaultdict
from pathlib import Path
from mem_retriever import MemRetriever
from metrics import dedup_docs, hit_at_k, recall_at_k, reciprocal_rank
EVAL = Path(__file__).resolve().parent.parent / "data" / "eval" / "rag_eval.jsonl"
def load_eval() -> list[dict]:
return [json.loads(line) for line in EVAL.read_text(encoding="utf-8").splitlines() if line.strip()]
def evaluate(retriever: MemRetriever, mode: str, k: int, rows: list[dict]) -> dict:
per_type: dict[str, list[dict]] = defaultdict(list)
failures = []
for row in rows:
expected = set(row["expected_docs"])
if not expected:
continue # 无答案的问题不算检索指标,留给生成评估
chunks = retriever.search(row["question"], mode, k=max(k, 10))
docs = dedup_docs([c.doc_id for c in chunks])
m = {
"hit@1": hit_at_k(docs, expected, 1),
f"recall@{k}": recall_at_k(docs, expected, k),
"mrr": reciprocal_rank(docs, expected),
}
per_type[row["type"]].append(m)
per_type["ALL"].append(m)
if m[f"recall@{k}"] < 1:
failures.append((row["id"], row["question"], sorted(expected), docs[:k]))
summary = {t: {name: round(sum(x[name] for x in ms) / len(ms), 3) for name in ms[0]} | {"n": len(ms)}
for t, ms in per_type.items()}
return {"summary": summary, "failures": failures}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--modes", default="bm25,vector,hybrid,hybrid+rerank")
ap.add_argument("--k", type=int, default=4)
ap.add_argument("--chunk", type=int, default=300)
ap.add_argument("--overlap", type=int, default=50)
args = ap.parse_args()
rows = load_eval()
retriever = MemRetriever(args.chunk, args.overlap)
print(f"切块 max_chars={args.chunk} overlap={args.overlap},共 {len(retriever.chunks)} 块")
for mode in args.modes.split(","):
result = evaluate(retriever, mode, args.k, rows)
print(f"\n== {mode} ==")
for t, s in sorted(result["summary"].items()):
print(f" {t:<11} {s}")
for qid, q, exp, got in result["failures"]:
print(f" ✗ {qid} {q} 期望 {exp} 实际 {got}")
if __name__ == "__main__":
main()
eval_retrieval.py 逐段在干什么? 一句话:拿评估集里每个有答案的问题去检索,看标准答案的文档排在第几,按问题类型求平均。它只评检索,不评生成。
EVAL = Path(__file__).resolve().parent.parent / ...:resolve()先转成绝对路径,两次.parent从w3/eval_retrieval.py退到仓库根目录,/是Path重载的拼路径运算符。这样从哪个目录运行都能找到文件。load_eval():列表推导式从右往左读:读整个文件 →splitlines()切成行 →if line.strip()跳过空行 →json.loads每行解析成 dict。相当于 TS 的text.split('\n').filter(l => l.trim()).map(l => JSON.parse(l))。per_type = defaultdict(list):访问不存在的 key 时自动建一个空 list,所以后面能直接per_type["新类型"].append(...)。普通 dict 这样写会抛KeyError。expected = set(...):转成集合有两个好处,判断「在不在」是 O(1),后面 metrics 里还要用&求交集。if not expected: continue跳过的是无答案问题(空集合为假),它们没有正确文档可找,留给生成评估。k=max(k, 10):这是最容易忽略的一行。检索返回的是切块,打分按文档算。同一篇文档的几块会挤在前面,去重后文档数比块数少。只取 4 块,去重后可能只剩 2 篇,recall@4就被冤枉地算低了,所以至少多取 10 块。dedup_docs([c.doc_id for c in chunks]):块 → 文档,保持顺序去重(不能用set(),会丢排名)。m = {..., f"recall@{k}": ...}:f-string 相当于 TS 模板字符串,k=4 时 key 就是"recall@4"。per_type[row["type"]].append(m)和per_type["ALL"].append(m):同一份分数记两处,一处按类型,一处算总体。if m[f"recall@{k}"] < 1:漏了任何一篇期望文档就进失败清单。存的是元组(不可变的小记录),sorted(expected)让打印顺序稳定。summary = {...}:这行最绕,拆成普通循环就是:
summary = {}
for t, ms in per_type.items(): # t=类型, ms=该类型下每题的分数字典
avg = {}
for name in ms[0]: # 指标名:hit@1 / recall@4 / mrr
avg[name] = round(sum(x[name] for x in ms) / len(ms), 3)
summary[t] = avg | {"n": len(ms)} # 3.9+ 的字典合并,相当于 {...avg, n}
main():argparse相当于 Node 的 commander,type=int把命令行字符串转成整数。MemRetriever只建一次,四种模式共用,embedding 也只算一次。f"{t:<11}"是左对齐占 11 格,让几行对齐。for qid, q, exp, got in ...是元组解包,相当于 TS 的for (const [qid, q, exp, got] of failures)。if __name__ == "__main__":直接运行才执行main(),被别的文件import时不执行,这样evaluate可以复用。
注意:这个脚本读的是 data/eval/rag_eval.jsonl,要先跑上一节的 make_eval_set.py 生成它,否则会报 FileNotFoundError。
先只跑 BM25,因为它不需要任何 API key,结果完全确定。下面是我在本机实际跑出来的输出:
$ uv run python w3/eval_retrieval.py --modes bm25
切块 max_chars=300 overlap=50,共 42 块
== bm25 ==
ALL {'hit@1': 0.846, 'recall@4': 0.885, 'mrr': 0.856, 'n': 26}
colloquial {'hit@1': 0.333, 'recall@4': 0.5, 'mrr': 0.375, 'n': 6}
error_code {'hit@1': 1.0, 'recall@4': 1.0, 'mrr': 1.0, 'n': 6}
multi {'hit@1': 1.0, 'recall@4': 1.0, 'mrr': 1.0, 'n': 4}
policy {'hit@1': 1.0, 'recall@4': 1.0, 'mrr': 1.0, 'n': 10}
✗ q17 买错了能把钱要回来吗 期望 ['kb-002'] 实际 []
✗ q19 老板想看所有账但不能让他改 期望 ['kb-015'] 实际 ['kb-002', 'kb-008', 'kb-003', 'kb-018']
✗ q22 外币的账怎么记 期望 ['kb-020'] 实际 ['kb-002', 'kb-003']
这组数字很能说明问题。总分 hit@1 是 0.846,看起来还行。但拆开看,错误码类是满分,口语类只有 0.333。「买错了能把钱要回来吗」一个结果都没有,因为分词后没有任何一个词出现在文档里。「外币的账怎么记」,文档里写的是「多币种」,BM25 不知道外币和多币种是一回事。如果只看总分,这个短板就被掩盖了。这正是要按类型拆开看的原因,也印证了 Day 18 的判断:BM25 擅长精确匹配,对口语无能为力。
现在配好 .env,跑全部四种模式,把结果填进下表:
| 模式 | ALL hit@1 | ALL recall@4 | ALL MRR | colloquial hit@1 | error_code hit@1 |
|---|---|---|---|---|---|
| bm25 | 0.846 | 0.885 | 0.856 | 0.333 | 1.0 |
| vector | |||||
| hybrid | |||||
| hybrid+rerank |
我对另外三行的预期是:vector 在 colloquial 上明显好于 bm25,在 error_code 上可能掉分;hybrid 两类都不差;rerank 主要提升 hit@1 和 MRR,对 recall@4 帮助有限(rerank 只能重排已经召回的候选)。你的数字如果和预期不一样,先看失败清单里具体是哪几条,再下结论。
我的实测结果(bge-m3 + bge-reranker-v2-m3,自己跑完再看)
三种模式一起跑大约 23 秒。
| 模式 | ALL hit@1 | ALL recall@4 | ALL MRR | colloquial hit@1 | error_code hit@1 |
|---|---|---|---|---|---|
| bm25 | 0.846 | 0.885 | 0.856 | 0.333 | 1.0 |
| vector | 1.0 | 0.981 | 1.0 | 1.0 | 1.0 |
| hybrid | 0.923 | 0.962 | 0.944 | 0.667 | 1.0 |
| hybrid+rerank | 0.923 | 0.981 | 0.955 | 0.667 | 1.0 |
失败清单:
vector ✗ q26 导入流水支持哪些银行,文件编码有要求吗 期望 ['kb-009', 'kb-011'] 实际 ['kb-009', 'kb-010', 'kb-014', 'kb-020']
hybrid ✗ q19 老板想看所有账但不能让他改 期望 ['kb-015'] 实际 ['kb-002', 'kb-008', 'kb-006', 'kb-004']
hybrid+rerank ✗ q26 导入流水支持哪些银行,文件编码有要求吗 期望 ['kb-009', 'kb-011'] 实际 ['kb-009', 'kb-010', 'kb-014', 'kb-020']
结果和我的预期不一样:纯向量是四种里最好的,混合反而比纯向量差。
- bge-m3 在错误码上没掉分。Day 15 那个反例里,向量把 E1043 排在了 E1042 前面,但那个问题是故意写成和 E1043 文章几乎一样的说法。评估集里的 6 条错误码问题都很短,没有这种干扰,所以没暴露出来。想测出这个短板,就得往评估集里加这类「措辞像 A、问的是 B」的问题,练习 3 正好可以加一条。
- 混合把口语类拉低了。RRF 给两路同样的权重,BM25 在「老板想看所有账但不能让他改」上召回的全是不相关的文章(kb-002、kb-008),和向量的正确结果一平均,正确答案就被挤出了前 4。
- rerank 把 colloquial 的 recall@4 从 0.833 拉回 1.0,但 hit@1 没变。rerank 只能在 RRF 给的 20 个候选里重排,而 q26 的 kb-011 在向量那一路就没排进来。
这就是验收标准里说的「数字告诉我的、和我直觉不一样」的发现。但别马上得出「BM25 没用」的结论:26 条问题、42 块文档太少了,错误码类也只有 6 条。合理的下一步是给 RRF 加权重(比如向量 1.0、BM25 0.5),或者多加 10 条错误码和型号类问题再比。
生成评估:让大模型当裁判
检索评估回答的是「资料对不对」,生成评估回答「回答好不好」。人工逐条看当然最准,但 30 条看一遍要半小时,每改一次 prompt 都看一遍不现实。所以我们让大模型来当裁判(LLM-as-judge),人工只抽查。
我们评三件事,每件事都是二元判断(是/否),而不是 1 到 10 分:
- 忠实(faithful):回答里的每一个事实,都能在检索到的资料里找到依据吗?这一项抓的是幻觉,比如资料说当月不退,回答说按天退;或者资料没提微信支付,回答说支持。
- 完整(complete):回答覆盖了评估集里标注的要点吗?这一项抓的是漏答,比如只说了「可以退款」没说「按剩余整月比例退,当月不退」。
- 拒答(refused):对无答案的问题,回答有没有老实说不知道、并且没编造内容?
为什么用二元判断?因为让模型打 1 到 10 分,它打的分数很不稳定,同一个回答这次 7 分下次 8 分,而且你也说不清 7 分和 8 分的区别。二元判断更稳定、更容易和人工对齐、也更容易追问「为什么判否」。这是 Hamel Husain 等人反复强调的经验,Day 32 会专门讲。
# agent-60-lab/w3/prompts.py
"""RAG 回答用的 prompt。从 rag_numpy.py 挪出来,避免 import 时顺带读索引文件。"""
SYSTEM = """你是云簿 CloudLedger 的客服助手。
只根据 <资料> 里的内容回答用户问题,不要用你自己的常识补充 CloudLedger 的规则。
每一个事实性的句子后面,用方括号标注出处,例如 [kb-002#1]。一句话用到多块资料就都标上。
如果资料里没有足够的信息回答,就直接说「帮助中心里没有找到这个问题的说明,建议转人工客服」,不要猜。
回答用简洁的中文,先给结论,再给条件和步骤。"""
def build_messages(question: str, chunks: list) -> list[dict]:
"""chunks 里的元素要有 id、title、text 三个属性(chunking.Chunk 就满足)。"""
docs = "\n\n".join(f'<doc id="{c.id}" title="{c.title}">\n{c.text}\n</doc>' for c in chunks)
return [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"<资料>\n{docs}\n</资料>\n\n用户问题:{question}"},
]
"""RAG 回答用的 prompt。之前 prompt 写在rag_numpy.py里,eval_generation.py要用它就得import rag_numpy,而 import 会执行那个文件的顶层代码,顺带去读索引文件。单独放进一个只有字符串和函数的模块,谁 import 都没副作用。SYSTEM = """你是云簿三引号字符串可以跨多行,换行原样保留。这里不是 f-string,因为里面没有要替换的变量。只根据 <资料> 里的内容回答第一条规则管「忠实」:模型自己的常识可能和 CloudLedger 的规则不一样(比如它以为退款按天算),必须以资料为准。生成评估里的 faithful 裁判查的就是这一条有没有做到。每一个事实性的句子后面要求标出处[kb-002#1]。实跑 q02「月付的能退钱吗」,回答是不能。月付套餐不支持中途退款;取消订阅后,当期结束时停止服务、不再续费,当期费用不退。[kb-002#2],出处正好是讲月付的那块。如果资料里没有足够的信息回答拒答规则,还给了固定话术。固定话术的好处是好检查:实跑 q27「你们支持微信支付吗」,检索回来 4 块都不相关(多币种、两步验证、月付退款、免费版),回答一字不差就是帮助中心里没有找到这个问题的说明,建议转人工客服。回答用简洁的中文格式要求:结论在前。q17「买错了能把钱要回来吗」答成了「怎么退」,就是没先回答「能不能」(见下面实测结果)。def build_messages(把问题和检索到的块拼成 OpenAI 格式的消息列表。chunks: list没写元素类型,只要求有id、title、text三个属性,这是 Python 的「鸭子类型」,TS 里会写成一个 interface。docs = "\n\n".join(生成器表达式:对每个块生成一段<doc id=".." title="..">原文</doc>,块之间空一行。f-string 外层用单引号,是因为里面要写双引号id="{c.id}"。q02 实跑,检索回kb-002#2, kb-002#1, kb-003#1, kb-002#3四块,拼出来的开头是:<资料><doc id="kb-002#2" title="退款政策">退款政策 > 月付套餐月付套餐不支持中途退款。取消订阅后,当期结束时停止服务,不再续费,当期费用不退。</doc>
整段 user 内容 518 个字符。用 XML 标签包资料,模型更容易分清「哪里是资料、哪里是问题」,id 属性让它能照抄出处。return [两条消息:system 放规则,user 放资料加问题。{"role": "user", "content": f"<资料>资料在前、问题在后。问题放最后,模型读完资料再看到问题,比反过来更不容易漏读。
Day 17 留的那个小重构顺手做了:prompt 单独放进 prompts.py,以后 W4 的工单 Agent 也从这里导入。
# agent-60-lab/w3/eval_generation.py
"""生成评估:跑完整 RAG,再让大模型当裁判,检查三件事:
faithful 回答里的每个事实是否都能在检索到的资料里找到依据
complete 是否覆盖了 answer_points 里的要点
refused 对无答案的问题,是否老实说了不知道/转人工
"""
import argparse
import json
from pathlib import Path
from eval_retrieval import load_eval
from llm import chat
from mem_retriever import MemRetriever
from prompts import build_messages
OUT = Path(__file__).resolve().parent.parent / "data" / "eval" / "generation_results.jsonl"
JUDGE_FAITHFUL = """你是严格的审核员。下面有一段【资料】和一个客服【回答】。
逐句检查回答中的事实性陈述(规则、数字、步骤、时限),判断它们是否都能在资料中找到明确依据。
礼貌用语和「建议转人工」这类话不算事实陈述。
只输出 JSON:{"faithful": true 或 false, "unsupported": ["没有依据的陈述", ...]}"""
JUDGE_COMPLETE = """你是严格的审核员。下面有一个用户【问题】、一份【必须包含的要点】和客服【回答】。
判断回答是否覆盖了每一个要点(意思一致即可,不要求字面相同)。
只输出 JSON:{"complete": true 或 false, "missing": ["缺失的要点", ...]}"""
JUDGE_REFUSED = """下面是用户【问题】和客服【回答】。帮助中心里其实没有这个问题的答案。
判断回答是否老实表示没有找到相关信息或建议转人工,并且没有编造具体的规则、数字或联系方式。
只输出 JSON:{"refused": true 或 false, "reason": "一句话理由"}"""
def judge(system: str, user: str) -> dict:
raw = chat([{"role": "system", "content": system}, {"role": "user", "content": user}],
temperature=0, response_format={"type": "json_object"})
try:
return json.loads(raw)
except json.JSONDecodeError:
return {"error": raw[:200]}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--mode", default="hybrid+rerank")
ap.add_argument("--k", type=int, default=4)
args = ap.parse_args()
retriever = MemRetriever()
OUT.write_text("", encoding="utf-8") # 先清空上一次的结果,下面每跑完一道追加一行
results = []
for row in load_eval():
chunks = retriever.search(row["question"], args.mode, k=args.k)
answer = chat(build_messages(row["question"], chunks), temperature=0)
context = "\n\n".join(c.text for c in chunks)
r = {"id": row["id"], "type": row["type"], "question": row["question"],
"answerable": bool(row["expected_docs"]), "answer": answer}
if r["answerable"]:
r |= judge(JUDGE_FAITHFUL, f"【资料】\n{context}\n\n【回答】\n{answer}")
r |= judge(JUDGE_COMPLETE, f"【问题】{row['question']}\n【必须包含的要点】{row['answer_points']}\n\n【回答】\n{answer}")
else:
r |= judge(JUDGE_REFUSED, f"【问题】{row['question']}\n\n【回答】\n{answer}")
with OUT.open("a", encoding="utf-8") as f:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
results.append(r)
flags = " ".join(f"{k}={r[k]}" for k in ("faithful", "complete", "refused") if k in r)
if "error" in r:
flags += " 裁判出错"
print(f"{r['id']} {flags} {row['question']}")
errors = [r for r in results if "error" in r]
judged = [r for r in results if "error" not in r]
answerable = [r for r in judged if r["answerable"]]
unanswerable = [r for r in judged if not r["answerable"]]
rate = lambda rs, key: sum(bool(r.get(key)) for r in rs) / max(len(rs), 1) # noqa: E731
print(f"\n忠实率 {rate(answerable, 'faithful'):.0%} 完整率 {rate(answerable, 'complete'):.0%} "
f"拒答正确率 {rate(unanswerable, 'refused'):.0%} 明细见 {OUT}")
if errors:
print(f"裁判出错 {len(errors)} 题,没算进上面的比率,去明细里看 error 字段:"
+ " ".join(r["id"] for r in errors))
if __name__ == "__main__":
main()
"""生成评估:和eval_retrieval.py分工:那个只看「资料找对没有」,这个看「回答好不好」。下面拿两道题实跑走一遍:q02「月付的能退钱吗」(有答案)和 q27「你们支持微信支付吗」(无答案)。import argparse解析命令行参数,相当于 Node 的 commander/yargs。from eval_retrieval import load_eval复用检索评估里读 JSONL 的函数。能这样 import,是因为eval_retrieval.py把main()放在了if __name__ == "__main__"下面,import 时不会把检索评估也跑一遍。实测load_eval()返回 30 条,第 2 条是{'id': 'q02', 'question': '月付的能退钱吗', 'expected_docs': ['kb-002'], 'type': 'policy', 'answer_points': ['月付不支持退款', '当前周期结束后停止服务']}。from llm import chat生成回答和当裁判用的是同一个chat()、同一个模型(这里是 deepseek-flash)。正式项目里裁判最好用比被评的模型更强、或者至少不同的模型,自己判自己容易手软。from mem_retriever import MemRetriever上面写的内存检索器,不依赖 pgvector。from prompts import build_messages和线上 RAG 用同一份 prompt。评估用的 prompt 和线上的不一样,分数就没有意义了,这也是要把 prompt 单独抽出来的原因。OUT = Path(__file__)结果写到data/eval/generation_results.jsonl,和评估集放在一起。这个目录是make_eval_set.py建的,没先跑它的话,循环前那句OUT.write_text("")就会报FileNotFoundError,一道题都还没花钱就停下来,这样反而好。JUDGE_FAITHFUL = """忠实裁判。注意它只拿到资料和回答,不给问题:它只管「说的每句话有没有依据」,不管「答没答到点子上」,后者交给完整裁判。礼貌用语和「建议转人工」这一行是踩坑后加的(正文「几点说明」里讲了):不写的话,裁判会把「建议转人工客服」也当成没依据的事实陈述。只输出 JSON:{"faithful"规定输出格式,除了 true/false 还要列出哪些话没依据。unsupported是给人看的:判否时打开明细一眼就知道是哪句编的。JUDGE_COMPLETE = """完整裁判,拿问题、要点和回答。「意思一致即可,不要求字面相同」很重要,否则回答写「月付套餐不支持中途退款」、要点写「月付不支持退款」,字面不同就会被判漏答。JUDGE_REFUSED = """拒答裁判,只用在无答案的 4 道题上。它判两件事:说了不知道,并且没编具体的规则、数字、联系方式。def judge(system: str, user: str) -> dict:三个裁判共用一个函数,区别只在 system prompt。raw = chat([{"role": "system"裁判 prompt 放 system,待判材料放 user。temperature=0, response_format=temperature=0让同一份材料每次判得尽量一样;response_format={"type": "json_object"}是 JSON 模式(Day 6),接口保证返回合法 JSON。前提是 prompt 里出现「JSON」这个词,否则有的厂商会报 400,上面三个 prompt 都写了。return json.loads(raw)JSON 字符串转 dict。q02 的忠实裁判实测返回{'faithful': True, 'unsupported': []}。except json.JSONDecodeError:万一不是合法 JSON(JSON 模式下极少见,但模型输出被截断时会发生),不抛异常,让 30 道题能跑完。return {"error": raw[:200]}只留前 200 个字符,方便排查。合并进 r 之后,这道题就多了一个error键、少了这个裁判的结论。第一版代码是靠「r 里有没有faithful键」来认有答案题的,裁判一出错,这道题就从分母里悄悄消失,打印出来的忠实率看着还挺高。现在的写法是把出错的题单独数出来,见下面errors =那几行。def main() -> None:主流程:每道题「检索 → 生成 → 裁判」,最后汇总三个比率。ap = argparse.ArgumentParser()建解析器。不带参数运行时,args实测是Namespace(mode='hybrid+rerank', k=4)。ap.add_argument("--mode"默认用 Day 19 检索评估里最好的组合之一。想对比可以--mode vector。ap.add_argument("--k"type=int把命令行的字符串"8"转成整数 8,不写的话args.k是字符串,传给search切片会报错。args = ap.parse_args()读取sys.argv并按上面的规则解析,参数名里的--去掉后就是属性名:args.mode、args.k。retriever = MemRetriever()建内存索引,实测切出 42 块,只用了 0.02 秒(BM25 是本地算的;向量是第一次检索时才去调 embedding)。OUT.write_text("", encoding="utf-8")先把上一次的结果文件清空。下面是每道题追加一行,不清空的话,跑第二次文件里就有 60 行。results = []每道题的 dict 在内存里也留一份,最后算比率用。for row in load_eval():30 道题逐个跑。下面以 q02 为例。chunks = retriever.search(q02 实测取回kb-002#2, kb-002#1, kb-003#1, kb-002#3:正确文档 kb-002 的三块都在,排第一的就是讲月付的那块。answer = chat(build_messages(生成回答,temperature=0。q02 这次实跑返回:不能。月付套餐不支持中途退款;取消订阅后,当期结束时停止服务、不再续费,当期费用不退。[kb-002#2]context =把 4 块的原文拼起来给忠实裁判。只要原文,不要<doc>标签,q02 实测 331 个字符。r = {"id": row["id"]先放基本信息,裁判结果后面合并进来。"answerable": bool(row["expected_docs"])有没有标准答案,直接记成一个字段:q02 的expected_docs是['kb-002'],非空列表bool后是 True;q27 是[],是 False。后面分组靠它,不再靠「r 里有没有某个裁判的键」去猜。if r["answerable"]:有答案就判忠实和完整,没答案走 else 判拒答。r |= judge(JUDGE_FAITHFUL|=是 3.9 起的字典原地合并,相当于 TS 的Object.assign(r, judgeResult)。q02 合并后多了faithful: True和unsupported: []。r |= judge(JUDGE_COMPLETEuser 消息开头实测是【问题】月付的能退钱吗换行【必须包含的要点】['月付不支持退款', '当前周期结束后停止服务']。要点列表直接用 Python 的 list 转字符串塞进去,带方括号和引号,模型照样看得懂。q02 判{'complete': True, 'missing': []}。此时 r 的键依次是id, type, question, answerable, answer, faithful, unsupported, complete, missing。else:q27「你们支持微信支付吗」走这里。它检索回来的 4 块是kb-020#1(多币种)、kb-016#1(两步验证)、kb-002#2、kb-001#2,没有一块讲支付方式。r |= judge(JUDGE_REFUSEDq27 的回答是那句固定话术,裁判实测返回{'refused': True, 'reason': '回答明确表示未找到相关信息并建议转人工,未编造具体规则或联系方式。'}。with OUT.open("a", encoding="utf-8") as f:每道题判完立刻追加一行。"a"是 append 模式,写在文件末尾,不覆盖前面的内容("w"才会清空)。with块结束文件就关闭,内容落到磁盘上。我模拟过跑到第 4 道时网络断掉:脚本直接崩了,文件里已经有 q01、q02、q27 三行,前面的钱没白花。第一版是 30 道全跑完才一次性write_text,同样的情况文件里一行都没有。f.write(json.dumps(r, ensure_ascii=False) + "\n")一道题一行 JSON,也就是 JSONL 格式。ensure_ascii=False让中文原样写进去,不会变成\u6708这种转义。results.append(r)内存里也收集一份。flags = " ".join(只挑 r 里实际存在的那几个指标拼成一行。q02 是faithful=True complete=True,q27 只有refused=True。if "error" in r:有裁判出错,行尾加个提示,跑的时候就能看到,不用等最后。模拟 q02 的忠实裁判返回非法 JSON 时,这一行打印成q02 complete=True 裁判出错 月付的能退钱吗。print(f"{r['id']} {flags}实测打印q02 faithful=True complete=True 月付的能退钱吗和q27 refused=True 你们支持微信支付吗。f-string 里取字典值用单引号r['id'],因为外层是双引号。errors = [r for r in results if "error" in r]只要有一个裁判出错,这道题就记进 errors。judged = [r for r in results if "error" not in r]剩下裁判都正常给了结论的题,只用它们算比率。q02 的完整裁判其实是正常的,但忠实那一项缺了,只算一半反而容易误导,所以整道题都先拿出来,等人去看。answerable = [r for r in judged if r["answerable"]]正常判完的有答案题。拿 q02 和 q27 两道来算(都正常):answerable =[q02]。unanswerable =正常判完的无答案题,也就是[q27]。rate = lambda rs, key:匿名函数,相当于 TS 的const rate = (rs, key) => ...。bool(r.get(key))把 True/False/None 变成布尔值,sum一堆布尔值时 True 当 1、False 当 0。max(len(rs), 1)防止列表为空时除以 0。# noqa: E731是告诉 lint 工具「我知道 PEP 8 不推荐把 lambda 赋值给变量,这里是故意的」。代入两道题:rate([q02], 'faithful')= 1/1 = 1.0。忠实率 {rate(:.0%是百分比格式,乘 100、不留小数、加 % 号:1.0打印成100%,0.923打印成92%。全量 30 道的实测结果见下面的 details。if errors:有出错的题才多打一行。模拟 q01、q02、q27、q03 四道题、其中 q02 的忠实裁判出错时,实测汇总是:忠实率 100% 完整率 50% 拒答正确率 100% 明细见 .../generation_results.jsonl裁判出错 1 题,没算进上面的比率,去明细里看 error 字段:q02
有答案的只剩 q01、q03 两道(q03 判了不完整),所以完整率是 1/2。第一版代码在这种情况下打印的比率一模一样,只是没有第二行,你不会知道 q02 被丢掉了。" ".join(r["id"] for r in errors)把出错的题号用空格连起来,方便去明细里搜。join里直接写生成器表达式,不用先套一层列表。if __name__ == "__main__":直接运行才执行main()。
几点说明。
裁判 prompt 里给了明确的判断标准,比如「礼貌用语不算事实陈述」。不写这条,裁判会把「建议您联系客服」也判成「资料里没有依据」。我第一版就是这样,忠实率低得离谱,一看明细全是这种误判。
每个裁判只判一件事。 把忠实、完整、拒答放进一个 prompt 让模型一次判完,省钱,但准确率会下降,出了问题也不好排查。
一定要人工看明细。 跑完打开 generation_results.jsonl,至少抽 10 条看:裁判判「不忠实」的,是回答真编了,还是裁判误判?判「完整」的,是真的完整吗?裁判本身也会错,在你确认它和你的判断大体一致之前,别太信它给的百分比。这件事叫「对齐裁判」,Day 32 详细讲。
成本。 30 条问题,每条 1 次生成 + 1 到 2 次裁判,总共大约 85 次调用。每次输入大约 1,000 到 1,500 token,总输入约 10 万 token,按 DeepSeek 这类模型的价位,一次完整评估几毛钱。价格以你用的厂商当前价格页为准,但量级就是这样:评估是很便宜的,不做评估才贵。
| 配置 | 忠实率 | 完整率 | 拒答正确率 | 备注 |
|---|---|---|---|---|
| hybrid+rerank, k=4 | ||||
| vector, k=4 | ||||
| hybrid+rerank, k=8 | ||||
| 去掉 SYSTEM 里「不知道就说不知道」 |
我的实测结果(deepseek-flash 关思考模式,自己跑完再看)
hybrid+rerank、k=4,30 条跑了 1 分 44 秒:
忠实率 100% 完整率 92% 拒答正确率 100%
两条判成不完整的,刚好一条是检索的问题、一条是生成的问题,正好说明为什么要分两层看:
- q26「导入流水支持哪些银行,文件编码有要求吗」:是检索的问题。 上面检索评估里,它就是 hybrid+rerank 的失败项,kb-011(编码)没进前 4。模型手里没有资料,老实回答了「关于文件编码:帮助中心里没有找到这个问题的说明,建议转人工客服」。所以忠实是 True,完整是 False。这时候去改 prompt 没用,要改检索。
- q17「买错了能把钱要回来吗」:是生成的问题。 检索是对的,kb-002 和 kb-003 都在前 4。但模型只答了退款的操作步骤和到账时间,没说「年付按剩余整月比例退,当月不退」「月付不退当期」这两条最关键的规则。用户其实是在问「能不能退」,模型却回答了「怎么退」。要修这一层,得改 prompt(比如「先回答能不能、有什么条件,再给步骤」)。
忠实率 100% 也别太当真。去 generation_results.jsonl 抽几条看看,裁判是不是把「每 24 小时重试,最多 3 次」这类数字都对照过资料了。
用评估集做一次实验
有了评估集,之前悬而未决的问题都可以用数字回答了。挑一个最实际的:切块多大?
for c in 100 300 800; do
uv run python w3/eval_retrieval.py --modes hybrid --chunk $c --overlap 20
done
这里我要坦白一个情况。我先用 BM25 模式跑了 100、300、800 三档,三档的分数完全一样,块数分别是 45、42、42。原因是 CloudLedger 的文章太短了,大部分小节本来就不到 100 字,切块参数基本没起作用。这不是实验失败,而是一个有用的结论:在这份数据上,切块大小不是瓶颈,别在这上面浪费时间。 真正拉开差距的是检索方式(BM25 在口语类上的短板)。
这正是评估集的价值。如果没有它,你可能会花一下午调切块参数,凭感觉觉得「好像好一点」。有了它,五分钟就知道该把精力放在哪。等 W7 毕业项目把帮助中心扩充到 60 多篇、加入几千字的长文档后,切块大小的影响才会显现,到时候再用同一个脚本测。
做实验时要守几条纪律:
- 一次只改一个变量。 同时改切块和 k,分数变了你不知道是谁的功劳。
- 记实验日志。 日期、评估集版本、改了什么、各项分数、结论。一张表格就够,但必须有。几周后你会感谢自己。
- 看失败清单,不只看分数。 分数从 0.85 涨到 0.88,具体是哪几条变对了?有没有原来对的变错了?
- 小心对评估集过拟合。 反复针对评估集里那几条失败的问题改 prompt,分数会越来越高,但真实效果未必。办法是定期补充新问题,特别是从线上真实反馈里来的。
RAGAS 和其他评估框架
你会在 JD 和面试里听到 RAGAS 这个名字。它是一个开源的 RAG 评估框架,提出了几个流行的指标:faithfulness(忠实度)、answer relevancy(回答相关性)、context precision(上下文精确率)、context recall(上下文召回率)。它的 faithfulness 做法和我们差不多:先把回答拆成一条条陈述,再逐条判断能否从上下文推出。
我的建议是:先像今天这样自己写,把每个指标的含义吃透;然后去看 RAGAS 的指标文档,对照它的定义,你会发现很容易理解。生产上用不用框架,看团队习惯。框架的好处是指标标准、和社区可比;坏处是黑盒,出了奇怪的分数不好排查,而且版本更新时 API 变化很快。面试时能说出「RAGAS 的 faithfulness 是怎么算的」以及「我们为什么自己写/为什么用它」,比单纯说「我们用了 RAGAS」强得多。
动手练习
- 跑
make_eval_set.py,然后eval_retrieval.py --modes bm25,对照我贴的输出,确认完全一致(BM25 是确定性的,数字应该一模一样;如果你改过帮助中心文章或分词词典,会有差异)。 - 配好 API 后跑四种模式,填完检索对比表。找出 hybrid+rerank 仍然失败的问题,逐条分析原因。
- 给评估集加 5 条你自己写的问题,至少包含 1 条无答案问题和 1 条多问题。重新跑,看分数变化。记得这时候评估集版本变了。
- 跑
eval_generation.py,打开结果文件,人工检查 10 条裁判的判断,记录裁判和你意见不一致的次数。 - 删掉 SYSTEM 里「没有就说不知道」那句,只重跑生成评估,看拒答正确率的变化。
验收标准: 检索对比表填完;生成评估跑通并人工抽查过;实验日志里至少有 3 行记录(配置、分数、结论);能讲出一个「数字告诉我的、和我直觉不一样」的发现。
名词解释
| 名词 | 意思 |
|---|---|
| 评估集(eval set) | 一组带标注的测试问题,用来给系统打分 |
| hit@k | 前 k 个结果里是否包含正确文档 |
| recall@k | 正确文档中有多少比例出现在前 k 个结果里 |
| MRR | 平均倒数排名,第一个正确结果排名的倒数的平均值 |
| nDCG | 支持分级相关性、按排名位置做对数折扣的排序指标 |
| 忠实度(faithfulness) | 回答里的事实是否都有资料依据,衡量幻觉 |
| 完整度 | 回答是否覆盖了必须包含的要点 |
| 拒答 | 资料不足时回答「不知道」或转人工 |
| LLM-as-judge | 用大模型给另一个模型的输出打分 |
| JSONL | 每行一个 JSON 对象的文件格式,评估集常用 |
| RAGAS | 开源 RAG 评估框架,提出了 faithfulness、context recall 等指标 |
| 过拟合评估集 | 针对评估集里的具体问题反复调优,分数升高但真实效果不变 |
常见误区
只看最终回答,不分检索和生成。 出了错不知道该改哪一层,改了也不知道是哪一层起的作用。
评估集问题照抄文档原文。 分数会虚高。用户的真实说法和文档措辞差得很远,口语类问题必须有。
没有无答案的问题。 系统最危险的行为(编造)完全没被测到。
只看总分。 总分可能掩盖某一类问题的严重短板,BM25 的口语类只有 0.333 就是例子。
完全相信 LLM 裁判。 裁判会误判,尤其是标准写得含糊的时候。要人工抽查,确认它和人的判断一致。
评估集一成不变。 它应该随着线上发现的问题持续增长。一份半年没更新的评估集,大概率已经不能代表真实流量了。
差 2 分就宣布胜利。 30 条评估集,一条就是 3.3 分。小幅差异要多跑几次、扩大样本再下结论。
学习资料
文档与文章
- Hamel Husain · Your AI Product Needs Evals:讲评估为什么是 AI 产品迭代的核心,W5 会精读,今天先读前半部分建立观念,30 分钟。
- Hamel Husain · Creating a LLM-as-a-Judge That Drives Business Results:二元判断、裁判对齐的方法论,今天裁判 prompt 的设计思路来自这里。
- Jason Liu · Systematically Improving Your RAG:从合成评估数据、分类型分析到迭代改进的完整方法,非常实战,1 小时。
- RAGAS 指标文档 和论文 Ragas: Automated Evaluation of Retrieval Augmented Generation:对照今天自己写的指标看。
- Evidently AI · RAG evaluation guide:一篇把检索和生成指标整理得很全的指南。
B 站视频
其他经典视频
- Evidently AI《6.1 How to evaluate a RAG system: methods and metrics》(YouTube ID
qI2qQfOG0Js,7 分钟):指标的快速梳理。
书籍
- Chip Huyen《AI Engineering》(O’Reilly,2025)第 3 章「Evaluation Methodology」和第 4 章「Evaluate AI Systems」:评估方法论讲得最系统的一本书,今天先读第 3 章里关于 AI as a judge 的部分,W5 再通读,约 2 小时。
- Christopher Manning 等《Introduction to Information Retrieval》免费在线版第 8 章「Evaluation in information retrieval」:precision、recall、MRR、nDCG 的经典定义。
开源项目与代码
- explodinggradients/ragas:RAGAS 源码,看 faithfulness 指标的实现,和今天的裁判 prompt 对比。
- datawhalechina/llm-universe:里面有评估与优化 RAG 的中文章节。
自测
1. 检索结果文档排名是 [kb-003, kb-007, kb-002],标准答案是 {kb-007, kb-002}。求 hit@1、recall@2、MRR。
答案
hit@1 = 0(第一名 kb-003 不在答案里)。recall@2 = 1/2 = 0.5(前两名里只有 kb-007)。MRR:第一个正确结果 kb-007 排第 2,倒数是 0.5。
2. 为什么检索结果要先按文档去重,再算指标?
答案
检索是块级的,标注是文档级的。同一篇文章的多个块排在前面,如果不去重,会挤占 top-k 的名额,也会让 recall 计算混乱。去重时保留每篇文档第一次出现的位置,代表这篇文档的排名。
3. 评估集里为什么要有无答案的问题?它们在检索层怎么处理?
答案
它们测试系统在资料不足时会不会编造,这是客服场景危害最大的错误。检索层没有正确文档,不计入 hit/recall/MRR;在生成层专门用「拒答」裁判判断回答是否老实说不知道、有没有编造。
4. BM25 的总 hit@1 是 0.846,但口语类只有 0.333。这说明了什么?你会怎么做?
答案
说明 BM25 擅长精确词匹配,对和文档措辞不同的口语问题无能为力,总分掩盖了这个短板。应该引入向量检索做混合,或者加 query 改写把口语转成文档用语;改完按类型重新评估,确认口语类上升的同时错误码类没有下降。
5. 为什么 LLM 裁判用「是/否」判断,而不是 1–10 分?
答案
1–10 分的打分不稳定,同一个回答多次打分会波动,而且分数之间的界限说不清;二元判断标准更清晰,更稳定,更容易和人工判断对齐和核对,也更容易从「判否」的理由里找到可操作的问题。
6.(面试题)你们的 RAG 系统上线前,老板问「准确率有多少」,你怎么回答?怎么得出这个数字?
答案
不给一个笼统的数字,而是分层分类型回答:检索层在 N 条标注问题上 recall@4 是多少、MRR 是多少;生成层忠实率、完整率多少,无答案问题的正确拒答率多少;按问题类型(政策、错误码、口语、多问题)分别是多少,哪一类最弱、计划怎么补。说明评估集的构成和规模(来源、条数、版本)、裁判经过人工抽查的一致率。再说上线后的计划:收集线上反馈和点踩案例补充评估集,定期回归。体现的是「数字有来源、有拆解、有局限说明」。
明天预告
Day 20 · 上下文工程与记忆:窗口预算、压缩、长期记忆、Agentic RAG。到今天,RAG 还是一个「每次都检索」的固定流程。明天把视角拉高到整个 Agent:上下文窗口里到底该放什么,对话越来越长怎么压缩,用户上次说过的事怎么记住,以及把「检索」做成一个工具,让 Agent 自己决定什么时候查、查几次。
评论