29 切片的权限隔离(实战篇):Milvus + Spring AI,从 0 搭一条带权限过滤的检索链路

上一篇(#28)把原理讲透了:权限要沉到切片、过滤要下推检索层、多租户要隔离、边角要想全。道理都懂,但面试官最爱追的一句是------"那你具体怎么落地?"

这篇不聊理论,直接从 0 搭起来:用 Milvus + Spring AI,建表、灌数据、带权限过滤地检索,跑一条能用的链路。前端怎么展示先不管,我们只盯后端这一条线。

先看全貌。

整条链路一句话概括:文档切分时给每块打上权限元数据 → 用 Spring AI 的 EmbeddingModel 算向量 → 存进 Milvus → 检索时把"当前用户能看什么"翻译成一个过滤表达式,和向量相似度一起算 → 命中的切片交给 ChatClient 生成回答。


一、起一个 Milvus(standalone,一条命令)

开发环境不用搭集群,官方给了独立版一键脚本:

bash 复制代码
# Linux / macOS,需已装 Docker
curl -sfL https://raw.githubusercontent.com/milvus-io/milvus/master/scripts/standalone_embed.sh -o standalone_embed.sh
bash standalone_embed.sh start

Windows 上如果跑脚本不方便,用官方给的 standalone docker-compose 起也一样,对外端口不变。起来之后记两个端口:

  • 19530:SDK / gRPC 端口,Java 端连的是它;

  • 9091:健康检查与指标端口,排查"起没起来"用它。


二、schema 是这套东西的地基

Milvus 里一个 collection 就是一张"能存向量的表"。Spring AI 的 MilvusVectorStore 会在 initializeSchema=true 时帮你把表建好,默认结构就四个字段:

字段 默认名 存什么
主键 doc_id 每块切片的唯一 id
正文 content 切片文本
向量 embedding 切片的向量
元数据 metadata JSON,你想带的字段都塞这儿

关键点在这里:权限字段(tenantid/ dept / level / acl tags)不用单独建列,全部塞进 metadata 这个 JSON 字段。** 检索时 Spring AI 的 filterExpression 会自动把它们翻译成 Milvus 的过滤条件------这也是这套方案最省事的地方。

配置文件大致长这样:

properties 复制代码
spring.ai.vectorstore.milvus.client.host=localhost
spring.ai.vectorstore.milvus.client.port=19530
spring.ai.vectorstore.milvus.collection-name=kb_chunk
spring.ai.vectorstore.milvus.embedding-dimension=1536
spring.ai.vectorstore.milvus.index-type=HNSW            # 默认是 IVF_FLAT
spring.ai.vectorstore.milvus.metric-type=COSINE         # 默认 COSINE
spring.ai.vectorstore.milvus.index-parameters={"M":16,"efConstruction":200}   # HNSW 参数
spring.ai.vectorstore.milvus.initialize-schema=true     # 让 Spring AI 自动建表
spring.ai.vectorstore.milvus.auto-id=true               # 主键自增,省得自己生成

几个默认值面试时提一嘴很加分:embedding-dimension 默认 1536 (正好是 OpenAI text-embedding-3-small 的维度)、默认索引 IVF_FLAT(改成 HNSW 召回通常更好,但要配 M / efConstruction)、默认度量 COSINE。


三、灌数据:切分 + 算向量 + 带上权限

入库的难点不在代码本身,而在"权限元数据要从来源文档继承下来"这一步------切片自己不知道它是谁,得从它出处的文档把属性带过来。

java 复制代码
@Service
public class KbIngestService {

    private final VectorStore vectorStore;

    public KbIngestService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    // docMeta 来自源文档表;权限属性从这里继承,而不是写死在切片里
    public void ingest(DocMeta docMeta, List<String> chunks) {
        List<Document> docs = new ArrayList<>();
        for (String text : chunks) {
            Map<String, Object> meta = new HashMap<>();
            meta.put("tenant_id", docMeta.tenantId());   // 租户:多租户隔离的第一道
            meta.put("doc_id",    docMeta.docId());      // 回链原文 / 删文联动
            meta.put("dept",      docMeta.dept());       // 部门
            meta.put("level",     docMeta.level());      // 密级
            meta.put("acl_tags",  docMeta.aclTags());    // 白名单标签 List<String>
            meta.put("status",    "active");             // 软删标记

            docs.add(new Document(text, meta));
        }
        // add() 内部:把每块 text 交给 EmbeddingModel 算向量 → 连 metadata 一起写进 Milvus
        vectorStore.add(docs);
    }
}

vectorStore.add(docs) 这一句,Spring AI 帮你把"每块的文本拿去算 embedding、再连同元数据一起落库"全做了。Document 的第二个参数就是元数据 map,acl_tags 直接放 List<String> 即可------到 Milvus 里就是一份 JSON。

留意 status 这个字段:它是第六节"先软删后慢清"要用的伏笔。


四、检索:把"用户能看啥"翻译成 filterExpression(全篇核心)

到了最要紧的地方。检索时不只要问"哪块最相似",还要问"哪块这个用户有权限看 "。Spring AI 把它写成 filterExpression:

java 复制代码
@Service
public class KbSearchService {

    private final VectorStore vectorStore;

    public KbSearchService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    // ctx 来自登录态(JWT / SSO),业务层无法省略
    public List<Document> search(String query, AuthCtx ctx) {
        String filter = String.format(
                "tenant_id == '%s' && level <= %d && status == 'active' && (dept == '%s' || acl_tags in ['public'])",
                ctx.tenantId(), ctx.level(), ctx.dept());

        return vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(query)
                        .topK(5)
                        .filterExpression(filter)     // 关键:过滤下推给 Milvus
                        .build());
    }
}

