GEOZ

嵌入模型分不清「我没干」和「我干了」:Llama Village 实测暴露的语义盲区

2026/10/13
嵌入模型分不清「我没干」和「我干了」:Llama Village 实测暴露的语义盲区

AIAI Summary (BLUF)

本文是 Llama Village 系列的第二部分,详细介绍了如何利用本地嵌入模型(EmbeddingGemma)和 llamadart 库,在 AI 对话中判断信息传递。教程涵盖了嵌入生成、余弦相似度比较、关键词匹配以及游戏规则设计,并指出了当前实现中相似度回退可能绕过否定语义的缺陷。

核心洞察

这篇文章最有意思的地方在于它没有把嵌入模型当成万能工具。作者用一个小游戏把"语义相似"和"真的听懂了"这两件事拆开,还顺手暴露了自己代码里的一个漏洞:否定句和肯定句的余弦相似度可以很接近,光靠相似度阈值会把"我没干"当成"我干了"。如果你正在做 RAG 或者任何基于嵌入的匹配系统,这个坑值得看一眼。

Pip 的红围巾不见了。Mo 知道它在哪。他也知道围巾是怎么到那儿的,但这事儿他不太想提。

这就给村子留了个小悬念。也给游戏出了个更难的问题:两只羊驼聊天的时候,听的那只到底应该学到什么?

我在Llama Village:一个用 llamadart 和本地 AI 驱动的虚拟世界里介绍过这个游戏。这次我们只跟一条线走:本地嵌入模型怎么帮我们比较一只羊驼说的话和可能被透露的事实。

最终你能看到的变化很具体:一只羊驼获得了信息,游戏记录了是谁告诉它的,但它仍然可以不信。对话、嵌入、游戏规则,各干各的活。

核心结论

  1. 嵌入模型只产出数值向量和相似度分数,不判断"意思是否相同"或"事实是否为真";语义相似度阈值无法区分否定句和肯定句。实测中,否认句"I did not knock the scarf into the pond."与"Mo 的意外"事实的余弦相似度高达 0.6085,远高于无关句"The sky looks beautiful today."的 0.3137。

  2. Llama Village 使用 embeddinggemma-300M-Q8_0.gguf 模型,返回 768 维向量,输入文本需加 title: none | text: 前缀;向量归一化后,点积即为余弦相似度。

  3. 当前游戏规则存在真实漏洞:关键词匹配加上相似度 ≥ 0.5 即可接受一次秘密传递,即使结果模型并未声称该事实。将实测否认句分数 0.6085 输入游戏实际的 keywordHit 和 acceptTransfer 函数,返回 accepted=true 和 claimed=false。

  4. 作者建议的改进方案是引入 llamadart 的 DecisionEngine,形成"嵌入筛选 → DecisionEngine 判断 → 游戏应用规则"的三段式流程,只有明确的"透露"判断才能让事实进入知识传递检查,光靠相似度不再能接受它。

  5. 可复用模式:用应用状态缩小候选范围,用嵌入找到可能的匹配,对有后果的状态变更单独做评估决策;同时应将来源和出处与向量一起存储,模型或输入格式变更后需重建缓存向量。

一个秘密从一只羊驼开始

游戏把围巾的位置和 Mo 的那次意外存成两条独立的事实。一开始,Mo 两条都知道。知道围巾在哪,不等于知道是谁把它撞进池塘的。

检查一段对话之前,游戏会先缩小范围:说话者知道、而听者不知道的那些事实。它拿每条事实去跟说话者的台词比,而不是拿双方所有台词一起比。

嵌入模型从训练数据里学到了文本的表示方式。它把每句台词和每条事实映射成一个数值向量,然后用余弦相似度来比较这些向量。措辞相近、含义相关的文本,向量距离会更近,但这个分数不是"意思相同"的是非判断,也不代表事实为真,更不代表某句话蕴含了某条事实。游戏取的是每条事实在说话者所有台词中的最高相似度。

实现上用的是 llamadart 里的 EmbeddingGemma,按文本缓存向量。台词和事实都加了 title: none | text: 前缀。这篇走读复现的就是游戏里的这个选择。Google 的模型卡建议新应用使用针对任务的前缀,包括句子相似度的查询提示;改了格式就得重新评估分数和阈值。

用词不需要一样

拿一个简单的例子来说,把存储的事实"Pip 的红围巾躺在池塘的芦苇丛里"和示例台词"我看到 Pip 的围巾缠在池塘边的芦苇里了"放在一起比。它们用不同的词描述了相关的信息。这句台词是为这篇解释写的,不是从游戏录像里摘出来的。

输入是文本。嵌入的输出是一个数值向量。然后应用程序计算相似度分数;模型不会返回"June 知道了围巾的位置"。那个判断是后面的事。

两段文本要用同一个嵌入模型和兼容的设置。向量长度一样,不代表表示方式就兼容。

