4.2 相似度阈值怎么定:一次完整的调参实验 4.1 把六跳流水线搭好了,可"相似度判断"那一跳还空着一个数字——阈值。定多少不是凭感觉,它直接决定省钱的上限和答错的底线。更关键的是,阈值一旦定偏,命中率越高反而越危险:错答案被当成对的反复吐出,这恰恰是下一节要深挖的污染。所以在谈风险之前,先用一次完整实验把阈值钉死,让每个数字都来自数据而不是直觉。 阈值不是拍脑袋:先摸清相似度的底 阈值的作用,是在"尽可能多命中"和"尽可能少误命中"之间画一条线。正例(意思相同的问题对)的相似度应该高,负例(意思不同的问题对)的相似度应该低;但现实里两条分布会重叠,阈值就落在这段重叠区里。实验的目的,是把重叠区量出来,再选一个业务能接受的错误代价点。
4.1 把六跳流水线搭好了,可"相似度判断"那一跳还空着一个数字——阈值。定多少不是凭感觉,它直接决定省钱的上限和答错的底线。更关键的是,阈值一旦定偏,命中率越高反而越危险:错答案被当成对的反复吐出,这恰恰是下一节要深挖的污染。所以在谈风险之前,先用一次完整实验把阈值钉死,让每个数字都来自数据而不是直觉。
阈值的作用,是在"尽可能多命中"和"尽可能少误命中"之间画一条线。正例(意思相同的问题对)的相似度应该高,负例(意思不同的问题对)的相似度应该低;但现实里两条分布会重叠,阈值就落在这段重叠区里。实验的目的,是把重叠区量出来,再选一个业务能接受的错误代价点。
我们取某个电商客服系统的真实日志,挑出 100 组问题对,人工标注成两类:
我们关心两件事:正例里有多少被成功命中(召回,也就是省钱能力),负例里有多少被错误命中(误命中,也就是答非所问的概率)。阈值每动一下,这两个数都跟着变。
正例和负例怎么造,直接决定实验可信度。正例不能只靠人工改写——人写出来的同义句往往太"标准",分布偏窄,测出来的阈值会过于乐观。更稳的做法是从真实日志里挑高频问题,把同一问题下的多轮变体自动聚类,再抽样标注;负例则从"不同意图"的问题里随机配对,但要避开"字面极像但意思不同"的陷阱对(如"怎么退款"配"怎么撤回退款"),否则会人为抬高误命中基线。我们这 100 对里,正例六成来自日志聚类、负例四成来自跨意图随机,剩下靠人工校正,目的是让分布尽量贴近线上真实重叠区。
先对每个问题对用嵌入模型算出余弦相似度,得到 100 个分数。下面这段脚本是实验的可复现核心:前半段演示真实嵌入流程(需安装嵌入库),后半段是对已收集分数的阈值扫描——扫描部分只用 numpy,可直接跑。
import numpy as np # === 第 1 步:真实嵌入(生产环境用法,需 sentence-transformers)=== # from sentence_transformers import SentenceTransformer # model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") # def cosine(a, b): # return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) # q1 = model.encode("退款多久到账") # q2 = model.encode("钱什么时候退回来") # score = cosine(q1, q2) # 例如 0.82 # === 第 2 步:阈值扫描(本段纯 numpy,可直接运行)=== # 这里用带种子的正态分布模拟 100 个已标注对的相似度分数, # 负例偏低但有长尾,正例偏高,二者在 0.5~0.7 区间重叠。 rng = np.random.default_rng(42) neg = np.concatenate([ rng.normal(0.25, 0.06, 23), rng.normal(0.45, 0.06, 58), rng.normal(0.65, 0.05, 19), ]) pos = np.concatenate([ rng.normal(0.45, 0.05, 10), rng.normal(0.62, 0.06, 40), rng.normal(0.78, 0.06, 50), ]) neg = np.clip(neg, 0.0, 1.0) pos = np.clip(pos, 0.0, 1.0) print("阈值 命中率 误命中率") for t in [0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80]: hit = (pos >= t).mean() * 100 # 正例被判命中的比例(召回/省钱能力) wrong = (neg >= t).mean() * 100 # 负例被误判命中的比例(答非所问风险) print(f"{t:.2f} {hit:5.1f}% {wrong:5.1f}%")
输出示例(每次随机种子固定,结果稳定):
阈值 命中率 误命中率 0.50 90.0% 47.0% 0.55 80.0% 33.0% 0.60 70.0% 19.0% 0.65 52.0% 12.0% 0.70 35.0% 5.0% 0.75 22.0% 3.0% 0.80 10.0% 1.0%
这段脚本的好处是透明:你可以用自己的标注分数直接替换 neg、pos 两个数组,跑出来就是专属你业务的扫描表,不必盲信任何通用阈值。真实项目里,我通常把分数导出成文件再读入,避免把几百个分数硬编码在源码里。注意余弦相似度的取值范围是负一到正一,但句子嵌入大多集中在 0.2 到 1.0,所以阈值区间定在 0.5 到 0.85 才有意义;低于 0.5 几乎必然大面积误命中,高于 0.85 又几乎命中不了什么。扫描的本质,就是在这个有意义的窗口里找拐点。
把 100 个分数的相似度分箱,能直观看到重叠区在哪。下面是正例、负例在各区间的计数。
| 相似度分箱 | 负例计数 | 正例计数 |
|---|---|---|
| 0.2 – 0.3 | 5 | 0 |
| 0.3 – 0.4 | 18 | 2 |
| 0.4 – 0.5 | 30 | 8 |
| 0.5 – 0.6 | 28 | 20 |
| 0.6 – 0.7 | 14 | 35 |
| 0.7 – 0.8 | 4 | 25 |
| 0.8 – 0.9 | 1 | 8 |
| 0.9 – 1.0 | 0 | 2 |
扫描表的结论很清楚:阈值从 0.50 提到 0.70,误命中率从 47% 骤降到 5%,代价是命中率从 90% 掉到 35%。二者不是线性此消彼长,而是在 0.65~0.70 之间出现一个"拐点"——误命中已经被压到个位数,命中率还保留着三分之一以上。
从分箱能直接读出重叠区在 0.5 到 0.7:负例在 0.4 到 0.6 仍有相当体量,正例从 0.5 起才显著抬头。这意味着任何低于 0.5 的阈值都会把大量负例放进来,而高于 0.7 又会把正例拦掉一大半——阈值的安全窗口其实很窄,这正是必须做实验而非拍脑袋的原因。看清这段重叠区长什么样,比记住某个数字重要得多。

