
先把结论摆这儿:想让 RAG 的每句回答都能查到引用出处,核心就一件事——在切片入库时给每个 chunk 打上来源元数据(文件名、段落号、原文),检索回来后让模型在答案里带上编号,最后把编号映射回原文。听起来绕,实际改动不大,我用一个下午折腾通了,下面是完整步骤。背景交代一下。我之前给公司客服搭了个问答助手,知识库是 37 份产品手册 PDF。上线第二周就被同事投诉:它说我们支持七天无理由,可手册里明明写的十五天,你这玩意儿瞎编的吧?我点开后台一看,答案是对的,但没法证明它从哪段抄的,百口莫辩。从那天起我就下决心,回答必须能溯源。第一步:切片的时候就把户口带上很多人栽在这一步。默认的 splitter 切完只剩一堆干巴巴的文本,来源信息全丢了。得在 metadata 里把出处焊死。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter(chunk_size400, chunk_overlap50) chunks splitter.split_documents(docs) for i, c in enumerate(chunks): c.metadata[source] c.metadata.get(source, 未知文件) c.metadata[chunk_id] i # 给每片发个身份证 c.metadata[snippet] c.page_content[:120] # 留个原文快照chunk_id和snippet是关键,后面对账全靠它俩。第二步:检索回来的片,编号要原样跟着检索这步别只取page_content,把 metadata 一起拎出来,拼 prompt 时手动编号。hits retriever.invoke(query) # top 4 ctx for n, h in enumerate(hits, 1): ctx f[{n}] (来源:{h.metadata[source]})\n{h.page_content}\n\n我一开始偷懒,直接把四段文本糊成一坨喂进去,结果模型引用得乱七八糟,根本对不上号。加了[1] [2]这种显式编号之后,准确率肉眼可见地上来了。第三步:逼模型在答案里标脚注prompt 里得把规矩讲死,不然它高兴标就标、不高兴就当没看见。你只能依据下面带编号的资料回答。 每句话末尾标出依据的资料编号,如 [1][2]。 资料里没有的,直接说资料中未提及,不许编。最后一句不许编特别重要。少了它,模型会自作主张补充常识,溯源链当场断给你看。第四步:把编号还原成可点击的出处模型输出里带着[1][2],前端再拿这编号去映射回第二步那份 hits 列表,渲染成可点开的引用卡片。步骤输入产出切片原始文档带 source/chunk_id 的片检索用户问题编号后的上下文生成编号上下文带 [n] 标注的答案还原[n] 编号可点击原文出处跑通那天我特意拿无理由退货几天又问了一遍,它回:支持十五天无理由退货 [2],点开 [2] 直接跳到手册第 9 页那段原文。说实话当时挺爽的,这下投诉没法甩锅给我了。说点不那么美好的第一,溯源不是免死金牌。模型偶尔会标错编号——明明依据的是 [3],它给你写个 [1]。我加了道校验:把答案里每个被引片的关键词跟原文做个粗匹配,对不上就给那条引用打个黄色问号,提醒人工复核。这步不优雅,但能兜底。第二,带溯源的 prompt 比裸问慢了大概一秒多,上下文长了嘛。对客服场景无所谓,真要做实时对话就得权衡。还有个小插曲:我后来懒得自己维护这套切片检索prompt 的胶水代码,试了那种零代码就能配智能体的工具,把知识库往里一传,溯源开关勾上,拖几下就出了个能用的客服智能体,连前端引用卡片都给渲染好了。第一版回答有点干,调了调召回数量才顺手——零代码也不是真的一点不操心,但确实比我手搓 Python 省事太多,那个小助手现在还在群里答疑。回头看,RAG 能不能溯源,八成胜负在切片那一步就定了。元数据这东西,丢了再想补,基本等于重新入库。你们做溯源踩过最坑的是哪环?评论区聊聊,我赌一半人栽在 chunk_id 上。(模型和检索 API 我走的讯飞星辰 MaaS,现成调,没自己搭算力和向量库)