跟着文本走一遍 llamadart

加载好合适的嵌入引擎之后,核心调用长这样。完整的加载和比较示例在文章末尾。

final facts = [
  "Pip's red scarf is lying in the pond reeds.",
];
final factVectors = await engine.embedBatch(
  facts.map((text) => 'title: none | text: $text').toList(),
  normalize: true,
);
final lineVector = await engine.embed(
  "title: none | text: I saw Pip's scarf tangled "
  "in the reeds by the pond.",
  normalize: true,
);

游戏配置的嵌入模型文件是 embeddinggemma-300M-Q8_0.gguf。换别的模型就按它的输入格式要求来。llamadart 嵌入指南里有加载、后端支持和输入限制的说明。

向量归一化之后,点积就是余弦相似度。游戏里关键词证据会加 0.3 的排名加分,最好的六个候选送给结果模型。这些是游戏自己的选择,不是通用的嵌入阈值。

我用发布版 llamadart 0.11.0 和本地的 embeddinggemma-300M-Q8_0.gguf 在 macOS arm64 上跑了配套示例。返回的是 768 维向量。下面是那次运行中挑出来的几个余弦相似度;这些是用真实模型跑的示例输入,不是游戏过程中录下来的对话。

输入台词 对比对象 余弦相似度
I saw Pip's scarf tangled in the reeds by the pond. 围巾位置 0.8449
I did not knock the scarf into the pond. Mo 的意外 0.6085
The sky looks beautiful today. 围巾位置 0.3137

否认那句最能说明问题:它说的是反话,但跟意外事实的相似度仍然很高。这些分数属于这个模型文件和这套格式,不是通用 cutoff,也不是概率。

谁来判定意思是否匹配

嵌入模型不会先检测主题再证明含义匹配。它只产出一个表示和一个相似度分数。要问"这个说话者到底有没有透露那条事实",Llama Village 会把对话和入围的事实一起发给一个单独的生成式结果模型。那个模型被要求返回内容确实被说出口的那些事实 ID。

对于示例中的否认句"I did not knock the scarf into the pond",预期解读是那次意外没有被承认。结果模型必须理解措辞和上下文才能得出这个结论;它也可能出错。它的 JSON 约束的是响应的结构和允许的 ID,不是解读是否正确。

然后代码把模型提出的透露结果和关键词、相似度结合起来。位置秘密需要"scarf"和一个位置词。意外秘密需要"scarf"和一个责任词,比如"knock"。关键在于,"I did not knock the scarf into the pond"照样能匹配这些子串组。它们不理解否定。

当前游戏规则里有个真实的弱点。对于秘密,关键词匹配加上相似度 ≥ 0.5 就能接受一次传递,即使结果模型没有声称它。把实测的否认句分数 0.6085 喂进游戏实际的 keywordHit 和 acceptTransfer 函数,返回的是 accepted=true 和 claimed=false。这是规则层面的复现,不是完整的游戏运行:事实还得符合条件并进入候选列表。

所以当前规则并不保证含义匹配。更严格的设计应该去掉秘密的纯相似度兜底,要求一个明确的透露判断加上支撑对话。

一个可能的方向是 llamadart 的 DecisionEngine,它在本地 AI 模型能学会玩俄罗斯方块吗?里随 Laya 一起介绍过。在这里,选择会围绕 Mo 的话:给定对话和一条候选事实,他是透露了、否认了,还是没说清楚?

嵌入筛选 → DecisionEngine 判断 → 游戏应用规则。

只有"透露"的判断才会让事实进入游戏的知识传递检查;光靠相似度不再能接受它。这是提议的扩展,不是当前实现。结构化回答也可能出错:我们得在否认、猜测、引用和模糊对话上评估 Laya,可能还得调一个专门的头,才能说它比当前的结果模型更好。

当前秘密传递检查 需要的证据
关键词 每个关键词组都匹配
额外支持 结果模型声称它或相似度 ≥ 0.5
已知弱点 相似度分支可以绕过未被声称的透露

June 听到了故事,但仍在怀疑

看 June 的检查面板:Mo 的围巾意外被标记为"从 Mo 那里听说的;可能是假的"。截图展示的是一个存储的结果,不是捕获的嵌入分数,也不能证明是哪段对话产生的它。

接受一次传递之后,游戏会记录讲述者,并把"听说过"和"相信"分开。信不信取决于讲述者自己信不信,以及听者的信任规则。这个区分让对话能改变村子,又不至于让每个听者都接受每个故事。

给你的应用同样的分离

可复用的模式是:用应用状态缩小候选范围,用嵌入找到可能的匹配,对有后果的状态变更单独做一个评估决策。Llama Village 演示了这个模式,也演示了一个失败模式:相似度兜底会抹掉"找到相关文本"和"接受一次透露"之间的区别。

把来源和出处跟向量存在一起。模型或输入格式变了就重建缓存向量。选一个适合运行时的嵌入模型,加载后检查后端支持。

