面试现场,面试官端起杯子,问了一个他大概率自己踩过坑的问题:
"公司要上 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_id | t_acme | 多租户隔离,永远排第一个条件 |
doc_id | doc_8891 | 回链原文;删文时联动清理 |
dept | customer-service | 按部门可见 |
level | 2 | 密级(比如 0 公开 / 3 机密) |
acl_tags | ["cs_public","l2"] | 白名单标签,灵活授权 |
updated_at | 2026-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 建集合、挂字段、灌数据、带权限过滤的完整检索链路——下一篇可以接着出实战篇,把它落地成可复现的代码。