☰
28 切片的权限隔离:企业知识库不让 AI 说漏嘴,得从检索层下手
2026/10/14 11:21:34 网站建设 项目流程

面试现场,面试官端起杯子,问了一个他大概率自己踩过坑的问题:

"公司要上 AI 知识库。数据是内部的:产品文档、客服工单,还有人力资源那几份薪酬方案。我最担心的不是它答得对不对——是客服随口问一句,AI 会不会把高管的薪酬方案念出来?你打算怎么保证不出这种事?"

这题看着像安全题,其实考的是你对整条 RAG 检索链路的理解深度。权限这东西,做浅了会翻车,做错了方向比不做还危险——因为你以为你防住了。

先把三档人的回答摆出来:

-小白:"搜出来之后在代码里判断一下,没权限的过滤掉。"——应用层事后过滤,最典型、也最危险的答法。

-中档:"文档入库的时候打个权限标签,检索的时候带上条件。"——方向对了,但停在文档级,没往深里想。

-高手:"权限要沉到切片这一级,当成元数据;过滤要下推到向量库,和相似度一起算;还得能扛得住权限变更和删文。"——这才是面试官想听的。

这篇文章就把这三句话一句句拆开。


一、为什么"搜完再过滤"是错的

上一节里,"应用层过滤"这个答法,很多人的直觉都是对的:搜都搜出来了,我循环一遍,把没权限的去掉不就行了?代码大概长这样:

// ❌ 反例:典型的事后过滤(post-filter) public List<Document> searchWithPermission(String query, String userId) { // 第一步:先按相似度捞一批 List<Document> docs = vectorStore.similaritySearch( SearchRequest.query(query).withTopK(10)); // 第二步:在应用层把没权限的过滤掉 return docs.stream() .filter(doc -> permissionService.hasAccess(userId, doc.getMetadata("docId"))) .collect(Collectors.toList()); }

这段代码能跑,面试官却会立刻皱眉。它有三个问题,一个比一个隐蔽。

第一个问题:召回被白白削掉。你向向量库要 Top-10,它老实地返回了 10 条"最相似"的。然后你在应用层一过滤,可能只剩 2 条——剩下 8 个名额全浪费给了你看不到的文档。更糟的情况是:库里明明有 5 条你有权限、也确实相关的切片,但它们相似度排在第 11 到第 15 位,压根没进这 10 条,于是模型回答"没找到相关资料"。权限过滤把答案挤出了候选集,这是召回率的直接损失。

第二个问题:顺序里藏着泄密风险。如果哪个兄弟图省事,先把这 10 条拼进 Prompt 发给模型了,等模型答完再去过滤——那已经晚了,敏感内容既进了 model 的上下文,也可能已经出现在回答里。事后过滤能兜住的,只有"进没进最终回答",兜不住"进没进模型大脑"。

第三个问题:规模一大就崩。数据量小的时候,随手捞 100 条再过滤没什么感觉。等知识库到了千万级切片,你为了过滤后还能剩 10 条,只能把 TopK 一路调到几百甚至上千,检索和网络传输成本直线上升,延迟也扛不住。

所以正确的姿势只有一句话:把权限条件交给向量库,让它先筛出"你有权限的候选集",再在这个子集里算相似度挑 TopK。这就是 pre-filter(前置过滤)。

要说明的是,pre-filter 也不是所有向量库都天生擅长。Milvus 这类原生向量库支持"标量字段过滤 + 向量检索"联合执行;pgvector 早期版本是先取相似度再过滤,过滤后不够还得靠人工重试,直到 0.8.0 才引入了迭代扫描(iterative scan)来缓解——细节第三节讲。


二、权限要沉到"切片"这一级

很多人脑子里,权限是"文档"这一级的事:这份文件谁能看。但在 RAG 里,真正进模型的是切片,不是文档。一个 80 页的手册被切成 200 块,其中只有 3 块是薪酬相关的敏感内容。你如果只能控制到"文档"这一级,要么整篇手册都别让人搜到,要么整篇都能搜到——两头都不对。

正确做法是:文档的权限,在切片的时候继承下来,作为元数据存进每一块。切片本身通常不单独定义权限,它继承来源文档的属性;只有在"同一份文档里不同章节密级不同"这种场景,才需要切片级的独立密级。

那元数据里到底要存哪些字段?这是一张值得背下来的表:

字段示例用途
tenant_idt_acme多租户隔离,永远排第一个条件
doc_iddoc_8891回链原文;删文时联动清理
deptcustomer-service按部门可见
level2密级(比如 0 公开 / 3 机密)
acl_tags["cs_public","l2"]白名单标签,灵活授权
updated_at2026-10-01时效过滤、增量重建的锚点

在这之上,企业里常见的权限模型其实就三种,面试时能说清楚区别就是加分项:

-RBAC(按角色):给用户分角色,角色映射权限。实现最简单,但角色一多、授权一细就爆炸,且"给某个人单独开个口子"很别扭。

-ACL(白名单标签):每条切片挂一组"能看到它的标签/部门/人",检索时拿"当前用户的标签集合"和"切片标签集合"求交集,非空才可见。灵活,适合"文档按分组授权"。

-密级 / ABAC(按属性):给用户和文档各定一个层级,规则是"用户级别 ≥ 文档级别才可见"(level <= user.level)。政府和国企的文档分级常用这套,判断简单、性能好,但授权粒度偏粗。

多数企业从ACL 标签 + 部门起步就能覆盖九成场景,密级作为补充。


三、把过滤下推到检索层(真实代码)

模型设计好了,接下来是落地。这时候你要区分两种后端。

用 Milvus 这类原生向量库,过滤就是查询语句的一部分。Spring AI 的SearchRequest支持filterExpression:

// 服务端从登录态拿到当前用户的租户、部门、密级 AuthCtx ctx = SecurityUtils.current(); // tenantId / dept / level String filter = "tenant_id == \"" + ctx.tenantId() + "\"" + " && level <= " + ctx.level() + " && (dept == \"" + ctx.dept() + "\" || acl_tags in [\"public\"])"; SearchRequest request = SearchRequest.builder() .query("年假怎么算") .topK(5) .filterExpression(filter) // 关键:过滤条件和向量检索一起下推 .build(); List<Document> docs = vectorStore.similaritySearch(request);

逐行看:tenantid == ...是租户硬隔离;level <= user.level是密级比较;dept和acltags用||兜底——用户所属部门能看,或者内容是公开标签。过滤表达式和向量相似度在同一次查询里执行,向量库先在满足标量条件的子集里做近邻搜索,不相关的切片根本不参与计算,也就不会占 TopK 名额。

用 pgvector(PostgreSQL),同理,过滤就是WHERE:

SELECT chunk_id, doc_id, content FROM kb_chunk WHERE tenant_id = 't_acme' -- ① 租户隔离,永远第一个 AND level <= 2 -- ② 密级:用户级别 >= 文档级别 AND acl_tags && ARRAY['cs_public','dept_cs'] -- ③ ACL:数组重叠即命中 ORDER BY embedding <=> :query_vec -- ④ 余弦距离排序 LIMIT 5;

