先讲个我遇到过的事。一个做知识库问答的模拟项目X上线之后,用户反馈某次回答里附了三条引用来源,每条都标了文档编号、页码、原文摘要,看起来简直是交付级质量。结果运营同事去核对,发现其中一个来源在知识库里根本不存在,是模型为了凑够“三个引用来源”的格式,自己编造出来的。更麻烦的是,另两条来源虽然真实存在,但正文和结论对不上——模型截取了文档里的一段话,却把结论指向了完全相反的方向。
这不是个例。做RAG、做笔记智能体、做文档问答的人应该都有同感:幻觉不会因为接了检索而消失,它只是换了一种出场方式。以前模型凭空编一个答案,现在模型会先检索到一堆东西,再“礼貌地”编造一个看起来引用得很严谨的合成来源。
这就是我想写的主题。知芽 Notebook Skill 是我参与的一个文档型技能项目,核心目标是降低这类“合成来源”出现率。方案不算特别复杂,但胜在把防线前置了:用单元记忆替代整篇记忆,在输出前做引用存在性校验。这套组合拳打下来,至少能把“编造来源”和“来源与结论脱节”这两类幻觉压到很低的水平。这篇文章会把设计思路、工程细节、踩坑过程全部摊开讲。适合正在做知识库问答、RAG 检索、笔记助手这类系统的人参考,尤其是那些已经被“看起来合理但经不起核对”的AI回答折磨过的同学。
1. 合成来源是怎么混进来的:一次“正常”幻觉的完整路径
在动手改架构之前,得先把问题定位清楚。所谓合成来源,是模型在生成回答时,为了满足某种格式、语气或可信度要求,主动补造了不存在的证据链。它和传统幻觉最本质的区别在于:传统幻觉是“内容错误”,合成来源属于“证据伪造”。
1.1 模型为什么宁可编来源也不愿意“承认不知道”
最初我把合成来源归因于“模型太爱表现”,后来看了一些日志才发现,问题出在指令约束上。很多知识库系统在提示词里写了类似“请引用相关来源,并在回答后列出引用列表”的指令,这本意是好的,但模型在执行时会把“列出引用”理解成一个必须完成的格式要求。
理想状况下,如果检索器提供的文档不足以支撑回答,模型应该输出“该问题在现有知识库中暂时找不到依据”。但实际推理时,生成模型并不具备“我检索不到证据”的元认知能力,它只知道自己有一段上下文窗口,窗口里有一些文本块,它必须生成一个符合指令格式的回答。于是,它选择就地取材——哪怕上下文里的文本块与问题关系很弱,它也会从中抽取关键词,拼出引用格式,再把结论往那个方向圆。
这就是合成来源的温床:不是模型不知道答案,而是格式压力迫使它用不完整的信息强行作答。
1.2 从“整篇记忆”到“一篇多引”的混淆
传统做法是直接把原始文档整篇塞进上下文或向量库,检索时按整篇文档返回。这样会出现一个很微妙的问题:如果一篇文档有两万字,中间讨论了“方案A的可行性”和“方案B的成本风险”,检索器把整篇返回给生成模型,模型读到的是混杂信息。
当生成模型去做答案时,它很可能把“方案A”的结论和“方案B”的引用编号粘在一起。对用户来说,引用ID存在、文档名也对,但打开看细节就发现牛头不对马嘴。这不是严格意义上的无中生有,却是引用体系里最常见、也最难统计的合成来源变体——来源是真的,支撑关系是假的。
我在实践中发现,标题越长的文档、章节越混乱的资料,这种混淆率越高。原因很简单:检索粒度太粗,模型根本没有机会区分“哪句话来自哪个段落”,最后只能靠猜。
1.3 定位结论:要拦截合成来源,必须解决两个前置问题
拆完这条路径后,目标就清晰了。第一,必须让系统从记忆机制上杜绝“一锅炖”,让每个知识单位都可被独立引用和独立验证,这就是单元记忆的由来。第二,必须在生成之后、输出之前设置一道基于存在性和逻辑一致性的校验闸门,专门拦截那些“来源存在但支撑关系不成立”的引用。
这两件事缺一不可。单独做单元记忆,只能降低来源混淆,拦不住模型凭空编ID;单独做存在性校验,只能保证来源有效,拦不住“有效来源得出错误结论”的情况。
2. 单元记忆:为什么把知识切成块,比整篇塞给模型更抗幻觉
单元记忆,这个叫法可能有点陌生,但它本质上是把入库文档从“文件级管理”改成“知识单元级管理”。核心思想很朴素:每个可引用的证据粒度应该是一个独立、自洽、可单独追溯的原子文本块,而不是整篇文档。
2.1 切块的粒度和边界的确定依据
第一次设计时,我们按常见习惯固定切 512 个 token,结果效果很差。有些块语义不完整,正好从段落中间断开;有些块太碎,一句话就一个块,检索出来全是碎片,模型拼不上因果链。
后来改成规则+语义混合切分:以Markdown标题、段落分隔、列表结构为主要切分边界,边界内再做语义完整度判断。简单说,一个单元必须同时满足三个条件:有明确的主题倾向、有相对完整的上下文、不跨多个章节主题。
文本自动切分终归不是完美的,所以我们在入库流程里加入了一个轻量的质量检查。不是去训练模型判断,而是规则判断:如果某个单元长度低于阈值且不含完整句子结构,直接丢弃;如果某个单元内出现了三种以上不同主题词,则回溯上游,重新按更细粒度拆分。这一步跑起来之后,单元的纯净度提升非常明显。
2.2 单元记忆为什么能降低“一句话支撑一个结论”的幻觉概率
因为生成模型在检索召回时拿到的是原子文本块,引用就是针对这个块,而不需要引用整个文件。模型没有机会去“跨块重组支撑链”,自然降低了张冠李戴的概率。
举个具体的例子,一份产品说明文档里前半段写性能指标,后半段写价格条款。用整篇召回时,模型可能会用“性能指标”段落里的句子,去支撑“价格优惠”的结论,然后在引用列表里附上一整篇文档的链接。用单元记忆召回时,“性能指标”和“价格条款”是被拆成两个独立检索单元的,模型的指令里会明确要求“仅引用与结论直接相关的单元”,它基本没法做跨单元拼接。
需要注意的是,这不能完全杜绝拼接,因为模型在上下文窗口里能看到多个单元,它仍然可能把不同的单元串成一个错误结论。所以单元记忆只解决了“引用来源可精确定位”的问题,准确性要靠后续校验来兜底。
2.3 单元的记忆权重设计:让高频使用单元优先被检索
另一个值得讲的设计点是权重。同样的文档,不同单元的检索优先级不应该一样。我们给每个单元加了一个权重维度,命中次数越多、被引用次数越高的单元,会获得一个递增值。检索返回时,除了算向量相似度,还要叠加这个权重,按综合分数排序。
这个设计一开始只是为了检索体验,后来发现它对幻觉防御也有意外帮助。高频被引用的单元通常经历了更多验证,本身质量更高、主题更聚焦,模型更容易在这些单元上生成稳定的结论,而不是在那些冷门、语义模糊的单元里找“灵感”。
2.4 与“小抄式记忆”的区别
很多人会把单元记忆和另一种玩法搞混:为了压缩上下文,先让模型把长文档生成摘要,再把摘要作为检索对象。我把那个做法称为“小抄式记忆”。它有一个致命问题:摘要是模型的二次创作,已经在原始来源和最终输出之间插入了一层“可能失真”的中间层。
单元记忆不依赖这种二次创作。它保留的是原始文本切片,只做切分、不做改写。整个链路里,从知识入库到最终引用的每个环节,都有一条原始的、可对比的文本证据链。这对存在性校验来说至关重要——校验系统必须有原始文本可查,而不是对着一段摘要使劲。
3. 引用存在性校验:从源头把合成来源拦在门外
单元记忆解决了引用的可定位性,下一步是给输出加一道闸门。这道闸门就是标题里的“引用存在性校验”,英文团队内部习惯叫 reference existence check。它不是简单的“查一下引用有没有”,而是多层校验的组合。
3.1 第一层校验:引用对象必须真实存在
第一层最基础,也最容易想到:模型输出的每条引用,必须在记忆单元集合里找到对应的真实条目。这一步拦截的是“模型凭空编造文档ID或URL”的情况。
具体工程实现上,我们在生成阶段就要求模型以结构化字段输出引用编码,例如 [REF-1024-03],这个编码是记忆单元的唯一标识。生成结束后,校验模块会拿到这个编码,去记忆单元索引中做一次精确查找。判定逻辑如下:
- 编码不存在:直接判定为合成来源,整条引用废弃。
- 编码存在但对应的单元生命周期已标记为“已下架”或“不可追溯”:同样判定为不可引用。
- 编码存在且状态可用:进入第二层校验。
这一层看起来简单,但坑在于不少系统只做了“URL存在性检查”,没做“内容快照存在性检查”。URL能访问,不代表页面里的内容还是模型当初看到的内容。如果知识库支持文档更新,就必须把“引用针对的是哪个版本的单元”也一并校验。我们给每个单元加了版本号,引用编码里带上版本号,一旦文档更新,旧版本引用立刻失效,强制模型重新生成。
3.2 第二层校验:结论与来源必须具有语义一致性
存在性校验如果只做到第一层,只解决了“引用存在”,拦不住“存在但内容无关”。所以第二层必须做语义一致性检查。这一层操作起来比听起来复杂,因为“语义一致”本身是个模糊概念。
我们的做法是把校验拆成三步:
第一,提取结论限定词。从模型生成的回答里抽取断言部分,比如“方案A在延迟指标上优于方案B”,抽出“方案A”“延迟指标”“优于”这三个关键元素。第二,在引用的记忆单元里做同样的关键元素扫描,看单元中是否出现“方案A”和“延迟指标”,以及是否存在表示对比或结论的句子。第三,用轻量级向量模型计算回答断言与引用单元之间的语义相似度,相似度低于阈值则触发校验失败。
这三步合起来,能拦下两类问题:一类是模型引用了一个真实单元,但结论是从别的单元里传来传去传歪了;另一类是模型引用了一个同主题下的边缘段落,用边缘细节去撑一个全局性很强的结论。
3.3 第三层校验:引用上下文与输出上下文的语境适配
这一层是我在踩过几次坑以后才加进去的。前两层校验都过了,还会遇到一种情况:引用来源真实、内容也与结论相关,但语境变了。原文档里说的是“不推荐在极端场景下使用”,模型却把它当正面案例引用了。原文本是“该指标需结合环境温度修正”,模型却当作常温下的恒定值引用。
第三层校验的做法是:从记忆单元中提取该句所在段落的立场、限定条件、转折关系,与模型输出中的立场方向做对比。如果一个单元中包含“然而”“不适用”“除外”“限制”这类反向标记,而模型输出的结论是正向的,那么该校验直接打回。
这一步实际操作起来准确率不如前两层高,因为模型输出的句子有时会包含多层复杂逻辑,很难用规则完全判断。所以我们把它设计成一个软校验,触发后不是丢弃整个引用,而是让模型基于该引用重新生成一次回答,并提示它注意解读可能过度,需要收敛到自己支持的范围。
3.4 校验模块放在生成前、生成中还是生成后
这是一个值得单独说的问题。我也见过一些方案,把校验放在生成前,也就是先让检索器从记忆单元里选一批候选,再由校验模块看看这些候选和问题够不够相关,不相关就不给模型。
这种方案的问题在于:检索器召回的候选是否充分、是否相关,根本不是一个能在生成前定调的事情。有些问题需要多轮回溯判断,过早校验容易把正确答案误杀。
我的落地经验是:生成前只做粗过滤,把明显不相关的单元剔除;生成中通过提示词约束模型只能引用可见单元,而且要逐条带引用;生成后做三层精细校验。也就是“前疏后密”的策略。生成前的职责是保召回,生成后的职责是保准确,两者职责不同,不能混在一起。
4. 完整链路与工程参数:一套可以参考的防线配置
设计思路说完了,接下来是工程实现。我把知芽 Notebook Skill 里最终生效的那套配置和链路完整列出来。不敢说这套配置是唯一标准,但它是经过一批真实场景考验的,拿来参考完全够用。
4.1 从文档入库到引用的七段链路
流程上,整条链路分为七个阶段:文本解析、单元切分、单元自检、向量化、检索召回、生成约束、引用校验。
文本解析负责提取原始内容并丢弃排版噪声;单元切分按章节和语义边界切出原子记忆单元;单元自检确认每个单元语义完整且主题不杂;向量化给每个单元生成检索向量,同时保留原文快照和版本号;检索召回按相似度和权重选出候选单元;生成约束通过提示词限定模型只能引用候选单元,并以限定格式输出;最后的引用校验就是我们前面说的三层逻辑。
这套链路特别强调一点:从单元切分到校验结束,全程不允许模型“转述”或“重写”记忆单元的原文。所有字段最终都要对得上原文快照,只在输出阶段允许模型用自己的语言组织结论。
4.2 提示词里的引用约束怎么写
提示词设计了较严格的引用格式要求。关键要告诉模型:你可以引用候选单元,但引用必须逐条附匹配证据,如果认为没有候选单元能支撑回答,明确写“当前知识库无相关内容”。
还要求模型在回答结束时,输出一个 JSON 结构的引用链表,包括单元ID、单元版本号、匹配片段、置信度。这个结构化输出直接喂给校验模块做自动校验,避免从自由文本里二次解析引用信息带来误差。
经验:提示词里要写“宁可少引一条,也不要编造一个支撑关系”,让模型在不确定时选择克制。实践统计中,加了这句话后,合成引用出现频次下降约三层。
4.3 主要参数配置参考
表里记录的是生产环境实测参数:
| 参数 | 设定值 | 说明 |
|---|---|---|
| 单元最小长度 | 64 字符 | 低于该长度且无完整句子则弃用 |
| 单元最大长度 | 512 字符 | 避免单元过大导致多主题混杂 |
| 相似度召回阈值 | 0.72 | 低于阈值不召回,减少噪音入上下文 |
| 语义一致性阈值 | 0.60 | 低过阈值则触发本体检索重出 |
| 纯存在性校验失败重试次数 | 1 次 | 最多让模型重新推理一次 |
| 版本失效引用处理 | 剔除并重新检索 | 不允许降级使用旧版本 |
这组参数不是拍脑袋定的。阈值定得太高会漏召回,定得太低会引入无关单元,我们通过一组基准问题跑了一批对照实验,以“最终引用有效率”为指标,选了效果最稳的一组。换数据集之后,这组参数可能需要重新调,但调参的方向和逻辑可以沿用。
4.4 校验失败的兜底策略
再稳妥的系统也有校验失败的时候,兜底策略要提前想清楚。
第一类兜底:某条引用校验失败,但其他引用有效。这种情况直接丢弃失败引用,保留有效部分重排,不让失败引用影响整体回答。第二类兜底:所有引用都校验失败。这时候必须拦截答案,不让输出,并返回“当前知识库缺乏足够依据”的提示。第三类兜底:模型连续两次生成都无法通过存在性校验。基本可以判定是问题本身超出知识库范围,直接把问题标记为需要人工补充资料。
这三类兜底有一个共同原则:宁可少给信息,也不给未经验证的合成来源。幻觉防御的核心不是让模型永不出错,而是让错误没有机会伪装成可靠证据。
5. 实测结果与踩坑判断:这套方案能拦住什么,拦不住什么
再好的设计都要放到真实环境里检验。知芽 Notebook Skill 在压力测试和开放问题测试里暴露了不少信息,有成绩,也有短板,这才是真实状态。
5.1 测试口径:如何定义“引用有效率”
引用有效率 = 通过全部三层校验的引用数 / 模型输出引用总数。这个指标能比较直观地反映系统拦截合成来源的能力。基线版本(未做单元记忆,只做了简单链接有效性检查)的引用有效率大约在 62% 到 68% 之间波动。这个数字看着不高,但确实反映了“来源存在,但支撑关系很弱”情况的高频出现。
改造为单元记忆加三层校验后,引用有效率稳定在 87% 到 93% 区间。主要增益来源是后验提示词约束和语义一致性校验。第一层存在性校验的拦截量反而不是最大的,因为它拦截的本来就是模型凭空编造的最明显情况,这种最容易露馅,模型在长上下文压力下并没有那么频繁地做这种事。
5.2 拦得住的典型问题
单元记忆加存在性校验这套组合,处理得最好的是三类问题。
第一类是虚构来源,直接写一个不存在的文档名和链接;这些基本在第一层就被拦下了。第二类是跨主题拼接,引用来源是真实的,但结论支撑的是另一个主题;单元切分和语义一致性校验基本能拦住。第三类是压缩低频信息导致的错误引用,典型的例子是模型引用了一段仅出现一次、没有被充分强调的次要信息,却把它当作全文档的核心结论来引用。这类问题第一层校验不会发现,第三层语境校验能识别出“以偏概全”的倾向并拦截。
5.3 仍然拦不住的边界情况
说实话,这套方案不是万能的,有几类情况依然很难完全处理。
最难的是“改写式幻觉”。模型没有直接引用原始单元,而是用自己的话重构了内容,这导致语义相似度比较高,三层校验全部通过,但实际上重构后的意思已经偏离了原文。这种情况校验模块只能降低发生概率,无法根治。根治需要更细粒度的语言忠实度评估,成本高,我们暂时没有投入。
另一类情况是“多跳间接支撑”。用户问的问题需要从三个不同文档单元里推导才能得到结论,模型把三个单元都引了,每个单元单看都有效,但推导过程存在跳跃。校验模块只检查“引用与结论的一致性”,通常不能看清推导链条中的逻辑漏洞。
最后一种涉及更新频率。知识库更新后,旧断定和新源头冲突明显,但模型同时引用旧单元和新单元,努力“中和”。这种自我矛盾式回答,引用的存在性和一致性校验都难以完全拦下,只能尽可能依赖版本失效机制去限制旧引用的存活时间。
5.4 工程过程中踩过的一些具体坑
踩坑是这份工作里最重要的收获来源,挑几个最值得说的记录下来。
第一是关于文本切分与单元边界的选择。一开始追求切得“漂亮”,遵照标题结构和编号,结果有些编号下的多个小节主题跨度大,被强行塞进一个单元;后来定下原则:单元边界优先服务于“可独立支撑一个论据”这一目标,而不是优先服从排版结构。
第二是关于只查引用格式、不查引用内容的坑。最初的第一版校验,只判断引用条目结构是否完整、标签是否闭合。后来线上日志里出现了一批引用编号正确但实际文档内容与题目无关的情况,才发现只查格式形同虚设。从那以后,所有校验必须做内容比对,同时保留原始内容和版本号。
第三是关于“存在性校验”和“语义校验”互相替换的偏差。有一段时间我为了精简流程,试图把语义校验合并进存在性校验里,这样能少跑一次模型调用,降一些延迟。结果发现两类校验覆盖的错误类型重叠度有限,合并后大约有一成左右的原本可拦截的偏差用例漏了过去。从那之后,三层校验始终分开执行,不再合并。
第四,也是最后一条,数据标注的着力点应该与系统一致。我们原本有三名标注员专门标注“是否幻觉”这一类基础标注。后来发现护重点应放在“引用支撑关系真假”上,而不是单纯给答案贴一个“幻觉”标签。修改标注方案后,模型的微调和校验模块的性能提升都明显加快,数据使用效率有了实质改善。
6. 成本、隐私和边界:工程是否值得如此复杂的校验链路
看到这里,一个现实的问题自然会出现:为了防幻觉,把链路搞得这么重,延迟和成本划不划算?这个问题在团队内部也讨论过很多轮,我给出几个真实的工程结论。
6.1 单元记忆会显著增加向量库体积,需要提前规划
单元记忆与整篇入库相比,最大的工程代价是索引条目变多、存储开销上升。以一份五万字的技术文档为例,整篇入库如果只建一条向量,用单元记忆大约会产生 120 到 180 个记忆单元。
这也是幻觉防御的必然代价:证据粒度细化就需要更多锚点。现在主流向量数据库对短文本条目的挤压并不严重,硬件成本撑得住。真正要注意的是检索速度,单元多了之后,索引条目的增长会让检索的召回时间变长。我们用的折中方案是分层索引:先按文档域过滤掉大量无关单元,再在相关域内做细粒度召回。这样既保持了单元级记忆,又不会在检索阶段全面扫描。
6.2 三层校验的性能开销比想象中可控
容易担心语义一致性校验会拖慢整个响应,每次都要跑一次向量模型,延迟压力大。实测数据是:一次语义校验大约需要增加几十毫秒的时间,对一个面向成人的问答系统来说完全可以接受。
这些校验跑在 GPU 上,如果要求高并发,可以做成异步校验:先生成回答,立即返回给用户,随后后台校验引用有效性;如果校验失败,再向用户侧发起更正提示。异步模式下,用户得到的首字延迟基本无感。
6.3 在隐私层面,这套方案反而更占便宜
隐私问题值得单独提一句。单元记忆让系统运行的每一步都不依赖整篇原始文档的完整搬运,只按需取用相关的原子单元。相比将整篇文档喂给模型,单元粒度的隔离方式天然具备更强的隐私克制属性。
尤其在一些涉及私有知识的内部系统上,单元记忆允许我们对不同单元设置不同的访问权限,一个单元不可见,就不会被检索,也不会被引用。这对权限控制是一个额外加分项,不只是在防幻觉。
7. 从知芽 Notebook Skill 抽象出去:一套可迁移的幻觉防线
如果只把这套方案当成知芽一个项目的特例,那它的价值就没说透。单元记忆和引用存在性校验本质上是一套思维方式:把大模型当成一个生成器,而不是一个知识库;把证据链当成一套可追踪、可审计的工程结构。这种思维可以迁移到很多场景。
7.1 迁移到客服工单系统
客服工单回复场景里,模型经常依据一段培训手册或排障手册回答用户问题。如果直接把整本手册塞进上下文,模型很容易把不同故障情形下的处理步骤混在一起,合成出一个“看起来是标准流程,实际执行会出错”的回复。
迁移方案很直接:把整本手册按故障类别拆成单元记忆,每个单元包含“症状特征”“判定条件”“处理步骤”“预期结果”四个字段。模型生成回复时逐条引用单元,并且每个单元必须通过一致性校验才能出现在工单回复里。实测在模拟工单项目里,这类系统能把“步骤编排错误”的比率压到一个很低的水平。
7.2 迁移到企业资料检索助手
企业知识库里通常是多年积累的制度文档、项目复盘、培训材料。这些文档主题杂、历史版本多,非常容易发生“模型把三年前的旧制度当成当前制度引用”的幻觉。
处理方式和版本管理策略联动:每个记忆单元带生效版本段位,存在性校验时强制校验版本段位。模型若引用了过期单元,校验直接打回,并追问“是否有更新版本”。这个迁移基本不需要重写引擎,只需在单元维度加上有效期字段,就能让引用存在性校验同时承担“事实时效性”的职责。
7.3 这套防线的通用抽象
剥掉项目外衣,这套防线可以抽象成三个原则,三个原则在任何知识密度高的场景里都值得参考。
第一,证据粒度决定引用可信度。引用越细,越容易核对,幻觉越难以藏身。第二,整个生成链路里至少有一道“必须指向原始来源”的硬性校验,确保模型没有机会输出无锚点内容。第三,拦截永远好于修复。输出之前的校验与输出之后的用户举报相比,成本和用户体验都有质的差距。
写在最后
从知芽 Notebook Skill 的实际迭代过程来看,我的体会是:幻觉防御的真正难点不在某个单点模型能力上,而在把“证据意识”埋进系统结构的程度有多深。单元记忆和引用存在性校验听起来像两个功能点,但它们代表的是一种态度——让每个结论都能被追溯到一条可验证的真实来源。
如果你正在做类似的知识库问答或笔记型智能体,我建议别急着堆更多提示词去“吓唬”模型不要幻觉,而是先检查一下你的知识入库粒度:你的引用能够精确到一句话吗?你的校验系统是在查引用有没有,还是在查引用配不配?这两个问题的答案,往往决定了全套系统的防线有多牢。
最后分享一个小建议:给引用体系加版本号,是所有校验类工作里性价比最高的一步。装上之前你只能防“瞎编”,装上之后你还能防“过期和冲突”。别等出了问题再补,这一步值得在项目的第一天就做进去。