翻一篇 dev.to 上的 RAG 复盘,正文那五条经验扫过去,真正让我停下来的东西在评论区里。整条线最后长到比正文还长,绕着一个数转:0.92。
背景是个很典型的受困环境:内网,没有 GPU,Mistral 7B 跑在 4 vCPU / 16 GB 上,一个完整回答 60 到 120 秒。为了不让每个用户都干等这 90 秒,在 LLM 前面挂一层语义缓存,余弦相似度过了 0.92 就当命中,直接返历史答案,一秒内出结果。这笔账不用算都知道划算。下面只说这笔账里没写出来的那部分。
余弦相似度,说人话就是:把两段文本各压成一根向量,量一下夹角。夹角小,值就高。它回答的问题是"这两段话在语义空间里挨得近不近"。
这里最容易被搞混的是,"挨得近"和"能互换"是两回事。
你可能会想拿地图上的两个点来理解:坐标挨着,说明两个地方离得近。这个类比一开始还能用,往下走就散了——地理距离是个能加减的量,向量夹角不是,两句话在某一根轴上靠近,不代表它们在"答案"这个维度上也靠近。类比到这儿就该收了,直接看样本。
西班牙语的行政域里有一对词,con goce salarial 和 sin goce salarial,带薪和不带薪。差在哪?就一个 con 对 sin。这两个问题在 nomic-embed-text 下的余弦相似度是 0.9984。
任何以 0.92 为门槛的缓存都会把它们当成同一个问题。用户问带薪怎么算,系统返一个不带薪的答案回去——句子通顺,引用齐全,一秒出结果。
作者一开始没意识到这一点。提醒他的是一个评论者,提了一个成本很低的探针:从日志里捞 20 个真实问题,每个配一个只改了实体或否定的近孪生,嵌进去算余弦,看分布。
作者照做了。20 对西班牙语问题,四类各 5 对:否定、时间、实体、纯改写。否定对 0.9702 到 0.9984,均值 0.9837,5 对全部越过阈值。时间对均值 0.9372,3 对命中。实体对均值 0.8641,1 对命中。改写对照均值 0.8067,0 对命中。
这组数最刺眼的地方是方向反了。真正该命中的那一类——意思一样、只是换了说法的问题——一对都没命中;最不该命中的那一类——只差一个否定词——全中。
作者自己的结论很干脆:在这套配置下,不存在能把对抗性问题跟真实改写分开的余弦阈值。听着有点绕,其实就是在一根数轴上摆位置。想挡住 0.9984 的否定对,t 得抬到 0.9984 以上,可改写对照最高才 0.9060,提前死光。想放行改写到 0.9060,t 得降到 0.7470 以下,否定对更随便过。中间那块地方,没有。
越要召回,越得放行否定。越要精确,越得连真实改写一起拒掉。两个愿望坐在同一根轴上,谁往左挪一点,另一边就塌一点。
他把 SemanticCacheEnabled 改成了默认关闭。
直觉当然是换个更好的模型。作者也这么想,而且真换了几轮。
先换嵌入,BAAI/bge-m3,比 nomic 重。再上交叉编码器。这里有个区别值得说一句:双编码器把问题和候选各自压成一根向量,再比距离;交叉编码器把两个文本拼成一整条输入逐对读一遍,能看见两段话之间的相互作用。按理说,交叉编码器更接近"相关性"这个目标本身,在极性这件事上应该靠谱一点。
结果不是。
cross-encoder/ms-marco-MiniLM-L-6-v2 在英语上 AUC 0.0667,p 0.0070,margin 负 6.27 logits,西班牙语主对比上不显著。作者的解读是,它排出来的顺序几乎完全跟着词面重叠走,失败方式和双编码器一模一样。
BGE-reranker-v2-m3,568M 参数,CPU 上每对 571.1 毫秒,比那个 22M 的 ms-marco 慢约 15.7 倍。它在时间和实体对比上 AUC 能到 1.0000,分得清今天和明天、分得清张三和李四,但极性照样分不出来——跟它同族的 bge-m3 双编码器是同一个失败形态。
我原本以为"换更贵的模型"至少能把否定这件事修好一点。上面这两条结果反而是更难看的那种:一个跟着词面重叠走,一个在别的维度上打满分、在极性上原地不动。作者自己补了一句我觉得比所有数字都值钱的话:这是运行成本,不是证据,更贵的模型不代表更不会反转。
接下来这段是整条线里我最想拿出来的。
作者回头检查那 20 对否定样本,发现里面有两对标注错了。西班牙语里有一类确认式否定疑问句,形式上带否定词,语义上并不反转答案,那两对本来就不该算对抗对。勘误发了。
但真正要命的不是这两对。他顺着往下查,发现否定对之间只差一个 token,而改写对照差了大部分 token。两类样本的词面距离本来就不一样。前面那组分出高下,有多少来自语义、有多少只是"哪边字面上更像",根本没分开。
于是他补了一组词面重叠对照,数字又翻了一遍。bge-m3 对低重叠改写的 AUC 是 0.3556,对匹配改写的 AUC 是 0.9333,差出这么多,说明原来那版实验有一部分确实在量词面距离。nomic 西班牙语在匹配对照下 AUC 0.1333,margin 负 0.1017,用误差最小化选出来的阈值不接受任何命中。nomic 英语 AUC 0.4444,p 0.797,不显著。九对否定样本平均相似度 0.9520,比之前那个 0.9837 低了一点,方向没变。
他撤回了自己原来那个解释,不再说这是西班牙语的问题,承认这个分数在此场景下没有可用信息。
这层修正比前面所有模型对比都值得看。探针能告诉你阈值不安全,探针不保证你不安全的原因就是你以为的那个原因。要确认原因,得再做一次对照,把词面这个混杂变量按下去。作者如果停在 0.9837 那个数上,就会讲出一个听起来很顺的故事——西班牙语里否定在嵌入空间里太近——而实际测到的东西可能一半是字形。
顺带说个细节:他最早的复现命令用大于号重定向,每跑一次就把上一次的输出文件覆盖掉。这种坑不高级,但正好说明这类小规模实验最贵的成本不在算力,在你以为自己看的是累积结果,其实每次都是新的一份。
同一条线上还有件同味道的事。仓库里那个取消令牌一路传到服务内部就算完,接口和控制器没动,所以到 Qdrant 和 Ollama 的始终是 CancellationToken.None,请求断了,CPU 还在替它算。提交 d93eab5 修的。更细的一处:README 里注册的客户端叫 ollama,代码里实际请求的是 ollama-embedding、ollama-generation 和 qdrant。名字没注册,就静默退回默认客户端,那个好不容易改成 300 秒的超时又回到 100 秒。提交 061aa3b 修。作者自己说,仓库的故障排查页早就记着同一个失败,主路径照样复现了一遍——文档和代码在往相反的方向漂。
回到缓存。作者自己的说法是,怎么让缓存在极性差异上显式失败而不是静默失败,这个问题他还没解决。这是个设计问题,不是调参问题,参数空间里没有那个安全点,所以只能往别处找:命中之后重新校验权限范围,保留稳定来源 ID,缓存键按集合、嵌入模型、对话模型、提示词版本、检索 top-K 分区。这些是评论里 Ivan 提的,作者认了,也写了 ADR。八个关键组件里落地了五个,授权范围、语料版本、授权策略版本还没做,检索缓存至今只是提案。
但这些都还是"让缓存错得少一点",没有一条能让它错的时候你知道。
最后一步他做得很干脆:SemanticCacheEnabled 默认关闭,20 对样本、分组原始分布、复现脚本、决策理由,全写进 docs/experiments/threshold-safety.md。
所以这笔账,收益和代价不在同一个地方结算。收益全在均值上,命中率、平均延迟、一秒出结果的体感,好看也好测。代价全在长尾上,而且它不报警。命中路径返回的答案格式完整、引用齐全,跟正确答案长得一模一样,你只有真去问那个只差一个否定词的问题,才会当场看见它错。
作者选了默认关。你的场景要不要跟着关,取决于你的错误能不能在别处被兜住——这个我不替你算。