欢迎光临
我们一直在努力

RAG 多租户知识库越权召回:命名空间、过滤下推与二次授权排查

RAG 系统接入企业知识库后,最怕的问题不是“没有搜到答案”,而是“搜到了不该被当前用户看到的证据”。生成模型本身并不知道企业内部的组织边界、项目权限和文档密级,它只能根据检索链路提供的上下文回答。如果召回阶段把其他团队的片段混进候选集,后面的重排、拼接和生成都会建立在错误前提上。即使回答文字看起来克制,只要引用或摘要泄露了受限内容,这次查询就已经失败。在这里插入图片描述

这类故障很容易被误判为模型幻觉。用户看到一段陌生项目的制度、报价说明或接口字段,第一反应通常是“模型编出来了”。但排查时必须先把问题拆开:模型是否凭空生成,还是检索器确实返回了越权片段;越权片段来自向量库过滤错误、缓存污染、权限同步延迟,还是引用服务二次打开时绕过了授权。只有沿着查询链路逐段取证,才能把一个泛泛的安全问题转化为可修复的工程问题。

本文使用一个可复现的最小场景说明排查方法:同一个 RAG 应用服务多个租户和多个团队,文档按知识库、空间、项目和用户角色授权。某个用户只属于 team_blue,却在提问时召回了 team_red 的制度片段。文章重点不讨论具体向量数据库或模型供应商的宣传能力,而是讨论服务端如何固定租户上下文、如何让过滤发生在召回之前、如何设计缓存键、如何做证据二次授权,以及如何通过测试和日志证明问题已经被消除。

一、先判断泄露发生在检索前、检索中还是生成后

排查越权召回时,不要先改提示词。提示词只能约束模型“如何使用上下文”,不能保证上下文本身合法。第一步应给一次查询分配 trace_id,把检索输入、过滤条件、候选片段、重排结果、最终送入模型的证据集合和引用打开记录串起来。只要能看到模型实际拿到了哪些片段,很多争论就会立刻变清楚。

一个可回放的查询记录至少包含这些字段:

层级需要记录的内容排查意义
请求入口 tenant_id、user_id、team_ids、kb_id、trace_id 确认用户身份和知识库范围
权限快照 principal_hash、acl_version、allowed_scope_hash 确认查询时使用哪一版授权
召回请求 namespace、过滤表达式、top_k、检索配置摘要 判断过滤是否下推到向量检索
候选片段 doc_id、chunk_id、分数、命中的权限标签 判断越权片段何时进入候选集
重排结果 排序前后候选、被丢弃原因 判断重排是否扩大或改写权限范围
生成上下文 最终证据编号、片段摘要、内容哈希 确认模型是否拿到受限原文
引用打开 引用 ID、二次授权结果、读取状态 判断展示环节是否绕过鉴权

如果候选片段中已经出现 team_red 的文档,问题在召回或召回缓存之前;如果候选合法,但生成上下文混入了其他团队片段,问题多半在上下文拼接、重排缓存或批处理合并;如果模型上下文合法,只有引用链接打开了其他文档,问题在引用服务或对象存储授权;如果上下文和引用都合法,但回答仍提到外部信息,才需要分析模型是否使用了参数知识并加强拒答约束。

因此,越权问题的第一条原则是:先证明证据链,再讨论模型表现。没有候选日志,只看最终回答,工程团队会在提示词、向量分数和权限系统之间来回猜测;有了 trace_id,每个组件都必须说明自己读了什么、过滤了什么、交给了下一层什么。

二、构造一个最小复现数据集

不要直接在真实知识库里反复试探。真实文档多、权限关系复杂、缓存状态不透明,很难快速定位。更好的办法是构造一个最小数据集,让两个团队的文档内容在语义上相似,但权限完全不同。这样可以验证系统是否只是“凭语义相似召回”,还是严格遵守授权边界。在这里插入图片描述

示例数据如下:

文档团队可见角色片段文本摘要
doc_blue_policy team_blue blue_reader 蓝队报销系统使用项目编号 B-100,审批人为蓝队负责人
doc_red_policy team_red red_reader 红队报销系统使用项目编号 R-900,审批人为红队负责人
doc_public_help public all_staff 报销申请需要填写项目编号和审批人

