你的RAG忠实性检查测量的是复制粘贴,而非忠实性
你的RAG忠实性检查测量的是复制粘贴,而非忠实性
我为一个检索增强生成(RAG)pipeline构建评估工具时,写的第一个忠实性检查悄悄错了。它看起来很合理,每个样本都能免费运行。但它测量的根本是错误的东西,直到我开始故意喂给它边缘用例才发现问题。它的失败方式,与我复制来源的大多数RAG教程的失败方式如出一辙。
一句话总结就是:token重叠并不能衡量忠实性,它衡量的是复制粘贴的忠实度,而这两者之间的差距,恰好在你关心的那些案例中毁掉了你的评估。
让我展示一下我的意思,因为"用更好的指标"这种建议听起来很聪明,但帮不了任何人。
忠实性本应意味着什么
忠实性与相关性是不同的维度。相关性问的是答案是否解决了用户的问题。忠实性问的更窄:答案是否基于检索到的上下文实际内容?你完全可以做到高度忠实却毫无用处(你精确引用了上下文但完全没有回答用户的问题),或者相关但不忠实(你用自己编造的事实正确回答了问题)。真正的评估需要两者,并分开测量。将它们混为一谈是第一个错误,但这不是我想讨论的。
我实际上构建了什么
当你想要一个快速的忠实性信号时,教程答案几乎总是某种token重叠。统计答案中有多少单词出现在检索到的上下文中。重叠率高意味着答案来自上下文,所以它一定是基于上下文的。大致代码如下:
def faithfulness(answer: str, context: str) -> float:
answer_tokens = set(answer.lower().split())
context_tokens = set(context.lower().split())
if not answer_tokens:
return 0.0
overlap = answer_tokens & context_tokens
return len(overlap) / len(answer_tokens)
简洁、快速、无需调用模型,每个样本都能免费运行。这正是它无处不在的原因。但它同时向两个相反的方向出错,这是我直到深入检查才发现的部分。
失败模式一:停用词拉高分数(假阳性)
看看是什么主导了那个重叠集合。是"the"、"is"、"of"、"a"、"to"、"and"。功能词。每个英文句子大部分由功能词构成,你的上下文也是如此,所以它们总是匹配。分数因语法而上升,而不是因为基于上下文。
answer = "The device is covered for a period of thirty-six months." # 幻觉的数字
context = "The device is covered for a period of twenty-four months."
# 重叠:the, device, is, covered, for, a, period, of, months
# 只有"thirty-six" vs "twenty-four"不同
# 分数 ~ 0.9,记录为忠实
模型对句子中最重要的那个token——实际数字——产生了幻觉,而指标报告了90%的忠实度,因为那些框架词汇全部都匹配了。在保修机器人、合同助手等任何有效负载是数字或名称的场景中,这就是会让你被起诉的失败。指标对此视而不见,因为有意义的token只占总token的一小部分,而停用词淹没了它们。
去除停用词有一点帮助,但这只是对更深层问题的修补,也就是失败模式二。
失败模式二:同义词拉低分数(假阴性)
现在反过来。模型做得很好。它阅读上下文并改写而不是照搬,因为好的回答就该这样。
answer = "Shipping is free for orders above fifty dollars."
context = "We provide complimentary delivery on purchases over $50."
# 去除停用词后共享的内容token:基本上没有
# free != complimentary, shipping != delivery, orders != purchases, above != over
# 分数 ~ 0.1,记录为不忠实
这个答案完全基于上下文。每一个主张都可以追溯到上下文。指标将其标记为幻觉,因为模型使用了同义词词典。所以现在你的评估惩罚了你想要的行为——流畅的改写,奖励了你不想看到的行为——逐字复制。如果针对这个指标优化,你会训练或提示你的系统去鹦鹉学舌,这恰好也是最可能泄露你不希望暴露的逐字原文的行为。
这两种失败不是随机噪声。它们是结构性的,并且在同一个数字上指向相反方向。当基础不好时停用词把分数拉高;当基础好时同义词把分数拉低。一个在两个方向都出错的指标不是有噪声,而是毫无意义。对一千个样本取平均值会看起来非常稳定,但实际上什么也没告诉你。
我做了什么改变
解决方法是停止比较字符串,开始比较声明。我正在转向的方法将答案分解成原子事实陈述,然后针对上下文检查每个声明是否蕴含,而非单词匹配。
# 1. 将答案拆分成原子声明
claims = extract_claims(answer) # "shipping is free", "threshold is $50"
# 2. 对每个声明,问:上下文是否支持它?
# 语义蕴含,而非token重叠
supported = [entails(context, c) for c in claims]
faithfulness = sum(supported) / len(claims)
entails步骤才是真正的工作所在。一个cross-encoder NLI模型处理同义词和改写,因为它评分的是语义而不是表面形式。LLM作为评判的prompt也能做到,但成本更高,且有其自身的校准麻烦。任何一种方法都能修复两种失败模式,因为"complimentary delivery"蕴含"free shipping",而"covered for thirty-six months"不蕴含"covered for twenty-four months"。数字现在再次重要了,改写也不再受到惩罚。
有一件事值得坦白:在实践中,extract_claims本身通常是一个LLM调用,这意味着你引入了第二个可能失败的模型。拆分不足会隐藏复合错误。拆分过度会创建过于琐碎以至于几乎任何东西都能蕴含的声明。你只是把一个难题换成了另一个,而且在开始时知道这一点很有价值。
这样做更慢,每个样本要花钱,这个权衡是真实存在的。但是一个说谎的便宜指标并不比一个不说谎的昂贵指标更便宜。当幻觉的数字悄悄溜过,而仪表盘显示百分之九十忠实的时候,就足以让我做出改变。
要点
如果你的忠实性评估是基于token或n-gram重叠,那么它奖励的是复制粘贴,却称之为基于上下文。在相信它之前,请用两个案例来检查:一个是答案虚构了一个关键token但保留了句子框架,另一个是答案正确改写了上下文。如果你的指标没有标记第一个案例,也没有通过第二个案例,那么它根本没有测量忠实性,你的仪表盘变绿是因为错误的原因。
我目前卡住的一个开放问题是声明提取步骤,因为过度拆分会产生过于琐碎而无法证伪的声明,拆分不足则会隐藏复合错误。如果你在实际流量上运行过声明级别的忠实性评估,我很想知道它在哪里失效了,因为我只在我自己能想到的案例上测试过。
Top comments (0)