核心摘要

**RAG 幻觉治理本质上是证据控制问题,不是 Prompt 技巧。**生产系统必须保留经过授权的来源版本,检索充分而不只是相似的证据,显式暴露来源冲突,把每个重要声明绑定到支持片段,再由经过校准的策略决定完整发布、缩减回答、拒答或升级人工。模型只提出回答;确定性的应用代码决定回答能否展示。

目录

核心要点

  • 检索相关性不等于证据充分性,高相似度分数也不是事实证明。
  • 如果来源过期或错误,忠于上下文的回答仍可能不符合现实。
  • Prompt 约束、低温度、引用和第二个模型都是信号,不是强制边界。
  • 权限必须在检索前执行;生成后再过滤泄露内容已经太晚。
  • releasepartialabstainreview 应是经过损失权衡测试的明确策略结果。

什么是 RAG 幻觉

RAG 幻觉是指回答中的重要声明与本次运行提供的证据矛盾,或无法从这些证据中推出。这个工程定义聚焦答案忠实度,不会把所有质量问题都混成一个“幻觉率”。

以下四个属性必须分开:

属性 要回答的问题 失败示例
检索覆盖 必要证据是否进入候选集 退款例外条款从未被召回
证据质量 来源是否权威、当前有效且适用 检索到已经失效的政策
答案忠实度 每个声明是否能从证据推出 回答补充了无依据的期限
外部正确性 声明是否符合外部参考或现实 来源本身含有错误

回答可能忠实复述过期文档,也可能依靠模型记忆说出正确事实,却没有得到本次授权证据快照支持。前者是来源治理失败;后者即使碰巧正确,也违反封闭证据任务的边界。

若要建立跨系统的检测契约,可阅读LLM 幻觉检测。本文只回答更具体的实现问题:RAG 应用怎样阻止无依据回答到达用户?

先定位哪一层失败

治理的第一步是判断证据链在哪一层断裂。更换生成模型无法找回检索阶段遗漏的片段;增加 top_k 也不能修复生成器忽略有效上下文的问题。

flowchart LR A["版本化语料"] --> B["授权后检索"] B --> C["重排与充分性判断"] C --> D["识别冲突的上下文"] D --> E["声明与引用提案"] E --> F["声明-证据校验"] F --> G{"发布策略"} G -->|支持充分| H["完整发布"] G -->|部分支持| I["部分回答"] G -->|证据不足| J["拒答"] G -->|冲突或高风险| K["人工复核"]

故障记录必须保存准确的运行快照:

json
{
  "run_id": "rag-202",
  "query": "外包人员的国际差旅能否报销?",
  "principal": "user-42",
  "tenant": "acme",
  "corpus_revision": "policy-index@sha256:7ad1",
  "retriever_version": "hybrid-v4",
  "reranker_version": "rerank-v3",
  "generator_version": "answer-v8",
  "policy_version": "evidence-policy-v5",
  "evidence_ids": ["travel-2026#p18", "contractors-2026#p4"],
  "decision": "review"
}

缺少这份快照时,后续重放可能使用不同文档、排序、Prompt 或策略,无法复现事故。

控制一 治理证据语料

第一层控制是把每个检索片段变成版本化 Evidence Object。纯文本加文件名不足以判断来源是否适用于当前用户和问题。

证据记录至少应包含:

json
{
  "evidence_id": "travel-2026#p18",
  "document_id": "travel-policy",
  "revision": "sha256:3c81",
  "locator": {"page": 18, "section": "International travel"},
  "authority": "finance-policy-owner",
  "valid_from": "2026-01-01",
  "valid_to": null,
  "tenant": "acme",
  "allowed_roles": ["employee", "contractor-manager"],
  "status": "active",
  "content_hash": "sha256:915f"
}