用 user_blue_01 提问:“报销系统项目编号和审批人怎么填写?”正确结果可以引用蓝队文档和公共说明,但不能召回红队文档。由于蓝队和红队文本结构接近,向量相似度通常都会较高。如果系统只依赖语义检索而没有权限过滤,红队片段很容易混入 TopK;如果系统先按权限过滤,再做向量近邻,红队片段从一开始就不会参与排序。

最小请求可以设计成内部调试接口,不暴露给普通用户:

curl -s http://localhost:8080/debug/rag/query \\
-H 'Content-Type: application/json' \\
-H 'X-Debug-User: user_blue_01' \\
-d '{
"trace_id": "trace_acl_001",
"tenant_id": "tenant_demo",
"kb_id": "kb_expense",
"question": "报销系统项目编号和审批人怎么填写?",
"top_k": 5,
"debug": true
}'

期望的调试输出不是最终自然语言回答,而是结构化证据:

{
"trace_id": "trace_acl_001",
"principal": {
"user_id": "user_blue_01",
"tenant_id": "tenant_demo",
"team_ids": ["team_blue"],
"acl_version": "acl_2026_07_22_0900"
},
"retrieval": {
"namespace": "tenant_demo.kb_expense.generation_12",
"filter": "team_id IN ['team_blue','public'] AND status = 'active'",
"candidates": [
{"doc_id": "doc_blue_policy", "chunk_id": "c_blue_01", "score": 0.82, "authorized": true},
{"doc_id": "doc_public_help", "chunk_id": "c_public_01", "score": 0.76, "authorized": true}
]
},
"blocked_candidates": []
}

如果输出里出现 doc_red_policy,即使最后回答没有引用它,也应视为失败。因为重排模型、摘要器或上下文压缩器都可能在后续步骤使用候选文本。权限边界必须在候选进入语言模型之前建立,而不是寄希望于模型忽略它。

三、常见根因一:命名空间隔离只写在客户端配置里

很多越权召回来自一个简单但危险的设计:前端或调用方传入 namespace、kb_id、team_id,检索服务直接拿这些参数查向量库。这样做在单团队测试时很方便,但在多租户环境里风险很高。客户端参数属于请求意图,不属于可信授权事实。用户能访问哪个租户、哪个知识库、哪个团队文档,必须由服务端根据登录态、组织关系和权限快照解析。

正确的入口应该是:客户端只表达业务问题和目标知识库,服务端用认证上下文生成 RetrievalContext。上下文中包含不可由客户端覆盖的租户、活动索引代际、权限版本和过滤范围。后续召回、重排、缓存、引用服务都从这个上下文取值,而不是各自解析一遍请求参数。

type RetrievalContext = {
traceId: string;
tenantId: string;
kbId: string;
activeGeneration: string;
userId: string;
principalHash: string;
aclVersion: string;
allowedTeamIds: string[];
allowedDocIds?: string[];
retrievalProfile: string;
};

async function buildRetrievalContext(req: QueryRequest, session: Session): Promise<RetrievalContext> {
const tenantId = session.tenantId;
const userId = session.userId;
const kb = await controlPlane.getKnowledgeBase(tenantId, req.kbId);
const acl = await authz.resolveAclSnapshot({ tenantId, userId, kbId: kb.id });

return {
traceId: req.traceId,
tenantId,
kbId: kb.id,
activeGeneration: kb.activeGeneration,
userId,
principalHash: hashPrincipal(userId, acl.roles, acl.teams),
aclVersion: acl.version,
allowedTeamIds: acl.allowedTeamIds,
allowedDocIds: acl.allowedDocIds,
retrievalProfile: kb.retrievalProfile
};
}

这里的关键不是代码形式,而是信任边界。tenantId 不能从普通请求体读取,activeGeneration 不能由用户指定,allowedTeamIds 不能由前端传入,aclVersion 不能在召回和引用阶段各自临时计算。一次查询从召回到引用展示应使用同一份权限快照,否则用户在查询中途被移出团队时,不同组件可能得出不同结论。

在通过统一接入平台维护模型或检索服务配置时,可以把 https://178.nz/dn 作为人工核对入口之一,记录其对应的路由别名、用途和核对时间;但运行时权限判断仍必须依据本系统的租户、知识库、索引代际和 ACL 快照,不能把任何页面地址当成授权边界或产品能力证明。

