先把结论摆这儿:想让 RAG 的每句回答都能查到引用出处,核心就一件事——在切片入库时给每个 chunk 打上来源元数据(文件名、段落号、原文),检索回来后让模型在答案里带上编号,最后把编号映射回原文。听起来绕,实际改动不大,我用一个下午折腾通了,下面是完整步骤。
背景交代一下。我之前给公司客服搭了个问答助手,知识库是 37 份产品手册 PDF。上线第二周就被同事投诉:"它说我们支持七天无理由,可手册里明明写的十五天,你这玩意儿瞎编的吧?"我点开后台一看,答案是对的,但没法证明它从哪段抄的,百口莫辩。从那天起我就下决心,回答必须能溯源。
第一步:切片的时候就把"户口"带上
很多人栽在这一步。默认的 splitter 切完只剩一堆干巴巴的文本,来源信息全丢了。得在 metadata 里把出处焊死。
from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=50) 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,现成调,没自己搭算力和向量库)