这份契约支持 Embedding 无法提供的控制:

  1. **权威性:**区分已批准政策、草稿、评论、转载页面和模型摘要。
  2. **时效性:**保存生效时间和替代关系,而不是按上传时间猜测新旧。
  3. **适用性:**保留司法辖区、产品、套餐、租户、角色等范围限定。
  4. **可追溯性:**保存不可变 Revision 与 Locator,让引用以后仍解析到同一证据。
  5. **可删除性:**把撤销和删除传播到全部派生 Chunk 与 Index。

访问控制必须在检索和排序之前执行。先检索其他租户的片段,再在生成后隐藏,仍然会把内容暴露给模型和日志。事实锚定(Grounding)词条进一步解释了为什么 Provenance、Authorization 和 Validity 是证据契约的一部分,而不是可选 Metadata。

控制二 用证据充分性约束检索

检索阶段应回答两个不同问题:**哪些片段是候选,以及这些候选是否足以回答问题?**相似度、Reranker Score 或非空结果列表都不能通用地回答这两个问题。

将检索拆成两步:

  1. 根据查询分布选择词法、稠密、结构化、图或 API 检索,召回候选证据。
  2. 重排候选,并判断入选证据的相关性、覆盖度、适用性和冲突。

混合检索可以改善标识符精确匹配和同义表达召回,但必须与标注基线比较。例如 Anthropic 的 Contextual Retrieval 收益来自其特定语料、Embedding 配置和 Recall@20 实验;它证明该方法值得测试,不代表迁移到任意生产语料后都有固定增益。

查询改写也遵循同一原则。Rewrite 是搜索提案,不是证据。系统应保留原始查询,记录改写器版本,限制变体数量,并测试意图漂移。如果扩展查询虚构了产品名或政策前提,它可能召回一个很有说服力、但回答了错误问题的结果。

按问题要求定义充分性

每个评测问题都应标注证据要求:

yaml
query_id: contractor-international-travel
required_facts:
  - contractor eligibility
  - international travel rule
  - approval authority
required_qualifiers:
  - employment type
  - effective date
unanswerable_when:
  - contractor policy is absent
  - applicable revisions conflict without precedence

检索门禁才能区分:

  • **sufficient:**所有必要事实与限定条件都有适用证据;
  • **partial:**部分信息有用且受支持,但不足以完整回答;
  • **conflicting:**适用来源相互冲突,且无法按优先级解决;
  • **insufficient:**缺少必要证据;
  • **unauthorized:**证据可能存在,但当前主体无权访问。

任何模型分数或阈值都应使用代表性的可回答、不可回答、冲突、过期来源和跨租户切片校准。从其他语料复制来的阈值,在当前系统中没有经过证明的含义。

控制三 把上下文当作不可信证据

被检索内容是数据,不是指令。文档可能过期、相互矛盾、格式损坏,也可能故意携带提示注入文本。

Context Assembler 应做到:

  • 把 System Instruction 放在 Evidence Delimiter 外;
  • 为每个片段标注不透明 Evidence ID、Revision、Locator 和适用范围;
  • 在模型调用前排除越权或非 Active 证据;
  • 保留重要限定条件,不因截断而丢失例外;
  • 对冲突声明分组或打标,不让模型静默选择;
  • 对近重复片段去重,避免重复文本看起来像独立佐证;
  • 控制 Token Budget,同时保留引用背后的原始 Span。

OWASP Prompt Injection 指南明确将 RAG 内容视为间接提示注入路径。Delimiter 与提示约束可以降低风险,但不能形成安全边界。模型不能因为检索片段中的文字而获得权限、执行 Tool 或选择受保护来源。

Context 应显式暴露冲突:

text
<evidence id="travel-2026#p18" status="active" valid_from="2026-01-01">
外包人员国际差旅必须由项目所属总监审批。
</evidence>

<evidence id="travel-2024#p11" status="superseded" valid_to="2025-12-31">
外包人员国际差旅必须由部门经理审批。
</evidence>

如果优先级确定,应用代码可以删除被替代片段。如果有效性或权威性存在歧义,应保留冲突并升级复核,而不是让生成器猜测政策。