四、常见根因二:先 TopK 再过滤导致候选饥饿和漏拦截

有些系统的实现顺序是:先从全库召回 TopK,再在应用层过滤当前用户无权访问的文档。如果 TopK 很大,似乎能在过滤后留下足够结果;如果 TopK 很小,则可能被无权文档占满,导致合法结果被挤出。更严重的是,应用层过滤前的候选可能已经进入日志、重排或缓存,形成旁路泄露。在这里插入图片描述

权限过滤应尽可能下推到向量检索阶段,也就是让“不属于当前租户、知识库、代际、权限范围”的向量根本不参与近邻搜索。过滤表达式至少包含四类条件:租户边界、知识库边界、活动代际、授权范围。对于支持 metadata filter 的向量库,应使用服务端生成的过滤条件;对于过滤能力不足的向量库,可以通过物理 collection、分区、命名空间或预计算授权集合来隔离,不能用全库 TopK 作为常态方案。

错误顺序如下:

const raw = await vectorStore.search({
queryVector,
topK: 20
});

const visible = raw.matches.filter(item => canRead(user, item.metadata));

更稳妥的顺序如下:

const filter = {
tenant_id: ctx.tenantId,
kb_id: ctx.kbId,
generation: ctx.activeGeneration,
status: "active",
team_id: { $in: [ctx.allowedTeamIds, "public"] }
};

const visible = await vectorStore.search({
namespace: `${ctx.tenantId}.${ctx.kbId}.${ctx.activeGeneration}`,
queryVector,
topK: 20,
filter
});

过滤下推还需要验收。不能只看代码里有 filter 字段,还要查看向量库实际请求日志,确认过滤表达式被发送并生效。有些 SDK 会忽略不支持的操作符,有些字段类型不匹配会导致过滤条件失效,还有些迁移脚本没有给旧向量补齐 metadata。最小复现数据集可以专门验证这些情况:红队文档语义分最高,但用户没有红队权限,期望它不出现在原始候选中。

如果系统必须在应用层做二次过滤,也应把它定义为防御层,而不是主要授权机制。二次过滤发现越权候选时,不应静默丢弃后继续回答,而应记录 retrieval_acl_violation 告警,附带候选标识、过滤表达式摘要和索引代际。因为这说明上游召回边界已经破坏,需要修索引或检索配置。

五、常见根因三:缓存键缺少权限版本

RAG 系统常见缓存包括问题向量缓存、召回结果缓存、重排结果缓存、上下文压缩缓存和最终回答缓存。越靠后的缓存越危险,因为它可能包含原文片段或生成后的敏感摘要。如果缓存键只包含问题文本和知识库 ID,不包含用户权限,团队 A 的查询结果就可能被团队 B 命中。

一个不合格的缓存键可能是:

rag:answer:kb_expense:sha256(question)

它看似提高了命中率,却把所有身份的答案混在一起。正确缓存键至少应包含租户、知识库、索引代际、检索配置、权限快照和用户可见范围摘要:

rag:retrieval:{tenant_id}:{kb_id}:{generation}:{retrieval_profile}:{principal_hash}:{acl_version}:{question_hash}

这里的 principal_hash 不应直接暴露用户 ID、部门名称或角色明文,可以由服务端将用户 ID、角色集合、团队集合和授权文档集合规范化后计算摘要。acl_version 用于处理权限变更:当用户加入或离开团队、文档密级调整、知识库授权策略更新时,新查询自然使用新的缓存键,不会复用旧结果。

还要区分不同缓存的安全等级。问题向量只由问题文本生成,不包含知识库内容,可以按租户或更宽范围复用,但仍要避免保存敏感原问。召回结果包含文档标识和片段分数,必须绑定权限快照。上下文压缩结果包含原文摘要,必须按最严格级别隔离。最终回答可能包含多个证据的综合结论,也必须绑定权限快照,并设置较短有效期。

缓存失效策略也不能只依赖时间。文档权限收紧时,相关权限版本应立即前移;知识库发布新代际时,检索代际进入缓存键;发现越权召回时,可以按 tenant_id + kb_id + generation 批量失效候选缓存,再按 principal_hash 精确清理回答缓存。若历史缓存键没有权限字段,修复上线时应清空旧 RAG 缓存,否则代码已修复,旧结果仍可能继续泄露。

