ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

在RAG与生成之间加一道闸门:Jev如何拦截幻觉答案

在RAG与生成之间加一道闸门:Jev如何拦截幻觉答案 先说一个我实际遇到的场景。系统上线第三周有用户问“我们的报销流程里电子发票需要打印几份纸质版存档”我翻遍检索结果知识库里根本没有这一条但生成模型还是编了一段“打印两份交由财务审核”的答案。用户信了去问了财务被告知根本没有这种规定。这是一个很典型的 RAG 翻车现场——不是模型不会答而是检索阶段就没捞到正确材料生成阶段又“自信”地把空白补上了。后来我把所有误答样本拉出来归因发现真正由“生成幻觉”导致的只占不到两成八成以上是检索质量或者检索-生成之间的衔接出了问题。从那时起我开始认真思考一个问题能不能在 retriever 和 generator 之间加一道闸门让不合格的检索结果在进入生成模型之前就被拦下来这道闸门我做了三个月代号叫 Jev一直在生产环境跑到现在。这篇实录就是把从设计、实现、评估到上线的完整过程写下来适合那些已经跑通 RAG、但正在被“答非所问”“胡编乱造”困扰的同学。1. RAG 系统跑起来之后问题才真正开始跑通一个 RAG demo 很容易文档灌进向量库写一段相似度检索拼进 prompt调用大模型看起来一切正常。但放到真实业务里问题全变了。我做过的项目里RAG 最大的瓶颈从来不是“能不能把文档找回来”而是“找回一堆东西之后系统知不知道哪些能用、哪些不能用”。1.1 三个被教程忽略的翻车现场第一个翻车现场叫“检索到了噪声块”。向量相似度高不代表内容真的相关。比如用户问“员工年假天数怎么算”知识库里有篇文档提到了“年假期间工资计算”里面有大段的工资公式还有一句“年假天数按工龄折算”。向量相似度把这篇文档排在前面但真正回答“天数怎么算”的表格在另一篇文档里。生成模型拿到噪声块后很可能把工资公式当成核心内容答出一段文不对题的话。第二个翻车现场是“多块自相矛盾”。用户的 query 涉及新旧两版制度知识库里一篇文档说“报销需提供电子发票”另一篇说“报销需提供纸质发票及电子发票双份”。两篇都被检索出来生成模型没有能力判断哪个是现行有效版本于是把两个说法糅在一起输出一段逻辑完全混乱的答案。第三个翻车现场最隐蔽——“知识库里根本没有答案系统硬答”。这是所有 RAG 场景里最危险的一种。用户问的制度、流程、参数知识库里连影子都没有。模型为了完成“回答问题”这个任务会从其他相关但不涉及的文档里抓取概念拼出一个看起来合理、实际完全虚构的答案。我在开头提到的报销案例就属于这一类检索阶段没有任何一个 chunk 能覆盖答案但生成阶段仍然“自信”地完成了补全。这三个场景加在一起指向同一个结论RAG 的问题本质上是输入侧的问题。你给生成模型喂了什么它就只能基于什么作答。而检索结果的质量恰恰是整个链路里最不可控的一环。1.2 Hit Rate 虚高背后的真相很多团队汇报 RAG 效果时喜欢说“我们的 hit rate 到了 85%”。我一开始也这么干后来发现这个指标在真实业务里参考价值有限。Hit rate 的定义通常是在 top-k 检索结果中是否包含至少一个正确答案对应的文档。也就是说只要正确答案出现在第 1 位、第 5 位或者第 10 位都算“命中”。但生成模型真正使用的是整个上下文窗口里的全部内容检索结果里的噪声块、矛盾块、无关块统统会被打包送进生成阶段。Hit rate 高只能说明“能捞到”完全不能说明“捞得干净”。我当时做了一个统计用线上日志里的 1000 条真实 query 做评估传统的 hit rate 大概在 85% 左右但如果把标准换成“检索结果 top-3 里没有明显噪声块且答案来源块的前三位相关度都达标”可用率直接掉到 63%。这两个数字之间的差距就是所有 RAG 翻车事故的来源。从此我看 RAG 效果不再只看 hit rate而是重点关注“实际进入生成上下文的内容质量”。1.3 为什么把生成模型从 7B 换成 70B 也救不回来团队里有人提过最直接的方案换更大的生成模型。他们说“7B 模型理解能力不够换个 70B 就不幻觉了”。我一度也信了花了两个星期做对比测试结果很打脸把同样的检索结果喂给更大的模型它确实能把话写得更通顺、更像那么回事但真相是——它给我编得更流利了。大模型的能力再强也强不过“输入里根本没有正确答案”这个事实。RAG 的核心不是生成是上下文工程。生成模型只是把限定好的资料转述成自然语言它在转述过程中天然倾向于“把话说完整”这种倾向在检索结果质量差的时候反而是致命的。所以问题很清楚与其换更大的生成模型去“兜底”不如在输入端做更严格的治理。这也是 Jev 闸门最初的设计动机——在 retriever 和 generator 之间加一个不参与生成、只负责审查的中间层。2. Jev 闸门的设计判定什么、权重怎么定Jev 这个名字没什么特殊含义当时随手起的内部代号。真正花心思的是它的判定逻辑。我开始设计的第一个版本只有“相关性评分”一个维度结果误杀率极高后来重构了三版才定下多维判定的架构。2.1 三个判定维度相关、可答、证据充分我把一个检索结果块对某个 query 的适用性拆成三个正交维度第一个是相关性。query 和 chunk 在语义上是否属于同一个主题。这个维度解决的是“检索词相似但内容无关”的问题。比如用户问“报销流程”检索里出现的“发票真伪查验”文档词面上有交集但主题不是一回事。第二个是可回答性。chunk 里是否真的包含用户所问的那个具体信息。相关性解决“话题对不对”可回答性解决“答案在不在”。同样拿报销举例一篇文档通篇都在讲“报销注意事项”相关度很高但用户问的是“报销单需要几个人签字”文档里一个字都没提这就是相关但不可回答。第三个是证据充分性。chunk 能否支撑最终回答的完整表述。这一维度针对的是“多个 chunk 拼起来才能回答”的情况。单个 chunk 只覆盖了答案的一半需要和另一个 chunk 合并才能构成完整证据链。这三个维度分开打分再综合效果远比只做一个相关性打分稳定。2.2 打分模型与阈值路由架构Jev 不是一个单独的大模型而是一个混合信号层。我给它取了个内部术语叫 jscore来源包括四部分向量余弦相似度、一个轻量 reranker 的得分、query 与 chunk 的关键词覆盖度、实体重合度人名、制度名、编号、金额等。各部分权重我实际用得比较顺手的配置如下信号权重说明向量相似度0.25语义召回的基础但不能单独信任Reranker 得分0.35核心信号能有效区分“话题相关”与“答案相关”关键词覆盖度0.20对制度、编号类场景尤其有效实体重合度0.20识别具体人名/日期/金额强证据信号综合成 jscore 后路由逻辑分成三档jscore 区间处理策略 0.72直接拦截不送入生成模型0.72 ~ 0.88降级处理保留该块但在 prompt 中明确标注“证据不足” 0.88直通正常进入生成上下文这三个阈值不是拍脑袋定的。我是挑了一批历史误答样本逐个算 jscore观察分布后找出来的分界点0.72 以下几乎没有正确答案0.88 以上的样本基本都能直接支撑回答。中间的模糊地带交给降级策略而不是强行二值化。2.3 为什么不用大模型做闸门判定团队里另一种方案是用大模型直接判断“这个 chunk 能不能回答这个问题”我测过效果也可以但我最终没选它。核心原因是稳定性和成本。大模型判定存在两种不稳定状态一种偏宽松chunk 稍微沾点边就放行闸门形同虚设另一种偏紧张明明答案在但模型边边角角挑出毛病就拦截误杀率冲到 30%。同一个模型在同一批样本上隔一天跑出来的判定结果都不一样这在生产环境里是没法接受的。闸门组件需要的不是“聪明”而是“稳定”。哪怕它偶尔有一两个误判也必须保证误判模式是持续一致的这样我们才能针对性地调优。大模型在这点上天然不占优势。Jev 最终采用的轻量编码器加规则层的混合方案判定逻辑可控、延迟低误判模式也可解释——每个拦截动作都能追溯到底是被哪一个信号拉低了分数。3. 接入管线在 retriever 和 generator 之间做一次“外科手术”设计定下来之后真正的工程量在接入。Jev 的定位不是重写一套 RAG 框架而是作为一个独立的中间件插进现有链路。改造成本控制在一天以内这是上线前就立下的硬指标。3.1 插桩位置与接口设计Jev 的插入位置很明确检索完成后、prompt 组装前。它不参与检索不修改向量库也不干预生成模型。输入是 query 加上检索返回的 chunk 列表输出是过滤后的 chunk 列表和对应的 jscore。接口设计成无状态组件一个函数搞定:def jev_gate(query: str, chunks: List[Chunk], config: JevConfig) - GateResult: scored [score_chunk(query, chunk, config) for chunk in chunks] passed, degraded, rejected route_by_score(scored, config) return GateResult(passedpassed, degradeddegraded, rejectedrejected)无状态的好处是方便灰度。我可以随时把线上流量切到 Jev 的日志模式、只读模式或全量拦截模式不用重启服务也不用改调用方的代码。3.2 中间态处理截断、重排、压缩检索返回的 top-k 往往不是按质量排序的。向量检索的结果天然偏向“主题相关”而不是“答案相关”所以 Jev 在打分之后还会做一步重排把 jscore 高的块挪到 prompt 最前面。第二步是截断。LLM 上下文窗口有限不能把 top-10 的块全部塞进去。我的策略是按 jscore 从高到低取前 N 块直到累计 token 数达到窗口上限的 80%剩下的全部丢弃。这一步看似简单实际效果比单纯按向量相似度截断好很多因为 jscore 的排序逻辑和生成模型的“有用性”感受是高度一致的。第三步是合并去重。知识库里经常出现同一个内容被切到多个 chunk 里的情况切分工具按固定长度硬切经常把一张表格切成两半。Jev 会检测相邻 chunk 的内容重复度和继承关系如果两个块的 jscore 都高且内容有交叉就合并成一个再送回 prompt。这套处理逻辑其实也回应了一个常见疑问RAG 文本拆解工具到底该切多细我的经验是切分粒度直接影响闸门效果。切得太粗一块里混合多个主题单块 jscore 会被拉低切得太细信息碎片化证据充分性又不够。实践下来按段落语义切分、单块控制在 300~500 token 是最稳的区间。3.3 降级路径当闸门判定为“模糊”时怎么办jscore 落在中间地带的块单独拿出来既不能说它没用也不能直接信。我在设计里给它专门开了一条降级路径根据业务场景分三种处理方式第一种带标注回答。如果最终生成上下文里只有降级块可用prompt 会明确附加一句“这些资料置信度较低可能存在信息不完整”让生成模型在措辞上保持保守不要使用“一定”“必须”这类绝对化表达。第二种触发知识库补全工单。降级块往往意味着知识库有内容缺口Jev 会把这类 query 自动记录下来整理成“疑似知识库缺失清单”。我后来专门给这个清单接了一个机器人每周自动推送给知识库运营的同学让他们确认是不是有新文档需要补充。第三种追问澄清。这条主要针对 agent 型 RAG 场景。系统判定信息不足时不再硬答而是反问用户“您问的是 A 场景还是 B 场景”或者“需要我按现有资料给您一个大致的参考吗”。这招在真实业务里非常管用用户的满意度反而比分条列出答案时更高。4. 评估方法与上线策略没有这套流程我不敢碰线上Jev 上线的最大难点不是算法而是怎么证明它上线之后不会误伤正常请求。我花了一半以上的时间在评估和灰度上。没有这套流程我是不敢把闸门开到全量流量上的。4.1 离线评估集构建从用户真实问题里挖样本很多人评估 RAG 组件时喜欢找现成的公开数据集我做了一轮对比就放弃了公开数据集和我们的知识库风格差太远制度、流程类问题的文本特征和新闻、百科完全不同。还是得用自己系统的真实数据。我从线上日志里抽最近三个月的真实 query过滤掉测试流量后标注了 1800 条。标注分四类有标准答案、无答案、边界模糊、多块矛盾。这个分类比单纯的“对/错”要更接近真实场景因为 Jev 的拦截逻辑只对“无答案”和“多块矛盾”的样本真正有意义。标注过程很耗时但值得。1800 条样本覆盖了各种刁钻问法比如“带错字的问题”“中英混杂的问题”“只给关键词的问题”。这些样本在后续阈值标定里发挥了很大作用。4.2 阈值标定用拦截率与幸存率曲线找拐点评估 Jev 的核心指标有两个拦截率被拦掉的无答案 query 占所有无答案 query 的比例和误杀率被错误拦掉的有答案 query 占所有有答案 query 的比例。理想状态下闸门应该同时做到拦截率 90% 和误杀率 5% 以下但现实里这两个指标是互相拉扯的。我把 1800 条样本按 jscore 从低到高排序画出一条曲线横轴是阈值纵轴是拦截率和误杀率。结论很清晰阈值设在 0.68 以下误杀率确实低但大量无答案 query 也会溜过去阈值推到 0.75 以上无答案的拦截率上来了误杀率也开始快速抬升。拐点出现在 0.72 左右这个点上拦截率能到 85%误杀率控制在 8% 以内。这里有个经验要分享阈值不能只看整体分布要看细分类。制度类 query 的 jscore 天然偏高事实类查询偏低。我当时做了一个保险做法——按 query 模板设置阈值档位制度类用 0.74事实类用 0.70混合类走默认 0.72。效果比单一阈值好不少。4.3 灰度发布日志先行、只读模式、半接管即便离线评估做得再充分也不敢直接全量上线。我分了三个阶段推进。第一阶段是日志先行。Jev 以旁路模式部署对所有线上流量打分但结果只写日志、不拦截任何请求。这个阶段持续了一周目的是验证线上真实分布和离线标注分布是否一致。事实是基本一致但多了几个长尾噪声模式比如某些特殊格式文档的 jscore 整体偏低。第二阶段是只读告警模式。设置一个很保守的阈值比如 0.60 以下才提示Jev 开始对“极大概率无答案”的请求发告警。这个阶段我只观察不动作确认了告警内容 95% 以上确实有问题后才进入第三阶段。第三阶段才是真正拦截。筛选出一批低风险 query 类型例如“制度查询”类先对这些流量开启全量拦截其他流量继续日志模式。跑了三天误杀反馈为 0才逐步扩大到全量。每一步都有独立回退机制任何一个环节指标异常一键回到日志模式。这听起来保守但 RAG 系统一旦上线用户很快会产生依赖一次明显的误拦截就可能让整个功能口碑崩掉。5. 本地部署与性能优化CPU 也能跑得动Jev 的设计目标之一是不依赖外部 API、不把核心质量链路交给第三方服务。整个闸门组件完全本地部署数据不用出内网。这在企业场景里是硬性要求很多公司的知识库里都有内部制度文档不允许出网。5.1 部署架构与硬件选型Jev 的本地部署包含三部分一个轻量编码器模型负责语义特征提取、一个 reranker负责核心相关度打分、一个规则引擎负责关键词覆盖度和实体重合度计算。编码器和 reranker 都可以用开源权重量化后在 CPU 上就能跑。我的最低配置测试环境是 8 核 CPU 16GB 内存实测单条 query 下 10 个 chunk 的总判定时长在 80~120ms。生产环境我推荐 16 核 32GB 内存或者一张消费级 GPU能把平均延迟压到 25ms 以内。Windows 和 Linux 我都部署过。Windows 环境下踩过一个坑编码器模型路径不能含中文否则加载阶段直接报错。后来统一把模型放在纯英文路径下问题消失。如果你是在 Windows 上做本地部署建议从一开始就把模型目录规范成纯英文。5.2 延迟优化三板斧量化、batch、缓存上线初期我发现 Jev 自身的延迟占比接近整个 RAG 链路的三分之一有点偏高。后来做了三项优化把平均延迟降了一半以上。第一板斧是量化。编码器和 reranker 全部转成 int8 精度精度损失在可接受范围实际 A/B 对比后 jscore 偏移不超过 0.02但推理速度提升了接近 3 倍。第二板斧是批量推理。同一个 query 对应的多个 chunk 合并成一个 batch 送入模型比逐个请求省去大量模型加载开销。实测 10 个 chunk 的场景下延迟从 90ms 降到 55ms。第三板斧是缓存。相同或相似 query 的判定结果直接命中缓存不再重复计算。考虑到很多用户会反复查询同样的制度条目缓存命中率能到 40% 左右整体平均延迟进一步降到 40ms 上下。三项优化做完后Jev 在整个 RAG 链路中的耗时占比从 30% 降到 12%已经完全不影响用户体验。5.3 和 LangChain4j 及主流 RAG 框架的对接方式如果你的项目不是自研管线而是基于 LangChain4j 或者其他 RAG 框架搭的Jev 也能以中间件形式插进去。以 LangChain4j 为例核心机制是拦截 RetrievalResult在最终组装 prompt 之前插入 Jev 的处理逻辑public class JevRetrievalAugmentor implements RetrievalAugmentor { Override public ListRetrievalResult augment(Query query, ListRetrievalResult results) { return JevGate.filter(query, results); } }对接成本其实很低因为 Jev 只操作框架给出的中间结果不侵入检索源管理、不碰 embedding 模型、不干预 prompt 模板。凡是框架允许你在检索后和组装前动一手的都能无缝接入。还有一个进阶场景值得提如果你的 RAG 模块引入了 ontology知识图谱作为结构化知识源Jev 可以把图谱实体和文本块做一次交叉校验。比如文本块里出现了制度编号 A-2023-018但图谱里根本没有这个节点Jev 会把这个可疑块拉低分数。这种结构化信号比纯文本语义信号更硬能有效避免生成模型被虚构编号带偏。6. 实测中发现的问题与最终调优Jev 上线不是终点而是新一轮调试的开始。生产环境的数据和离线评估集总有些微妙差异我跑了一个季度陆续踩出几个有代表性的问题也对应做了调整。6.1 阈值调高的“虚假安全感”第一个问题是“阈值调高了但用户满意度没有同步提高”。我观察了几周拦截数据发现高阈值确实把大量低质量结果挡在了门外但被放行的那些 query 里仍然存在不少“答了但答不准”的情况。原因在于jscore 只代表“这个块看起来能用”不代表“这个块真的能把问题答透”。后来我加了一层更细的校验在 jscore 高分的基础上检查文本块能否覆盖 query 的所有子问题。实现方式是把 query 拆成若干个信息点逐一检查块内是否包含对应表述。这项“逐点覆盖”检查补上了单纯分数阈值的盲区误答率又降了一截。6.2 长文本块判定失效的反例第二个问题出在长文本上。知识库里有几篇制度文件长度接近一万字切分工具按固定窗口切导致某些 chunk 特别长。这类 chunk 里确实包含了答案但 Jev 的 jscore 却偏低。定位后发现原因长块里的冗余信息太多特征向量被大量无关内容稀释相关信号被淹没在噪声里。 reranker 对长文本的响应也不如短文本敏感。我不去改切分逻辑而是在 Jev 内部加了一道滑动窗口二次打分一个超长 chunk 会被切成 300 token 的小段分别打分取最高分为该块的最终 jscore。这样既保留了长文本的上下文完整性又避免信号被稀释。6.3 知识库更新后的静默漂移第三个问题比较隐蔽。知识库新增了一批文档之后我注意到整体 jscore 分布慢慢往右偏了但用户反馈的误答事件并没有明显增加所以拖了两周才发现。原因是新文档的文本风格和旧文档差异较大编码器对新风格的相似度打分存在系统性偏差。更麻烦的是如果没有意识去查这种偏差会在日志里静默存在很久。从这之后我养成了一个习惯每次知识库批量更新后自动用线上抽样 query 跑一轮 jscore 分布回归和历史分布做对比提前发现漂移。同时保留一份“黄金样本集”每次更新后都拿它做回归一旦拦截率或误杀率偏离基线超过 5 个百分点就要重新标定阈值。顺带提一个容易被忽略的点知识库目前大多以纯文本为主图片、扫描件里的信息在向量化阶段就丢失了。经常有人问“RAG 知识库能存图片嘛”结论是能存文件但检索和判定都不理解图片内容。Jev 能从文本侧识别出“证据不足”但无法替你补上图片里的信息。这类问题要从数据侧解决——OCR、多模态向量化——单靠闸门是救不回来的。最后说一点个人体会这套 Jev 闸门做完之后我最大的感受是RAG 系统真正需要的不是“答得多漂亮”而是“知道什么时候该闭嘴”。大多数 RAG 翻车翻的不是生成能力是对自身知识边界的不诚实。闸门组件做的事情很简单:在系统没有把握的时候老老实实告诉用户“我没有找到答案”而不是硬编一个。上线后我把无答案 query 的拦截率和误答率做了个联动统计发现每拦住 10 个无答案请求就有 3 个用户会转而通过补充资料的方式最终获得答案——这说明用户更愿意接受“没有”而不是“瞎编”。最后分享一个我在训练闸门模型时的小技巧准备评估样本时把无答案问题的比例刻意拉高到 30% 以上。大多数人在构建 RAG 数据集时习惯只放“有标准答案”的问题但闸门模型恰恰需要大量的无答案样本才能学会“拒绝”。这个比例放到 30% 之后我再测线上误答率下降了一个明显的台阶。如果你的闸门组件总是不拦截先别调阈值回头看看训练数据里“该拦的样本”是不是太少了。
返回列表