上周三下午,安全风控部的老王端着水杯晃悠到我工位,压低声音说:“然哥,出事了。B 轮刚签进来的大客户‘云海物联’在试用智能知识库问答时,搜‘VIP 返点结算条款’,系统竟然把另外一家竞品商户内部培训文档里的敏感折扣原模原样吐出来了。”
我后背瞬间冒出一层冷汗。在 SaaS 商业系统里,多租户数据隔离是绝对不可逾越的高压红线。客户能忍受大模型偶尔胡说八道几句废话,但绝对不能容忍自己的商业机密被友商的智能客服直接“借用”。
把线上排查链路拉出来一看,负责 RAG(检索增强生成)的小伙子一脸无辜:“然哥,我往向量库写数据的时候,每条 Chunk(文档块)的元数据里明明都带了merchant_id啊!而且我在 Java 代码里取回 Top 50 之后,明明写了stream().filter(doc -> doc.getMerchantId().equals(currentId))过滤啊!”
听完这句解释,我真是又气又笑。这就是典型的用写传统 MySQL 业务的思维生搬硬套向量数据库,直接踩进了“后过滤(Post-filtering)”的巨坑。
为什么在内存里做 Filter 会直接导致数据灾难?
传统关系型数据库执行SELECT * FROM table WHERE merchant_id = ? AND content LIKE ?时,B+ 树索引能够天然地先砍掉非目标租户的数据行。
但在高维向量空间(比如 1536 维或 1024 维)里,检索算法(如 HNSW、IVF-Flat、SCaNN)计算的是向量之间的余弦相似度或欧氏距离。
如果你采用“先搜向量,再在 Java 内存里过滤商户 ID”的**后过滤(Post-filtering)**逻辑,灾难就会接踵而至:
- TopK 被无关租户打满,导致结果为空:假设商户 A 是个新入住的小商户,库里总共只存了 20 条文档;而商户 B 是存了数万条文档的大商家。当商户 A 的员工提问一个通用问题时,向量空间里距离最近的 Top 50 极大概率全是商户 B 的高分向量。你的服务从向量库拿到这 50 条数据,再经过 Java 内存里的
merchant_id == A过滤,结果匹配项直接变成 0 条。用户明明上传了相关文档,系统却信誓旦旦回答“未找到相关信息”。 - 后过滤逻辑存在代码缺陷与边界漏洞:一旦某次上线重构,上层调用没取到租户上下文(TenantContext),或者在批处理任务中弄丢了 ThreadLocal,由于向量检索本身不受限制,后置过滤条件被意外绕过,敏感数据就会赤裸裸地暴露在提示词(Prompt)中。
因此,多租户隔离必须且只能做在向量数据库底层的预过滤(Pre-filtering)阶段,在向量遍历图节点之前,就把不属于当前商户的数据从候选集内物理剔除。
向量库的多租户隔离流派与架构取舍
在设计企业级知识库时,面对多租户隔离通常有三种架构方案:
| 方案 | 隔离级别 | 运维复杂度 | 资源消耗 | 适用场景 |
|---|---|---|---|---|
| 物理分库/分集合 (Collection-per-tenant) | 极高(物理硬隔离) | 灾难级(数千租户导致句柄耗尽) | 极高(内存与索引碎片暴增) | 超大私有化 VIP 独立客户 |
| 分区隔离 (Partition-per-tenant) | 中高(逻辑分区) | 较高(分区数受限于引擎上限) | 中等 | 租户数在几十到数百之间 |
| 元数据标量过滤 (Filter Expression) | 高(严格预过滤) | 极低(单 Collection 统一维护) | 最低(资源利用率最充分) | 海量多租户 SaaS(主流标准解) |
对于绝大多数 SaaS 业务,每个商户单独建一个 Collection 是纯粹的自掘坟墓。Milvus、Qdrant、Elasticsearch 等底层引擎,每个索引都需要消耗文件句柄与内存常驻元数据。一旦租户突破数千,集群会直接被元数据撑爆。
最成熟的方案就是采用共享 Collection + 标量倒排索引 + 严格预过滤表达式(Filter Expression)。
Spring AI 结合 Filter Expression 的标准落地
在 Spring AI 体系中,官方抽象了强大的FilterExpressionBuilder,允许我们在跨不同向量数据库(Milvus、PgVector、Qdrant、Chroma 等)时,编写统一的过滤语义,而底层的 Adapter 会将其自动翻译成对应数据库的原生语法。
下面是我们在生产环境构建的安全多租户检索组件:
@Component public class MultiTenantVectorRetriever { private final VectorStore vectorStore; public MultiTenantVectorRetriever(VectorStore vectorStore) { this.vectorStore = vectorStore; } public List<Document> search(String query, int topK, double minScore) { String currentMerchantId = TenantContextHolder.getRequiredMerchantId(); List<String> userRoles = TenantContextHolder.getUserRoles(); // 构建强安全隔离的过滤表达式 FilterExpressionBuilder b = new FilterExpressionBuilder(); // 核心约束 1: 必须严格限定为当前商户,绝对不可妥协 Filter.Expression merchantFilter = b.eq("merchant_id", currentMerchantId).build(); // 核心约束 2: 租户内部的权限细分(如文档访问级别公开或匹配角色) Filter.Expression accessFilter = b.or( b.eq("is_public", true).build(), b.in("required_role", userRoles).build() ).build(); // 组合预过滤条件:merchant_id 必须精准命中,且必须满足内部权限 Filter.Expression combinedFilter = b.and(merchantFilter, accessFilter).build(); SearchRequest request = SearchRequest.builder() .query(query) .topK(topK) .similarityThreshold(minScore) .filterExpression(combinedFilter) .build(); return vectorStore.similaritySearch(request); } }在底层执行时,比如对接 Milvus,Spring AI 会将上述表达式翻译为 Milvus 原生的布尔过滤语句:
merchant_id == "M10086" and (is_public == true or required_role in ["ADMIN", "MANAGER"])生产必须搞定的两大硬核调优
解决了“能过滤”的问题,在百万级向量规模下,很多团队又会遭遇“性能腰斩”的尴尬。要让 Filter 跑得快且稳,以下两个底层细节必须牢记。
1. 标量字段必须显式构建倒排索引(Inverted Index)
默认情况下,很多向量数据库虽然允许你在 Payload/Metadata 里存 JSON,但如果你没有对merchant_id单独建立标量索引,向量引擎在执行预过滤时,就必须先全表扫描(Full Scan)所有向量节点的元数据去比对字符串!
这样做的直接恶果是:即使向量检索采用 HNSW 只要 5 毫秒,标量全表扫描却花了 200 毫秒,延迟飙升数十倍。
以 Milvus 为例,在初始化 Collection Schema 时,必须针对merchant_id显式创建标量倒排索引:
// 在初始化集合时,显式配置标量索引 IndexParam merchantIndex = IndexParam.builder() .fieldName("merchant_id") .indexType(IndexType.INVERTED) .build(); milvusClient.createIndex(CreateIndexParam.builder() .collectionName("tenant_knowledge_base") .indexParam(merchantIndex) .build());有了标量倒排索引,向量引擎在执行查询时,会先通过倒排索引极速拿到属于该商户的向量 ID 位图(Bitset),在遍历 HNSW 图结构计算距离时,直接通过位运算跳过所有无关节点,检索速度重回个位数毫秒级。
2. 防范“租户上下文丢失”的防御性编程
在 Java 架构中,最怕的就是隐式状态穿透。很多研发在 Controller 层写了校验,但在定时任务、MQ 异步消费、或者CompletableFuture线程池里,TenantContextHolder的 ThreadLocal 变成了 null。
如果过滤表达式构建器接收到了一个 null 的merchant_id,而底层代码没有做防御性阻断,生成的 SQL 可能会变成没有merchant_id条件的裸查,瞬间导致全库数据穿透。
我们在框架层强制执行两道锁:
- 表达式构建器非空熔断:在
MultiTenantVectorRetriever中,如果currentMerchantId为空,直接抛出SecurityException("Tenant context is missing, vector search aborted"),绝不向下传递。 - 动态切面兜底:通过 AOP 切面拦截所有
VectorStore.similaritySearch调用,一旦发现生成的Filter.Expression中不包含当前租户的隔离键,直接拦截报错并报警。
总结与架构思考
大模型时代的 RAG 架构看似简单,无非是“切块、Embedding、检索、组装提示词”。但在复杂的企业级 SaaS 系统里,最考验架构师功底的往往不是 Prompt 写得多华丽,而是底层的工程边界是否牢固。
不要把向量数据库当成一个单纯的数学余弦计算器,它在底层本质上是一套混合计算引擎。理清预过滤与后过滤的底层差异,用标量倒排索引给租户字段加速,用严苛的防御性代码守住租户边界,这样搭出来的企业级知识库,才经得起线上高并发与黑客攻防的严酷考验。