六、常见根因四:重排和上下文压缩绕过授权对象

不少系统把召回候选交给 Rerank 或压缩器时,只传文本和分数,不传稳定的文档身份和授权状态。这样后续组件可能把候选重新排序、合并、摘要,却丢失了 doc_id、chunk_id、acl_version、content_hash 等字段。等到生成阶段需要引用时,服务只能按标题或文本片段反查文档,越权和错引都会发生。在这里插入图片描述

一个证据对象应在链路中保持完整:

{
"evidence_id": "ev_01",
"doc_id": "doc_blue_policy",
"chunk_id": "c_blue_01",
"tenant_id": "tenant_demo",
"kb_id": "kb_expense",
"generation": "generation_12",
"acl_version": "acl_2026_07_22_0900",
"content_hash": "sha256:…",
"score": 0.82,
"text": "蓝队报销系统使用项目编号 B-100,审批人为蓝队负责人。"
}

重排器可以改变顺序,可以给出新的相关性分数,但不能删除授权字段,也不能新增未经过检索授权的文本。上下文压缩器可以缩短内容,但压缩结果仍要指向原始证据 ID,并保存压缩前后的哈希关系。生成模板中传给模型的每个片段都应带服务端生成的证据编号,最终引用只能从这些编号中选择。

如果系统使用批量重排,还要注意批次隔离。为了提高吞吐,后台可能把多个用户的候选放进同一个批次调用重排服务。批量本身不是问题,问题是返回结果按数组下标拆分时发生错位,或者日志中把整个批次文本写成一条明文记录。安全的实现应为每个候选保留 request_id 和 evidence_id,拆分结果时按双键校验;日志只记录摘要、长度、分数和错误码,不保存跨用户原文。

七、引用服务必须做二次授权

有些 RAG 页面展示回答时,会为每个引用附上“打开原文”链接。开发时常见的简化做法是:只要模型引用了 doc_id,前端就拼接一个下载地址,或者引用服务按 doc_id 返回对象存储 URL。这会把授权压力全部放在召回阶段。一旦召回缓存污染、引用编号错位或用户权限在回答后发生变化,打开引用就可能泄露原文。

引用服务应把每次打开原文视为新的读取操作,重新执行授权判断。它可以沿用查询时的 trace_id 和证据 ID,但不能盲目信任前端传入的文档标识。服务端应读取证据清单,确认该证据属于当前用户当时可见的集合;同时再用当前权限判断是否仍允许打开。如果权限已经收紧,可以提示“该引用当前不可访问”,并记录审计事件。

二次授权伪代码如下:

async function openCitation(input: OpenCitationInput, session: Session) {
const evidence = await evidenceStore.getByTraceAndEvidenceId(input.traceId, input.evidenceId);
if (!evidence) throw new NotFoundError("citation evidence not found");

const currentAcl = await authz.resolveAclSnapshot({
tenantId: session.tenantId,
userId: session.userId,
kbId: evidence.kbId
});

if (evidence.tenantId !== session.tenantId) {
throw new ForbiddenError("tenant mismatch");
}

if (!canReadDocument(currentAcl, evidence.docId, evidence.requiredScope)) {
audit.log("citation_denied", {
traceId: input.traceId,
evidenceId: input.evidenceId,
aclVersion: currentAcl.version,
reason: "current_acl_denied"
});
throw new ForbiddenError("citation denied");
}

return documentStore.openImmutableBlock({
docId: evidence.docId,
revisionId: evidence.revisionId,
blockId: evidence.blockId
});
}

这里的一个细节是“不可变块”。引用不应只指向当前最新版文档,因为文档后来可能被修改、删除或降级。证据应指向查询时使用的修订和块标识;打开时再判断当前用户是否还能读取这个修订。这样既能解释历史回答,又不会绕过当前权限。

八、权限同步要有版本和水位线

越权召回并不总是因为代码漏了过滤。有时权限系统已经更新,向量 metadata、检索服务缓存和引用服务缓存却还停留在旧状态。例如用户从项目中移除后,关系库 ACL 立即生效,但向量库中的 team_id 或 allowed_role 仍是旧值;或者文档密级从团队可见改为仅负责人可见,索引增量任务还没完成。此时系统需要明确“哪一版权限用于哪一次查询”。在这里插入图片描述