几个点值得说:&&是 PostgreSQL 的数组重叠运算符,acltags && ARRAY[...]表示两组标签有交集;<=>是 pgvector 的余弦距离算子(<#>内积、<->L2)。为了配合WHERE里的tenantid、level,记得在这些标量列上建普通 B-tree 索引,否则过滤本身就成了全表扫描。

还有那个老问题——过滤会不会把召回削没了?pgvector 0.8.0 给了出路:打开迭代扫描。

SET hnsw.iterative_scan = relaxed_order; -- 或 strict_order

它的意思是:HNSW 索引第一轮找到的候选里,被WHERE过滤掉一些、不够LIMIT时,索引会继续往下扫,直到凑够数量或扫完为止,而不是"一轮捞完就认命"。这样既有前置过滤的正确性,又不至于像纯 post-filter 那样白丢召回。

还有一条,也是最容易被忽略的一条:过滤条件必须由服务端强制注入,绝不能交给调用方。

// ✅ 正确:从登录态(JWT / SSO session)里取,业务代码无权省略 AuthCtx ctx = SecurityUtils.current(); // ❌ 错误:让上层/前端传 filterExpression // search(query, userSuppliedFilter)

前一种写法,租户、部门、密级全部来自认证上下文,任何业务代码都改不掉;后一种写法,只要有个接口忘了传、或者传错了,整套校验就形同虚设。安全设计上有个原则:默认拒绝,而不是默认放行,更是"默认必须显式带上条件"。另外,拼 SQL 时该用参数化就用参数化(?/$1),表达式拼接只在 Milvus 这种拿不到"参数位置"的地方,且值必须来自可信的服务端变量。


四、多租户:最容易在公司级产品上翻车

如果你做的知识库是卖给多家公司的 SaaS,那租户隔离就是一条红线,比文档权限更硬。三种常见做法,隔离强度和成本各不相同:

方案做法隔离强度成本
每租户一个 collection物理隔离,各存各的最高资源占用最多
分区 partition / partition_key同一 collection 内逻辑分区中高中等
单 collection +tenant_id过滤纯逻辑隔离取决于过滤"永不失手"最低

结论很直接:多租户场景,至少用 partition 起步;条件允许就 collection per tenant。只靠tenant_id过滤的单 collection 方案,一旦有哪次查询漏了租户条件——比如某个新写的内部调试接口、某个统计脚本——吐出来的就是跨公司的数据。这种事故的代价,远高于多占的那点存储。


五、三个最容易被问倒的边角

前面的都是"静态"问题:权限明确、数据不动。而生产里最难的,是"数据在动、权限在变"。三个高频追问:

追问一:员工调岗了、文档密级改了,检索会跟着变吗?

这里有个关键区分:文档属性(部门、密级、ACL 标签)存在切片里;用户权限每次检索时实时算。只要守住这条线,员工调岗这件事就不用碰向量库——他的新权限下次检索自动生效。真正需要重刷切片的,是文档自己的属性变了(比如一份产品文档从"内部"降级为"公开")。这种要么走 CDC/增量管道重新打标签,要么靠updated_at定期重建。千万别把"谁能看"直接写死进切片,不然改一次权限就得重刷全库。

追问二:文档删了,切片还在,怎么办?

删文档只删了源文件,向量库里的切片不会自己消失,用户照样可能搜到已删内容。做法是软删 + 异步清理:源文档标记删除后,检索时用status != 'deleted'把它排除(立即不可见),后台再慢慢把对应doc_id的切片物理清掉。顺序很重要——先保证"立刻搜不到",再去管"彻底删干净"。

追问三:检索结果缓存,会不会造成越权?

这个最隐蔽。为了省调用、降延迟,很多系统会缓存"query → 检索结果"。但如果缓存 key 只有 query 字符串,那A 用户搜"薪酬方案"得到的(他有权限的)结果,会被 B 用户命中——B 就看到了不该看的。修法是把缓存 key 带上租户 ID + 权限指纹(用户级别、标签集合的哈希),不同权限的人各存各的。用户权限缓存在 Redis 里时,也务必给短 TTL,别缓存一整天,否则调岗、离职后的权限收回要等到缓存过期才生效。


🎯 面试官视角的标准回答

如果面试官问:"企业 AI 知识库,你怎么做权限隔离,防止越权泄露?"

我分三层来讲。<br><br>第一层是权限建模。我把权限做成切片的元数据,而不是事后再判。切片从文档继承属性——租户、部门、密级、ACL 标签、文档 ID、更新时间。用户的权限(能看哪些标签、什么密级)不写进数据,每次检索时从登录态(JWT 或 SSO)实时取。这样人员变动不用碰向量库,只有文档属性变更才需要重建。<br><br>第二层是检索时把过滤下推到向量库。用 Milvus 就写filterExpression,用 pgvector 就写WHERE,让"标量过滤"和"向量相似度"在一次查询里完成,而不是先搜完 TopK 再在应用层过滤——那样既损失召回,又有把敏感内容送进模型的风险。pgvector 可以开iterativescan解决过滤后召回不够的问题。过滤条件由服务端从认证上下文强制注入,调用方无权省略。<br><br>第三层是边角。多租户用 partition 或 collection 做隔离,绝不只靠一个tenantid过滤;删文档做软删加异步清理,检索先排除 deleted;检索缓存和权限缓存的 key 要带权限指纹,并设短 TTL,防止跨权限命中。<br><br>再补一句:权限过滤是必要条件,不是充分条件。它是检索层的一道护栏,还得配合 Prompt 加固、工具权限控制、输出脱敏,整套下来才算完整。

这篇把前面第 09 篇那句"文档级权限控制"和第 19 篇里"acl 只是元数据"两个半成品接上了,讲的是原理和取舍。如果你想要一套能直接跑起来的版本——Milvus 建集合、挂字段、灌数据、带权限过滤的完整检索链路——下一篇可以接着出实战篇,把它落地成可复现的代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询