跳到主要内容
一句“我没踢”,余弦相似度 0.6085

一句“我没踢”,余弦相似度 0.6085

画唠
画唠

· 阅读约 5 分钟

I did not knock the scarf into the pond。

跟“Mo 把围巾弄进了池塘”这条事实摆在一起,量出来的余弦相似度是 0.6085。

这条链路的阈值是 0.5。于是这句否认,被判成了泄密。

不是段子。llamadart 那个 Llama Village 系列第二篇,作者 Jhin Lee 在 dev.to 上写他那个本地模型跑的小镇,这一篇讲的就是怎么用本地 embedding,把角色说出口的台词跟“她可能知道的那堆事实”比一遍。我看到这行数就坐直了——终于有人把“相似度高 ≠ 意思相同”这件事,用一个能跑出分数的 bug 摆到台面上,比讲十遍理论都管用。

好,画开。

整条链路大概长这样:

候选事实 = 说话人知道 ∧ 听话人不知道
        │
        ▼
只跟“说话人的台词”比(不是双方全部对话)
        │
        ▼
embedding → 向量 → 余弦相似度 → 排序
        │
        ▼
取前 6 条 ──► outcome model(生成式)
        │
        ▼
返回“哪几条被明确说出口了”的 id
        │
        ▼
规则判定:keywordHit ∧ similarity ≥ 0.5 → 接受转移

图里有两格,我第一版画的时候直接省了,得单独拎出来。

第一格在顶上:候选事实先砍一遍,说话人知道、听话人不知道。这一步纯靠应用自己的状态,跟向量半点关系都没有。但它是整套东西能省下来的前提——不砍,等于每句话都拿去跟全世界的知识比一遍,噪音和算力一起爆。而且它只拿说话人的台词去比,不拿双方全部对话。这个细节很要紧:听话人说的话,不该被当成“泄密的证据”。

第二格在底下那个 0.5。这玩意儿不是通用阈值,是这个游戏自己拍的。

中间那截才是 embedding 干的活。打个比方:它把每句话钉到一张坐标纸上,然后看两个点离得多近。就这么回事。llamadart 里跑的是 embeddinggemma-300M-Q8_0.gguf,768 维,向量归一化之后,余弦相似度就等于一个点积,省事。

三个数摆一起看更有意思:真说了围巾缠在芦苇里,0.8449;否认句,0.6085;“今天天真蓝”,0.3137。

0.84 到 0.31,隔得挺开,看着很美。问题全在 0.6 那一档。

等等等等,先别急着说“调高阈值不就行了”。调到 0.65,否认句是被挡住了,可那些换了说法、绕了弯子的真泄密也一起被挡。这个阈值不是调歪了,是这个位置本来就不该由分数来守。

画第二张图,bug 就一目了然:

outcome model 说:claimed = false(她没说出来)
  │
  └─ 但规则还有一条兜底:
     keywordHit(台词里有 scarf + 位置词) ∧ similarity ≥ 0.5
     → accepted = true

结果:accepted = true,claimed = false

两个信号互相打脸,规则选了分数那一个。

作者把这个复现得挺老实:把 0.6085 直接喂进真实的 keywordHit 和 acceptTransfer,输出就是 accepted=true、claimed=false。他自己也明说,这只是规则层面的复现,不是完整跑一局——正常游戏里这条事实还得先够格、还得挤进前六。这个自我限定我很服,比含糊过去强多了。

现在说这个比喻在哪儿漏风。

坐标纸这个比方,讲“为什么意思近的句子分数高”,太好用了。可它漏了一个大洞:这张纸上没有“否定”这根轴。

“我踢了围巾”和“我没踢围巾”,词几乎全一样,钉上去两个点挨得极近。向量空间里压根没给“不”留一个维度。同理,它也没有“谁说的”这根轴——评论区 Mahdi 举的那个例子就很准:June says Mo knocked it in,这句里 Mo 是被指控的,不是说话人,可它在坐标纸上跟“Mo 把围巾弄进池塘”几乎重合。

这不是模型不行。是我们拿一把量“主题接近”的尺子,去量“有没有把这件事说出来”。

所以那 0.3 的关键词加分也是游戏特调,不是通用做法。关键词匹配本身也看不懂否定——子串能对上,语义对不上,knock 出现在“我没 knock”里,照样算命中。

那怎么办。

方向其实很朴素:把“纯靠相似度兜底”这条删掉。秘密的转移得由一个显式的判断来背书,而且这个判断要指到具体哪句台词支撑它。作者提的那个 DecisionEngine(llamadart 里跟 Laya 一起出来的那个东西)就是往这儿走——把候选事实分成“说了 / 否认了 / 说不清”三类。

但这是提案,不是现状。他自己也标了:结构化输出能约束 JSON 的形状和允许的 id,约束不了语义对不对;而且否认、猜测、转述、含糊其辞这几种都得单独评,真要压过现在这个 outcome model,可能还得单独调一个头。

我写到这儿其实有点犹豫——“再加一层判断”听着像是在把问题往后推一层。但转念一想,这层的性质不一样:outcome model 干的本来就是“读对话、认出说了什么”这件事,它天生就是干这个的;相似度分数干不了,它从来就没打算干。让合适的部件干合适的事,不叫加层,叫把放错位置的东西挪回去。

顺手记两个实操上的坑,跟这个 bug 无关但会咬人:那串 title: none | text: 前缀是有讲究的,模型卡里建议新应用用任务专用的输入前缀;而一旦你改了输入格式,之前那批分数和阈值全部作废,得重测。缓存要是按文本做 key 的,模型或前缀一换,缓存也得重建,不然你拿旧向量跟新向量比,比出来的东西谁也不认识。

最后留个自检,比总结有用:去你自己的 RAG 里搜一句否定句,看看排在最前面的都是些什么。要是你那条链路上也有一个“分数够高就算数”的兜底——它现在正在替谁做决定?

这玩意儿真的太酷了。一句“我没踢”,能让整条流水线的判断逻辑当场现形。

画唠
画唠

把被讲玄的概念用图 + 比喻 + 动手实验拆到咔哒扣明白,错的也保留。

查看主页 →