权限同步应使用版本号和水位线,而不是依赖“几分钟后最终一致”。可以把授权变更写成事件:acl_event_id 单调递增,检索控制面记录已处理到哪个事件。查询上下文中的 acl_version 来自已确认的权限快照。若知识库要求强一致权限,查询必须等待权限事件处理完成,或者退回到关系库实时过滤;若允许短暂延迟,也要在产品和审计层明确窗口,并对高密级文档使用强制实时校验。

推荐把权限事件分为三类处理。第一类是授权扩大,例如用户加入团队,可以允许异步生效,因为延迟只会导致暂时搜不到。第二类是授权收紧,例如用户离开团队、文档密级提高,应优先处理并清理相关缓存。第三类是紧急撤回,例如误上传敏感文档,应在所有查询入口增加全局拒绝列表,先阻断召回和引用,再重建索引。不同事件的风险不同,不能都排在同一个低优先级队列里。

水位线指标也应进入监控:权限事件接收时间、索引 metadata 更新时间、缓存失效完成时间、引用服务可见版本。告警不只看任务是否失败,还要看收紧事件的处理延迟是否超过阈值。一个后台任务显示成功,但缓存清理漏了一类键,仍然可能导致旧答案可见。

九、日志要能排障,但不能变成新的泄露面

为了排查越权召回,日志需要足够细;为了避免二次泄露,日志又不能保存完整问题、完整片段、密钥、内部下载地址或用户隐私。两者并不矛盾。工程上可以把普通日志和受控取证分开:普通日志保存标识、摘要、长度、哈希、分数和决策结果;需要查看原文时,通过受控调试工具按 trace_id 拉取,并记录操作者、原因和时间。

一条合格的召回日志可以长这样:

{
"event": "rag_retrieval_candidates",
"trace_id": "trace_acl_001",
"tenant_id": "tenant_demo",
"kb_id": "kb_expense",
"generation": "generation_12",
"acl_version": "acl_2026_07_22_0900",
"question_hash": "sha256:…",
"question_len_bucket": "20-40",
"filter_hash": "sha256:…",
"candidate_count": 2,
"candidates": [
{"doc_id_hash": "sha256:blue", "chunk_id_hash": "sha256:c1", "score": 0.82, "authz": "allowed"},
{"doc_id_hash": "sha256:public", "chunk_id_hash": "sha256:c2", "score": 0.76, "authz": "allowed"}
]
}

如果二次过滤发现越权候选,日志应记录事件类型和摘要,但不要把受限片段原文写进集中日志。受控调试工具可以在权限审批后读取证据对象,且只对有排障权限的人员开放。这样既能回答“为什么这次查询泄露”,又不会让日志平台成为绕过知识库权限的全文检索入口。

还要注意错误信息。对普通用户,引用被拒绝时可以提示“当前账号无权查看该引用或引用已不可用”;对内部排障日志,记录具体原因,如租户不匹配、ACL 版本过期、文档已撤回、证据哈希不一致。用户界面不应展示内部文档 ID、对象存储路径或团队名称,否则错误页本身也可能泄露信息。

十、用流程图固定查询边界

下面的流程图可以作为实现评审时的检查清单。它强调两件事:权限上下文由服务端解析,证据在进入生成前和打开引用时都要校验。