这个表达式就是"且 / 或 + 比较 + in"这一套,跟 Milvus 的过滤语法一一对应:&& 是且、== 是相等、in [...] 是命中集合。上面这句读作:同一租户、没被删、密级不高于用户级别、并且(属于用户部门 或 带 public 标签)------全部满足的切片,才有资格进入相似度排序。

这就是 #28 说的 pre-filter 落地版:过滤没有被搬回应用层,而是和向量相似度在 Milvus 内部一次完成。不相关的切片压根不参与计算,既不会白占 TopK 名额,也不存在"敏感内容先被搜出来再被过滤"的路径。

条件一长,手拼字符串容易在引号转义上翻车,Spring AI 也提供了 DSL:

java 复制代码
FilterExpressionBuilder b = new FilterExpressionBuilder();

String expr = b.and(
        b.eq("tenant_id", ctx.tenantId()),
        b.lte("level", ctx.level())
).build();

eq / ne / gt / lte / in / and / or 这些都在这个 DSL 里,拼长条件时比裸字符串稳得多。

这里再重复 #28 那条铁律:过滤条件只能来自登录态,绝不能由调用方传进来。 上面 ctx 从 SecurityUtils.current() 解 JWT / SSO 拿到,任何接口都改不掉------否则只要有个新接口忘了带条件,整套校验就形同虚设。


五、多租户:别指望一个 filter 兜住所有

如果你的知识库是卖给多家公司的 SaaS,只靠 tenant_id == 过滤是不够硬的------一旦某个脚本、某个新接口漏传了这个条件,吐出来的就是跨公司的数据。

Milvus 里更稳的做法是分区(partition):一个租户一个分区,检索时只查对应分区。Spring AI 直接支持:

properties 复制代码
spring.ai.vectorstore.milvus.partition-name=t_acme

加了它之后,insert / delete / search 都会被限定在该分区内。真正做 SaaS 时,这个分区名由租户上下文动态决定------常见做法是用 Milvus 客户端为每个租户建好分区,检索时按当前租户切到对应分区(或按租户各建一个 VectorStore bean)。

对应 #28 那张表:collection 每租户一个 = 隔离最强、资源最费;partition = 同库内分区,性价比最高,多数企业从这儿起步;单库 + 一个 filter = 最省,但最险。能物理隔离就物理隔离,退一步至少要分区。


六、删文与权限变更,落到代码上

原理篇讲过"先搜不到、再删干净",这里给代码。

删文档。 源文档标记删除后,别去拼字符串,用 Filter.Expression:

java 复制代码
// 立即不可见:把该文档的切片打上软删标记(重新写一遍 status)
// 或者直接用表达式删除
vectorStore.delete(new FilterExpressionBuilder()
        .eq("doc_id", docMeta.docId())
        .build());

delete(Filter.Expression) 会按条件删掉对应切片,检索立刻搜不到;剩下的物理空间回收交给 Milvus 后台的 compaction 慢慢做。如果你要"软删优先、物理清理放到后面",就更简单:检索时的 filterExpression 里带上 status == 'active'(上面的代码已经带了),删文只是把 status 改掉,索引层的清理由后台异步完成。

权限变更。 分两种:

  • 文档属性变了 (一份内部文档降级为公开):把这份 doc_id 的切片重新灌一遍(先删后插,或覆盖),新属性立刻生效;

  • 用户调岗了 :什么都不用动。因为用户权限是检索时从登录态实时算的(ctx),切片里只存了文档属性。这正是 #28 强调的那条线------文档属性写进切片,用户权限每次实时算------守住了它,人员变动就不必碰向量库。


七、面试官视角的标准回答

面试官问:"你说权限过滤要下推检索层,具体怎么落地?"

用 Milvus + Spring AI 这套来讲:切分入库时,把权限属性(租户、部门、密级、ACL 标签)写进每块切片的 metadata;检索时把当前登录用户的权限翻译成 filterExpression,比如 tenantid == '...' && level <= 3 && status == 'active' && (dept == '...' || acltags in ['public']),交给 Milvus 和向量相似度在一次查询里完成,而不是搜完再在应用层过滤------那样既丢召回,又有把敏感内容送进模型的风险。<br><br>条件从登录态强制注入,调用方无权传。多租户用 partition 做隔离,别只押一个 tenant_id 过滤;删文档用表达式删除加软删标记,检索先排除 status != 'active';权限变更靠文档属性重灌 + 用户权限实时计算,两件事分开。<br><br>一句话:过滤在检索层、隔离在分区层、时效在实时计算,三层各管各的。


八、跑起来之后的验证清单

写完不算数,得能自证。造点数据,按下面四条过一遍:

  1. 造两个租户、两个密级的数据 ,用不同用户的 ctx 搜同一个 query,确认结果集不串;

  2. 故意把 filter 里的 tenant_id 去掉,确认能搜到别的租户的切片------反过来证明这条条件就是"命门",少一个都不行;

  3. 删一篇文档再搜,确认立刻搜不到;

  4. 把用户的 level 从 3 调到 1,确认高密级切片这次搜不出来(实时生效,不用重建索引)。

四条全过,这套权限隔离就算立住了。


Milvus 的路子走通了。如果你的向量库是 pgvector,思路完全一样,只是把 filterExpression 换成 SQL 的 WHERE(#> 26 那篇里写过完整写法)。下一篇可以聊点别的------比如把同一套检索用 LangChain4j 再实现一遍,看看两套框架的写法差在哪。

相关推荐
.Bryce.1 小时前
MCP+A2A+ANP,Agent通信协议的完整知识地图
java·人工智能·后端·agent·通信协议
JuiceFS1 小时前
面向 EB 级 AI 存储:JuiceFS 企业版架构演进
人工智能·后端
聚城云-GeecityCloud1 小时前
一套物业管理系统多少钱?2026物业软件收费模式与成本明细
大数据·人工智能
成都旺小宝科技1 小时前
首访决策人没来,AI一客一策两天成交!
大数据·人工智能·机器学习·ffmpeg·语音识别
码农小白AI1 小时前
批次检验报告和入库单对不上?IACheck AI 报告文档审核 Agent 做原始记录匹配
人工智能
昇腾CANN1 小时前
10月21日16点直播丨CANN社区Agent能力介绍——模型、TileLang、AMCT开发
人工智能·开源·昇腾·cann·cann开源
马良神笔2 小时前
LightRAG简介
人工智能
原子延迟2 小时前
视频太大怎么压缩发微信?先查限制再压的3种方法(2026)
开发语言·图像处理·人工智能·计算机视觉·音视频
Gu_WenYun2 小时前
人口老龄化加速,创新药为何成为医药长期主线
人工智能·科技·金融