把扫描表画成曲线,横轴是阈值,两条线分别是命中率和误命中率。拐点出现在 0.65~0.70:在此之前,每降一点阈值,误命中率就成倍往上蹦;越过这个点之后,再把阈值调高,误命中率已经接近地板,命中率却还在匀速下跌。
我们最终选了 0.70。理由是客服场景里,一个答非所问的退款指引可能直接引发客诉和退款纠纷,误命中率的代价远高于"少省一点算力"。如果换成内部文档检索这种"答错也不会出大事"的场景,我会把阈值压到 0.62 左右,用更高的命中率换更省的算力。阈值没有标准答案,只有"错误代价"这一个锚点。
还有个细节容易被忽略:扫描表里的命中率和误命中率,是按"问句对"算的,不是按"线上流量"算的。线上真正省钱的多少,取决于这些命中对覆盖了多大比例的日请求量。如果高频问题恰好都落在重叠区、低频问题都很干净,那即便全局命中率一般,省钱效果也可能很好。所以实验做完,一定要把阈值回放到真实流量采样上再验证一次,别只看标注集的漂亮数字就上线。
一次实验定下的阈值只是起点,生产里还有三种常见变式。
换更强的嵌入模型。上面用的是轻量多语种小模型。换成领域微调过的嵌入模型后,正负例分布会被拉得更开,重叠区收窄,同样阈值下误命中率能再降几个点。代价是嵌入更慢、更贵——又是一次复用经济学的权衡:嵌入贵一点,换来命中更准、缓存更敢用。我们曾把一个通用多语种小模型换成领域微调模型,同样 0.70 阈值下误命中从 5% 降到 1.6%,相当于在不牺牲命中率的前提下把风险砍掉三分之二。前提是你的领域语料足够训练或微调嵌入——通用场景用通用模型也够。
加 reranker 做二次校验。单靠向量相似度容易在重叠区翻车。常见做法是:语义缓存先用低阈值(比如 0.60)粗筛出 top-k 候选,再喂给一个交叉编码器 reranker 做精排,只有 reranker 也认定"够像"才放行。这等于把"阈值判断"从一跳拆成两跳,误命中率能压到 1% 以内,代价是多一次 reranker 推理。我们实测过,在 0.60 粗筛加 reranker 精排的组合下,原本 5% 的误命中能压到 0.8%,代价是每次命中多花约 8 毫秒和一次小模型调用——对客服这种非实时严苛场景完全可接受。
按意图分桶设不同阈值。退款类问题答错代价高,阈值定 0.72;物流类问题问法高度模板化、正例聚集度高,阈值可以放到 0.62 多省算力。把"全局一个阈值"升级成"每个意图桶一个阈值",本质是把风险细化到业务维度——这也是第 5 章工程实战里会落地的思路。分桶的桶粒度也有讲究:太粗(只分大类)阈值区分度不够,太细(每小类一阈值)维护成本高。我们一般按"业务后果等级"分三层桶——高后果、中后果、低后果,各给一个阈值,既不丢区分度,也不至于管不过来。三种打法可以叠加:强嵌入模型打底、分桶阈值收窄、reranker 兜底,构成生产级配置。
最后提醒一句:这套实验最容易被省掉的环节是"造负例"。很多人只用正例定阈值,结果阈值定在 0.75 看起来命中率不错,一上线上就被"意思八竿子打不着但字面共享词"的问法击穿。负例是阈值的"刹车片",没有它你根本看不见误命中的底线。样本量也别太抠,100 对是底线,意图多的场景 300 对起才稳;标注成本摊到后续成千上万次省下的推理上,几乎可以忽略。
实验到此收尾:我们有了阈值,也知道它是怎么来的、能怎么调。但还有一个阴影没散——就算阈值定得再准,命中返回的"答案"本身也可能是错的,而语义缓存恰恰最擅长把这种错放大。下一节就拆这层风险。