问题背景
上一篇用两组模拟数据说明了一把太小的尺子如何让 47% 的正确结论被判反,结论是评测集必须存在、规模要按可分辨差距倒推。这一篇解决"尺子上的刻度从哪来":一个跑了三个月的客服 RAG 机器人,日志里躺着八万多条真实会话,怎么从中提炼出三百来条能代表线上真实分布、标注质量可复核的金标准数据集(golden dataset)。直接照抄线上问题会踩两个坑:一是头部意图吃掉八成流量,随机抽出来评测集全是"退款多久到账",越界拒答、多轮追问这些恰恰最容易出事故的长尾一条没有;二是线上原话本身是脏的——错别字、半截话、夹带订单号的隐私串,不清洗不能用。本篇把整条流水线拆成四段:分层采样、隐私脱敏、近重复去重、双人标注加一致性仲裁,每段给出可操作的判据。
核心原理
**采样的第一原则是按业务意图分层,而不是按流量均匀。**均匀随机抽样的方差与层内样本量成反比,而流量分布天然头重脚轻:占比 3% 的多轮追问意图,在 300 条均匀样本里平均只有 9 条,单这一层的通过率估计误差就逼近 ±30 个百分点——等于这层没测。工程解法是带下限的分层抽样(stratified sampling with floor):大层按流量占比分配配额,小层强制保底(比如每层不少于 25 条),代价是评测集分布与线上分布有意的偏离,收益是每层都有最低可用分辨率。要注意这个偏离必须在报告里写明:分层评测的总分不再是"线上期望表现"的无偏估计,它是各层分数的加权平均,权重由你声明而不是流量决定。
**标注对象不是"正确答案"而是"判分合同"。**golden dataset 里每条用例的真正资产是它的评分契约:参考回答要点(必须出现哪些事实)、负向约束(出现哪些内容直接判错,比如承诺具体到账日期)、以及分类标签(该条属于哪个意图、难度、是否含陷阱)。三个人独立对同一批 50 条写要点,交集部分就是可自动化的硬规则,差集部分暴露的是需求理解分歧——标注流程顺带产出的这份"分歧清单",往往比数据集本身更有价值。
**一致性用双标 + 仲裁闭环控制。**每条用例至少两人独立标注,不一致的进仲裁;批次级别跟踪标注者间一致性(下一篇会用 Cohen’s kappa 给出算法)。经验阈值:原始一致率低于 80% 的批次整批返工,因为这说明标注指南本身有洞,修补指南比返工标注便宜。
**去重要发生在标注之前。**近重复问题(“退款多久到账"与"退款大概几天能到呢”)不仅浪费标注预算,还会让评测集对某个高频措辞隐性过采样。字符级 n-gram 的 Jaccard 相似度在中文短query上简单有效:阈值 0.8 上下能把同一政策的换皮问法并进一个簇,每簇只标一条。
实验一:分层抽样 vs 均匀随机,长尾层的分辨率差多少
本机以确定性模拟演示采样机制(固定种子),生产用真实日志与抽样脚本。
importrandom INTENTS={"policy_qa":0.42,# 售后政策问答"order_query":0.28,# 订单/物流查询"complaint":0.12,# 投诉安抚"chitchat":0.09,# 闲聊跑题"edge_reject":0.06,# 应拒答的越界问题"multiturn_followup":0.03,# 多轮追问改写}FLOOR=25# 每层最低样本量(小意图保底)TARGET=300# 评测集目标规模pool=[]rng=random.Random(7)forintent,shareinINTENTS.items():for_inrange(int(5000*share)):pool.append((intent,"q_%s_%04d"%(intent,len(pool))))defstratified(pool,target,floor,seed):rng=random.Random(seed)by_intent={}foriteminpool:by_intent.setdefault(item[0],[]).append(item)picked=[]forintent,itemsinsorted(by_intent.items()):quota=max(floor,round(target*len(items)/len(pool)))picked+=rng.sample(items,min(quota,len(items)))returnpickeddefnaive(pool,target,seed):rng=random.Random(seed)returnrng.sample(pool,target)strat=stratified(pool,TARGET,FLOOR,seed=11)naive_set=naive(pool,TARGET,seed=11)defdist(sample):c={}forintent,_insample:c[intent]=c.get(intent,0)+1returncprint("意图 | 线上占比 | 分层抽样 | 均匀随机抽样")ds,dn=dist(strat),dist(naive_set)forintent,shareinINTENTS.items():print("%-20s | %6.1f%% | %7d | %7d"%(intent,share*100,ds.get(intent,0),dn.get(intent,0)))print("分层抽样总数 %d, 均匀随机总数 %d"%(len(strat),len(naive_set)))运行输出:
意图 | 线上占比 | 分层抽样 | 均匀随机抽样 policy_qa | 42.0% | 126 | 139 order_query | 28.0% | 84 | 75 complaint | 12.0% | 36 | 40 chitchat | 9.0% | 27 | 19 edge_reject | 6.0% | 25 | 17 multiturn_followup | 3.0% | 25 | 10 分层抽样总数 323, 均匀随机总数 300对比最后两行:均匀随机下多轮追问层只有 10 条(上一篇的置信区间表告诉我们,这个规模的通过率半宽超过 ±28 个百分点,纯属装饰),越界拒答层 17 条同样不够看;分层保底后两层各 25 条,总数只多花 23 条预算,长尾分辨率却从不存在变成可用。另一个细节是"总数 323 > 300":下限配额会让总量微超目标,工程上接受这个超支,或者从最大层里扣回——但绝不为凑整数砍掉小层。
实验二:标注前去重,把同一政策的换皮问法并进一簇
importrandom BASE_QUERIES=["七天无理由退货怎么操作","退款多久能到账","订单什么时候发货","发票开错了能重开吗","会员积分怎么兑换","跨境商品支持退换货吗","商品价格保价规则是什么","客服人工电话是多少",]SUFFIXES=["?","呢","啊",",急",",谢谢","麻烦告诉我","在线等"]rng=random.Random(5)candidates=[]forqinBASE_QUERIES:candidates.append(q)forsuffixinrng.sample(SUFFIXES,3):mutated=q+suffixifrng.random()<0.4:# 再抖掉一两个字制造脏变体pos=rng.randrange(len(mutated)-1)mutated=mutated[:pos]+mutated[pos+1:]candidates.append(mutated)extra=rng.randrange(len(BASE_QUERIES))# 另一主题的干净样本candidates.append(BASE_QUERIES[extra][:-2])defshingles(s,k=3):return{s[i:i+k]foriinrange(max(len(s)-k+1,1))}defjaccard(a,b):returnlen(a&b)/len(a|b)if(a|b)else0.0threshold=0.8clusters=[]# 每簇保留第一个成员作为代表shingle_cache=[shingles(q)forqincandidates]foridx,qinenumerate(candidates):placed=Falseforclusterinclusters:ifjaccard(shingle_cache[cluster[0]],shingle_cache[idx])>=threshold:cluster.append(idx)placed=Truebreakifnotplaced:clusters.append([idx])kept=[clusters[c][0]forcinrange(len(clusters))]print("候选 %d 条 -> 去重后保留 %d 条(阈值 Jaccard>=%.1f)"%(len(candidates),len(kept),threshold))sizes=sorted((len(c)forcinclusters),reverse=True)print("簇大小分布 Top5:",sizes[:5])print("示例簇(代表 | 被合并的近似重复):")biggest=max(clusters,key=len)print(" 保留:",candidates[biggest[0]])forminbiggest[1:]:print(" 合并:",candidates[m])运行输出:
候选 40 条 -> 去重后保留 27 条(阈值 Jaccard>=0.8) 簇大小分布 Top5: [4, 3, 3, 2, 2] 示例簇(代表 | 被合并的近似重复): 保留: 跨境商品支持退换货吗 合并: 跨境商品支持退换货吗啊 合并: 跨境商品支持退换货吗呢 合并: 跨境商品支持退换货吗,谢40 条候选压成 27 条,三成预算省下来了;更重要的是簇大小分布本身就是流量结构的信息——"跨境退换货"聚成 4 条簇,说明该政策有歧义、用户变着法问,这反而是提示该层要加权采样的信号。两点实操提醒:真实日志里 Jaccard 阈值建议从 0.8 起调,人工抽检 50 个合并决策再定,中文短文本对 3-gram 敏感,0.7 以下开始误伤同主题不同意图的问句;数据量上万后逐对比较是平方复杂度,换 MinHash 或倒排块(blocking)做候选对生成。
流水线落地四步
第一步,导出与脱敏先于行:日志落地评测工作区之前过一遍正则加实体识别,手机号、地址、订单号一律替换为保留格式的假值(138****0001这类),脱敏规则本身要 review——“帮我查订单 20240713xxxx"里的日期串经常被漏掉,但它能关联到真人。第二步,分层配额表评审:业务方、标注方、算法方对每张意图层的配额和目标占比签字,避免出分后争论"为什么闲聊占一成”。第三步,标注平台留痕迹:每条用例记录标注人、时长、修改轮次,纯秒标与十分钟标的可信度不是一个量级,事后质检按这个元数据抽查。第四步,季度滚动更新:每季度从线上新意图里补 20% 新样本、同时把过时的样本降级为"归档可见、不进总分",防止评测集老化成"2024 年的互联网"。
常见陷阱
其一,标注指南用形容词:“回答有帮助"没有可操作性,指南必须写成"若回答包含 X 且未包含 Y 则判通过"级别。其二,让最懂业务的人单干标注:专家标注最准,但单点不可复核、进度是瓶颈,且专家脑中的标准没写进指南就永远是黑箱——双标的意义一半在质量一半在逼专家把知识外化。其三,评测集里保留隐私原话"以便还原现场”:金标准数据集会被很多工具和很多人访问,脱不干净的样本直接不进集。其四,把失败案例只修不存:处理完一个线上投诉就删掉现场,等于每个 bug 只付一次学费,正确动作是清洗后进评测集。其五,去重用力过猛:把"支持退换货吗"与"退换货要多久"并成一簇,两个意图被一个合并决策吃掉,所以合并必须抽检。
落地清单
- 按意图分层设配额并给小层保底(≥25 条),报告里声明加权方式
- 全量脱敏先于一切,规则评审覆盖"能关联到真人"的组合字段
- 每条用例写"判分合同":必含要点 + 负向约束 + 意图/难度标签
- 双人独立标注,批次一致率 < 80% 整批返工并修指南
- 标注前 n-gram Jaccard 去重(0.8 起步,人工抽检合并决策)
- 季度补样 20%,线上坏案例清洗后入集
数据集有了,接下来的瓶颈立刻转移到打分:三百条用例每次变更都跑一遍,谁来判每一条的好坏?人判太快就糙、太准就贵。下一篇《LLM Ops 评测与可观测实战(3):自动打分:规则评分、LLM-as-judge 与一致性校验》给出三层打分器架构,以及怎么证明你的自动打分器不是在瞎打分。
参考来源
- Wikipedia:Stratified sampling:https://en.wikipedia.org/wiki/Stratified_sampling
- Wikipedia:Jaccard index:https://en.wikipedia.org/wiki/Jaccard_index
- Wikipedia:MinHash:https://en.wikipedia.org/wiki/MinHash
- Wikipedia:Inter-rater reliability:https://en.wikipedia.org/wiki/Inter-rater_reliability
- LangSmith 评测与数据集文档:https://docs.smith.langchain.com/