控制四 校验声明与引用

只有系统检查引用能否解析、是否蕴含声明以及是否完整覆盖,Citation 才有工程价值。模型生成的 [来源: policy.pdf] 只能证明它输出了一个看似合理的标识符。

采用 Claim-First 响应契约:

json
{
  "claims": [
    {
      "claim_id": "c1",
      "text": "外包人员国际差旅必须由项目所属总监审批。",
      "evidence_ids": ["travel-2026#p18"]
    }
  ],
  "answer": "外包人员国际差旅必须由项目所属总监审批 [c1]。"
}

按以下顺序验证:

  1. 响应符合 Schema。
  2. 每个 Evidence ID 都来自本次 Run 的授权证据快照。
  3. 每个重要回答句都映射到一个或多个原子声明。
  4. 每个引用片段对对应 Claim 给出支持、矛盾或证据不足判定。
  5. 所有发布声明都具有充分的引用覆盖。

ALCE Benchmark 把答案正确性与引用质量分开,因为引用可能存在,却不完整或不支持声明。RAGAS 同样分开 Context Relevance、Answer Relevance 与 Faithfulness。生产系统应保留这些差异,不能把它们压缩成一个 is_hallucinated Boolean。

LLM 或 NLI 模型可以提出 Claim Support Verdict,但它仍是会出错的评估器。应版本化其 Prompt 和模型,保存 Evidence Pair,并用人工标签校准矛盾与证据不足的 Precision 和 Recall。RAGTruth 同时包含与来源矛盾的声明和无依据新增内容,可用于测试 Verifier 是否能区分不同故障。

控制五 用确定性策略发布与拒答

最终发布决定应由应用策略执行,而不是再让模型自由回答一次。策略消费 Evidence 和 Verifier 结果,再选择有边界的结果:

结果 必要条件 用户看到的行为
release 所有重要声明均受支持 返回完整回答与可解析引用
partial 有用子集得到支持 只返回受支持声明,并说明缺失范围
abstain 证据不存在或不足 明确说明无法确定的内容
review 来源冲突或风险高 暂停回答并交给授权审核人
deny 请求或证据越权 不返回任何受保护证据

OpenAI 关于幻觉的研究解释了为什么只看 Accuracy 会奖励猜测。RAG 策略应让自信错误的代价高于适当拒答,同时度量错误拒答。永远拒答的系统虽然幻觉少,却没有业务效用。

不要在每次校验失败后自动重新生成。只有重试改变了可测试条件,例如修正查询、获取另一条授权来源、缩小 Claim 或更换生成配置时,才适合做有界重试。使用相同证据和 Prompt 重试,可能重复同一错误并增加延迟与成本。

实现最小证据门禁

以下 Python 3.9+ 程序实现契约中的确定性部分。Retriever 和 Verifier 可以使用机器学习模型,但只有这段 Policy 决定哪些内容可以发布。

python
from dataclasses import dataclass
from enum import Enum
from typing import Dict, FrozenSet, List, Optional


class Verdict(str, Enum):
    SUPPORTED = "supported"
    CONTRADICTED = "contradicted"
    INSUFFICIENT = "insufficient"


class Outcome(str, Enum):
    RELEASE = "release"
    PARTIAL = "partial"
    ABSTAIN = "abstain"
    REVIEW = "review"
    DENY = "deny"


@dataclass(frozen=True)
class Evidence:
    evidence_id: str
    tenant: str
    allowed_roles: FrozenSet[str]
    status: str
    valid_from: int
    valid_to: Optional[int]


@dataclass(frozen=True)
class Claim:
    claim_id: str
    text: str
    evidence_ids: tuple
    verdict: Verdict
    is_material: bool = True


@dataclass(frozen=True)
class Decision:
    outcome: Outcome
    released_claim_ids: tuple
    reasons: tuple


