长文本模型这一年的宣传口径基本都绕不开“上下文窗口有多大”,128K、256K、1M,数字一路往上堆。但真正在做线上业务的人心里都清楚,能把一百万 token 塞进窗口,和能在一百万 token 里准确地找到那句话、并且老老实实告诉你“我是从第几段第几行看来的”,完全是两码事。L-CiteEval 这类评测思路要拷问的就是后面这件事:模型读完之后,说的话有没有出处,出处对不对得上,对不对得全。这篇内容我打算把这类评测从头拆一遍,从前期的语料构造、任务设计、打分机制,到我自己动手搭流水线时的脚本和参数,再到踩过的那些坑。适合正在做长文档问答、合同审阅、研报分析、代码库理解这类业务的朋友,也适合刚接触评测、想知道“除了一直刷排行榜还能怎么测”的同学。不需要你是评测专家,能看懂 Python 和基本的指标概念就够。
1. 为什么“长上下文”不等于“长文本能力”:L-CiteEval 要解决的问题
我最早接触长文本评测,用的还是“大海捞针”那一套:在一篇超长文档中间随机塞一句特定的话,然后问模型这句话是什么。这个测试很有用,但它测的东西非常窄——它测的是检索到没检索到,本质上是个信息定位题,答案只有一个短字符串,模型只要“看见”了就能答对,看不见就答错。问题是现实业务里的长文本任务,几乎没有一个长得像“大海捞针”。
举个我自己碰到的场景:一份八十多页的技术协议,用户问“这份协议里关于数据留存期限的约定,和我们附件里那套 SLA 有没有冲突”。这个问题的正确答案不是一句话,而是“第 12 页第 3 段说了 A,第 47 页附件二第 2 条说了 B,两者存在冲突,冲突点在于……”。模型答得漂不漂亮是次要的,重要的是它能不能把 A 和 B 都点出来。如果它只说“存在冲突”却指错了段落,那这个答案在业务上是不可用的——因为下游要么得人工复核,要么就得把错误结论传下去。
这就是 L-CiteEval 这一类基准想填的空白。它把评测对象从“模型答对没答对”推进到“模型答对没答对,且引用准不准、全不全”。我更愿意把它理解成一套引用能力评测范式,而不是某个固定题库,因为它的核心价值在于任务结构和打分逻辑,题库本身是可以按业务替换的。
1.1 从“大海捞针”到“言之有据”的评测断层
大海捞针之所以流行,是因为它便宜、自动、可复现。但它有三个致命缺陷,我在实际使用中全踩过。
第一个缺陷是答案唯一且极短。模型只要吐出那个魔法字符串就算赢,中间过程完全不可观测。你不知道它是真读到了,还是靠上下文里的重复模式蒙的。
第二个缺陷是没有干扰项密度。真实长文档里,和问题相关但不构成答案的段落非常多。一份合同里可能七八处提到“数据”,但只有一处是“留存期限”。大海捞针的针是孤立的,周围是无关的干草,而现实里周围全是长得像针的草。
第三个缺陷是不惩罚虚构。模型如果没找到针,编了一个数字出来,在精确匹配的打分下就是 0 分,这没错;但如果它答对了针,同时顺带编了三处不存在的引用,分数依然是满分。而在实际业务里,一处编造的引用足以让整份输出作废。
L-CiteEval 这类设计要解决的正是这个断层:把“答对”和“引对”拆成两个可独立观测的维度,再加上“该引的都引了没有”这个召回维度。三个维度一交叉,模型的真实水平就藏不住了。我见过不少模型单看答案准确率能到 80% 以上,一上引用精确率直接掉到 40% 出头,这个落差才是它能不能上生产线的真实信号。
1.2 引用能力的三层拆解:定位、抽取、归因
把“引用”当成一个笼统的能力去测,最后只会得到一个说不清道不明的分数。我在设计自己的评测集时,习惯把它拆成三层,每一层的失败模式完全不同,修法也完全不同。
第一层是定位(Localization)。模型得在成百上千个段落里,把相关的那几段圈出来。这一层失败的表现是“引用了完全不相关的段落”,比如问留存期限,它引了一段讲服务可用性的。这类错误通常和检索质量、注意力分布有关,长文档里位置偏后的内容被忽略是最常见的形态。
第二层是抽取(Extraction)。定位对了,但从段落里提取的信息是错的。比如段落写的是“留存期限不少于 3 年,特殊场景可延长至 5 年”,模型答成“留存期限 5 年”,把可选项当成了默认项。这一层失败往往不是找不到,而是读得不够细,或者被段落里更醒目的数字带跑偏了。
第三层是归因(Attribution)。前两层都对,但引用和结论挂错了。模型说“根据第 30 段,留存期限为 3 年”,而 3 年这个信息其实在第 12 段,第 30 段讲的是别的事。这类错误最隐蔽,因为答案是对的、引用看起来也合理,只有你真的去翻第 30 段才会发现对不上。人工抽检时这一层最容易漏。
在这三层之外,我还习惯加一个拒答层:当证据根本不存在时,模型会不会老实说“文档里没有相关约定”,还是会硬编一个出来。这一层在实际业务中的权重非常高,因为大部分长文档问答的失败不是答错,而是不该答的时候答了。
三层加上拒答,构成了一个 2×2 的失败模式矩阵:定位对/错 × 归因对/错。这个矩阵比单一准确率有用得多,后面第 5 节我会具体讲怎么读这个矩阵。
1.3 这套评测适合谁,不适合谁
先说适合的。做 RAG 系统的团队最该用,因为 RAG 的整个价值主张就是“答案可溯源”,如果评测里根本没有溯源维度,那你优化的是个假目标。做合规审阅、法律文书比对、财务报告分析、医疗文献综述的团队同样适合,这些场景里“出处错了”比“答案错了”后果更严重。做长上下文模型训练的同学也应该看,因为训练时的 loss 曲线完全反映不出引用能力,必须靠外部评测。
不适合的场景也说清楚。如果你的任务是创意写作、摘要生成、风格改写,那引用能力不是核心指标,强行套这套评测只会给自己添堵。如果你的文档长度普遍在 8K token 以内,那普通的小规模评测就够了,上这套框架属于杀鸡用牛刀,构造成本反而更高。
还有一个容易被忽略的适配问题:你的文档有没有稳定的段落边界。表格密集的财报、扫描件转出来的合同、代码文件,段落切分本身就很难,引用粒度会变得非常模糊。这类文档要先解决结构化问题,再谈引用评测,顺序不能反。
2. 评测体系怎么搭:L-CiteEval 的设计思路拆解
我见过不少团队一上来就开始写 prompt、跑模型,跑完发现分数诡异,回头一看是数据构造就有问题。评测体系和被测模型一样,垃圾进垃圾出。这一节我把构造时最关键的几个决策点摊开讲,每个决策背后都有明确的取舍理由。
2.1 语料构造:长度分布、段落切分与编号体系
语料的第一原则是长度必须分层。只测 128K 一种长度是没意义的,因为你分不清模型是“长文本能力差”还是“短文本能力也差”。我通常分三档:
| 档位 | 目标 token 数 | 中文约合字符数 | 主要观测点 |
|---|---|---|---|
| 短 | 8K–16K | 1.2 万–2.4 万 | 基线能力,确认模型本身没问题 |
| 中 | 32K–64K | 4.8 万–9.6 万 | 主流业务文档区间,核心对比档 |
| 长 | 96K–128K | 14.4 万–19.2 万 | 接近窗口上限,观测衰减曲线 |
这里的换算按中文约 1 token ≈ 1.5 个汉字估算,实际会因分词器不同上下浮动 15% 左右,建议在构造脚本里用真实分词器实测一遍再定档,别直接照抄。我吃过这个亏:按 1.5 估出来的“128K 文档”实际只有 110K token,长档测出来偏乐观。
第二原则是段落粒度要统一。我的经验值是每段 300–500 个汉字,理由是:太短(100 字以内)会让证据过于显眼,模型很容易靠关键词命中,分数虚高;太长(1000 字以上)会让引用粒度变得模糊,你没法判断模型引的是“这一段里的哪句话”,打分只能放宽,一放宽就掩盖了归因错误。300–500 字这个区间,恰好是一段完整的论述或一条完整的条款,人为阅读时也是一个自然的语义单元。
第三原则是必须建立稳定的段落编号体系。这一点极其重要,却被很多自建评测忽略。我一开始让模型直接复制原文片段作为引用,结果解析成本高得离谱——模型会改标点、会省略、会加“……”、会中英混排,你写十个正则都覆盖不全。后来全部改成让模型输出段落 ID,形如P0037,解析成功率从 70% 出头直接拉到 98% 以上。编号规则建议用P+ 四位定长数字,定长是为了方便正则匹配和排序,别用P37这种不定长的,排序时会乱。
2.2 任务设计:单跳、多跳与“必须拒答”的负样本
任务类型我建议至少铺四类,覆盖不同的失败模式。
单跳定位题:答案在单一指定段落内。这是最基础的一层,用来确认模型在长文档里的基本检索能力。构造时把证据段落放在不同深度位置(我一般取 5%、25%、50%、75%、95% 五档),同一道题在不同位置各出一份,这样能直接画出位置偏差曲线。
多跳交叉题:答案需要两到三个段落联合推导,且这些段落之间距离要足够远。这里有个参数很关键:证据段落之间的最小间隔。我设的是至少相隔 5 个段落,实测下来如果两段紧挨着,模型会顺手把整块区域都引上,看起来召回很高,其实是“一引引一片”蒙对的。拉开距离之后,模型必须真的做两次独立定位。
干扰增强题:在证据段落附近插入高度相似的干扰段落,比如把“留存期限不少于 3 年”改成“备份保留期限不少于 3 年”,只改一个限定词。这类题最能暴露抽取层的粗粒度问题。
必须拒答题:问题在文档里根本没有答案,但话题高度相关。这道题的正解是明确说“文档中未提及”。构造时要注意,不能让文档里存在任何边缘相关的表述,否则模型拒答也可能是蒙的。我一般会人工核一遍,确保相关关键词在文档里的实际出现次数为零。
2.3 打分机制:精确率、召回率与“看似合理”的陷阱
打分是整个体系里最容易做歪的地方。我的做法是把分数拆成三个正交指标,再加一个拒答准确率。
引用精确率(Citation Precision)= 引用正确的段落数 / 引用总段落数。这个指标惩罚“乱引”,模型引了五段只有两段对,精确率就是 0.4。
引用召回率(Citation Recall)= 命中的必要证据段落数 / 应有证据段落总数。这个指标惩罚“漏引”,该引三段只引了一段,召回率 0.33。
答案准确率(Answer Accuracy):这个不能只做字符串匹配,要做语义判定。我的做法是双轨——先用规则匹配关键实体和数字(数字必须完全一致,这是硬约束),再用一个判定模型做语义等价判断。
拒答准确率:负样本上答“未提及”的比例,正样本上误拒答的比例也要统计,两个方向都要看。
匹配环节有个大坑必须讲:“看似合理”的陷阱。模型引用的段落和结论在语义上相关,但并不构成支持关系。比如结论是“留存期限为 3 年”,它引的段落是“本协议涉及的数据留存相关条款见附件二”,这段确实相关,但完全不支持结论。如果你只用相似度(比如向量余弦或 ROUGE)来判定引用是否正确,这类错误会被判成对的,因为它和问题、和答案的相似度都很高。
我的处理是加一层蕴含判定:把「引用段落原文 + 模型结论」拼起来,让判定模型判断“该段落是否支持该结论”,输出支持/不支持/部分支持三档。这一步会让评测成本上升约 30%(多一次模型调用),但精确率指标的可信度提升非常明显。我对比过,加判定前后同一批模型输出的精确率差距最大能到 18 个百分点,被高估的全是这类“引用沾边但不支持”的情况。
3. 动手实操:搭一套自己的长文本引用评测流水线
理论讲完,直接上能跑的东西。这套流水线我自己用了大半年,跑过十几份不同来源的长文档,成熟度还行。整体分四步:环境准备、数据构造、推理与解析、指标计算。
3.1 环境与依赖准备
依赖不多,都是常规件,避免引入过重的东西。
python -m venv cite_eval source cite_eval/bin/activate pip install "transformers>=4.40" \ "torch>=2.1" \ "pandas>=2.0" \ "numpy>=1.24" \ "tqdm" \ "rouge-score" \ "jieba" \ "sentencepiece" \ "openai" # 仅当你用 API 方式调被测模型目录结构我习惯这样摆,后面脚本都按这个路径写:
cite_eval/ ├── data/ │ ├── raw/ # 原始长文档 │ ├── segments/ # 切分后的段落 + ID │ └── tasks/ # 构造好的题目集 ├── scripts/ │ ├── build_segments.py │ ├── build_tasks.py │ ├── run_infer.py │ └── compute_metrics.py └── results/有几条实操心得值得先说。第一,分词器要和你被测模型对齐,用别的模型的分词器估 token 数会偏,长文档上偏差会累积。第二,rouge-score只用来做粗筛和人工抽检时的辅助,不要拿它当最终判定。第三,如果你打算跑多个模型对比,务必把温度固定成 0,或者固定采样次数取多数投票,否则同一模型两次跑出来的分数能差 3 个点以上,你会误以为是模型更新了。
3.2 数据构造:切段、编号与题目生成
切段这步看似简单,实际要处理的东西不少。中文标点、表格残留、页眉页脚、目录页,全得清掉。
# scripts/build_segments.py import re, json, hashlib from pathlib import Path SEG_MIN, SEG_MAX = 300, 500 # 目标段落字符数 def clean(text: str) -> str: text = re.sub(r"\n{3,}", "\n\n", text) text = re.sub(r"^\s*第\s*\d+\s*页.*$", "", text, flags=re.M) # 去页眉 text = re.sub(r"[ \t]{2,}", " ", text) return text.strip() def split_segments(text: str): """按空行/标题切粗块,再按句号合并到目标长度区间""" blocks = [b.strip() for b in re.split(r"\n\s*\n", text) if b.strip()] segs, buf = [], "" for b in blocks: if len(buf) + len(b) <= SEG_MAX: buf = (buf + "\n" + b).strip() else: if buf: segs.append(buf) buf = b if buf: segs.append(buf) # 过长的块按句子二次切 out = [] for s in segs: if len(s) <= SEG_MAX: out.append(s); continue sents = re.split(r"(?<=[。!?;])", s) cur = "" for sent in sents: if len(cur) + len(sent) > SEG_MAX and len(cur) >= SEG_MIN: out.append(cur); cur = sent else: cur += sent if cur: out.append(cur) return [s for s in out if len(s) >= 80] def build(doc_path: Path, out_dir: Path): raw = clean(doc_path.read_text(encoding="utf-8")) segs = split_segments(raw) records = [] for i, s in enumerate(segs, start=1): sid = f"P{i:04d}" records.append({ "seg_id": sid, "order": i, "text": s, "char_len": len(s), "hash": hashlib.md5(s.encode()).hexdigest()[:12], }) out_dir.mkdir(parents=True, exist_ok=True) (out_dir / f"{doc_path.stem}.json").write_text( json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8" ) print(f"{doc_path.stem}: {len(records)} segments, " f"total {sum(r['char_len'] for r in records)} chars")这段脚本有两个细节值得说明。hash字段是为了后面查重用的,同一份文档不同来源可能有重复段落,重复段落会让召回率虚高。order字段是位置基准,位置偏差分析全靠它。
跑完之后,每份文档的段落数大概是这样的量级:10 万字符的文档,平均 400 字一段,大约 250 段。这个数量级很关键——250 个候选段落,模型要在里面精确挑出 1 到 3 个,随机命中概率不到 1.2%,所以一旦精确率超过 20%,说明它确实在做定位,不是瞎猜。
题目构造我半自动做:先用一个小模型从每个段落里抽候选问题,再由人筛。筛选标准有三条:问题必须能由该段落直接回答;问题不能包含段落里的原文关键词(防止关键词匹配作弊);同一份文档里该问题的答案必须唯一。
多跳题目的构造要多一步距离约束:
# 多跳题:强制两个证据段落的 order 差值 >= 5 def pick_multihop(segments, seed_pairs): valid = [] for a, b in seed_pairs: if abs(segments[a]["order"] - segments[b]["order"]) >= 5: valid.append((segments[a], segments[b])) return valid这个 5 的门槛我试过 2、5、10 三档。设成 2 的时候,多跳题和单跳题的分数差只有 4 个点,区分不出来;设成 10 的时候,段落太少的长文档凑不够题量。5 是比较平衡的位置。
3.3 推理与解析:结构化输出是成败关键
prompt 这块我改过七八版,最后稳定的形态是这样的:把段落按[P0001] 正文……的格式顺序拼进去,然后在末尾给出输出格式要求。
你将看到一份由若干编号段落组成的文档。请仅依据文档内容回答问题。 文档: [P0001] …… [P0002] …… (此处省略若干段) 问题:{question} 输出要求: 1. 先给出答案,尽量简短、要点化。 2. 再给出你实际使用到的段落编号,放在 citations 数组中。 3. 如果文档中没有足够信息回答,answer 写"文档中未提及",citations 给空数组。 4. 只输出 JSON,不要任何额外说明。 输出格式: {"answer": "...", "citations": ["P0012", "P0037"]}几个设计意图说清楚。把“未提及”的写法固定成一句话,是为了后面拒答判定能直接匹配,避免模型花样表达导致误判。要求 citations 里放 ID 而不是原文,前面讲过,这是解析成功率的关键。要求“仅依据文档内容”,是为了减少模型调用自身知识导致的假阳性——这一点在事实类问答上尤其重要,模型可能凭记忆答对但引用完全错位。
解析端必须做容错,因为再规范的 prompt 也有 2% 左右的模型不听话:
import json, re def parse_output(text: str, valid_ids: set) -> dict: fallback = {"answer": "", "citations": [], "parse_ok": False} # 1) 直接尝试 try: obj = json.loads(text.strip()) return _sanitize(obj, valid_ids) except Exception: pass # 2) 抠第一个 JSON 对象 m = re.search(r"\{.*\}", text, flags=re.S) if m: try: obj = json.loads(m.group(0)) return _sanitize(obj, valid_ids) except Exception: pass # 3) 兜底:正则抓段落 ID ids = re.findall(r"P\d{4}", text) return {"answer": text.strip()[:500], "citations": [i for i in dict.fromkeys(ids) if i in valid_ids], "parse_ok": False} def _sanitize(obj, valid_ids): ans = str(obj.get("answer", "")).strip() cits = obj.get("citations", []) if isinstance(cits, str): cits = re.findall(r"P\d{4}", cits) cits = [c for c in dict.fromkeys(cits) if c in valid_ids] # 去重 + 过滤幻觉 ID return {"answer": ans, "citations": cits, "parse_ok": True}这里valid_ids的过滤动作不能省。模型编造不存在 ID 的比例,我在不同模型上测到过 1% 到 7% 不等,不过滤的话,这些幻觉 ID 会直接进精确率的分母,把分数拉低,掩盖了真正的问题。
推理这块还有个参数要定:跑几次。我的建议是关键对比实验至少跑 3 次取平均,因为长文本任务的方差比短任务大不少,单次结果的偶然性很明显。温度设 0 的话跑 1 次也行,但要注意有些模型的温度 0 并非完全确定,实测两次之间仍有 1% 到 2% 的输出差异。
3.4 指标计算与结果落库
计算部分逻辑不复杂,但要细心处理边界。
# scripts/compute_metrics.py import re from collections import defaultdict def norm_num(s): return re.findall(r"\d+(?:\.\d+)?%?", s or "") def answer_correct(pred, gold, judge_fn=None): """数字硬匹配 + 语义判定双轨""" p_nums, g_nums = norm_num(pred), norm_num(gold) if sorted(p_nums) != sorted(g_nums): return False if judge_fn: return judge_fn(pred, gold) # 返回 bool return pred.strip() == gold.strip() def cite_prf(pred_cites, gold_cites): p, g = set(pred_cites), set(gold_cites) if not g: return None # 拒答题单独统计 hit = len(p & g) prec = hit / len(p) if p else 0.0 rec = hit / len(g) f1 = 0.0 if prec + rec == 0 else 2 * prec * rec / (prec + rec) return prec, rec, f1 def run(records): agg = defaultdict(list) for r in records: if r["is_negative"]: agg["refuse_acc"].append(1 if "未提及" in r["pred_answer"] else 0) continue agg["answer_acc"].append( 1 if answer_correct(r["pred_answer"], r["gold_answer"], r.get("judge")) else 0 ) res = cite_prf(r["pred_citations"], r["gold_citations"]) if res: agg["cite_p"].append(res[0]) agg["cite_r"].append(res[1]) agg["cite_f1"].append(res[2]) agg["over_cite"].append(max(0, len(r["pred_citations"]) - len(r["gold_citations"]))) return {k: round(sum(v) / len(v), 4) for k, v in agg.items() if v}over_cite这个额外指标是我后来加的,统计平均超引段落数。它不参与主分,但非常有诊断价值——精确率低的时候,看这个数字就知道模型是“稍微多引了一点”还是“一引引一大片”。
结果落库建议一条记录一行,字段至少包含:文档 ID、题目 ID、题目类型、证据位置档位、预测答案、预测引用、金标答案、金标引用、解析状态、三个指标。有了这些字段,后面任何维度的切片分析都是一行 groupby 的事,不用重跑。
4. 实操踩坑实录:常见问题与排查技巧
这一节是全文最实在的部分,全是我在不同文档、不同模型上反复撞墙撞出来的。
4.1 “答对了但引错了”的三种典型形态
第一种,引用指向相邻段落。模型引P0038,正确是P0037。这种情况在切段边界处理不干净的时候特别多,比如一个完整条款被切成了两段。诊断方法是看错误引用和金标引用的 order 差值分布,如果集中在 ±1,那就是切段问题,不是模型问题,回去修切段脚本比调 prompt 有效得多。
第二种,引用为“最相关”而非“最支持”。模型选了一段语义上离问题最近的,但那段并不包含答案。典型场景是文档里有“关于数据留存,详见附件二第 3 条”这样的指引句,模型就引了这句,没去引附件里的实际条款。这类错误靠 prompt 修不好,得靠第 2.3 节讲的蕴含判定把它揪出来。
第三种,引用多个段落凑数。模型不确定答案在哪,干脆把相关的三五段全引上。表现出来就是召回率接近 1,精确率只有 0.3 左右。这种“全都要”策略在只看召回的场景里会显得很强,实际上毫无可用性。
4.2 长文档下的位置偏差与截断问题
位置偏差是长文本模型的普遍现象,但表现形式因模型而异。我用的五档位置(5%/25%/50%/75%/95%)测下来,多数模型呈现“两头低中间高”的 U 形,少数模型是明显的“首段偏好”,最后 20% 的内容几乎不看。这一块必须单独出图,不能混在总平均里——总平均会把位置差异摊平,你会误以为模型没问题。
截断问题更隐蔽。文档超出窗口被静默截断,模型看不到后面的内容,于是回答“未提及”。这种失败在指标上表现为“拒答准确率虚高、召回率偏低”,非常容易误判成模型保守。我的排查动作是:在构造阶段就把每份文档的实际 token 数记下来,和被测模型的窗口上限做比对,超限的直接标记出来,超限文档上的结果单独统计,不进主分。曾经有一批实验因为没做这个检查,把 6 个点的性能差异完全归因错了。
还有一个容易被忽略的点:段落编号本身占用了 token。250 个段落,每个前面挂一个[P0001],就是 250 × 7 ≈ 1750 个 token,加上分隔符可能到 3000。长档文档做预算的时候必须把这部分算进去,否则你以为是 128K,实际有效内容只有 125K,边界测试会失真。
4.3 常见问题速查表
我把这一年攒下来的高频问题和对应处置整理成表,出问题的时候按表走,比从头推理快得多。
| 现象 | 最可能的原因 | 排查动作 | 修复方案 |
|---|---|---|---|
| 引用 ID 大量不存在 | 模型幻觉编号 | 统计parse_ok=False比例 | 加valid_ids过滤,prompt 里强调只能从给定 ID 中选 |
| 精确率异常高但人工抽检发现引错 | 匹配用相似度,未做蕴含判定 | 抽 30 条样本人工核 | 引入支持/不支持/部分支持三档判定 |
| 召回率接近 1 但精确率很低 | 模型“一引引一片” | 看over_cite均值 | prompt 限制引用条数上限,或在评分里加超引惩罚 |
| 长档分数断崖式下跌 | 文档被静默截断 | 比对实际 token 数与窗口上限 | 超限文档单独统计,或分块后做多轮 |
| 同一模型两次结果差 3 个点以上 | 温度非 0 或采样不稳定 | 检查温度设置与并发批次 | 固定温度 0,或跑 3 次取均值 |
| 拒答准确率高但正样本也答“未提及” | 模型过度保守,或截断 | 分开统计正/负样本的拒答率 | 正样本上误拒答单独建指标 |
| 多跳题和单跳题分数差不明显 | 证据段落距离太近 | 查 evidence order 差值分布 | 强制最小间隔 ≥ 5 段 |
| 某些文档整体分数偏低 | 该文档段落切分质量差 | 抽查段落字数分布 | 重切,剔除 < 80 字的碎段 |
5. 结果怎么读:把评测分数翻译成工程决策
跑完一堆指标,最容易犯的错是盯着一个总分做决策。总分只适合发榜单,不适合做选型。真正有用的是几个指标的组合形态,不同的组合指向完全不同的工程动作。
5.1 三个维度交叉出来的四种模型画像
我按答案准确率、引用精确率、引用召回率三个维度的相对高低,大致归纳出四类模型,每类的处置策略都不一样。
第一类:高答案准确率 + 低精确率 + 高召回率。这是最典型的“能答但不严谨”。模型确实读到了对的段落,但它倾向于把周边相关段落一起引上。这类模型在需要人复核的场景(比如给分析师做初稿)可以直接用,因为答案靠谱,人只要自己挑对的引用;但在需要自动出结论的场景,精确率不过关就是硬伤。
第二类:中等答案准确率 + 高精确率 + 低召回率。这类模型很保守,引必求准,但常常漏引。在多跳题上表现尤其明显,它可能只找到两个证据段落里的一个就下结论。这类模型适合做“发现线索”的环节,不适合做“完整举证”的环节。
第三类:三个指标都中等。这是最常见的情况。这时候要做的是看细分:是某一类题目(比如多跳)拖了后腿,还是某一档长度衰减严重。把细分切出来,往往能找到明确的优化点。
第四类:答案准确率虚高 + 引用指标全面偏低。这个组合要特别警惕,因为它意味着模型的正确答案很可能来自自身知识而非文档。我把这类现象叫“记忆穿透”。诊断方法是:把证据段落替换成内容相近但事实相反的段落,如果答案不变,基本可以确认是记忆穿透。这种情况在事实密度高的领域(历史、地理、常识)最容易出现。
5.2 从评测结果到落地取舍的几个判断
第一,别追求单项满分。引用召回率刷到 1.0 往往意味着模型在乱引,精确率会塌下去。我一般把精确率放在 0.8 以上作为底线,在这个前提下再看召回率,因为一处错误引用造成的返工成本远高于让用户多看一眼。
第二,长档的性能衰减要看斜率,不看绝对值。从短档到中档掉 3 个点、中档到长档掉 5 个点,这个衰减曲线是健康的;如果中档到长档直接掉 20 个点,说明模型在窗口后半段基本失能,这时候限制输入长度比换模型更划算。
第三,拒答准确率必须单独定阈值。业务上能容忍多少误拒答,取决于用户愿意重问几次。我的经验是负样本拒答率 90% 以上、正样本误拒答率 5% 以下,这个组合比较可用。超出这个范围,说明 prompt 里的拒答引导要么太强要么太弱,先调 prompt 再换模型。
第四,位置偏差要结合业务文档的分布看。如果你的业务文档关键信息集中在开头(很多报告的执行摘要就是这种结构),那尾部衰减对你影响不大;如果关键条款散布全文(合同、法规),那位置偏差就是致命项,必须选尾部表现好的模型。
最后再分享一个小技巧。评测集做好之后,别急着删构造脚本,把它纳入版本管理。因为业务文档的格式会变、模型会升级、指标定义也可能调整,半年后你想复现一个历史结论,没有当时的构造脚本和参数记录,基本等于重做一遍。我现在的习惯是每次跑评测,把脚本版本号、模型版本、温度、样本量、实际 token 统计一起写进结果文件头,多花两分钟,能省后面好几天的折腾。