登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  科技周边 >  人工智能

AI 应用如何隔离租户知识库:检索权限、缓存键与引用验收

来源:17golang原创

时间:2026-08-25 23:51:04 369浏览 收藏

多租户 AI 知识库上线后,最难排查的往往不是“检索不到”,而是偶尔检索到了不该看到的内容:同一个问题在 A 租户命中,B 租户却看见了相似片段,或者答案引用了另一家公司的文档。这个问题不能靠提示词里加一句“不要串租户”解决,权限过滤、缓存键和引用回显必须在服务端形成闭环。

实践要点:
  • 从认证上下文得到不可伪造的 tenant_id,把租户条件下推到检索层。
  • 缓存键包含租户、权限版本和知识库版本,避免跨租户复用。
  • 引用只能来自本次检索结果,并留下可回放的审计记录。

先把真正需要保护的资产列出来

知识库隔离保护的不是一个抽象的“上下文”,而是文档片段、原文链接、文件名、检索分数和缓存结果。只要其中一项跨过租户边界,后面的模型回答再流畅也没有意义。

线上排查时,可以先固定四个字段:tenant_idprincipal_iddocument_idtrace_id。其中 tenant_id 来自登录态或网关签发的令牌,不能接受前端 JSON 里的同名字段覆盖;document_id 用来追溯来源;trace_id 让一次问答可以从检索日志追到引用回显。

多租户知识库中身份、权限过滤和片段返回的隔离链路示意图

检索权限要在召回之前生效

常见错误是先用向量相似度召回一批片段,再在应用层过滤租户。这样做既浪费检索预算,也容易在调试日志、重排服务或缓存层留下越权数据。更稳妥的顺序是:认证上下文确定租户,权限服务生成允许的范围,检索请求带着这个范围进入索引。

type SearchScope struct {
    TenantID        string
    PrincipalID      string
    PermissionEpoch  uint64
}

func buildFilter(scope SearchScope) Filter {
    return Filter{
        "tenant_id":  scope.TenantID,
        "principal":  scope.PrincipalID,
        "status":     "published",
    }
}

这里的 principal_id 不一定要直接写入每个向量记录,也可以由权限服务转换成可检索的文档集合或访问标签。关键是过滤条件由后端生成,并且在召回、重排、引用回显三个环节保持一致。

权限拒绝应该留下可判断的证据

检索不到结果时,不要简单记成“模型回答为空”。日志至少应区分 scope_emptyfilter_rejectedindex_timeout。前两种说明权限链在工作,后一种才是基础设施故障。日志中可以保留数量和哈希,但不要把被拒绝文档的正文写进普通业务日志。

缓存键不能只拼问题文本

缓存是租户串线最容易被忽略的一层。把用户问题标准化后直接作为键,例如 rag:answer:合同续期,在单租户演示环境里看不出问题,到了共享缓存就会把答案和引用一起复用。

缓存键至少应绑定租户、主体权限版本、知识库版本、检索配置和问题摘要:

key := fmt.Sprintf(
    "rag:v3:%s:%d:%s:%s",
    scope.TenantID,
    scope.PermissionEpoch,
    knowledgeBaseVersion,
    sha256(normalizedQuestion+searchMode),
)

权限发生变化时递增 PermissionEpoch,比依赖一个很短的过期时间更可靠。知识库重新切片或删除文档时也要改变版本号,否则旧答案可能继续带出已经撤回的引用。

引用回显必须回到本次检索结果

回答正文可以由模型生成,但引用列表不应由模型自由编造。服务端应保存本次检索返回的 document_id、片段范围和访问判断,再把模型输出中的引用标记映射回这份白名单。找不到对应片段的引用直接丢弃,并把异常记入审计记录。

验收时重点看三件事:引用文档是否属于当前 tenant_id,引用是否出现在本次召回集合,引用链接是否经过同一权限检查。只检查回答文字里的公司名是不够的,因为文件名、URL 参数和隐藏元数据同样可能泄露边界信息。

租户知识库缓存键、引用校验与审计记录的闭环验收图

用一组对照请求验证隔离是否真的生效

不要只拿一个租户做冒烟测试。至少准备 A、B 两个租户,各放入一段只有本租户能识别的哨兵文本,例如不同的项目代号。然后对同一问题交替发起请求,并清空与不清空缓存各测一遍。

for tenant in tenantA tenantB; do
  curl -sS -H "X-Test-Tenant: $tenant" \
    -H "X-Trace-Id: isolation-$tenant-01" \
    https://example.invalid/api/ask \
    -d '{"question":"项目代号是什么?"}'
done

测试结果应同时核对 HTTP 响应、引用数组、缓存命中日志和审计记录。A 的答案不能出现 B 的哨兵词;更严格的检查是,B 即使复用同一个问题文本,也必须得到自己的缓存键和自己的引用集合。

几个看似合理、实际危险的做法

  • 把租户 ID 放在前端请求体里,后端直接信任。
  • 只在最终答案阶段做敏感词过滤,却让重排服务先看到跨租户片段。
  • 缓存只按问题和模型名分组,忽略权限版本与知识库版本。
  • 让模型自行生成引用 URL,服务端只检查 URL 是否可访问。

这些做法的问题不在于“模型不够聪明”,而在于边界放错了位置。模型可以参与摘要和排序,但身份、权限、缓存分区和引用白名单都应由确定性的服务端逻辑负责。

上线前留下可回放的审计记录

一条合格的审计记录应能回答:谁在什么租户下发起了什么问题,本次用了哪个权限版本和知识库版本,召回了哪些文档,最终展示了哪些引用,缓存是否命中。正文可以脱敏,字段关系不能缺失。

建议把 trace_id 作为主关联键,把原始问题保存为受控哈希或按保留策略加密存储;对外排障时只暴露租户自己的记录。发生疑似串租户时,先冻结相关缓存命中记录,再按 trace_id 回放权限过滤和引用映射,不要直接删除日志。

相关问题

只在向量库里增加 tenant_id 够不够?

不够。还要确保重排、缓存、引用回显和下载入口使用同一套授权判断,否则向量检索虽然隔离,后续链路仍可能泄露。

权限变化后必须清空全部缓存吗?

不一定。把权限版本纳入缓存键,并让旧版本自然失效或进入受控清理队列,可以减少全量清空;高风险权限变更仍应主动撤销相关键。

如何判断一次串租户是缓存问题还是检索问题?

对同一问题做冷缓存和热缓存对照,并比较召回文档集合。如果冷缓存已经越权,先查检索过滤;冷缓存正常、热缓存异常,再查缓存键和权限版本。

结语:把隔离做成四道能验收的门

多租户知识库的安全边界可以收敛为四道门:身份决定租户,检索决定可见范围,缓存决定复用边界,引用决定最终展示内容。每道门都要有字段、有日志、有对照测试,出了问题才能定位到具体环节,而不是把责任推给模型“偶尔答错”。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>