#mermaid-svg-SIC7ePEQZ9ufLTxJ{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SIC7ePEQZ9ufLTxJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .error-icon{fill:#552222;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .marker.cross{stroke:#333333;}#mermaid-svg-SIC7ePEQZ9ufLTxJ svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SIC7ePEQZ9ufLTxJ p{margin:0;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .cluster-label text{fill:#333;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .cluster-label span{color:#333;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .cluster-label span p{background-color:transparent;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .label text,#mermaid-svg-SIC7ePEQZ9ufLTxJ span{fill:#333;color:#333;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .node rect,#mermaid-svg-SIC7ePEQZ9ufLTxJ .node circle,#mermaid-svg-SIC7ePEQZ9ufLTxJ .node ellipse,#mermaid-svg-SIC7ePEQZ9ufLTxJ .node polygon,#mermaid-svg-SIC7ePEQZ9ufLTxJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .rough-node .label text,#mermaid-svg-SIC7ePEQZ9ufLTxJ .node .label text,#mermaid-svg-SIC7ePEQZ9ufLTxJ .image-shape .label,#mermaid-svg-SIC7ePEQZ9ufLTxJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .rough-node .label,#mermaid-svg-SIC7ePEQZ9ufLTxJ .node .label,#mermaid-svg-SIC7ePEQZ9ufLTxJ .image-shape .label,#mermaid-svg-SIC7ePEQZ9ufLTxJ .icon-shape .label{text-align:center;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .node.clickable{cursor:pointer;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .arrowheadPath{fill:#333333;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SIC7ePEQZ9ufLTxJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SIC7ePEQZ9ufLTxJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SIC7ePEQZ9ufLTxJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .cluster text{fill:#333;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .cluster span{color:#333;}#mermaid-svg-SIC7ePEQZ9ufLTxJ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SIC7ePEQZ9ufLTxJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .icon-shape,#mermaid-svg-SIC7ePEQZ9ufLTxJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .icon-shape p,#mermaid-svg-SIC7ePEQZ9ufLTxJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .icon-shape .label rect,#mermaid-svg-SIC7ePEQZ9ufLTxJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SIC7ePEQZ9ufLTxJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SIC7ePEQZ9ufLTxJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SIC7ePEQZ9ufLTxJ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

用户提问

认证会话解析

构建 RetrievalContext

读取知识库活动代际

解析 ACL 快照

生成向量检索过滤条件

向量库过滤下推召回

应用层二次过滤与告警

重排保留证据身份

上下文压缩保留 evidence_id

生成回答与引用编号

引用服务二次授权

打开不可变证据块

如果现有系统的流程中,ACL 快照 出现在生成之后,或者引用服务直接从前端接收 doc_id 打开文件,就说明授权边界太靠后。RAG 的安全边界应当围绕证据对象建立,而不是围绕最终回答文本建立。 在这里插入图片描述

十一、建立可执行的验收用例

修复越权召回后,不能只用一个人工问题验证。至少要覆盖正向召回、负向隔离、缓存复用、权限收紧、引用打开和批量重排。下面是一组可落地的验收表:

用例操作期望结果
蓝队用户查蓝队制度 user_blue_01 查询报销编号 召回蓝队和公共文档,不召回红队文档
蓝队用户查红队关键词 问题中包含红队项目代号 返回无可用证据或公共解释,不返回红队片段
红队用户查询同义问题 user_red_01 使用相似问题 只召回红队和公共文档
两个用户连续查询同一问题 先红队后蓝队 蓝队不命中红队回答缓存
用户被移出团队后再查 更新 ACL 并前移版本 新查询不能复用旧权限缓存
打开历史引用 权限收紧后点击旧回答引用 引用服务拒绝或按当前权限返回
批量重排 多用户候选进入同一批 返回结果按 request_id + evidence_id 正确拆分
metadata 缺失 构造旧向量缺少 team_id 发布验收失败,不进入活动代际

自动化测试可以直接断言候选集合,而不是只断言最终回答文本:

it("does not return team_red chunks for a blue user", async () => {
const result = await debugQuery({
user: "user_blue_01",
tenantId: "tenant_demo",
kbId: "kb_expense",
question: "报销系统项目编号和审批人怎么填写?"
});

const docIds = result.retrieval.candidates.map((item) => item.doc_id);
expect(docIds).toContain("doc_blue_policy");
expect(docIds).toContain("doc_public_help");
expect(docIds).not.toContain("doc_red_policy");

for (const item of result.retrieval.candidates) {
expect(item.authorized).toBe(true);
expect(item.acl_version).toBe(result.principal.acl_version);
}
});

还应增加一个“故意破坏过滤”的测试环境。把向量库过滤条件去掉或把缓存键中的 acl_version 移除,测试应稳定失败。这样的负向测试能证明用例真的覆盖授权边界,而不是因为测试数据碰巧没有相似片段。

十二、上线修复时先止血,再重建长期治理

