
上一篇(#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>一句话:过滤在检索层、隔离在分区层、时效在实时计算,三层各管各的。
八、跑起来之后的验证清单
写完不算数,得能自证。造点数据,按下面四条过一遍:
-
造两个租户、两个密级的数据 ,用不同用户的
ctx搜同一个 query,确认结果集不串; -
故意把 filter 里的
tenant_id去掉,确认能搜到别的租户的切片------反过来证明这条条件就是"命门",少一个都不行; -
删一篇文档再搜,确认立刻搜不到;
-
把用户的
level从 3 调到 1,确认高密级切片这次搜不出来(实时生效,不用重建索引)。
四条全过,这套权限隔离就算立住了。
Milvus 的路子走通了。如果你的向量库是 pgvector,思路完全一样,只是把 filterExpression 换成 SQL 的 WHERE(#> 26 那篇里写过完整写法)。下一篇可以聊点别的------比如把同一套检索用 LangChain4j 再实现一遍,看看两套框架的写法差在哪。