做 RAG 应用的人,最头疼的不是检索不到,而是检索到了不该检索到的东西,然后模型一本正经地把它编进答案里。我在做知芽 Notebook Skill 这套工具链的时候,核心目标就一个:让模型说出的每一句话,都能被验证。先说结论,这套方案解决的是 RAG 链路里最隐蔽的一类问题——合成来源,也就是模型把多个来源的信息揉在一起,甚至凭空捏造出一个看起来合理、但实际上不存在的“出处”。解决方案拆成两层:底层用单元记忆做知识切分和定位,上层用引用存在性校验做答案溯源和拦截。两层配合下来,幻觉比例能压到很低的水平。这篇就把架构思路、核心实现和踩坑过程完整拆开讲。
这套东西适合谁参考?如果你在做知识库问答、私有文档助手、Agent 工具链,或者被 RAG 幻觉问题折磨过,认真看完应该能少走几个月的弯路。
1. 架构解构:为什么要把“记忆”和“引用”拆开设计
1.1 幻觉从哪里来:合成来源的三种典型路径
先说个反常识的结论:RAG 里的幻觉,很多时候不是模型“不知道”,而是模型“自信地知道了一个错误的东西”。我统计过自己项目里的失败案例,发现合成来源类幻觉基本走三条路径。
路径一叫“跨段落缝合”。模型同时检索到 A 段和 B 段,两段内容互补,模型就把它们拼成一个新表述,看起来信息量很大,实际上拼接处的逻辑关系是模型自己脑补的。路径二叫“隐式知识污染”。文档里有个数字是 3.2,模型在预训练阶段见过另一个场景里有个数字是 3.5,检索内容又存在模糊匹配,它就把 3.5 跟当前问题关联起来。路径三最坑,叫“无中生有引用”。文档根本没提某个结论,但模型觉得“这种说法应该有出处”,就编一个类似文献、类似章节的描述进去,甚至给你一个假的关键词定位。
这三条路径有一个共同点:它们在模型内部都“感觉很顺畅”。因为生成过程是一步步自回归的,前面已经写了“根据相关文档”,后面就倾向于补一个完整结论,补的过程里很容易滑向编造。传统做法是做完检索再做一次相关性过滤,但那只解决“检错了”的问题,解决不了“生成错了”的问题。
1.2 核心设计取舍:单元记忆与双重判据
所以我当时的思路转变是:不要试图在生成阶段堵住模型,而是把生成结果重新拉回“可验证轨道”。这就引出了两个关键设计决策。
第一个决策是,把知识库从“文档集合”改造成“记忆单元集合”。文档是一段连续文本,边界模糊;而记忆单元是带有唯一 ID、类型标签、来源坐标的最小知识块。每个单元都能独立被引用、被校验、被追溯。相当于你把一整块土地切成一块块带坐标的豆腐,哪一块出了问题,立刻能定位。
第二个决策是,在答案生成之后,强制走一遍引用存在性校验。模型生成答案时,我要求它必须为每个关键结论附带一个引用标识,标识指向某个记忆单元。然后系统校验:这个标识存不存在?指向的单元内容支不支持这个结论?支持程度够不够?两个判据任何一个不过,答案就被拦截或降级。
这套设计看着简单,但实施起来有一堆细节要抠。比如引用标识怎么生成、校验的相似度阈值怎么定、拦截之后怎么优雅降级。后面展开讲。
2. 单元记忆:把知识切成可校验的原子块
2.1 记忆单元的结构设计
记忆单元是整个体系的地基,结构设计直接决定上层校验能做得多严格。我在实践里跑过几版方案,最终稳定下来的字段结构是八个维度:单元 ID、内容、类型、来源坐标、时间戳、校验指纹、关联链、状态标记。
单元 ID 不是随机的 UUID,它有编码规则。ID 前缀表示类型,中段是来源文件的哈希短码,后段是单元序号。这样设计的好处很直接:只看 ID 就知道单元来自哪个文件、什么类型、第几个片段,排障时一眼就能看出问题链路。
内容段存储的是单元的实际文本,但这里有个容易犯的错——直接把文档段落截断就存进去。正确做法是引入一个“引用纯度”概念,即单元内尽量只包含单一语义主题。一个单元说清楚一个事,不把两个知识点塞进同一个块。切分时我用的策略是:先按结构边界(标题、段落、列表项)切,然后做语义边界检测,检测到主题漂移就强制截断。
来源坐标字段记录了来源文件路径、页码/段落位置、以及文档内的偏移量。为什么要单独存这个?因为后续引用校验需要展示证据上下文,没有坐标就没法回到原文。
校验指纹是单元内容的一个哈希摘要,它服务一个特殊场景:检测记忆库被更新后旧引用是否失效。如果来源文档修订了,指纹变化,所有指向旧版本的引用自动变为“待确认”状态。
2.2 记忆注册与索引策略
存储结构定了之后,关键问题就是单元如何进入系统。我给记忆注册设了三条管线:主动注册、增量注册、订阅注册。
主动注册是你直接把文档拖进来,系统做解析、切分、指纹计算、入库。增量注册是监听某个文件夹或数据库变更,新文件出现就自动切分。订阅注册针对的是动态数据源,比如工单系统、实时日志,按固定节奏拉取变化。
入库不是简单存进去就完事,还要同步做两件事:构建语义索引和构建引用索引。语义索引负责回答“跟这个查询语义相关的是哪些单元”,用的是向量化加近似最近邻检索;引用索引负责回答“这个单元 ID 是否存在、内容指纹是否匹配”,用的是精确匹配的键值结构。这两套索引必须保持原子性同步,否则会出现一种诡异状态:语义索引能检索到某个单元,但引用索引里查不到它,导致校验环节直接判定引用不存在。
我在这块踩过一个不小的坑,当时只做了语义索引,引用校验时发现所有引用都“不存在”,排查了很久才发现是同一个单元在两套索引里的 ID 编码不一致。后来解法是做一个统一注册表,任何单元变更只走一个入口,由注册表分发到语义索引和引用索引。
索引的更新策略也有讲究。这里我用的是双缓冲机制:写入先进待发布区,积累到一定规模或达到时间窗口后再统一切换。这样避免了写入过程中检索端读到半成品状态。
3. 引用存在性校验:让每个答案都有“出处”
3.1 校验流程与判定标准
引用存在性校验是整个系统里最核心的关卡,它像一个审计员一样,在模型生成答案之后、返回给用户之前,把答案里的每个引用节点都验一遍。
全流程我拆成了四个阶段。
第一阶段叫抽取,从模型生成的文本里解析出所有引用标记。这里有个格式约定问题。如果你等模型自由输出,它可能给出各种不规范的引用,比如“参见文档”“根据资料显示”,没法自动处理。我的做法是在 prompt 里强制要求引用使用严格的 JSON 格式标注,模型输出形如 “知识单元[UID=mem_ab12cd34_007]中提到...”。解析器只认这种结构,其他形式一律忽略。
第二阶段叫存在性检查。拿引用标记里的 UID 去引用索引里查,查不到就直接判定为“合成来源”。这里有个细节,模型有时会把 UID 稍微改一个字,比如把 007 改成 008,导致存在性检查误杀。为此我加了模糊匹配兜底:精确查不到时,用编辑距离做近似匹配,距离小于等于 1 就放行到内容比对阶段,同时在日志里记一条“疑似改写引用”。
第三阶段叫内容一致性比对。这是硬核环节。系统取出引用指向的记忆单元内容,计算该内容与答案中该引用附近文本的语义相似度。这个相似度不能简单用向量余弦,因为答案里往往是改写后的表述,语义等价但字面差异很大。我最终用的组合判据:向量相似度占 60%,关键词重叠率占 25%,结构化实体对齐占 15%。三类指标加权后得到一致性得分,低于阈值就判定为“引用与内容不符”。
第四阶段叫归因逻辑检查。模型有时引用的内容和结论本身能对上,但结论跟问题问的没关系。这属于更高级的跑题。我在这里引入一个“问题-结论相关性”校验,用一个小模型判断:给定问题、给定结论链条,结论是否合理回应了问题。这个判据稍微宽松,但它能拦掉很大一类“答非所问但自洽”的幻觉。
3.2 相似度阈值调参与回退策略
阈值怎么定,是实操里最让人挠头的问题。定高了,大量真实引用被拦截,用户体验变差;定低了,幻觉漏网。
我前前后后测了好几组数据。先说一组参考值:一致性得分高于 0.82 视为可信,0.72 到 0.82 视为存疑,低于 0.72 直接拒绝。这里说的得分不是相似度本身,而是加权组合后的分数。对应到向量相似度上,大约相当于 0.78 和 0.65 两个切点。
但阈值不能拍脑袋定,要结合实际知识域调节。如果领域术语非常标准,改写空间小,阈值可以适当调高;如果领域表述口语化,同样含义能换多种说法,阈值就需要调低。我建议的做法是:每种知识类型单独统计 500 条人工标注样本,做 ROC 曲线找最优切点,然后留 10% 的余量防止过拟合。
拦截之后不是直接抛错误,而是走三级降级链路。第一级是有替代引用可用时,自动换到相似度更高的记忆单元重新生成;第二级是没有替代引用但存在部分相关引用时,把答案降级为“提示:以下内容仅有部分依据”,并列出可用来源;第三级是完全无引用可依时,直接拒绝回答,转而引导用户换个问法或补充文档。降级策略非常关键。我试过直接拦截,结果用户反馈说系统“突然傻了”,因为用户不关心你有没有依据,他只关心能不能拿到答案。降级方案让用户始终有路可走,同时系统的可信度也保住了。
4. 实操演示:从接入到拦截一个幻觉案例
4.1 环境与接入准备
这一节给一个端到端的实操记录,大家能照着复现。先说环境:我这里用了一套内部实验框架,核心是自研的 Notebook Skill 运行时,语言为 Python,向量检索用轻量级的内嵌向量引擎,文本生成接了一个中等规模的通用模型,支持自定义 prompt 和结构化输出。硬件上单机部署,16 核 CPU 加一块消费级显卡,内存 32GB,跑起来没压力。
接入分三步。第一步搭建记忆注册管线,配置源目录和切分策略。第二步建校验服务,启动抽取、比对、判定三个子模块。第三步写一个路由脚本,把用户请求先发给检索模块,再把检索结果和用户问题一起交给生成模块,最后把生成结果送给校验模块。
下面是一个最小化的路由脚本骨架:
class NotebookSkillRoute: def __init__(self, memory_service, check_service, generator): self.memory_service = memory_service self.check_service = check_service self.generator = generator def handle(self, query): # 1. 检索相关记忆单元 units = self.memory_service.search(query, top_k=5) # 2. 构建带引用要求的上文 context = self.build_context(units) prompt = ( "根据提供的知识单元回答用户问题," "每个关键句后标注引用格式:[UID=xxx]," "不要编造任何知识单元中不存在的信息。\n" "知识单元:\n" + context ) raw_answer = self.generator.generate(prompt, query) # 3. 引用存在性校验 verified = self.check_service.verify(raw_answer) return verified这段代码把整个链路串起来了,核心逻辑都集中在这三步。实际生产里还要加缓存、并发控制、请求日志,这里从简。
build_context 方法里有个重要细节:传给模型的上下文不要只传检索到的 top5 单元,还要附加一条系统级约束,说明“如果问题不在任何知识单元范围内,直接回答‘没有找到相关信息’”。这能大幅减少模型为了“显得有用”而强行编造的概率。
4.2 端到端演示过程
我挑一个真实测过的案例来说明效果。某次测试,我给记忆库导入了三份产品手册,内容覆盖A产品的参数配置、错误码说明和升级日志。然后我问了这样一个问题:“当设备返回错误码 0xE104 时,是否需要先升级固件再排查网络参数?”
这个问题的坑在于:错误码 0xE104 在手册里对应的描述是“连接超时”,升级日志里提到最近的版本优化了连接稳定性。模型如果做跨段落缝合,会推理出一个“需要先升级固件再排查网络参数”的结论——这看着顺理成章,但没有任何一个记忆单元直接支持它。
系统生成的原始答案确实这么写了,而且引用了一个升级日志单元和一个错误码单元。校验流水线跑起来的判定过程如下:
第一步,存在性检查。两个 UID 都在引用索引里查到了,通过。第二步,一致性比对。答案中对升级日志单元的引用附近文本“固件升级提升了连接稳定性”,与记忆单元原文“通过优化握手逻辑减少连接超时情况的发生,具体参数不变”算了一致性得分。直接看有两处明显不对:原文强调“参数不变”,答案是“调整参数”;原文没有“先升级再排查”的顺序关系,答案是先升级后排查。加权算下来一致性得分只有 0.48。
第三步,归因逻辑检查也发现,结论对问题“是否要先升级”的回答,超过了知识单元的支撑边界。两条判定都不通过,这条答案被拦住了,系统走了第二级降级链路,最终回答是:“根据已有资料,错误码 0xE104 表示连接超时,建议排查网络参数;某版本固件优化了握手逻辑,但文档未指明操作顺序,请结合现场网络条件判断。”
这个最终回答不惊艳,但它每一句都有依据,没有一个是模型自己缝出来的。这就是引用存在性校验的典型价值:它拦住的不是“错得离谱”,而是“看起来对但没依据”。
5. 效果对比与高频踩坑
5.1 拦截效果量化对比
为了验证这套架构的收益,我做了几组对比实验。对照组是不带引用校验、直接做向量检索加模型生成;实验组是完整链路。测试集是 200 条手工构造的问题,其中包含 50 条合成来源陷阱题、50 条模糊边界题、50 条正常检索题、50 条跨域题。
三组关键数据,直接说结论。第一条,合成来源陷阱题,对照组正确率只有 38%,模型几乎每道题都在缝;实验组正确率到 84%,多数陷阱被拦截后走降级链路给出谨慎回答。第二条,答案引用覆盖率,实验组 91% 的答案关键句带有效引用,对照组只有 12%。第三条,整体用户接受度,实验组比对照组高 17 个百分点,原因不是答案更“聪明”,而是答案更“稳”。
收益背后也有代价。完整链路在单次请求上平均多了 180 毫秒延迟,其中一致性比对的大头。我做了多级缓存和并行校验后,把额外延迟压回 90 毫秒左右。如果你的场景对延迟极度敏感,建议把校验拆成异步模式:先返回带“待验证”标签的答案,后台跑校验,发现问题再推送更正。这一步对实时对话场景尤其好用。
5.2 高频踩坑与排查技巧
写这块的时候我翻了自己的排查日志,挑了四个最具代表性的问题。
第一个坑,引用格式漂移。模型的输出习惯会受对话上下文影响,某轮对话你用了相对随意的引用格式,后续的生成结果就延续那种松散格式,导致解析器抽不出 UID。解法是 prompt 里给一个正面示例加两个负面示例,比写一句“必须用JSON格式标注”管用得多。示例驱动对模型输出稳定性帮助极大。
第二个坑,切分单元过碎。早期切分时我按固定窗口切,每个单元 400 字,结果一个单元里可能残留上一段的半句话,引用比对时一致性分数波动很大。改成语义边界切分后,一致性得分的方差降了 30% 以上。如果你没有现成的语义切分工具,至少按段落和法律条文、步骤列表的结构边界切,不要裸用固定窗口。
第三个坑,校验阈值和检索阈值的耦合。检索阶段 top_k 拉回来的单元,如果不做二次筛选,很多低相关单元会混进上下文,模型引用它们的概率增加,校验阶段又不通过,形成恶性循环。我在检索之后加了一道相关度门槛,低于阈值的单元不让进上下文,这是整体准确率提升最大的一次改动。
第四个坑,模型在引用时“镜像”了记忆单元的文字。比如单元里写“连接超时”,答案里也写“连接超时”,一致性得分极高,但答案对问题的回应本身是错的。这类情况靠一致性比对拦不住,必须靠归因逻辑检查。我用了一个专门的小模型,判断结论是否真正回应问题,把这类案例又拦掉了一半。
排查技巧方面,我强烈建议做一份“校验日志明细表”。每条被拦截的答案都记录:问题原文、答案原文、引用UID、各维度得分、拦截理由。这个明细表作用巨大,既能反向优化 prompt 和切分策略,又能当人工复核的输入。没有数据支撑的幻觉防御,基本是盲人摸象。
还有一个容易被忽略的运维点:记忆库内容更新后,旧引用缓存必须主动失效。我因为没及时处理,好多次答案引用的都是旧版单元,校验虽然过了,但信息已经过时。现在我在单元存储里加了指纹字段,每次引用校验时对比当前指纹,不一致就强制回归最新版本。
我个人在实际操作中还有一个体会:幻觉防御永远不要只依赖某一个环节。通过 prompt 约束、检索过滤、生成约束、引用校验四层互补,系统才能扛住不同类型的攻击型问题。单靠其中一个环节都容易漏,四层叠起来才能感受到那种“稳了”的感觉。
最后再分享一个小技巧。你可以在校验模块里加一个“可信度分级”输出,每一条最终答案都附带分数和证据链。给用户的界面只显示“高”“中”“低”三个标签,既公开透明又不打扰阅读。实际跑下来,这个功能不仅提升了可信度,也大幅减少了用户追问和质疑的比例。