信任始于证据可追溯:我在智能体RAG项目里做溯源的全过程
上个月接手一个智能体RAG项目,内部知识库问答,模型回答得倒是挺流畅,结果业务方上来第一句话就是:“你这个结论是从哪份文档来的?你凭什么让我信你?”
这句话把我问住了。传统的RAG只要给个来源链接就完事了,但到了智能体RAG这里,Agent会自己规划问题、决定检索什么、调用工具、多轮推理,最后拼出一个答案。用户面对的是一个黑盒,黑盒里的每一步都没法验证,自然谈不上信任。后来我在这套系统里做了一整轮“证据可追溯性”改造,把每个回答背后涉及的文档来源、片段命中、工具调用链都结构化地返回给用户,这才算把信任这件事从口号变成了工程能力。
这篇文章就把我在这个过程中的设计思路、代码实现和踩坑记录完整写出来,适合正在做智能体RAG、或者被用户追问“答案从哪来”的开发者参考。
1. 智能体RAG的可信度危机:答案对了,用户为什么还是不放心?
1.1 从单跳检索到多跳推理,证据链发生了哪些变化?
传统RAG是单跳结构:用户提问—向量检索—拼接上下文—LLM生成。这个过程即便有幻觉,定位问题也比较容易,因为答案大概率就是从召回的几段文本里出来的,你把原文贴上去,用户自己就能比对。
但智能体RAG不一样。Agent在收到问题后,会先进行意图拆解,可能会把“帮我对比A方案和B方案的落地成本”拆成好几个子问题,每个子问题分别去检索,甚至还会调用外部工具,比如查数据库、查API、查实时报表,然后把这些碎片化的信息整合成最终答案。
这里出现了一个关键变化:最终答案不是直接对应某一段原文,而是经过了解构、检索、重组、推理的多层加工。每一层都可能引入信息损耗,也可能引入模型自己的补全,也就是幻觉。当用户追问依据时,你无法像传统RAG那样简单地说“看第3段原文”,因为答案里可能包含了第3段、第7段、以及一段Agent自己总结的推理结论,三者混在一起。证据链一旦在多跳中断裂,用户就再也无法判断哪些内容是有出处的,哪些内容是模型自己发挥的。
我之前见过一个项目,Agent回答中引用了两篇不同部门的制度文档,还把其中一篇的标准套到了另一篇的流程上,表面看都有出处,实质上是在张冠李戴。因为没有可追溯的细粒度证据链,这种问题在测试阶段根本没暴露出来,直到上线后被业务方直接打回。
1.2 可追溯性解决的不是“找得到”,而是“信得过”
很多团队以为做了RAG就有溯源了,给答案后面拼接一个“来源:某某文档”就完事,这是对可追溯性最粗浅的理解。可追溯性的核心目标不是让用户“能找到来源”,而是让用户“能验证答案为什么可信”。
这两者有什么区别?前者是单向告知,我说来源是A文档,你爱信不信;后者是双向验证,我把A文档的哪一段话、在第几页、哪一块文本支撑了答案里的哪一句话,全部拆开摆在你面前,你一眼就能看出这个结论是否被证据覆盖,有没有过度推断。
要让用户信得过,答案里每一句关键断言都必须能对应到具体的证据片段,而不是对应到“某个文档ID”这种粗粒度的层面。文档可能有几十页,一个PDF转出来可能有上百个chunk,如果只告诉用户“这是从某文档来的”,用户还是要自己去翻找,本质上还是没有降低验证成本。
这也是我在这次项目里给自己定的一条铁律:溯源粒度要细化到chunk级别。每个chunk要有稳定的标识、原文内容、来源文档、页码、检索得分,并且这些信息必须跟着答案一起输出,不能只存在后端日志里。只有把验证成本降到用户的忍耐阈值以下,信任才有可能建立起来。
2. 可追溯性的四个层次:从“有来源”到“可审计”
2.1 T0:只给来源链接,最基础但远远不够
最低层级是给答案附一个来源链接或者文档名称。这个方案实现成本极低,但缺陷非常明显:用户点击链接之后,看到的是一整篇文档,而答案里可能只用到了其中一两句话,用户需要自己在几十页里找。更麻烦的是,如果答案是由多个片段拼出来的,甚至经过Agent多轮推理整合,一个链接根本说不清楚。
这种T0级别的溯源,本质上只解决了“我给了你来源,你没理由说我没出处”这个表面合规问题,对真正的信任建设毫无帮助。我见过很多企业内部的RAG项目停留在这个阶段,被业务方吐槽“给了等于没给”,原因就是验证路径太长,用户没有耐心去翻原始文档。
2.2 T1到T3:引用锚点、工具链路、版本快照
我认为可追溯性至少需要做到三个层次,才能称得上真正可用的证据溯源。
T1是引用锚点。答案里的关键句子必须对应到具体的文本片段,而不是整个文档。实现方式是在生成阶段要求模型以结构化格式输出引用信息:哪一句话用了哪一块文本作为依据,每一条引用都要指向一个具体的chunk ID。这样用户能直接在原文片段上看到自己关心的那句话,验证成本降到最低。
T2是工具链路追溯。智能体RAG经常要调用多个工具,比如检索工具、SQL查询工具、计算工具。用户看到最终结论时,需要知道这个结论是经过哪些工具加工出来的,每个工具的输入是什么、输出什么。这个信息要记录下来,并随答案一起展示。否则Agent可能在检索后自己杜撰了一个计算结果,用户却无从查证。
T3是版本快照。知识库是会更新的,文档会被替换、删改。如果用户在周一看到答案,周五文档就更新了,那这个答案到底还成不成立?可追溯性要做到的是:回答中引用的文本片段是当时那个版本的原文快照,而不是当前文档的更新版本。这样即便知识库后续发生变化,当时给出的回答仍然可以被验证。
这三个层次合在一起,才构成一条完整的证据链,否则任何一个环节断裂,用户都会产生“你是在糊弄我”的感觉。
3. 实战:用LangChain实现一个带证据溯源的Agentic RAG
3.1 数据入库时的元数据设计
做可追溯性,第一件事不是写代码,而是设计数据模型。如果文档入库的时候没存够元数据,后面所有溯源都是空中楼阁。
我在实践里的做法是:每个chunk在入库时至少保存以下字段:
| 字段名 | 含义 | 示例值 |
|---|---|---|
| chunk_id | 片段唯一ID | doc-345-chunk-12 |
| document_id | 所属文档ID | doc-345 |
| source_file | 原始文件名 | 产品上线规范V3.pdf |
| page_number | 页码 | 12 |
| section_title | 所在章节标题 | 2.3 上线检查清单 |
| chunk_text | 片段原文 | 上线前需完成日志检查… |
| text_hash | 文本哈希值 | 8f3a2b... |
| doc_version | 文档版本号 | v3 |
这里有一个我踩过坑的点:text_hash非常重要。知识库更新后,同一个chunk的文本可能变了,但chunk_id可能不变,导致旧回答引用的内容和新文档不一致。用text_hash做比对,能在展示引用时快速发现“这段引用在当前版本中已不存在”,避免用旧证据支撑新答案。
另外一个容易忽略的字段是section_title。用户验证答案时,第一反应是找来源文档,第二反应是看文档的哪个章节。有了章节标题,用户可以把验证范围从整篇文档缩小到一个小节,体验提升非常明显。
3.2 检索结果的结构化输出与强制引用
要让模型在生成答案时带上引用,最可靠的方式不是靠prompt反复强调“请带上来源”,而是用结构化输出约束模型的回答格式。
我用LangChain的with_structured_output,把回答结果定义成一个Pydantic模型:
from pydantic import BaseModel, Field from typing import List class EvidenceItem(BaseModel): """单条证据引用""" chunk_id: str = Field(description="支撑该结论的文本片段ID") source_file: str = Field(description="来源文件名") page_number: int = Field(description="页码") section_title: str = Field(description="章节标题") text: str = Field(description="原文片段内容,不要改写") score: float = Field(description="检索相似度得分") class CitedAnswer(BaseModel): """带引用的最终回答""" answer: str = Field(description="对用户问题的回答") citations: List[EvidenceItem] = Field( description="支撑回答中关键论断的证据列表" )这里有一个关键细节:EvidenceItem里的text字段,必须是原文片段,而不是模型用自己的话复述的内容。原因很简单,如果允许模型改写证据文本,那模型完全可能把一段不存在的原文“合理地”改出来,这样就从根本上破坏了可追溯性的根基。
为了防止模型输出伪造的chunk_id,我在提示词中做了硬性约束,并把检索结果一并封进上下文里:
RAG_PROMPT = """ 你是一个严谨的知识库问答助手。请基于以下检索到的文本片段回答问题。 <context> {context} </context> 回答要求: 1. 你的所有关键论断都必须从<context>中的文本片段获取依据。 2. citations中的chunk_id必须严格从<context>中出现的chunk_id中选择,禁止自己编造。 3. citations中的text必须逐字引用<context>中的原文,禁止改写、概括或拼接。 4. 如果问题无法从<context>中得到答案,请直接说明不知道,不要猜测。 """配合LangChain的RAG实现,整体链路是这样的:
from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser from langchain_openai import ChatOpenAI from langchain_chroma import Chroma def format_docs(docs): """把检索结果格式化为带chunk_id标记的上下文""" lines = [] for doc in docs: chunk_id = doc.metadata["chunk_id"] text = doc.page_content lines.append(f"[{chunk_id}]\n{text}") return "\n\n".join(lines) llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) structured_llm = llm.with_structured_output(CitedAnswer) def retrieve_with_metadata(query: str, vectorstore, top_k: int = 5): results = vectorstore.similarity_search_with_score(query, k=top_k) docs = [] for doc, score in results: doc.metadata["score"] = float(score) docs.append(doc) return docs def build_rag_chain(vectorstore): def retrieve_docs(query: str): return retrieve_with_metadata(query, vectorstore) rag_chain = ( {"context": retrieve_docs, "question": RunnablePassthrough()} | RAG_PROMPT | structured_llm ) return rag_chain需要注意,similarity_search_with_score返回的是距离分,距离越小越相似。在展示引用得分时,我会做一个转换,比如score_display = 1 - distance,让用户看起来更直观,分数越高代表和问题相关性越强,符合直觉。
3.3 多跳链路追踪与日志结构
到了Agent场景,检索往往不止一次,可能Agent先检索了“A方案的成本结构”,又检索了“B方案的成本结构”,最后综合两者做对比。这时候,每个子步骤的检索结果和工具调用记录都必须被留存,否则用户无法追溯答案的生成过程。
我在代码里给每个对话会话生成一个trace_id,所有检索、工具调用、生成日志都打到同一个trace_id下。日志结构大致如下:
{ "trace_id": "conv-7f3a", "session_id": "sess-1024", "messages": [ { "role": "user", "content": "对比A方案和B方案的落地成本" }, { "role": "agent", "sub_steps": [ { "step": "retrieve", "query": "A方案落地成本构成", "tool": "vector_search", "top_k": 5, "results": [ {"chunk_id": "doc-12-chunk-5", "score": 0.92, "source": "A方案报价单V2.pdf"} ] }, { "step": "retrieve", "query": "B方案落地成本构成", "tool": "vector_search", "top_k": 5, "results": [ {"chunk_id": "doc-33-chunk-9", "score": 0.88, "source": "B方案实施计划书.pdf"} ] } ], "response": { "answer": "综合两份文档,A方案的初期投入低于B方案,但B方案的后期运维成本更低……", "citations": [ {"chunk_id": "doc-12-chunk-5", "source_file": "A方案报价单V2.pdf", "page_number": 3}, {"chunk_id": "doc-33-chunk-9", "source_file": "B方案实施计划书.pdf", "page_number": 7} ] } } ] }有了这个日志结构,Frontend可以按trace_id拉取整个问答链路,把每一步的检索情况、工具调用情况展示给用户。这一步的意义在于,用户不仅能验证最终答案,还能看到Agent的推理过程,知道它为什么去查这份文档,而不是另一份文档。这个过程本身就是建立信任的重要环节。
3.4 前端展示:从“一段文字”到“证据卡片”
后端输出了结构化引用,前端如果只是把它折叠成一个超链接,那就前功尽弃了。我在前端做了一个“证据卡片”的展示方式,每条引用都是一个独立卡片,包含文件名、页码、章节、原文摘录和检索得分。用户点击答案里的某句话,右侧会自动高亮对应的证据卡片,形成“结论—证据”的映射。
这个交互细节带来的提升是巨大的。业务方在使用后反馈说,以前是“你说了算”,现在是“我自己能查”,验证成本从原来的几分钟缩短到几秒钟。这也印证了我一直以来的观点:可追溯性不是后台功能,而是用户能感知的前台体验,只有把证据链呈现在用户触手可及的地方,信任才能真正发生。
4. 踩坑实录:我见过的三类“溯源翻车”现场
4.1 引用编号对不上:模型自行编造引用
第一次做结构化引用输出时,我遇到了最典型的翻车现场:答案生成了,citations也输出了,但仔细比对后发现,答案里那句关键结论引用的chunk_id,跟实际支撑它内容的chunk_id对不上。
出现这个问题的原因有两个。一是模型在长上下文里“迷失”了,它明明用的是chunk A的内容做推理,但输出引用时却随机指向了chunk B。二是模型在追求“看起来合理”,它觉得每句话都应该有个引用,于是硬凑了一个编号填上去。
解决思路是双管齐下。在prompt层面要求模型“逐句标注”,而不是“总体标注”,在生成时就明确每一句话对应哪个片段;在代码层面增加了“引用-内容一致性校验”,比对答案句子和引用文本的语义相似度,低于阈值就重试或者丢弃该引用。经过这两轮调整后,引用错位率大幅下降,从早期的约15%降到了2%以内。
4.2 溯源到文档而非片段:用户仍然找不到
另一个很隐蔽的问题,是数据入库时chunk划分太粗。有些文档是按章节一次性切进去的,一个chunk就是整个章节,几千字。这导致检索结果里的chunk范围过大,引用内容虽然没错,但用户打开原文后仍然需要在一大段文字里自行寻找关键句,验证成本依旧很高。
后来我把比较大的chunk做了二次切分,按语义段落粒度处理,每个chunk控制在300到500字以内。这样做的好处是引用粒度细、定位精准,检索命中率也会有提升。代价是chunk数量变多了,向量库的存储规模变大,但相比用户体验的提升,这个代价完全值得。
4.3 多轮对话污染:上下文窗口里的“幽灵证据”
做Agent聊天时还有一个特别坑的场景:多轮对话。用户连续追问时,Agent会把之前的对话历史也放进上下文窗口。问题在于,之前的回答里如果包含引用信息,这些东西进入上下文后,模型在生成新一轮回答时会“看到”这些历史引用,然后在新答案里错误地引用它们。
我遇到的具体情况是,用户在第一轮问了“A方案的成本”,Agent给出了引用C1;第二轮用户追问“那A方案的风险呢”,Agent在新回答里竟然也引用了C1。但C1的内容是关于成本的历史文档,跟风险毫无关系,这就是典型的“幽灵证据污染”。
解决方案是在每轮单独检索时,构建context时不要把历史回答的引用文本一并注入,只保留对话的历史意图信息,但检索上下文必须是当轮的实时结果。如果需要参考历史轮次的答案,要把历史条目单独标注清楚,让模型区分“这是之前说过的内容”和“这是本轮检索到的证据”,两者不能混用。
| 常见问题 | 根因分析 | 解决方案 |
|---|---|---|
| 引用编号错位 | 模型编造引用编号,长上下文信息迷失 | 逐句标注引用,增加引用-内容一致性校验 |
| 溯源粒度太粗 | chunk划分过大,用户验证成本高 | 按语义段落切分chunk,控制在300-500字 |
| 多轮对话引用污染 | 历史轮次的引用进入新上下文窗口 | 每轮重建上下文,历史引用和当轮证据严格隔离 |
| 文档更新后旧引用失效 | 未做版本管理,chunk_id对应文本变化 | 存text_hash,检测更新后标记旧引用不可用 |
5. 可追溯性的工程化闭环:评估、回归与迭代
5.1 把“可追溯率”作为核心评估指标
做完上述实现后,我的下一步是把可追溯性变成一个可量化的指标,纳入评测体系。我们团队内部定义了一个“答案可追溯率”的概念,计算公式是:
可追溯率 = 有有效引用支撑的回答数 / 总回答数 × 100%这里的“有有效引用支撑”有严格定义:回答中的所有关键论断,都必须有对应的citations,且citations的chunk_id真实存在于知识库中,引用的text和chunk原文一致。
用这个指标建立回归测试集后,每次改动prompt、调整检索参数或者更新知识库,我们都会跑一遍回归测试,确保可追溯率不会因为某个细节改动而大幅下降。这个监控机制非常有效,曾经在一次检索参数从top_k=5改成top_k=10的优化中,发现可追溯率下降了约8%,检查后发现是引入了一些低相关度的噪声片段,后来重新做了重排序才恢复。
5.2 从可追溯到可审计:长期演化的方向
当前这套实现已经让用户能验证单个回答的证据来源,但我认为这还不够,可追溯性最终要走向可审计性。可审计意味着,任何一个回答的生命周期都是清晰可见的:是谁在什么时间问的、系统基于哪些文档版本、经过哪些工具调用、每一步的参数是什么、有没有人为干预过。这些信息要能完整导出,成为企业合规审查的一部分。
这个目标还需要做三件事:一是建立文档生命周期管理体系,记录每次知识库更新前后的版本差异;二是完善工具调用的审计日志,不只记录检索环节,还要覆盖所有工具链;三是提供审计报表,定期给业务方发送“问答可信度报告”,列出本周哪些问题的高频回答是由哪几份关键文档支撑的。走到这一步,信任就不再是一个感性的评价,而是一套可以追溯、可以验证、可以审计的工程体系。
我在实际推进这个改造后,一个很深的体会是:用户对AI的不信任,大部分时候不是针对模型能力本身,而是针对“我无法确认你是对的”这种不安感。证据可追溯性,就是把这个不安感一点一点拆掉的过程。每一次引用都能被验证,每一次回答都有据可查,信任就会在一次次的“确实如此”中被积累起来。这套方案目前在知识问答、文档分析这类场景里效果显著,如果你也在做智能体RAG,建议从设计数据模型的那一刻就开始考虑可追溯性,而不是等功能上线后补做,那样返工的代价会大得多。