我在过去一段时间维护的一个垂直知识问答系统代号知芽做的是企业内部的资料检索与问答覆盖技术文档、项目总结、会议纪要这类场景。它不是简单的文档问答还要求答案能追溯到具体来源、能被复核、能离线解释——这在很多真实场景里是刚需特别是涉及决策参考、流程执行、风险判断的内容。今天要聊的是里面一个核心能力模块Notebook Skill。它最值得讲的地方不是某张算法图或者什么新奇模型而是在检索增强这个老话题上补上了一套从查得到到信得过的工程闭环——单元记忆Unit Memory与引用存在性校验Reference Existence Verification。一上来我得先纠正一个常见的理解偏差很多人觉得做知识库问答只要把资料切段、向量化、然后丢给大模型再加上一句prompt请根据提供的文档回答不要编造就能解决幻觉问题。实测下来这个想法过于乐观。模型在生成时往往会因为语言惯性而给出没有依据的内容更隐蔽的是它会生成一个看起来存在但实际不存在的来源——比如一段文档里根本没有的内容却被模型标注成了引用。这类问题我称之为合成来源synthetic source。它对系统的信任模型伤害极大。正是因为见过太多次这种翻车知芽Notebook Skill在设计上有意把答案质量问题拆成信息召回 证据核验 受控生成三段分别做了不同的机制。今天这篇我就把这两条主线——单元记忆机制以及引用存在性校验——完整拆一遍讲清楚为什么这样设计、怎么落地、有哪些坑。如果你是正在做RAG相关开发的同行这篇内容应该能让你少走不少弯路。1. 先明确问题什么比幻觉本身更麻烦1.1 三个来源模型为什么会出现虚假引用大模型的幻觉在错误形态上分很多种。我们最常见的是事实错误问一个问题模型给出了一个不符合事实的答案。但在知识库问答场景还有一种更隐蔽更危险的情况错误不是出现在答案本身而是出现在答案的出处上。从技术角度拆当前的大语言模型本质上是一个基于上下文的概率预测器。它生成每个token的时候只是在算按概率最高的下一个字是什么它并不知道自己说的信息是不是真的。理论上即便我们给它喂入了RAG检索到的上下文它仍然可能张冠李戴。原因主要有三类上下文漂移模型在多轮对话中容易把之前轮次的信息或自己的记忆当成当前检索到的上下文来用。忠实度不足模型在生成时图省事当上下文信息不足时它倾向于用内部知识训练参数里学到的来补全而不会主动说我不知道。引用生成不可控标准解码方式下模型生成的引用标记只是文本中的普通token它完全可能在知识库没有对应来源的情况下自行编造一个看起来合理的引用标记。第三点就是合成来源的根源。注意模型没有撒谎的动机它只是为了保持语言连贯性。这与人类的撒谎有本质区别——它甚至意识不到自己在编。1.2 合成来源的破坏力比错答更隐蔽我举一个实际场景。我让人工智能对着一份项目规划文档回答这个项目的风险是什么它列出来三条风险。其中两条确实在文档里有据可查但第三条风险的形象描述很具体模型把网络上类似案例的信息嫁接进来了还煞有其事地标了一个引用编号。你顺着编号回翻文档发现根本没有这段话。这种引用了不存在的内容比单纯答错更坑答错了用户至少知道它错了会去核实。造假引用用户会默认有据可查直接把错误信息当结论用风险在传播过程中被放大。从日志和审计角度看错误答案可以回滚和修正但伪造引用对应的上下文根本不存在连定位都困难。所以知芽Notebook Skill在设计时把引用真实性当成一等公民跟答案正确性分开治理。答案正确性由模型能力和检索质量来保证引用真实性则是工程层面对模型输出做硬约束。从我自己的经验看这两个问题的性质完全不一样。答案对错是一个质量问题可以靠更好的模型、更准的检索持续优化引用真实性是一个诚信问题必须靠机制兜底。一个系统的成败往往不是看那个模型有多强而是看它敢不敢对每一个引用都负责。于是就有了后面这套架构的核心目标让引用存在性校验成为一道物理防线任何没有证据支撑的引用在输出前就被拦截。2. 整体架构设计三层防线把编的机会降到最低在讲具体机制之前我先给一个整体视图。知芽Notebook Skill在架构上分了三层每一层解决一类问题层级核心能力职责记忆层单元记忆Unit Memory把知识库内容组织成最小颗粒的记忆单元建立索引与溯源链路校验层引用存在性校验Reference Existence Verification在生成与输出阶段对每个引用标记进行存在性、范围、语义一致性校验生成层受控生成Controlled Generation约束解码过程引导模型只基于已召回的记忆单元生成内容并强制输出引用标记这三层不是串行的单向流水线而是有回路的。核心理念是把我能不能说这个判断从模型身上转移给系统。这个转移说起来容易做起来难因为它意味着你要同时管住三件事数据底座得能找到证据生成过程得只能用证据输出结果得验证过证据。任何一个环节松一松合成来源就会从缝隙里溜出来。2.1 为什么需要单元记忆而不是整个文档传统的RAG方案一般是把文档拆成固定长度的chunk比如500字一个块然后做向量化召回把召回的chunk拼接到prompt里。这种做法有两个问题chunk边界粗糙上下文语义被切碎。模型很难判断一段引用到底落在chunk的哪个位置。chunk没有身份感。它是文件里的一个片段本身不具有可寻址的唯一标识校验时无从查起。单元记忆的做法完全不同。知识库里的每一条独立信息会被抽象成一个带身份标识的记忆单元Memory Unit。这个单元天然带有元数据来源文档、所在章节、起止位置、时间戳、语义向量、内容摘要。也就是说每一个记忆单元本身就是一条可引用的证据。这样设计的直接好处有三个可寻址每个记忆单元有唯一ID引用校验可以直接查ID是否存在可追溯从答案到记忆单元到原始文档链路完整可审计可组合答案可以引用多条记忆单元也可以组合单元做多来源交叉验证而不是只依赖单段文本。2.2 校验层怎么跟生成层配合这里的关键设计是模型在生成时不是自由输出 事后查错而是生成即受控。具体来说生成阶段会做两件事。第一通过约束解码在prompt中强制声明可用的记忆单元ID列表并明确约定引用标记的格式。模型只能在这些ID中选择引用标记不能自己发明新ID。第二生成过程中每个引用标记都会被临时校验器拦截检查。模型生成的每个引用token序列到达一定长度时系统会解析这个ID并实时去记忆索引中查存在性。这两个动作配合能挡住大部分无中生有的引用。但已有的约束解码技术并不能保证100%可靠——模型有时会通过近似ID或者指东打西的方式来绕过。所以在生成完成后还有一道后置校验Post-hoc Validation对最终输出中的所有引用标记做一遍完整的存在性、范围、内容一致性检查。任何一项不通过该句要么被重写要么被整句截断并返回抱歉这一部分我暂时无法确认来源。这道三道闸门配合下来基本把合成来源的路口全部堵上了。这个架构的精神概括成一句话就是宁可少答不可乱引。3. 单元记忆机制数据底座与写入流程单元记忆是整套架构的最底层也是设计上最花心思的一块。它不是简单的向量数据库而是一套带完整溯源能力的事实存储体系。3.1 记忆单元的结构一个记忆单元大概长这样字段做了简化{ mem_id: memo://kb/20250101_risk_0821, source_doc: 项目X_2025Q1_规划文档, chapter: 3.2 风险控制, offset_start: 12034, offset_end: 12178, content: A类风险主要来源于上游API限流预计影响时间为第一季度末。, semantic_vector: [0.12, ...], created_at: 2025-01-01T10:23:00Z, embedding_model: text-embedding-v4, status: active }这里有几个值得注意的设计细节mem_id采用memo://scheme 的可寻址ID路径中携带了来源文档、时间、序号信息。这样即便不查数据库光看ID也能大概知道这段内容来自哪里。offset_start和offset_end记录这个记忆单元在原始文档中的精确起止位置。后续校验时如果系统需要回到原文做交叉确认可以做到字符级定位。semantic_vector向量召回的核心用于语义检索。status记忆单元有生命周期可以被标记为active/deprecated/superseded。比如当文档更新后旧记忆单元会被标记为已废弃新单元替代它。为什么非要加status字段因为我踩过坑知识库是动态的文档经常修订。如果一个记忆单元已经过时但模型还在引用它它会成为合法但错误的来源。这比不存在的来源更难防范。所以单元记忆专门设计了废弃机制并把状态纳入引用校验的条件——引用一个deprecated单元会被校验器直接拒绝。3.2 写入门槛单元切分的颗粒度问题记忆单元应该多大这是整个系统最容易翻车的地方之一。我总结下来的经验是切太大整个段落或整节召回精度下降模型容易断章取义切太小一句话上下文丢失严重单元之间耦合断裂后续检索召回时难以理解语义。知芽Notebook Skill最初尝试过固定窗口切分比如512字符一刀切效果不稳定——有的长句会被切成两半信息断裂更麻烦的是跨段落关联的信息比如这个指标定义为X详见第4章找不到对应单元。后来我们把切分逻辑改成了语义边界优先 重叠覆盖的混合策略先用文档结构解析器把文档切分成自然区块章节、段落列表、表格块。对每个自然区块做语义密度分析判断是否是一个完整陈述。如果一句话承载了一个独立的断言一个主语一个谓词一个宾语就单独成单元。对跨单元引用的内容比如详见第4章生成链接单元把目标章节映射为可寻址的单元引用。这套切分策略见效很快但代价是解析逻辑复杂、单元数量膨胀。后面在实操调优部分我会专门讲怎么调这里先记住一个原则单元的本质是一个可验证的断言而不是一段文本。3.3 检索与召回给生成层提供许可证记忆单元写好了怎么用是另一回事。在推理阶段用户的问题进来后系统会做一次语义检索把候选记忆单元按相关度排序。但这里有个重要区别检索结果不是直接拼接到prompt里而是先被登记进一个可用引用白名单Allowlist。简单说这一步干两件事从记忆库中检索出Top-K个最相关的单元把单元ID和对应的原始文本拼进prompt同时把ID列表登记到本次会话的白名单中供校验器使用。白名单就是本次回答的可引用许可证。模型在生成时只能引用白名单上的记忆单元。它如果要引用一个不在白名单上的ID约束解码器会直接掐掉这从源头上挤掉了模型臆造ID的空间。不过这里有个技术细节要注意——白名单必须在生成过程中动态更新。如果用户在多轮对话中追问系统可能会做二次检索把新的单元加入白名单。这就要求会话级的检索上下文状态管理和白名单同步机制不能疏忽否则会出现模型能引用但校验器不认的尴尬情况这在调试阶段会让人挠头。4. 引用存在性校验来源验真机制怎么落地这是知芽Notebook Skill的另一根支柱。它解决的是即便模型生成了引用标记我们如何确认这个引用是真实存在、指向正确、内容一致的。4.1 校验的三个维度我把引用真实性拆成三个可校验的维度缺一不可存在性Existence引用的mem_id在记忆库中是否存在这是一切的前提。这个维度看起来简单但在分布式环境下记忆库的可能存在分片、缓存、副本一个ID在某些节点查不到、在另一些节点能查到就会出问题。指向性Relevance引用的记忆单元是否真的支持当前的断言。存在≠正确。比如记忆单元存在但内容讲的是前置条件引用却被安在一个讲部署方式的句子上这也是造假。一致性Consistency引用片段和记忆单元内容的语义一致程度。我们不允许模型借着一张皮说话——引用一个相关的单元但陈述的内容其实是从别处搬来的。三个维度都过了引用才算有效。4.2 校验的实现流程校验器在系统里是一个独立服务Reference Validator Service通过内部RPC被调用。整个流程分两种模式同步校验Inline Validation在约束解码阶段同步执行。每当模型生成一个引用标记的候选token时解码器调用校验服务的validate_reference(id, context)接口检查该ID是否在白名单内、单元是否active、指向是否合理。同步校验的延迟要控制在10-30ms以内否则生成速度会明显变慢。我们实测下来批量校验batching能显著降低这个开销。异步校验Final Validation在整段生成完成后对输出全文做一次扫描提取所有引用ID批量做完整的三维校验。异步校验的结果触发后置动作合格则放行不合格则整句重写或整段截断。这里我要补充一个踩过的重要的坑同步校验必须跟约束解码器密切配合不然会有漏网之鱼。约束解码器约束的是能输出的token集合它只能限制ID的文本形态格式是memo://开头、路径合法但它没法知道这个ID在记忆库里的status是不是active。曾经有一段时间我们的同步校验只检查了格式合法性没有查状态结果出现了引用了已废弃记忆单元的情况而且用户按照引用ID去翻文档发现文档已经更新新旧内容对不上。后来我们在校验器里加上了status active的强校验才把这条链路补严。4.3 校验失败怎么处理降级策略设计校验不是非黑即白的。我们设计了三级处置策略校验结果处置动作用户体验完全通过保留引用正常输出正常存在但语义不一致降级为弱引用去掉引用标记只保留陈述能看到内容但没有引用锚点不存在或已废弃整句删除并提示该部分来源待确认看到抱歉这里无法确认如果你问为什么存在但语义不一致不直接删句理由是有些场景下虽然引用对不上但陈述本身可能是对的比如模型用到了训练知识。强迫它删除会损失信息。但为了诚信我们宁可去掉引用标记也不能让它假装有出处。这个降级策略是经过多轮用户反馈打磨出来的。一开始我们的策略比较激进只要引用不一致就整句删结果在领域知识覆盖率不高的场景用户发现系统说话经常断体验很差。改成降级策略后准确率和可用性之间的平衡好了很多。5. 一次完整问答的旅程看三层防线如何协同前面讲的是机制模块这一部分把整个流程连起来看。我用一个具体的案例走一遍完整的推理链路。场景某研发知识库收录了一份《项目X部署手册》用户问项目X在部署时对Docker版本有什么要求5.1 输入解析与白名单登记用户问题进来后首先经过查询处理器Query Processor做实体识别和意图分类。这个步骤看起来简单但直接影响后续检索质量。项目X这么常见的词如果知识库里有多个类似命名比如项目X、项目X2.0需要靠实体消歧来解决否则召回会乱。查询处理器把问题向量化后去记忆库做语义检索。理想情况下会召回单元Amem_id: memo://kb/20250610_dep_x_0031内容部署项目X前需确认Docker版本不低于20.10单元B关于前置依赖的独立语句单元C关于升级策略的说明被误召回Top-K检索结果比如K15会统一登记进白名单。注意白名单不只是ID列表还带每个单元的检索相关度分数——这个分数后面会参与是否需要降级的判断。5.2 受控生成与同步校验构造完prompt后进入生成阶段。模型被提示以下是本次回答可以引用的记忆单元列表附ID与内容。回答时必须引用至少一个上述单元引用格式为【引用:ID】。不得引用列表之外的任何内容。模型开始生成。比如它生成了一句建议使用Docker 20.10及以上版本【引用:memo://kb/20250610_dep_x_0031】。 这时解码器解析出引用ID同步校验器立即检查这个ID在白名单吗单元状态是active吗单元内容是否与断言一致校验通过后生成继续。但假如模型在这里发挥了一下写成了建议使用Docker 20.10及以上版本并确保开启实验特性【引用:memo://kb/20250610_dep_x_0031】。 同步校验器一看确保开启实验特性这个信息在单元A里根本不存在语义一致性比对不通过于是解码器对这个token序列发出约束信号要么强制改写后半句要么删除引用标记并做降级处理。5.3 后置校验与最终输出整段生成完成后后置校验器再对全文扫描一遍。最后输出的答案可能是建议使用Docker 20.10及以上版本【引用:memo://kb/20250610_dep_x_0031】。整个过程用户看到的回答依然流畅但对于哪些内容有出处、哪些没有系统内部清清楚楚。万一将来用户对某个答案提出质疑运维人员可以直接拿着记忆单元的mem_id去定位到《项目X部署手册》的原始文档章节。审计这条链路只需要一条查询。这条完整链路的精髓用个比喻更好理解模型是写作者单元记忆是文献库引用校验是审核编辑。写作者可以自由发挥文笔但审核编辑手里有一份文献库目录任何引用都需要在目录里找到对应条目找不到就删掉。6. 实操调优几个关键参数的工程视角如果说前面的部分偏重架构设计那这一部分是关键的工程落地经验。知芽Notebook Skill上线以来的多次调优让我深刻感受到这类系统的效果很大程度上是由细节参数决定的而不是模型本身。6.1 检索Top-K与白名单大小Top-K直接影响答案可得性和幻觉率的平衡K调小比如5白名单太小模型可能找不到足够材料给出全面回答频繁触发降级体验差K调大比如50白名单太大约束变弱模型更容易选中一个并不十分相关的单元导致指向性校验不通过的比例上升。我们的经验值是知识库单元平均长度在200字符左右时K值设在15到20比较合适。这个数值需要根据库的聚合度、单元平均长度来调不能照抄。调试时可以观察一个指标引用校验通过率。如果通过率低于70%说明白名单里混了太多无关单元如果通过率高于95%可能说明Top-K太小回答覆盖不足。6.2 语义一致性的判定阈值引用存在性校验里最不好拿捏的是一致性维度。我们用语义相似度分数来做度量但阈值怎么定是个问题。阈值设太高0.95系统会经常误杀模型明明引用了正确单元但因为表述角度不同相似度低被判定为不一致导致大量正常回答被降级。阈值设太低0.7一些语义上确实偏离的引用会漏过幻觉拦截形同虚设。我们最终的做法是双阈值第一道0.85是硬校验低于0.85的直接不通过第二道是0.75到0.85之间的软区间落在这个区间的引用不做一刀切而是进入重写流程——让模型基于该记忆单元重新改写断言然后再次比较。一轮重写还达不到0.85就放弃该引用。这个双阈值机制比单一阈值要复杂但准确率和可用性都明显更优。我们内部跑过评测单阈值0.85的版本误杀率大概在11%双阈值版误杀率降到了4%左右而且漏网率没有明显上升。6.3 记忆单元的粒度调整记忆单元的切分策略是决定整个系统质量上限的环节。前面提到我们采用了语义边界优先 重叠覆盖的混合策略具体落地时有几个调优技巧表格要单独处理。不要把表格和段落混在一起切。表格本身是一个结构化整体切碎后语义丢失非常严重。我们为表格单独设计了一种表格记忆单元表头行数据单元格定位。长列表需要父子结构。一个十条的列表如果只切成一个单元召回时容易让模型针对整个列表做泛泛引用如果切成十个单元彼此缺乏上下文。我们采用了父单元子单元结构父单元包含列表的完整语义子单元是每一条的具体内容。引用时可以精确到子单元召回时可以借助父单元补充上下文。版本更新时做合并/拆分。文档修改后新版本里某个章节从原来的三句话扩展成了五句话如果机械地重切会导致记忆单元ID不稳定旧引用全部失效。所以切分器必须跟版本管理系统配合尽量复用未变化部分的mem_id只对变化区域重新生成。这个细节不做系统在文档频繁更新时会出现大量失效引用后置校验天天报警。6.4 与外部知识检索的协同知芽Notebook Skill并不排斥外部知识库。很多场景下知识库里没有的实时信息比如最新版本号、新特性说明需要从外部API或者网络检索补充。但这时候路径要分清楚外部知识检索到的内容同样要经过记忆单元化处理可以临时生成一个memo://ext/{source}/{hash}的临时单元并登记进白名单作为一种临时记忆单元。临时记忆单元要不要持久化成正式单元可以在回答完成后由人工或规则决定。如果一段被频繁引用就应该沉淀为正式单元。这个临时单元机制解决了外部实时信息也能被引用和校验的问题。否则外部知识的答案就完全游离在校验体系之外会有很明显的漏洞。7. 踩坑实录与排查技巧这些坑我替你走过了这部分是我眼中全文最有价值的内容因为每一条都是用真实故障记录换来的。7.1 问题一引用标记被模型半路截断在约束解码初期我们发现模型偶尔会生成一个不完整的引用标记比如【引用:memo://kb/20250610_dep_x_003少了尾部。原因是模型在下一个token预测时概率分布突然波动结束符没跟上。同步校验器如果只校验格式是否完整这种半截引用会被忽略掉最终用户看到的是一个残缺、无效的引用标记。排查了很久才定位到根因约束解码器的token级约束无法约束引用标记必须完整闭合这种跨token的结构属性。解决办法是在解码端增加一个闭合强制逻辑一旦检测到引用标记开始就切换到专用子解码状态只允许生成合法ID字符和闭合符直到引用标记完整结束才恢复自由生成。这个改动彻底解决了半截引用问题。7.2 问题二存储选型拖慢校验上线初期记忆单元的元数据存储在SQLite里方便单机调试结果到了200万单元的时候引用校验的P95延迟飙到了80ms以上生成速度肉眼可见变慢。后来做了一次架构升级把元数据搬到了内存中的预构建索引hash map HNSW向量索引存储介质改成嵌入式KVP95延迟降到了8ms。这件事给我的教训是校验链路必须走内存索引不能背存储延迟异构存储方案在上线前就得规划好别等数据量大了再亡羊补牢。7.3 问题三多轮对话里的白名单泄漏这是一个特别隐蔽的bug。场景是第一轮用户问A方面的问题系统把关于A的单元加入了白名单第二轮用户接着问那B方面呢如果会话管理器没有及时把第一轮的白名单清理或标记过期模型在回答B方面的问题时可能仍然引用第一轮的白名单单元校验器又恰好通过于是出现答B却引A的出处这种串台引用。修复方式白名单改成了按轮次隔离turn-scoped每轮对话结束上一轮的白名单单元会被标记为不可引用只有当前轮检索命中的单元才能被引用。这需要在会话上下文中做严格的状态管理。顺便说一句这种问题在单轮对话测试时根本测不出来必须做多轮压力测试。7.4 问题四误杀率高根源在向量化一开始的同步校验直接用生成句子的向量和记忆单元内容的向量算余弦相似度阈值0.85。结果发现大量引用被判定为语义不一致哪怕是模型直接摘抄原句的情况。排查后确认问题出在向量化时引用了太多上下文干扰信息——生成句子里除了核心断言还有很多铺垫性内容如根据前面的分析这些词把句子向量搅浑了。后来我们改成抽取断言的核心部分去除开口、转折、修饰再做相似度计算误杀率立刻降下来。这其实靠的是工程细节不是高深算法。7.5 问题五废弃单元的僵尸引用知识库文档更新后旧单元被标记为deprecated但当时校验器没有查状态导致一些旧问答里的引用指向了已废弃单元用户点开关联文档发现内容对不上。这暴露的是校验维度缺失。后来强制增加status校验并在废弃时给所有引用该单元的旧答案打上内容可能过时的标记。7.6 其余常见问题速查现象可能原因处理方案引用标记全部消失约束解码被误关或生成阶段未传入白名单检查解码器开关与prompt构造回答带我不确定很多检索召回不足Top-K太小调大K检查召回日志校验服务负载过高同步校验未做批处理批量合并校验请求增加缓存文档更新后旧答案仍含旧引用版本管理未同步到记忆单元增加文档版本与记忆单元映射表跨会话引用串用白名单按轮次隔离失效强制会话状态清理向量召回偶尔跑偏嵌入模型更新后向量空间不一致统一版本管理避免新旧向量混用说实话这些踩坑列表还在增长。做这类系统的最大体会就是它更像一个软件工程问题多于算法问题。很多幻觉防御方案在论文里很好看落到真实数据、真实用户行为上漏洞往往是链路管理层面的。写到这里架构层面的东西基本拆完了。我自己的感受是幻觉防御没有银弹你没法指望一个提示词、一个模型微调或者一个校验器模型就解决全部问题。知芽Notebook Skill能把合成来源拦截到这个程度靠的是三个机制互相咬合单元记忆先做到事事可追踪存在性校验做到句句要验真受控生成做到不给你编的机会。三条腿缺一条整个系统都会漏风。如果要把这个设计的核心提炼成一句话我会说让输出里的每一个来源指针都经过数据库查询而不是经过文本预测。模型可以自由组织语言但指向哪里这件事最终审核权必须交给确定性的校验系统。这是我在做这套系统过程中最深的体会。如果你也在做类似的知识问答系统或者正在被RAG的引用问题困扰我的建议是一步一步来先把你现有的知识库组织成可寻址的记忆单元再对生成结果做一次后置引用校验至少这一步就能拦截掉绝大多数无中生有的引用。至于单元记忆、白名单、约束解码这些更复杂的机制可以在第一道防线稳定之后再逐步加。别想着一口吃成胖子幻觉防御这样的工程最忌讳的就是一个劲堆复杂度。