ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

向量索引过滤(Filter Expression):如何结合商户 ID 实现多租户语义隔离

向量索引过滤(Filter Expression):如何结合商户 ID 实现多租户语义隔离 上周三下午安全风控部的老王端着水杯晃悠到我工位压低声音说“然哥出事了。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 ListDocument search(String query, int topK, double minScore) { String currentMerchantId TenantContextHolder.getRequiredMerchantId(); ListString 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); } }在底层执行时比如对接 MilvusSpring 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 写得多华丽而是底层的工程边界是否牢固。不要把向量数据库当成一个单纯的数学余弦计算器它在底层本质上是一套混合计算引擎。理清预过滤与后过滤的底层差异用标量倒排索引给租户字段加速用严苛的防御性代码守住租户边界这样搭出来的企业级知识库才经得起线上高并发与黑客攻防的严酷考验。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进