news 2026/10/21 2:24:44

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
29 切片的权限隔离(实战篇):Milvus + Spring AI,从 0 搭一条带权限过滤的检索链路

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

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

先看全貌。

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


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

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

# 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切片的向量
元数据metadataJSON,你想带的字段都塞这儿

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

配置文件大致长这样:

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(正好是 OpenAItext-embedding-3-small的维度)、默认索引IVF_FLAT(改成HNSW召回通常更好,但要配M/efConstruction)、默认度量COSINE。


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

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

@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:

@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:

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 直接支持:

spring.ai.vectorstore.milvus.partition-name=t_acme

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

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

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

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

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

// 立即不可见:把该文档的切片打上软删标记(重新写一遍 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 再实现一遍,看看两套框架的写法差在哪。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 2:24:18

【存储小知识】无防呆设计下E1.S/E3.S SSD和插槽如何对齐?

我们之前写了不少文章介绍在无防呆措施下应该如何避免将E1.S或者E3.S SSD在插槽插反从而导致烧盘&#xff0c;这个在当前Gen6 E1.S和E3.S SSD测试的情况显得更加重要。在 E3.S SSD&#xff08;属于 EDSFF 接口标准&#xff09;以及其他许多电脑硬件&#xff08;如 PCIe 扩展卡、…

作者头像 李华
网站建设 2026/10/21 2:22:32

STM32F407 HAL库驱动AD9854 DDS模块实现线性扫频方案

简介&#xff1a;这份资源面向参加电子设计竞赛或学习信号源开发的学生与工程师&#xff0c;基于STM32F407与HAL库&#xff0c;实现AD9854模块的任意频率正弦波输出及扫频功能&#xff0c;可用于扫频仪、阻抗分析仪等需要高频、幅度平坦信号源的场景。压缩包共1123个文件&#…

作者头像 李华
网站建设 2026/10/21 2:21:55

前端锚点跳转与回到顶部:原理、方案与踩坑指南

写前端页面的时候&#xff0c;我估计好多人都遇到过这种情况&#xff1a;内容区很长&#xff0c;目录放在页面顶部&#xff0c;用户点了一下目录里的链接&#xff0c;页面确实滚了&#xff0c;但滚到的位置不对&#xff0c;老是被固定在顶部的导航栏遮住半截标题。再把目光移到…

作者头像 李华
网站建设 2026/10/21 2:20:51

2026年做期货量化,和三年前已经不是一回事了

先抛一个观察&#xff1a;2026年国内期货量化的竞争&#xff0c;已经从"会不会写策略"转移到"用什么基础设施写策略"。 三年前你问一个人做不做量化&#xff0c;判断标准是他会不会Python、懂不懂talib。现在这两个门槛基本被工具填平了&#xff0c;真正的…

作者头像 李华