如果线上已经出现越权召回,第一阶段不是完整重构,而是止血。可以临时关闭回答缓存和上下文压缩缓存,降低 TopK,启用应用层强制二次过滤,并对所有命中的文档执行实时授权判断。若发现候选中出现无权文档,直接返回“当前知识库未找到可用证据”,不要把过滤后的少量残余片段交给模型凑答案。这个策略可能降低召回率,但能先控制泄露风险。在这里插入图片描述

第二阶段修复召回边界。为向量数据补齐租户、知识库、代际、团队、密级、状态等 metadata;对缺失关键字段的向量,不允许进入活动索引;检索服务统一使用服务端生成的 RetrievalContext;缓存键增加权限快照和索引代际;引用服务改为按证据 ID 二次授权。上线前用最小数据集和真实脱敏样本一起回放。

第三阶段建立发布门禁。每次索引构建完成后,验收脚本应检查 metadata 完整率、过滤表达式覆盖率、权限负样本、引用可打开率和缓存隔离。任一权限负样本失败,都不能激活新代际。这里的门禁比平均相似度更重要:一个召回质量略低但不越权的系统可以继续调优,一个召回质量很高但会泄露其他团队内容的系统不能上线。

第四阶段完善审计与演练。定期抽取跨团队相似问题,检查候选集合是否严格隔离;定期模拟用户离职、项目归档、文档撤回和密级提升,观察权限水位线和缓存失效延迟;定期检查日志中是否出现原文片段、内部 URL 或明文用户标识。RAG 权限治理不是一次修复,而是一组需要持续运行的工程不变量。

十三、不要把无答案问题强行回答

越权召回常和“无答案处理”混在一起。用户问了一个当前权限范围内没有证据的问题,系统为了给出看似有用的回答,可能扩大检索范围、降低阈值、使用全局知识库或让模型自由发挥。这些策略都会把安全边界变成质量调优参数。权限范围内没有证据时,正确行为是明确拒答或给出可操作的下一步,而不是跨团队找一个语义相似片段。

拒答逻辑应建立在授权后的证据集合上。先完成权限过滤,再判断候选数量、分数、证据一致性和引用可打开状态。无权片段不能参与“是否有答案”的判断,也不能作为模型内部参考。换句话说,系统宁可说“当前可访问知识库中没有找到依据”,也不能说“我看到了其他团队的资料但不方便展示”。后者已经说明系统读取了不该读取的内容。

可以把回答策略分成三类:证据充分时回答并引用;证据不足但存在公共流程时,只给公共说明并提示需要补充权限内资料;证据不存在或引用不可打开时拒答。这样的策略会让用户体验略显保守,却能让每个回答都回到可验证、可授权的证据对象。

十四、从数据模型上约束权限不变量

长期看,权限治理不能只靠代码评审。数据模型也要表达不变量。向量表或向量 metadata 中的 tenant_id、kb_id、generation、doc_id、revision_id、chunk_id、visibility_scope 应视为必填字段。发布清单中应记录每个片段的权限摘要和内容哈希。构建索引时,只要发现片段缺少授权字段、字段类型不一致、租户与知识库关系不匹配,就应让候选代际失败。在这里插入图片描述

关系库可以保存更完整的授权关系,向量库保存检索所需的最小过滤字段。两者之间要有校验任务:从发布清单抽样或分桶读取向量 metadata,与关系库中的文档权限快照比较。比较结果不要求把所有权限细节复制到向量库,但必须证明向量过滤字段足以排除无权文档。若权限表达过于复杂,metadata filter 无法准确表达,就不要把所有文档放在同一个大索引里碰运气,可以按租户、密级或稳定权限集合拆分索引。

数据删除也要遵守同一套规则。文档撤回后,活动代际应切到不含该文档的新代际,引用服务应按当前权限和撤回状态拒绝打开,旧向量在回滚窗口结束后再清理。对于必须立即阻断的内容,应先写全局拒绝列表,让所有代际查询前检查,再异步完成索引重建。这样可以在安全响应和工程完整性之间取得平衡。

十五、灰度发布要观察权限指标,而不只看回答质量

