主题
11.12 答案对了却没人敢用?—— 生成层防幻觉与引用溯源
痛点场景:你的 RAG 系统检索越来越准,可法务部一句话把项目打回重做:“答案没有出处,出了事谁负责?”更糟的是,模型偶尔在“检索回来的正确资料”旁边,自作主张掺一句编的。企业级 RAG 的最后一公里不在检索,而在生成层:**每句话都要有出处,没把握就老实说不知道。**本节讲透四道闸门:接地生成、引用标注、忠实度复检、拒答兜底,外加两个工程必备件:结构化输出与流式生成。
痛点:企业要的不是“聪明”,是“可追责”
| 用户/老板的常见问题 | 背后的需求 |
|---|---|
| “这个数字哪来的?” | 引用溯源:答案能点回原文档原文段落 |
| “它说赔 5000,合同写 500,谁背锅?” | 防幻觉:答案不得超出上下文 |
| “库里根本没有的,它为什么还答?” | 拒答机制:没依据就承认不知道 |
| “等 30 秒才蹦一整段,用户早跑了” | 流式生成:边生成边吐字 |
| “我想要 JSON 进我的业务系统,谁帮我抠?” | 结构化输出:答案即数据 |
普通 RAG 像一个学霸答题——答得又快又好但从不翻书给你看;企业级 RAG 像出庭律师——每句陈述后面必须跟一句“依据第 X 份证据第 Y 页”,没证据的话一个字都不能说。
思路:给生成层装四道闸门
| 闸门 | 一句话原理 | 失效后果 |
|---|---|---|
| ① 接地生成(Grounded Generation) | Prompt 里明令“只许基于资料回答”,给资料编号 | 模型自由发挥,混入脑补内容 |
| ② 引用标注(Citation) | 答案每句带 [1][2] 编号,后处理校验编号真实存在 | 用户无法核实,答案不可追责 |
| ③ 忠实度复检(Verification) | 用 11.9 的忠实度指标在返回前再查一遍,低分拦截/重写 | 幻觉直通用户 |
| ④ 拒答兜底(Refusal) | 检索相关性低于阈值或模型自认无依据 → 礼貌说不知道并给人工入口 | “一本正经胡说”摧毁信任 |
💡 四道闸是串联的,不是四选一:①是地基(写对 Prompt),②是交付物(可核实的答案),③是质检(返回前拦截),④是安全网(兜底不硬答)。
代码实现一:接地生成 + 引用标注 + 引用校验(三合一管道)
核心做法:给每条检索资料编号 → 要求模型引用编号作答 → 程序校验“被引用的编号确实出现在模型输入中”。程序校验不能省略,因为模型可能生成并不存在的编号,例如
[5]。
python
# 11.12 可溯源 RAG:编号 → 引用作答 → 引用校验
from dataclasses import dataclass
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
@dataclass
class Source:
doc_id: str
text: str
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
CITE_PROMPT = ChatPromptTemplate.from_template(
"你是企业知识库助手。严格遵守:\n"
"1. 只依据下方带编号的资料回答,禁止使用任何资料外的知识;\n"
"2. 每个关键陈述句末尾必须标注依据编号,如 [1] 或 [2];\n"
"3. 资料不足以回答时,只回复:【资料不足】。\n\n"
"资料:\n{numbered_sources}\n\n问题:{question}"
)
def format_sources(sources: list[Source]) -> str:
return "\n".join(f"[{i+1}] ({s.doc_id}){s.text}" for i, s in enumerate(sources))
def check_citations(answer: str, n_sources: int) -> tuple[bool, list[int]]:
"""校验编号是否越界,并检查每个陈述句是否带引用。"""
import re
cited = sorted({int(x) for x in re.findall(r"\[(\d+)\]", answer)})
valid = [c for c in cited if 1 <= c <= n_sources]
claims = [x.strip() for x in re.split(r"(?<=[。!?!?;;])\s*|\n+", answer) if x.strip()]
complete = bool(claims) and all(re.search(r"\[\d+\]", claim) for claim in claims)
return bool(valid) and len(valid) == len(cited) and complete, valid
def answer_with_citation(question: str, sources: list[Source]) -> dict:
resp = llm.invoke(CITE_PROMPT.format(
numbered_sources=format_sources(sources), question=question))
ok, cited = check_citations(resp.content, len(sources))
if resp.content.startswith("【资料不足】") or not ok:
return {"answer": "抱歉,知识库中暂无可靠依据,已转人工。", "citations": []}
# 把编号映射回真实文档,交付给前端做“点引用跳原文”
return {"answer": resp.content,
"citations": [{"marker": f"[{i}]", "doc_id": sources[i-1].doc_id}
for i in cited]}
srcs = [Source("差旅制度_2026.pdf#p3", "一线城市住宿标准为每人每天不超过 500 元。"),
Source("报销指南.docx#p1", "差旅报销单须在返回后 5 个工作日内提交 OA。")]
print(answer_with_citation("去上海出差住一晚能报多少?", srcs))
# {'answer': '一线城市住宿标准为每人每天不超过 500 元 [1]。', 'citations': [{'marker': '[1]', 'doc_id': '差旅制度_2026.pdf#p3'}]}这段管道与“随手一问”的本质区别:
- 资料带编号进 Prompt,编号同时是“引用协议”——模型只需要学会一种极简单的格式;
check_citations是程序性校验,不信任模型的自称,杜绝“幽灵引用”;- citations 里带
doc_id(含页码/锚点),前端可以做成“点 [1] 跳原文高亮”,这就是法务敢签字的原因。
引用粒度怎么选?
| 粒度 | 用户体验 | 实现成本 | 适用 |
|---|---|---|---|
| 文档级 | 能看到来源文件 | 最低(metadata 就有) | 快速上线基线 |
| 段落级 | 点引用跳到原文段落(chunk 元数据带 chunk_index) | 中(11.2 的元数据在此变现) | 企业知识库主流选择 |
| 句子级 | 精确到句子高亮 | 高(需句子对齐或让模型引用原文片段) | 法务/医疗等强合规场景 |
代码实现二:返回前的忠实度复检 + 结构化输出
引用校验挡住了“幽灵编号”,但挡不住“编号真实、内容编造”。最后一道质检:答案返回前,让裁判 LLM 对照资料逐句验真(复用 11.9 的忠实度思路,但从“离线报表”搬进了“在线链路”):
python
# 11.12 在线忠实度复检 + 结构化输出
from pydantic import BaseModel, Field
class VerifiedAnswer(BaseModel):
is_faithful: bool = Field(description="答案每个陈述是否都能在资料中找到依据")
hallucination_spans: list[str] = Field(description="无依据的陈述句列表,没有则空")
verifier = ChatOpenAI(model="gpt-4o-mini", temperature=0).with_structured_output(VerifiedAnswer)
def verify_before_return(answer: str, sources: list[Source]) -> dict:
v = verifier.invoke(ChatPromptTemplate.from_template(
"资料:\n{sources}\n答案:{answer}\n逐句检查答案是否有资料外编造的内容。"
).format(sources=format_sources(sources), answer=answer))
if not v.is_faithful:
# 有幻觉:剥掉无依据句子再返回(或触发 11.8 的重写/重检索闭环)
return {"answer": answer, "stripped": v.hallucination_spans, "returned": False}
return {"answer": answer, "stripped": [], "returned": True}⚠️ 复检的代价与取舍:多一次 LLM 调用 = 延迟与成本翻倍。生产上通常只对高风险答案(数字、金额、法条、医疗)触发复检,普通闲聊级答案直接放行——用 11.9 的评测集决定哪些问题类型值得多花这一笔钱。
代码实现三:流式生成 —— 让用户“看着字蹦出来”
首字延迟(TTFT)是 RAG 体验的生命线。检索花 800ms 可以接受,但等 LLM 生成完 30 秒再整段返回,用户早跑了:
python
# 11.12 流式生成:LangChain 的 stream 接口
def stream_answer(question: str, sources: list[Source]):
chain = CITE_PROMPT | llm
for chunk in chain.stream({"numbered_sources": format_sources(sources),
"question": question}):
print(chunk.content, end="", flush=True) # 每收到一小片立即下发
# Web 场景对应 FastAPI 的 StreamingResponse / SSE(见 11.13)
stream_answer("去上海出差住一晚能报多少?", srcs)💡 流式 + 引用的一个小冲突:引用编号
[1]出现在句尾,流式输出时用户可能先看到内容后看到编号。主流做法是前端拿到citations元数据后,把编号渲染成角标徽章(脚注样式),而不是依赖模型把编号“流”在正文里。
拓展:防幻觉是一个“纵深防线”,不是一道 Prompt
| 防线 | 位置 | 拦截什么 |
|---|---|---|
| 接地 Prompt | 生成前 | 约束行为,减少自由发挥(但挡不住全部) |
| 检索质量(11.5/11.6) | 生成前 | 资料都搜错了,生成再乖也没用 |
| 引用校验 | 返回前 | 幽灵编号、无出处答案 |
| 忠实度复检(本节 + 11.8) | 返回前 | 内容编造、数字张冠李戴 |
| 评测回归(11.9) | 上线前/后 | 幻觉率整体回退 |
别指望任何单一闸门 100% 拦截——就像机场安检有 X 光机还有开箱抽查,层层设防的目标是把幻觉率压到业务可接受、可追责的程度。
引用质量要过三关
“答案里出现 [1]”只完成了最容易的一关:
| 关卡 | 要问什么 | 失败例子 |
|---|---|---|
| 引用有效性 | [1] 是否对应真实提供过的来源 | 模型引用不存在的 [9] |
| 引用正确性 | 来源是否真的支持紧邻的主张 | 原文写 500 元,答案写 800 元却仍标 [1] |
| 引用完整性 | 每个需要证据的主张是否都有来源 | 第一段有引用,第二段凭空增加补贴 |
程序正则可以可靠检查第一关和部分第三关;第二关需要把答案拆成原子主张,再对每条主张和对应证据做蕴含/矛盾判断。高风险数字可增加确定性规则:抽取金额、日期、百分比和实体名称,要求它们在证据中一致出现。
来源冲突时不能让模型自由投票
当旧制度写 450 元、新制度写 500 元,两个来源都“真实”,问题在于哪个当前有效。上下文进入模型前先按以下顺序治理:
- 过滤无权访问、草稿、隔离和已废止资料;
- 按权威等级、生效日期和用户问题的时间范围选择版本;
- 两个同级现行来源冲突时,不强行合并,明确展示冲突并转责任人;
- 答案与引用同时显示来源日期和状态;
- 缓存键绑定知识快照,制度更新后旧答案立即失效。
外部论坛不能用“数量多”压过一份内部正式制度。来源可信度是治理规则,不应交给语言模型临场猜。
拒答阈值要同时控制两种错误
- 误答:资料不足却硬答,风险高;
- 误拒:资料明明足够却总说不知道,系统失去价值。
在评测集中分别标注应答题和应拒题,画出阈值变化下的覆盖率与错误率。医疗、法务、权限等高风险场景可以偏保守;普通 FAQ 可接受更高覆盖。拒答还应说明缺什么信息,并给出可执行下一步,例如补充订单号或转人工,而不是只回“无法回答”。
流式输出会绕过“生成后总检查”
如果 Token 已经发到浏览器,生成完成后才发现幻觉就来不及收回。高风险场景可选择:
- 先生成到缓冲区,验证通过后再模拟流式发送;
- 按句缓冲,每句通过引用和敏感信息门禁后再发送;
- 只流式发送低风险正文,把数字、结论和引用作为最终结构化事件交付;
- 任何校验失败都发送明确的终止事件,前端撤销未确认状态。
因此 TTFT 与安全存在真实取舍,不能只追求“字蹦得快”。配套脚本新增 citation_metrics(),同时检查幽灵引用和逐句引用完整性,并明确提示格式门禁不等于语义验证。
引用正确要经过三次核对
第一步检查编号是否对应实际提供的片段;第二步检查片段是否包含答案所依据的内容;第三步检查回答有没有夸大、拼接或省略条件。只有编号存在,最多说明格式正确。
来源冲突时,应显示文档版本、日期和适用范围,必要时请用户选择或转人工。流式回答若要先输出内容,应考虑先完成证据筛选,再流式生成;否则后置校验发现错误时,用户已经看到了未经检查的结论。