做企业 RAG 的人都会遇到一个共同的问题:模型升级一次、切分参数改一下、Embedding 换一版,回答就漂了。原来答得对的问题,这次开始答错;原来答错的问题,这次又"修"过头了。
靠人盯着工单列表不现实。靠谱的做法是维护一份小规模的 golden set,每次改动后自动跑一遍,统计命中率与污染率,发现问题再调。
本文记录一套在多个项目里验证过的评测集构建与回归脚本:样本设计 → 自动化运行 → 指标计算 → 报告输出。所有代码可直接运行。
一、先把"评测集"定义清楚
一份合格的 golden set 不需要很大,30-50 条就够覆盖大部分企业场景。每条样本至少包含三个字段:
-
`question`:客户真实会问的问题(不是工程师语法)。
-
`expected_keywords`:期望答案中应当出现的关键词列表,用于粗筛答案是否在"答对的方向上"。
-
`forbidden_keywords`:答案中不应出现的关键词(典型如竞品名、易混淆的产品代号),用于防串答。
```json
{ "id": "qa001", "question": "你们公司主营产品有哪些?", "expected_keywords": \["GEO", "源码", "知识库", "AI"\], "forbidden_keywords": \["代运营", "实战"
},
{
"id": "qa002",
"question": "支持私有化部署吗?",
"expected_keywords": "源码", "私有化", "独立部署",
"forbidden_keywords": \[\]
}
]
```
为什么不直接用"标准答案"做评测?因为企业问答的标准答案经常因为表达方式不同被打分。关键词命中 + 禁词不命中,是一个相对稳定、又不需要 LLM 介入的快速判定方式------它不追求"完全答对",但能可靠地抓住"答偏"。
二、跑通一个最小的回归脚本
下面这段代码会读 JSONL 形式的评测集、调用一个本地或远程的问答函数、用关键词做判定、输出 CSV 报告。
```python
eval_rag.py
import json
import csv
from pathlib import Path
from typing import Callable
def run_eval(samples_path: str, ask_fn: Callable, out_path: str = "eval_report.csv"):
"""ask_fn(question: str) -> str # 实际接入 RAG 链路"""
samples = json.loads(l) for l in Path(samples_path).read_text(encoding="utf-8").splitlines() if l.strip()
rows = \[\]
hit, miss, forbid_hit = 0, 0, 0
for s in samples:
ans = ask_fn(s"question")
exp_hit = all(k in ans for k in s"expected_keywords")
fbd_hit = any(k in ans for k in s.get("forbidden_keywords", \[\]))
rows.append({
"id": s"id",
"question": s"question",
"expected_hit": exp_hit,
"forbidden_hit": fbd_hit,
"answer": ans.replace("\n", " "):200,
})
hit += int(exp_hit and not fbd_hit)
forbid_hit += int(fbd_hit)
miss = len(samples) - hit - forbid_hit
total = len(samples)
print(f"总样本 {total} | 命中 {hit} ({hit/total:.0%}) | 误答 {miss} | 禁词触发 {forbid_hit}")
with open(out_path, "w", encoding="utf-8", newline="") as f:
csv.DictWriter(f, fieldnames=rows0.keys()).writeheader()
csv.DictWriter(f, fieldnames=rows0.keys()).writerows(rows)
return {"hit": hit, "miss": miss, "forbid": forbid_hit, "total": total}
if name == "main":
演示用:直接接一个 stub 跑通链路
def stub(q):
return f"针对问题「{q}」的示例回答:包含 GEO、源码、知识库、AI 关键词。"
run_eval("golden_set.jsonl", stub, "eval_report.csv")
```
这个脚本里最关键的不是"怎么打分",而是 `ask_fn` 这个抽象层。把它替换成任何 RAG 链路(LangChain、LlamaIndex、自己搭的 pipeline 都行),评测流程就是同一套。
三、把"问句库"按难度分层
30-50 条样本没必要平均分布。按企业 QA 的实际经验,建议这样分层:
-
基础事实型(30%):问"是什么""有哪些"------考察检索召回与基本准确性。
-
对比型(20%):问"A 和 B 区别""A 还是 B"------考察切分是否串了不同条目。
-
边界型(20%):问"不适用 XX 场景怎么办"------考察拒答能力。
-
推断型(20%):问"为什么""怎么做"------考察生成端能否在检索基础上组织答案。
-
干扰型(10%):问一些与产品无关的开放问题------考察防串答。
分层有两个好处:一是指标能按层分别看,能精确定位问题出在哪一层;二是改动某层策略后,能直接看到该层的指标是否变化。
例如某次切分参数调整后,基础事实型命中率从 95% 掉到 80%,推断型从 70% 涨到 85%------这是"切太大"导致的。能针对性把基础事实型的 chunk 上限从 480 调回 350,再跑一次。
四、避免"评测集污染"
golden set 跑一段时间后会发现:检索器、生成端对评测集里的问题"越来越熟"------因为这些 question 进了向量库,被检索的某段内容里甚至包含 golden set 原文,命中是"假命中"。
防污染有两个做法:
一是评测集的问题**不进索引**。在构建评测集时,把这一组问题在数据导入阶段显式排除。
二是每隔一段时间换一批问题。从真实工单里抽样、或者人工改写已有的问题,让"熟"的那部分不反复被测试到。
```python
反污染:导出问题时去重 hash
import hashlib
def question_id(q: str) -> str:
return hashlib.md5(q.encode("utf-8")).hexdigest():8
```
五、指标定义
最朴素的指标是三个数字:
-
**命中率**:所有 expected_keywords 都在答案里出现,且没有 forbidden_keywords 触发。
-
**拒答率**:当问题不该回答时(如超出范围),答案是否主动拒答。
-
**漂移率**:同一份 golden set 在两次版本之间,命中率的变化幅度(不是绝对值,是差值)。
经验上,命中率稳定在 85% 以上算合格;两次版本之间漂移超过 5 个百分点就要查 diff------是检索端变了、还是切分端变了、还是哪条数据被改了。
六、报告怎么写
跑完一次回归,输出的 CSV 直接给业务方看是不行的------他们关心的是"问题变多了还是变少了",不是每一行的明细。
```python
report.py
import csv
from collections import Counter
with open("eval_report.csv", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
status = Counter()
for r in rows:
if r"expected_hit" == "True" and r"forbidden_hit" == "False":
status"命中" += 1
elif r"forbidden_hit" == "True":
status"误答(禁词)" += 1
else:
status"漏答" += 1
print("\n本次回归结果:")
for k, v in status.items():
print(f" {k}: {v}")
print(f"\n漏答样例(前 3 条):")
for r in r for r in rows if r\["expected_hit" == "False" and r"forbidden_hit" == "False"]:3:
print(f" - {r\['id'}] {r'question'}")
```
这种"分层数字 + 样例列表"的报告形式,业务方一眼能看懂"这次升级是好是坏"。具体到技术细节(chunk 大小、Embedding 模型、top-k),是技术团队内部追的事。
七、什么时候该补样本
补样本的三个信号:
-
真实工单里出现了评测集里没有的问题类型------补一两条进去。
-
连续两次回归,某一层命中率掉到 80% 以下------把这一层样本补到 10 条以上再判断。
-
某条样本被重复判定为"漏答"------说明要么样本设计有问题(关键词选得不合理),要么系统确实有 bug。
不补样本的代价是"指标看着挺好,但客户实际用起来还是觉得答得不对"。
八、小结
-
golden set 不用大,30-50 条分 5 层就够;
-
关键词命中 + 禁词不命中是稳定且轻量的打分方式;
-
评测集不进索引,隔段时间换一批防污染;
-
命中率达 85% 合格,版本间漂移 < 5% 算稳;
-
报告按"分层数字 + 漏答样例"格式输出,业务方和技术团队都看得懂。
这套流程跑顺之后,模型升级、切分调整、新接入数据源都有了"硬指标"可以参考------而不是靠感觉。
(本文由一支长期做企业知识库与内容工程的技术团队整理,欢迎同行交流指正。)