AI 工程基础体系 · 第 20/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

RAG 权限与评测:ACL、版本、缓存、忠实度、无答案和删除

RAG(Retrieval-Augmented Generation,检索增强生成)不是“向量数据库加一个大模型”。它是一条把数据摄取、解析、切块、索引、检索、重排、上下文组装、生成和引用连接起来的生产流水线。原始 RAG 论文将外部非参数记忆与生成模型结合起来;在工程实现中,检索结果还必须同时满足权限、版本、生命周期和可审计性要求。

如果这些条件没有被纳入同一个系统,常见故障并不是模型“回答得不够好”,而是:

  • 用户看到了自己无权访问的文档;
  • 文档已更新,但回答仍来自旧版本;
  • 文档已删除,向量库或缓存仍然召回它;
  • 模型给出了一段语气肯定、但上下文没有支持的内容;
  • 正确答案不在知识库中时,模型编造了答案;
  • 评测集和线上权限分布不一致,离线指标很好,线上事故频发。

下面把这些问题放在同一条数据流中分析。


1. 先定义 RAG 的对象和信任边界

设文档集合为 DD,每个文档 dd 至少包含:

d=(id,tenant,content,acl,version,status,embedding)d = (\text{id}, \text{tenant}, \text{content}, \text{acl}, \text{version}, \text{status}, \text{embedding})

其中:

  • id:稳定的逻辑文档标识;
  • tenant:租户或数据域;
  • content:原始内容或规范化内容;
  • acl:访问控制列表;
  • version:内容版本;
  • status:有效、撤销、删除、隔离等状态;
  • embedding:由内容版本计算出的向量。

一个查询请求还包含用户身份和当前权限:

q=(user,tenant,groups,scopes,query)q = (\text{user}, \text{tenant}, \text{groups}, \text{scopes}, \text{query})

因此,合法的候选集合不是全部文档,而是:

Dq={dDtenant(d)=tenant(q)Allow(q,d)status(d)=active}D_q = \{d \in D \mid \text{tenant}(d)=\text{tenant}(q) \land \text{Allow}(q,d) \land \text{status}(d)=\text{active}\}

这里的 Allow 是授权函数,而不是模型判断。例如:

Allow(q,d)={1,user(q)acl(d)1,group(q)groups(d)1,scope(q)scope(d)0,其他情况\text{Allow}(q,d)= \begin{cases} 1,& \text{user}(q)\in \text{acl}(d)\\ 1,& \text{group}(q)\cap \text{groups}(d)\neq\varnothing\\ 1,& \text{scope}(q)\supseteq \text{scope}(d)\\ 0,& \text{其他情况} \end{cases}

实际系统通常还要处理显式拒绝、继承权限、部门边界、时间条件和文档密级。授权规则必须由确定性代码执行,不能把“请只使用有权限的内容”作为唯一安全措施。

1.1 RAG 的正确数据流

一个较完整的请求路径如下:

flowchart LR
    U[用户请求] --> I[身份认证]
    I --> A[构造授权上下文]
    A --> Q[查询改写/嵌入]
    Q --> R[带 ACL 和版本约束的检索]
    R --> F[重排与过滤]
    F --> C[上下文、版本和引用组装]
    C --> G[生成模型]
    G --> V[忠实度/引用/安全校验]
    V --> O[答案或无答案]
    
    D[原始文档] --> P[解析与切块]
    P --> E[嵌入]
    E --> X[索引]
    D --> M[元数据、ACL、版本、删除状态]
    M --> X

关键路径是:授权上下文必须在检索前形成,并进入检索条件;生成后的检查只能降低风险,不能修复已经发生的越权检索。

例如,先从全库召回 20 个结果,再在应用层过滤掉无权文档,仍可能产生以下问题:

  1. 检索器、重排器或日志已经处理过受限内容;
  2. 无权文档影响了查询改写或上下文截断;
  3. 缓存把另一个用户的结果返回给当前用户;
  4. 过滤后上下文为空,但模型仍根据提示或参数记忆作答。

因此,ACL 过滤至少需要出现在检索候选生成之前或检索器内部。应用层还应再次校验,形成纵深防御。


2. ACL:权限不是一个字段,而是检索条件

ACL(Access Control List)是“哪些主体可以对对象执行哪些操作”的授权表示。在 RAG 中最重要的操作通常是 retrieveread,而不是只控制最终页面是否展示。

2.1 文档级 ACL 与切块级 ACL

文档切成多个 chunk 后,权限通常应从父文档继承:

document_id = handbook-42
document_version = 7
acl = {group: engineering, action: read}

chunk-001 -> 继承 handbook-42 的 acl
chunk-002 -> 继承 handbook-42 的 acl

不要只把 ACL 存在父文档表中,却让向量索引中的 chunk 没有可过滤的权限信息。否则检索时无法高效且确定地排除无权 chunk。

如果一份文档内存在不同权限段落,则不能简单继承同一个 ACL,必须在切块前识别安全边界,或者为每个安全区间生成独立的对象。一个 chunk 不能混合“所有员工可见”和“仅财务可见”的文字,否则即使最终引用看起来合法,也可能通过相邻上下文泄漏秘密。

2.2 预过滤、后过滤和混合方案

设相似度函数为 s(q,d)s(q,d),需要返回 kk 个结果。

正确的授权检索目标是:

R(q)=TopKdDqs(q,d)R(q)=\operatorname{TopK}_{d\in D_q}s(q,d)

而不安全的后过滤是:

R(q)=FilterDq(TopKdDs(q,d))R'(q)=\operatorname{Filter}_{D_q} \left(\operatorname{TopK}_{d\in D}s(q,d)\right)

两者不等价。考虑 k=2k=2

文档 相似度 权限
A 0.99 无权
B 0.98 有权
C 0.97 有权

后过滤得到 {B},而正确结果应是 {B, C}。如果系统只取前 2 个再过滤,可能错误地认为“知识库没有足够证据”。

常见实现有三种:

  1. 索引过滤:向量数据库在 ANN 搜索时使用 tenant、ACL、状态和版本过滤器。效率通常较好,但要求索引支持这些谓词。
  2. 候选分区:按租户、数据域或安全级别建立独立索引。隔离强,但索引数量、更新和成本增加。
  3. 混合检索:先用结构化条件缩小候选,再做向量和关键词检索。适合 ACL 复杂、全文检索和向量检索并存的场景。

分区不能代替授权判断。用户权限可能随时间变化,用户组也可能变化;分区只是在数据层提供隔离,最终仍需根据请求时的身份状态授权。

2.3 ACL 的典型错误

错误一:把用户 ID 放进提示词。

用户是 alice。请不要回答她无权查看的内容。

模型并不具备可靠的授权执行能力。它可能引用上下文中已经存在的受限文字,也可能根据自身知识猜出敏感信息。

错误二:缓存只使用查询文本作为键。

cache["公司裁员计划"] = answer

这会把 Alice 的答案直接返回给 Bob。权限相关缓存键至少要区分租户、权限版本、检索版本和查询规范化结果。

错误三:把“可引用”误当成“可检索”。

有些系统允许模型读取文档,但禁止向用户展示原文;这仍需定义可执行的脱敏、摘要和字段级策略。只在输出阶段删除引用,不能保证模型没有从受限内容中生成事实。


3. 版本:回答必须知道自己基于哪一份知识

版本控制解决的不是 Git 式代码协作,而是回答与知识状态的一致性问题。至少要区分四种版本:

  1. 内容版本:文档正文从版本 6 变为版本 7;
  2. 解析版本:PDF 解析器或 OCR 结果发生变化;
  3. 索引版本:embedding 模型、chunk 策略或向量索引构建发生变化;
  4. 生成版本:提示模板、模型、重排器或安全策略发生变化。

一个 chunk 的可验证身份可以表示为:

chunk_key=(doc_id,doc_version,chunker_version,chunk_id)\text{chunk\_key} = (\text{doc\_id},\text{doc\_version},\text{chunker\_version},\text{chunk\_id})

其 embedding 也应记录:

embedding_key=(content_hash,embedding_model_version)\text{embedding\_key} = (\text{content\_hash},\text{embedding\_model\_version})

如果只使用 doc_id,文档更新后可能出现旧 chunk 和新 chunk 共存,检索结果便无法解释。

3.1 版本更新的原子性

文档更新不能简单地按以下顺序异步执行:

1. 更新文档表
2. 删除旧向量
3. 写入新向量

在步骤 1 和 2 之间,系统可能检索到旧向量;在步骤 2 和 3 之间,系统可能完全检索不到文档;如果新向量写入失败,文档表和索引状态还会永久不一致。

更可靠的做法是使用索引代次(generation):

active_generation = 41

构建新版本时:

generation 42:
  写入文档版本 7
  写入全部新 chunk
  校验数量、哈希、ACL 和索引可查询性
  将 generation 42 标记为 ready
原子切换:
  active_generation = 42

查询只读取当前活动代次。旧代次可以保留一段时间用于回滚,但不能继续被默认检索。

这是一种“发布后切换”模型。它的代价是需要额外存储和构建时间,但避免了半成品索引暴露给线上请求。

3.2 请求级一致性

在多副本系统中,用户刚修改权限或文档刚删除时,不同副本可能看到不同状态。可以给每次写入分配单调递增的水位线:

w=mutation sequencew = \text{mutation sequence}

请求携带 required_watermark = w,检索副本只有在已应用到至少 ww 时才服务该请求;否则等待、转发到主副本,或明确返回暂不可用。

如果业务不能接受等待,也必须定义可接受的陈旧窗口,例如“权限撤销最多 30 秒生效”。这不是模型指标,而是安全和一致性协议,应该写入系统契约。


4. 缓存:缓存的是带权限和版本的计算结果

RAG 常见缓存包括:

  • 查询嵌入缓存;
  • 检索候选缓存;
  • 重排结果缓存;
  • 上下文组装缓存;
  • 最终答案缓存。

不同缓存的安全边界不同。

4.1 哪些结果可以跨用户复用

查询嵌入通常只依赖规范化文本和 embedding 模型版本,因此可以使用:

Ke=H(normalized_query,embedding_model_version)K_e = H(\text{normalized\_query}, \text{embedding\_model\_version})

但检索结果依赖权限和数据状态,缓存键至少应包含:

Kr=H(tenant,authz_snapshot,query,retriever_version,index_generation,required_watermark)K_r = H( \text{tenant}, \text{authz\_snapshot}, \text{query}, \text{retriever\_version}, \text{index\_generation}, \text{required\_watermark} )

其中 authz_snapshot 可以是权限集合的稳定摘要,而不是直接把完整 ACL 放进键中。

最终答案还依赖提示模板、模型、上下文、工具结果和语言设置:

Ka=H(Kr,context_hash,prompt_version,model_version,locale,tool_state)K_a = H(K_r,\text{context\_hash},\text{prompt\_version}, \text{model\_version},\text{locale},\text{tool\_state})

如果答案中包含用户个性化信息,通常不应做跨用户共享缓存。

4.2 缓存失效不是“过期时间”一个问题

TTL 只能处理时间,不知道“某个文档刚被撤销”。因此需要事件驱动失效:

document handbook-42 version 6 -> version 7
        |
        +--> 删除/隔离相关检索缓存
        +--> 删除旧上下文缓存
        +--> 标记旧答案缓存不可用
        +--> 推进权限或索引水位线

缓存命中后仍应做轻量状态校验。否则一个长 TTL 缓存可能绕过最新 ACL。

缓存污染也可能来自提示注入:如果把恶意文档内容生成的答案缓存为“可信答案”,后续请求即使上下文不同,也可能复用错误结果。缓存对象应记录来源文档 ID、版本、引用集合和安全检查结果,不能只保存一段字符串。


5. 忠实度:答案是否由上下文支持

忠实度(faithfulness)表示生成答案是否被提供给模型的上下文支持。它与“答案正确”不是同一个指标。

设上下文为 CC,答案被拆成声明集合:

A={a1,a2,,an}A=\{a_1,a_2,\ldots,a_n\}

若某个声明 aia_i 能由 CC 蕴含,则记:

Supported(ai,C)=1\operatorname{Supported}(a_i,C)=1

一个简单的声明级忠实度为:

F(A,C)=iwiSupported(ai,C)iwiF(A,C)= \frac{\sum_i w_i\operatorname{Supported}(a_i,C)} {\sum_i w_i}

其中 wiw_i 可以按声明重要性加权。实际评测可使用人工标注、规则匹配、自然语言推理模型或 LLM judge,但自动评判本身也会误判,关键样本仍需要人工复核。

5.1 忠实度、正确性和检索质量的区别

有四种典型情况:

检索上下文 模型答案 忠实度 外部事实正确性
含正确证据 正确回答
含正确证据 编造额外细节 部分或低
未含证据 模型凭参数记忆答对 低或不可判定 可能高
含错误文档 忠实复述错误文档

因此,RAG 评测至少要分解为:

  • 检索召回:支持答案的文档是否进入候选;
  • 检索精确性:召回结果中有多少是相关的;
  • 忠实度:答案中的声明是否由上下文支持;
  • 答案正确性:答案是否符合参考答案或事实;
  • 引用正确性:引用是否真的支持对应声明;
  • 拒答质量:无证据时是否拒答,有证据时是否避免误拒答。

5.2 完整算例

问题是:

工程师申请生产数据库只读权限需要什么审批?

知识库中有:

d1,版本 3:
生产数据库只读权限需要直属主管和数据 владельца 审批。

d2,版本 5:
生产数据库写权限需要安全团队审批。

正确检索上下文为 C={d1}C=\{d1\}。模型回答:

只读权限需要直属主管和数据 владельца 审批,并且还需要安全团队审批。

将答案拆成两个声明:

  • a1a_1:需要直属主管和数据所有者审批;
  • a2a_2:还需要安全团队审批。

其中 a1a_1 被 d1 支持,a2a_2 只在 d2 中出现,且 d2 讨论的是写权限。因此:

F=12=0.5F=\frac{1}{2}=0.5

即使用户组织流程恰好也要求安全团队审批,这个回答在“基于给定上下文作答”的意义下仍不忠实,因为答案没有说明该结论来自哪条证据,且把写权限规则迁移到了只读权限。

5.3 引用不能只放在答案末尾

更可验证的输出是把声明与引用绑定:

{
  "answer": "只读权限需要直属主管和数据所有者审批。",
  "claims": [
    {
      "text": "只读权限需要直属主管和数据所有者审批",
      "citations": [
        {"document_id": "d1", "version": 3, "chunk_id": "d1-004"}
      ]
    }
  ]
}

这样可以逐条检查引用是否支持声明,也能在文档版本删除或撤销后定位哪些答案缓存需要失效。仅在整段答案后附一个来源列表,无法判断每个句子是否被来源支持。


6. 无答案:没有证据时应当拒答

“无答案”有两种不同含义:

  1. 知识库无答案:所有有权限且有效的文档都没有支持该问题的内容;
  2. 用户无权获得答案:系统可能有相关文档,但当前用户不能确认其存在或内容。

第二种情况不能直接返回“文档存在但你无权访问”,因为这可能泄漏文档标题、数量或主题。对外可以统一返回:

当前可访问的知识中没有足够信息回答该问题。

但内部审计日志仍应区分“无证据”和“权限拒绝”。

6.1 拒答判定不是单一相似度阈值

设最高检索分数为 s1s_1,第二名为 s2s_2,支持声明的证据覆盖率为 ee,权限过滤后的候选数量为 mm。可以定义一个拒答策略:

Answer={1,m>0s1τseτe0,其他情况\operatorname{Answer} = \begin{cases} 1,&m>0 \land s_1\ge \tau_s \land e\ge \tau_e\\ 0,&\text{其他情况} \end{cases}

但这只是工程决策,不是普遍有效的数学定理。向量相似度通常未校准,不同问题长度、领域和 embedding 模型下分数不可直接比较。实际需要在带标签验证集上选择阈值,并测量:

  • 覆盖率:多少问题系统愿意回答;
  • 选择性风险:系统选择回答的问题中有多少是错误的;
  • 误拒答率:其实有证据却拒答的比例;
  • 误答率:无证据却给出实质答案的比例;
  • 校准:模型或检索器给出的置信度是否与真实正确率匹配。

一个简单的二分类混淆矩阵如下:

实际情况 系统回答 系统拒答
有足够证据 真阳性 假阴性
无足够证据 假阳性 真阴性

在企业知识库中,假阳性通常比假阴性更危险:错误的肯定答案可能导致生产操作、合规判断或权限配置错误。

6.2 无答案示例和反例

问题:

2025 年第三季度新加坡办公室的差旅上限是多少?

检索结果只有:

d1:2024 年全球差旅政策
d2:2025 年美国办公室差旅政策

这两个结果可能语义相似,但都不能支持目标声明。正确输出应是无答案,而不是把美国政策或旧年份政策迁移到新加坡。

反例是:

2025 年第三季度差旅上限是多少?

如果系统把“2024 年政策”的相似度设为高于阈值,就可能输出一个过时数字。解决方法不是单纯提高阈值,还要把年份、地区等结构化约束纳入检索和证据判定。


7. 删除:从搜索结果消失不等于真正删除

删除操作至少有三个层次:

  1. 逻辑删除:线上查询不再返回对象;
  2. 索引删除:向量、倒排索引、缓存和派生 chunk 不再可查询;
  3. 物理删除:原文、备份、日志、临时文件和第三方处理副本按策略销毁。

不同法规和业务场景对物理删除的要求不同,不能笼统地声称“删除接口调用成功就完成了删除”。

7.1 删除状态机

可以把文档生命周期表示为:

stateDiagram-v2
    [*] --> Active
    Active --> Revoked: 权限撤销
    Active --> Deleting: 删除请求
    Revoked --> Deleting: 删除请求
    Deleting --> Tombstoned: 写入墓碑并停止新摄取
    Tombstoned --> Purged: 索引/缓存/副本清理完成
    Deleting --> DeleteFailed: 某个派生系统失败
    DeleteFailed --> Deleting: 重试或人工处理

**墓碑(tombstone)**是一个不可忽略的删除标记,通常包含:

document_id
deleted_at
delete_sequence
reason
retention_until

摄取任务和索引重建任务必须读取墓碑。否则会出现这种故障:

1. 文档 d 被删除
2. 当前向量被删除
3. 旧的离线备份恢复
4. 重建任务把 d 重新写入索引

墓碑可以阻止第 4 步,直到删除策略允许清除它。

7.2 删除必须覆盖派生数据

原文删除后,以下数据仍可能包含可识别内容:

  • 解析后的纯文本;
  • chunk 表;
  • embedding;
  • BM25 或倒排索引;
  • 重排特征缓存;
  • 查询结果缓存;
  • 上下文缓存和答案缓存;
  • 评测样本、调试日志和 tracing;
  • 备份、复制副本和第三方 API 日志。

尤其要注意 embedding 不是“无法还原的安全摘要”。向量通常不能直接等价还原原文,但可能泄漏语义,且仍然是由受保护内容派生的数据。是否需要删除应由数据分类和合规要求决定,而不能依赖“向量看不懂”。

删除流程应具备幂等性:重复收到同一个删除事件不能报错或恢复数据。可用 delete_sequence 或事件 ID 去重,并为每个派生系统记录:

document_id = d42
delete_sequence = 1088
vector_index = applied
keyword_index = applied
cache = applied
backup = pending

只有所有必要系统达到规定状态,才能把删除请求标记为完成。

7.3 删除验证

验证不应只查询一次向量库。至少需要:

  1. 用原始标题、正文片段和同义改写查询;
  2. 检查向量索引和关键词索引;
  3. 检查热缓存和持久缓存;
  4. 检查当前活动索引代次;
  5. 检查新建索引不会重新摄取墓碑对象;
  6. 检查审计日志中没有不必要的原文残留。

如果系统采用最终一致性,需要记录删除延迟和最坏情况,而不是声称删除瞬时生效。


8. 把 ACL、版本、缓存、无答案和删除放进评测

单一的“回答准确率”无法覆盖这些风险。评测集中的每个样本至少应包含:

{
  "query": "工程师申请生产数据库只读权限需要什么审批?",
  "user": {
    "tenant": "acme",
    "id": "u-17",
    "groups": ["engineering"]
  },
  "expected": {
    "answerable": true,
    "required_claims": [
      "需要直属主管和数据所有者审批"
    ],
    "forbidden_documents": ["hr-秘密-9"],
    "required_citations": [
      {"document_id": "d1", "version": 3}
    ]
  }
}

测试集必须覆盖以下边界:

  • 同一个问题由不同权限用户提出;
  • 用户刚被撤销权限;
  • 文档从旧版本更新到新版本;
  • 文档被删除后重复查询;
  • 有相似但年份、地区或权限不匹配的文档;
  • 知识库完全没有答案;
  • 检索到恶意提示注入文档;
  • 多租户中出现相同标题和相同问题;
  • 缓存命中与未命中两条路径;
  • 索引切换发生在请求处理中。

8.1 检索指标

如果标注了支持答案的文档集合 GG,检索结果为 RkR_k,则:

Recall@k=GRkG\operatorname{Recall@k}= \frac{|G\cap R_k|}{|G|}

Precision@k=GRkk\operatorname{Precision@k}= \frac{|G\cap R_k|}{k}

对 RAG 而言,还应测量:

  • AuthorizedRecall@k:只在当前用户有权文档中计算召回;
  • UnauthorizedHitRate:结果中出现无权文档的比例,生产目标应为零;
  • FreshnessViolationRate:召回文档版本低于请求要求的比例;
  • DeletedHitRate:删除后仍被召回的比例,生产目标应为零;
  • CitationCoverage:答案声明中有有效引用的比例。

权限泄漏即使只发生一次,也不能被整体平均指标掩盖,所以应同时报告最大风险、按租户分组的指标和每条越权样本。

8.2 端到端答案指标

对可回答问题,测量正确性、忠实度和引用准确性;对不可回答问题,测量拒答质量。一个简单的加权评估可以是:

Risk=λ1HallucinationRate+λ2UnauthorizedHitRate+λ3DeletedHitRate+λ4StaleAnswerRate\text{Risk} = \lambda_1\cdot\text{HallucinationRate} +\lambda_2\cdot\text{UnauthorizedHitRate} +\lambda_3\cdot\text{DeletedHitRate} +\lambda_4\cdot\text{StaleAnswerRate}

这里的 λi\lambda_i 不是固定标准,而是业务风险权重。例如医疗、财务和生产运维场景通常会给越权和幻觉更高权重。

不要把“LLM judge 给了 4 分”当成安全证明。评判模型可能看不出版本错误、权限错误或引用与声明之间的细微不匹配。权限、版本和删除应尽可能使用确定性断言:

assert result.document.tenant == request.tenant
assert allow(request.user, result.document.acl)
assert result.document.status == "active"
assert result.document.version >= request.required_version

语义质量则可以结合人工标注和模型辅助评估。


9. 一个最小的授权过滤示例

下面的 Python 代码不实现向量检索,而是展示检索结果进入上下文前必须经过的确定性检查。它可以直接运行,输入是候选 chunk,输出是当前用户可用的 chunk。

from dataclasses import dataclass
from typing import FrozenSet, List


@dataclass(frozen=True)
class User:
    tenant: str
    user_id: str
    groups: FrozenSet[str]


@dataclass(frozen=True)
class Chunk:
    document_id: str
    version: int
    tenant: str
    groups_allowed: FrozenSet[str]
    status: str
    score: float
    text: str


def authorized(user: User, chunk: Chunk, required_version: int) -> bool:
    if chunk.tenant != user.tenant:
        return False
    if chunk.status != "active":
        return False
    if chunk.version < required_version:
        return False
    return bool(user.groups & chunk.groups_allowed)


def filter_candidates(
    user: User,
    candidates: List[Chunk],
    required_version: int,
) -> List[Chunk]:
    visible = [
        c for c in candidates
        if authorized(user, c, required_version)
    ]
    return sorted(visible, key=lambda c: c.score, reverse=True)


if __name__ == "__main__":
    alice = User("acme", "u-1", frozenset({"engineering"}))

    candidates = [
        Chunk("d-secret", 8, "acme", frozenset({"hr"}), "active",
              0.99, "未公开的裁员计划"),
        Chunk("d-public", 3, "acme", frozenset({"engineering"}), "active",
              0.95, "生产数据库只读权限需要审批"),
        Chunk("d-old", 2, "acme", frozenset({"engineering"}), "active",
              0.94, "旧版审批规则"),
        Chunk("d-other-tenant", 9, "other", frozenset({"engineering"}), "active",
              0.93, "其他租户的内容"),
    ]

    result = filter_candidates(alice, candidates, required_version=3)
    print([(c.document_id, c.version) for c in result])

预期输出是:

[('d-public', 3)]

d-secret 因群组不匹配被拒绝,d-old 因版本过低被拒绝,d-other-tenant 因租户不同被拒绝。生产实现还需要处理显式拒绝、用户权限快照、字段级脱敏和索引层预过滤;这个示例不能替代数据库或向量引擎中的授权约束。

一个重要风险是:即使最终 result 经过了过滤,前面的候选生成器也可能把 d-secret 写入日志、重排服务或外部模型。安全边界应尽量前移到检索器内部,并对每个下游组件定义它是否允许接触原文。


10. 常见失败表现与诊断路径

10.1 用户刚失去权限,仍能看到旧答案

可能路径是:

权限服务已更新
    ↓
检索缓存未失效
    ↓
答案缓存命中
    ↓
请求没有重新执行 ACL

诊断时应同时检查:

  • 请求使用的权限快照 ID;
  • 检索缓存键是否包含权限版本;
  • 答案缓存是否记录来源文档;
  • 权限变更事件是否到达所有缓存;
  • 是否存在绕过检索层的答案缓存。

修复方式不是简单缩短 TTL,而是让权限变更事件主动使相关缓存失效,并在缓存命中时验证权限水位线。

10.2 文档已经更新,回答仍引用旧版本

通常是旧 chunk 没有被删除,或者答案缓存没有关联版本。检查:

answer_id
  -> context_hash
  -> document_id + document_version + chunk_id
  -> index_generation

如果链路中缺少任何一个版本字段,系统很难证明答案基于当前知识。对于高风险问题,宁可在版本水位线不足时暂时无答案,也不要静默使用旧知识。

10.3 无答案问题得到一个“听起来合理”的回答

检查三个分数:

  1. 授权后候选数是否为零;
  2. 最高相似度是否只是相对较高,而非经过校准;
  3. 答案声明是否能映射到引用 chunk。

如果候选为空却仍生成答案,说明生成路径没有强制经过 answerable 判断。生成模型应收到明确的结构化状态,例如:

{
  "evidence_status": "insufficient",
  "contexts": []
}

然后由程序或受约束的输出协议决定返回拒答,而不是只依赖自然语言提示。

10.4 删除后仍能被相似查询召回

这通常说明只删除了一个精确键,没有清理:

  • 文档的所有 chunk;
  • 旧版本;
  • 其他索引;
  • 查询结果缓存;
  • 备用索引代次;
  • 重建任务输入;
  • 备份恢复源。

应通过文档 ID、内容哈希和版本集合执行删除,而不是只删除当前看到的一个向量 ID。


11. 生产取舍:安全保证与成本的边界

有些保证可以做成系统不变量:

  • 不同租户的文档不能互相召回;
  • status != active 的对象不能进入上下文;
  • 版本低于请求水位线的对象不能被使用;
  • 删除墓碑之后不能被新索引重新摄取;
  • 缓存命中不能绕过当前授权检查。

这些属于规范保证,应通过代码、索引约束、事件处理和自动化测试实现。

另一些是经验性策略:

  • 取多少个 chunk;
  • 相似度阈值设为多少;
  • 是否使用 LLM judge;
  • 多久刷新索引;
  • 是否缓存最终答案;
  • 忠实度低于多少就重新生成。

这些参数没有跨系统通用的正确值,必须在目标领域验证集上测量,并按模型、embedding、数据分布和业务风险重新校准。

成本也必须纳入设计。更严格的租户分区、实时权限过滤、双索引发布、删除确认和多轮忠实度检查都会增加存储、延迟和计算开销。但把权限检查、版本校验和删除清理放在事后补救,通常会把一次局部优化变成全链路事故。


12. 最小生产契约

一个可审计的 RAG 请求,至少应能回答以下问题:

这次请求是谁发起的?
属于哪个租户和权限快照?
使用了哪个索引代次?
召回了哪些文档、哪些版本?
每个文档当时是否有效且有权?
答案中的每个声明由什么引用支持?
是否因为证据不足而拒答?
请求完成后,相关缓存和删除状态是什么?

如果系统无法回答这些问题,就很难区分“模型质量问题”和“数据、权限、版本、缓存问题”。

RAG 的核心不是让模型在更多文本上生成更流畅的句子,而是让每个答案都满足一个可验证条件:

AnswerAuthorized EvidenceCurrent VersionActive DataSupported Claims\text{Answer} \Rightarrow \text{Authorized Evidence} \land \text{Current Version} \land \text{Active Data} \land \text{Supported Claims}

ACL 保证证据属于当前用户可见范围;版本保证证据不是过时状态;缓存保证复用不会绕过这些条件;忠实度保证答案没有超出证据;无答案机制保证证据不足时停止生成;删除机制保证已经撤销的数据不会从派生系统中重新出现。只有这几个条件同时成立,RAG 才能作为一个可控的生产知识系统运行。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。