一个检索增强生成(RAG)管道可能会连续数周出现性能退化,而没有任何指标能察觉到。相关性评分看起来正常,检索延迟没有变化。触及受损主题的评估切片(eval slice)向错误方向移动了 0.25 个点,而你的每周审查将其归因于噪声。直到有人阅读了模型为某个客户工单实际接收到的上下文窗口,发现同一个段落出现了三次——一次是标题格式,一次是小写格式,一次是去除了标点的格式——你才意识到,一个月以来,你的前五名(top-five)在暗地里其实只是前二名。
这类故障的特点是:系统完全在按照指令行事。检索器正在返回与查询最相似的向量。这些向量中的每一个都确实与正确的主题相关。索引根本不知道其中三个向量来自同一个段落(只是以三种方式索引了三次),因为原本应该捕获这种情况的入库阶段去重环节正在静默跳过它。
丢掉了小写转换环节的重构
我最常见到的版本故事是这样的:入库管道在调用嵌入(embedding)之前会运行一个 normalize_and_hash 步骤。它会去除空格、将文本转为小写、移除少量的零宽字符、对结果进行哈希处理,并跳过任何哈希值与已索引项匹配的分块(chunk)。哈希表是防止近似重复项进入向量库的一种低成本、有效的机制。
半年后,一名平台工程师清理了规范化模块。他们重命名了一个导入,将一个辅助函数移动到另一个文件,并更新了三个调用点。小写转换步骤在其中一个调用点被错误地丢掉了——它原本是一个构建器对象上的方法,现在构建器的构造方式发生了变化,而本应捕获这一回归的测试只是一个仅关注覆盖率的测试,断言“该函数返回一个字符串”。函数确实返回了字符串。只是字符串不再被转换为小写了。
管道继续运行。文档继续入库。哈希表充满了重复项:语料库中任何以大小写混合形式存在的段落都会产生两个条目——一个是标题格式版本,一个是全小写版本。向量库最终拥有的向量数量是应有数量的两倍,而且没人注意到,因为每个文档的分块计数仍在上一季度数值的合理范围内。
这些向量并不完全相同。它们的嵌入向量存在微小差异,因为分词器(tokenizer)在某些地方是大小写敏感的,而嵌入模型在训练时却将这些地方视为语义等价。因此在检索时,当一个查询从词法上匹配底层段落时,余弦相似度会将这两个副本都排在列表的前列——并且由于它们的嵌入几乎相同,它们会紧挨着排列。你的前五个名额中,现在有两个被花在了相同的内容上。
为什么相关性评分会奖励冗余
这里的核心架构失误在于:检索评分函数和去重函数运行在对“同一事物”的不同定义之上。
检索器根据与查询的相似度进行排名。三个重复分块中的每一个都确实与查询相似。从检索器的角度来看,返回三个几乎相同的命中结果是正确的行为——它们都是相关的,而当它们的向量几乎相等时,余弦相似度的逻辑就是将它们排在一起。相关性评分并没有损坏。它正在执行其设计的任务。
去重环节本应强制执行另一个不变量:索引不包含冗余内容。这个不变量存在于检索评分函数之外。当去重环节静默回归时,没有任何检索指标会标记它,因为每个检索指标都是在“索引已经去重”的假设下计算的。这些指标是在针对错误的索引测量正确的事物。
这就是为什么“监控检索评分”无法发现问题。评分是正常的。问题在于,位于检索下游两个阶段的答案质量评估,才是重复内容真正改变可观测指标的第一个地方——而当信号到达评估阶段时,它已经被摊平到了每一个恰好触及重复主题的查询中。据生产团队报告,RAG 中大约 80% 的准确性问题可以追溯到包括重复在内的数据质量问题,这种失效模式很难简单地归因于单次检索调用。
模型的过度权重问题
一旦近似重复项充斥上下文,模型会以其特有的方式复合这一故障。语言模型将上下文窗口视为证据。当三个分块以略微不同的方式说着大致相同的事情时,模型会将其视为对同一主张的三次独立确认——并在答案中给予该主张更高的排名。
这是一个披着现代外衣的经典混杂因素。模型重复计算并不是因为它不擅长概率。它重复计算是因为上下文窗口不包含“这些来自同一来源”的注释。分块作为独立项从检索器传来。模型没有理由不区别对待它们。
其结果是一种微妙且持续的偏见。命中重复内容的查询得到的答案,会对重复段落所说的内容表现出系统性的过度自信。如果重复段落是正确的,你的评估指标在这些查询上可能会略微上升。如果它是错误的或片面的,你的评估指标就会下降。无论哪种情况,这种偏见都是不可见的,直到你阅读单个上下文并注意到同一句话出现了三次。
这种模式还降低了对抗性提示词的成本。任何能够让一段内容被索引三次的人(例如,通过以三种格式提交同一个常见问题解答,或者在合作伙伴供稿中使用略微重新格式化版本的博客文章),都能在不改变检索评分函数的情况下,在模型的回答中获得不成比例的权重。
在 Top-K 上进行检索后的去重处理
首要的修复方法是不要再将摄入时的去重作为唯一的防线。摄入步骤可能会出现退化。向量数据库可能会接收来自流水线之外的重复数据。同一内容的各种不同格式变体可能会被嵌入为不同的向量,从而绕过所有已知的基于哈希的去重检查。
在 Top-K 结果上进行检索后的去重处理既廉价又稳健。在你检索到(例如)20 个候选分块后,对它们进行两两内容相似度检查,并合并任何超过阈值的内容。这种检查可以是词法的(直接比较标准化后的文本),也可以是语义的(比较嵌入向量)。对于前 20 条结果,这两种方式都很快。然后,你将去重后的前 5 条结果返回给模型。
这种想法的一个更具原则性的版本是最大边际相关性(Maximal Marginal Relevance,简称 MMR),这一 1998 年的算法随着 RAG 系统的成熟正在悄然复兴。MMR 通过最大化组合得分从候选池中迭代选择:得分由对查询的相关性减去对已选分块的多样性惩罚构成。一个可调参数 λ 控制着算法在查询相关性与多样性之间权衡的力度。输出的 Top-K 明确不包含近似重复的内容,因为与已选分块近似重复的内容在选择步骤中会被惩罚。
MMR 已被 Azure AI Search、Elasticsearch 和 OpenSearch 采用为内置重排序选项,正是因为它解决了 Top-K 中的冗余问题,而无需你重新训练底层的排序模型。它是一个关注点分离清晰的评分后处理层:检索器负责相关性,MMR 负责多样性,而模型则获得一个每个分块都在发挥不同作用的上下文窗口。
会显著报错的摄入契约测试
检索后处理层保护你免受漏网的重复数据影响。而摄入契约测试(Ingestion Contract Test)则是用来告诉你去重步骤本身是否仍在正常工作。
针对摄入去重的契约测试非常直接。你定义一小组已知的重复输入 —— 即你手动确认过、只是格式不同但内容相同的段落。你将它们运行在摄入流水线中。你断言生成的索引中每个逻辑段落只包含一个向量,而不是三个。当标准化和哈希步骤出现退化时,测试就会失败,而且其报错足够显著,以至于没人会将其视为不稳定的测试。
这种测试的价值在于它不衡量检索质量,而是衡量检索质量所依赖的“不变性”(invariant)。在性能退化发生后的一周内,评估结果(eval)可能看起来仍然正常,因为重复的内容被检索的次数还不足以影响平均值。而契约测试在退化代码合并的那一刻就会失败。这就是为什么它是一个质量底线的不变性属性,而不是一个质量上限的衡量指标。
以下几种互补的信号也应该出现在同一个监控面板中:
- 每个主题的索引重复密度。 跟踪每个主题聚类中唯一分块与总分块的比率。该比率下降意味着重复数据正在某处堆积,即使你的总分块计数看起来很正常。
- 嵌入向量归一化检查。 一种更隐蔽的静默失败发生在嵌入向量完全没有归一化时 —— 搜索开始衡量向量模长而不是语义相似度,长文档会系统性地排在更相关的短文档之前,且没有任何报错或警告。在插入时进行单位长度断言(unit-length assertion)可以在第一批次就捕获到这一点。
- 由已知会命中重复主题的查询构成的评估切片。 当你发现由重复引起的事故时,来自该事故的查询将成为永久的评估切片。未来同类别的退化将有据可查。
质量上限是薄弱不变性的产物
这个架构教训超出了去重本身。一个 RAG 流水线拥有一系列不变性 —— 唯一性、归一化、分块边界一致性、索引文本中不存在零宽字符、以及在分块后依然保留的源文档 ID —— 每一个下游组件的质量都受限于其中最薄弱的一环。评估(eval)是一个质量上限指标,只有当违反不变性的情况触及模型的上下文窗口时,它才会发生变化。而不变性本身是质量底线的属性,需要独立的观察。
做得好的团队会将不变性视为一等信号。他们及早编写契约测试,接入监控面板,并接受其中一些面板可能会连续数月毫无波澜,然后在某一天帮他们避免一次宕机。他们还实现了关注点分离:摄入去重是一个不变性,检索去重是另一个,而模型的过度加权行为是第三个。每一个都有自己的机制,彼此不可替代。
做得不好的团队最终会发布一个模型,该模型会自信地重复同一个段落三次,因为它拿到的证据就是这么说的 —— 而且直到有人阅读上下文窗口并注意到模型自始至终都在如实陈述证据之前,他们都不会明白原因。证据本身是错的。原本用于确保证据准确性的机制,自从一次无人标记的重构后就一直静默失效了。教训在于,RAG 流水线的质量上限是其最薄弱不变性的产物,而只有当你直接观察不变性,而不是从其下游指标中推测其健康状况时,不变性才能保持强健。
会员专享
余下内容仅对会员开放。
会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。
- —完整文章,包含未公开存档的部分
- —可落地的工作框架,附带权衡与决策依据
- —新文章抢先看,先于公开发布
随时取消 · 一次订阅,畅读全部