def is_authorized(
    evidence: Evidence,
    *,
    tenant: str,
    roles: FrozenSet[str],
    now: int,
) -> bool:
    return (
        evidence.tenant == tenant
        and bool(evidence.allowed_roles & roles)
        and evidence.status == "active"
        and evidence.valid_from <= now
        and (evidence.valid_to is None or now <= evidence.valid_to)
    )


def decide(
    claims: List[Claim],
    evidence_by_id: Dict[str, Evidence],
    *,
    tenant: str,
    roles: FrozenSet[str],
    now: int,
    high_risk: bool,
) -> Decision:
    reasons = []
    supported = []

    for claim in claims:
        evidence = [evidence_by_id.get(item) for item in claim.evidence_ids]
        if not evidence or any(item is None for item in evidence):
            reasons.append("%s:unknown_evidence" % claim.claim_id)
            continue
        if not all(
            is_authorized(item, tenant=tenant, roles=roles, now=now)
            for item in evidence
        ):
            return Decision(Outcome.DENY, (), ("%s:unauthorized" % claim.claim_id,))
        if claim.verdict == Verdict.CONTRADICTED:
            return Decision(Outcome.REVIEW, (), ("%s:contradicted" % claim.claim_id,))
        if claim.verdict == Verdict.SUPPORTED:
            supported.append(claim.claim_id)
        elif claim.is_material:
            reasons.append("%s:insufficient" % claim.claim_id)

    material = [claim for claim in claims if claim.is_material]
    if not material or not supported:
        return Decision(Outcome.ABSTAIN, (), tuple(reasons or ["no_material_claims"]))
    if high_risk and reasons:
        return Decision(Outcome.REVIEW, (), tuple(reasons))
    if reasons:
        return Decision(Outcome.PARTIAL, tuple(supported), tuple(reasons))
    return Decision(Outcome.RELEASE, tuple(supported), ())


evidence = {
    "travel-2026#p18": Evidence(
        evidence_id="travel-2026#p18",
        tenant="acme",
        allowed_roles=frozenset({"employee", "contractor-manager"}),
        status="active",
        valid_from=1_767_225_600,
        valid_to=None,
    )
}

supported_claim = Claim(
    claim_id="c1",
    text="Contractors need sponsoring-director approval.",
    evidence_ids=("travel-2026#p18",),
    verdict=Verdict.SUPPORTED,
)
missing_claim = Claim(
    claim_id="c2",
    text="The company always reimburses business-class fares.",
    evidence_ids=("travel-2026#p18",),
    verdict=Verdict.INSUFFICIENT,
)

release = decide(
    [supported_claim],
    evidence,
    tenant="acme",
    roles=frozenset({"contractor-manager"}),
    now=1_800_000_000,
    high_risk=False,
)
partial = decide(
    [supported_claim, missing_claim],
    evidence,
    tenant="acme",
    roles=frozenset({"contractor-manager"}),
    now=1_800_000_000,
    high_risk=False,
)
denied = decide(
    [supported_claim],
    evidence,
    tenant="other",
    roles=frozenset({"contractor-manager"}),
    now=1_800_000_000,
    high_risk=False,
)

assert release.outcome == Outcome.RELEASE
assert partial.outcome == Outcome.PARTIAL
assert partial.released_claim_ids == ("c1",)
assert denied.outcome == Outcome.DENY
print(release.outcome.value, partial.outcome.value, denied.outcome.value)

预期输出:

text
release partial deny

程序刻意不自行推断语义支持关系。它消费版本化的 Verifier Verdict,并独立证明 Policy 行为。生产系统应把 Verifier Version、Evidence Digest、Claim Digest、Policy Version 与 Decision 一起存储。

验证完整控制闭环

测试必须扰动每个边界,不能只观察几个正常回答是否“看起来不错”。