Pip 失踪的围巾让技术问题变得可见:是有人说了它在哪,承认了它怎么到那儿的,否认了,还是只是提到了池塘?分开测这些。一段有说服力的对话和一次正确的状态更新,是两件需要分别评估的事。

关于这个世界的其他部分,包括 Dash、浆果节和故事书结局,看Llama Village 那篇文章。这个系列的下一篇:本地语音。

完整的嵌入比较示例

这个示例用 Dart 3.13.5 和发布版 llamadart 0.11.0 在 macOS arm64 上分析和执行过。创建一个 Dart 控制台项目,在 pubspec.yaml 里固定 llamadart: 0.11.0,运行 dart pub get,把代码存成 bin/village_embeddings.dart。用一个本地的 EmbeddingGemma Q8_0 GGUF,运行下面的命令。两个前缀是有意复现游戏的格式。这里比较五条固定事实;它不实现角色资格、结果模型或知识更新。

dart run bin/village_embeddings.dart \
  /absolute/path/to/embeddinggemma-300M-Q8_0.gguf \
  'title: none | text: ' 'title: none | text: '
import 'package:llamadart/llamadart.dart';

Future<void> main(List<String> args) async {
  if (args.length != 3) {
    throw ArgumentError(
      'Usage: dart run village_embeddings.dart model.gguf '
      '"query prefix" "document prefix" (use "" for no prefix)',
    );
  }

  const facts = [
    'Pip\'s red scarf is lying in the pond reeds.',
    'Mo knocked Pip\'s red scarf into the pond reeds by accident and never told her.',
    'Bramble writes anonymous love poems to June.',
    'Pip\'s red scarf has gone missing from her hut.',
    'June made up the rumour that Mo\'s bread made Bramble sick.',
  ];
  final engine = await LlamaEngine.load(
    LlamaModel(ModelSource.path(args[0])),
  );
  try {
    if (!(await engine.capabilities).supportsEmbeddings) {
      throw StateError('The active backend does not support embeddings.');
    }
    // Index these once; reuse them for subsequent searches.
    final vectors = await engine.embedBatch(
      facts.map((text) => '${args[2]}$text').toList(),
      normalize: true,
    );
    for (final query in [
      'I saw Pip\'s scarf tangled in the reeds by the pond.',
      'I did not knock the scarf into the pond.',
      'The sky looks beautiful today.',
    ]) {
      final vector = await engine.embed(
        '${args[1]}$query',
        normalize: true,
      );
      final ranked = [
        for (var i = 0; i < facts.length; i++)
          (text: facts[i], score: dot(vector, vectors[i])),
      ]..sort((a, b) => b.score.compareTo(a.score));
      print('\n$query — ${vector.length} dimensions');
      for (final hit in ranked.take(3)) {
        print('${hit.score.toStringAsFixed(4)}  ${hit.text}');
      }
    }
  } finally {
    await engine.dispose();
  }
}

double dot(List<double> a, List<double> b) {
  if (a.length != b.length) {
    throw ArgumentError('Vector dimensions differ.');
  }
  var result = 0.0;
  for (var i = 0; i < a.length; i++) {
    result += a[i] * b[i];
  }
  return result;
}

实现参考:候选选择和接受以及嵌入格式化。

常见问题(FAQ)

生成式引擎优化中,如何用本地嵌入模型判断信息传递?

使用EmbeddingGemma生成向量,计算余弦相似度,结合关键词匹配。但相似度阈值可能将否定句误判为肯定,需额外评估。

为什么余弦相似度无法区分否定和肯定语义?

嵌入模型只产出表示和相似度分数,不检测主题或证明含义匹配。否定句和肯定句的向量可能很接近,光靠阈值会把'我没干'当成'我干了'。

llamadart中如何实现嵌入比较和游戏规则设计?

用llamadart的embedBatch和embed生成向量,归一化后点积得余弦相似度。游戏规则结合关键词和相似度,但当前实现有相似度回退绕过否定语义的缺陷。

Roger深圳
本文由 Roger 审核,最后更新于 2026年10月13日
联系编辑 →
← 返回文章列表
分享到:微博

版权与免责声明:本文仅用于信息分享与交流,不构成任何形式的法律、投资、医疗或其他专业建议,也不构成对任何结果的承诺或保证。

文中提及的商标、品牌、Logo、产品名称及相关图片/素材,其权利归各自合法权利人所有。本站内容可能基于公开资料整理,亦可能使用 AI 辅助生成或润色;我们尽力确保准确与合规,但不保证完整性、时效性与适用性,请读者自行甄别并以官方信息为准。

若本文内容或素材涉嫌侵权、隐私不当或存在错误,请相关权利人/当事人联系本站,我们将及时核实并采取删除、修正或下架等处理措施。也请勿在评论或联系信息中提交身份证号、手机号、住址等个人敏感信息。