Skip to content

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'}]}

这段管道与“随手一问”的本质区别:

  1. 资料带编号进 Prompt,编号同时是“引用协议”——模型只需要学会一种极简单的格式;
  2. check_citations 是程序性校验,不信任模型的自称,杜绝“幽灵引用”;
  3. 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 元,两个来源都“真实”,问题在于哪个当前有效。上下文进入模型前先按以下顺序治理:

  1. 过滤无权访问、草稿、隔离和已废止资料;
  2. 按权威等级、生效日期和用户问题的时间范围选择版本;
  3. 两个同级现行来源冲突时,不强行合并,明确展示冲突并转责任人;
  4. 答案与引用同时显示来源日期和状态;
  5. 缓存键绑定知识快照,制度更新后旧答案立即失效。

外部论坛不能用“数量多”压过一份内部正式制度。来源可信度是治理规则,不应交给语言模型临场猜。

拒答阈值要同时控制两种错误 ​

  • 误答:资料不足却硬答,风险高;
  • 误拒:资料明明足够却总说不知道,系统失去价值。

在评测集中分别标注应答题和应拒题,画出阈值变化下的覆盖率与错误率。医疗、法务、权限等高风险场景可以偏保守;普通 FAQ 可接受更高覆盖。拒答还应说明缺什么信息,并给出可执行下一步,例如补充订单号或转人工,而不是只回“无法回答”。

流式输出会绕过“生成后总检查” ​

如果 Token 已经发到浏览器,生成完成后才发现幻觉就来不及收回。高风险场景可选择:

  1. 先生成到缓冲区,验证通过后再模拟流式发送;
  2. 按句缓冲,每句通过引用和敏感信息门禁后再发送;
  3. 只流式发送低风险正文,把数字、结论和引用作为最终结构化事件交付;
  4. 任何校验失败都发送明确的终止事件,前端撤销未确认状态。

因此 TTFT 与安全存在真实取舍,不能只追求“字蹦得快”。配套脚本新增 citation_metrics(),同时检查幽灵引用和逐句引用完整性,并明确提示格式门禁不等于语义验证。


引用正确要经过三次核对 ​

第一步检查编号是否对应实际提供的片段;第二步检查片段是否包含答案所依据的内容;第三步检查回答有没有夸大、拼接或省略条件。只有编号存在,最多说明格式正确。

来源冲突时,应显示文档版本、日期和适用范围,必要时请用户选择或转人工。流式回答若要先输出内容,应考虑先完成证据筛选,再流式生成;否则后置校验发现错误时,用户已经看到了未经检查的结论。


权威官方参考 ​