权限修复上线时,灰度范围不能只按流量比例切分。更合适的方式是选择权限关系复杂但文档可控的知识库,覆盖普通成员、项目负责人、跨团队成员、临时访客和已移除成员等身份。灰度期间每一次查询都记录候选授权状态、二次过滤次数、缓存命中类型和引用打开结果。只要出现无权候选进入召回结果,即使最终回答正确,也应暂停扩大灰度。

观测指标也要从“回答是否有用”扩展到“证据是否合规”。建议至少建立六个指标:授权过滤下推覆盖率、无权候选出现次数、二次过滤拦截次数、引用二次授权拒绝次数、权限收紧到缓存失效的延迟、缺失 metadata 的片段数量。前两个指标用于发现召回边界问题,第三个指标用于发现防御层承压,第四个指标用于发现引用服务是否正在拦截历史风险,后两个指标用于发现同步和发布质量问题。

这些指标需要结合场景解读。引用二次授权拒绝不一定都是坏事,用户权限收紧后打开旧回答引用,被拒绝正是预期行为;但如果同一个知识库在短时间内出现大量拒绝,说明回答缓存、证据存储或前端展示可能没有及时更新。二次过滤拦截也不能长期被当成正常现象,它意味着向量召回阶段仍在返回不该返回的候选,短期可以止血,长期必须回到过滤下推和索引 metadata 完整性上修复。

灰度回滚要提前设计。回滚不是简单恢复旧代码,因为旧代码可能正是越权来源。更可靠的回滚目标是“更保守的安全策略”:关闭跨用户回答缓存,禁用上下文压缩缓存,强制所有候选做实时授权校验,必要时只允许公共知识库回答。回滚后继续保留审计日志和追踪字段,便于分析失败原因。这样即使功能体验暂时下降,也不会重新打开已知泄露路径。

十六、把责任边界写进组件接口

RAG 权限问题经常在团队协作中被推来推去:应用层认为向量库应该过滤,向量库接入层认为调用方应该传正确 metadata,引用服务认为召回阶段已经授权,前端认为后端返回的链接都可打开。要避免这种情况,需要把责任边界写进接口契约,而不是停留在口头约定。在这里插入图片描述

检索入口负责创建可信的 RetrievalContext,不得接受客户端覆盖权限字段。索引构建器负责保证活动代际中每个片段都有完整 metadata。向量检索适配层负责证明过滤条件已经下推,不能静默忽略不支持的操作符。重排服务负责保持证据身份,不得只返回裸文本。生成服务负责只使用授权证据集合,不得在证据不足时扩大范围。引用服务负责二次授权和不可变证据打开。日志系统负责保存可追踪摘要,但不保存可直接还原敏感内容的明文。

接口契约还应包含失败行为。例如过滤表达式构造失败时,检索服务应失败关闭,而不是降级为全库搜索;ACL 快照不可用时,应返回临时不可回答,而不是使用上一次缓存权限;证据哈希不一致时,应阻断生成,而不是只降低引用置信度。安全相关链路的默认行为应是“不确定则不读、不传、不展示”,这和普通召回调优中的“尽量返回一些内容”是两套不同原则。

十七、结论

RAG 多租户越权召回的根因通常不是某一个参数写错,而是检索链路没有把“证据是否可被当前用户读取”当成核心不变量。只在前端隐藏知识库、只在生成后过滤引用、只在应用层丢弃无权候选、只靠缓存过期等待权限同步,都会留下可被事故触发的缝隙。

可治理的做法是把权限上下文服务端化,把租户、知识库、代际和 ACL 快照固定在一次查询中;把过滤下推到向量召回之前,并用二次过滤作为告警防线;把缓存键绑定权限版本和检索配置;让重排、压缩和生成全程保留证据身份;让引用服务重新授权;让日志可排障但不保存敏感原文;最后用最小数据集、负样本和权限变更演练持续验证。

当系统能够回答“这个用户在这个时间点、基于哪一版权限、从哪一代索引、读取了哪些证据,并且为什么没有读取其他团队文档”时,RAG 才真正具备多租户工程治理能力。检索质量、相似度分数和回答流畅度都很重要,但它们必须建立在授权边界清晰、证据对象可追溯、引用读取可审计的基础上。否则,召回越准,泄露越快。 在这里插入图片描述

赞(0)
未经允许不得转载:171主机测评 » RAG 多租户知识库越权召回:命名空间、过滤下推与二次授权排查
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址