测试切片 注入条件 预期行为
缺少证据 必要片段不在候选集 abstain,或在预算内再次检索
来源过期 已被替代的政策排在首位 在生成前过滤
跨租户来源 相关片段属于其他租户 deny,且不把内容发送给模型
来源冲突 两个适用 Revision 相互矛盾 review 或显式冲突回答
限定条件丢失 Chunk 缺少例外或日期 不通过充分性门禁
间接注入 文档要求模型忽略 Policy 只作为数据处理,不改变权限
引用伪造 Claim 引用未见过的 Evidence ID 发布前拒绝
无依据补充 回答增加未引用的重要声明 partialabstainreview
错误拒答 证据充分但系统拒答 计入拒答召回与效用损失
Verifier 漂移 Judge 版本改变判定分布 重新校准前阻断发布

具体指标与发布测试集设计可参考生产级 RAG 评估指南。至少报告:

  • Evidence Recall@k 与 Sufficiency Recall;
  • 过期、越权和冲突证据比例;
  • Claim Support 与 Contradiction 的 Precision/Recall;
  • Citation Precision 与重要声明覆盖率;
  • Abstention Precision、Abstention Recall、Selective Risk 与 Answer Coverage;
  • 外部正确性和端到端任务成功率;
  • 延迟、模型调用数、Token 消耗与人工复核负担。

这些指标必须按查询类型、来源类型、语言、租户、风险和可回答性切片。平均值可能掩盖最关键政策或客户群体上的完全失效。

上线检查清单

发布前确认:

  1. 每次 Corpus Build 都有不可变 Revision 与 Deletion Manifest。
  2. Authorization Filter 在候选检索之前执行。
  3. Query Rewrite 有数量上限、完整日志和意图漂移测试。
  4. Retrieval 测试包含可回答、不可回答、冲突、过期来源和跨租户案例。
  5. Context 保留限定条件、Source Locator、Validity 与 Conflict State。
  6. 被检索文本不能授权 Tool 或覆盖系统 Policy。
  7. 每个重要 Claim 都映射到可解析的 Evidence ID。
  8. Claim-Support Judge 已版本化,并用人工标签校准。
  9. 发布策略支持 partialabstainreviewdeny
  10. 生产事件记录 Digest 与 Decision,不记录原始 Secret 或无必要的完整文档。

常见问题

RAG 已经检索到文档,为什么仍会产生幻觉?

检索得到的是候选证据,不是事实保证。正确片段可能缺失,限定条件可能在切分时丢失,来源可能过期或冲突,生成器也可能添加任何片段都不支持的声明。语料、检索、上下文、生成、引用与发布策略必须分别诊断。

相似度或重排阈值能防止 RAG 幻觉吗?

不能用通用阈值证明可回答性。Retriever、Corpus、语言、Query Class 和模型版本都会改变分数分布。应在已标注的可回答和不可回答案例上校准,再把分数与必要证据覆盖及冲突检测结合。

带有引用的 RAG 回答一定忠实吗?

不一定。系统要检查三个属性:引用能解析到本次授权运行的 Evidence Object;引用 Span 支持精确 Claim;引用覆盖所有重要 Claim。只有来源标签却没有 Entailment 与 Coverage,只是展示,不是验证。

RAG 回答校验失败后应该自动重新生成吗?

只有有界重试改变了可测试条件时才适合,例如检索另一条授权来源、修复发生漂移的查询或缩小待回答 Claim。其他情况应返回受支持的部分回答、拒答或交给授权人员复核。

如何衡量 RAG 幻觉治理效果?

要测量完整证据链:来源权威性与时效、检索覆盖与充分性、冲突与权限泄露、声明支持与矛盾、引用质量、拒答行为、外部正确性、任务成功、延迟、成本和复核负担。报告时保留分母和风险切片。

总结

当应用把每个回答视为 Claim-Evidence Proposal 时,RAG 幻觉治理才真正可执行。治理来源身份与范围,为充分性而检索,在组装不可信上下文时保留冲突,逐条校验重要声明与引用,再由确定性 Policy 决定完整发布、缩减、拒答、复核或拒绝访问。任何 Prompt、分数、引用标签、模型升级或 Judge 都不能替代这套控制闭环。

相关资源

一手来源