做了好幾個Spring AI項目之后,最大的感受就是:入門Demo隨便跑,真到企業(yè)落地,一堆細(xì)節(jié)坑等著你。
其中最坑的一個就是RAG的多租戶權(quán)限控制——如果你做的是SaaS產(chǎn)品,或者給公司多個部門提供知識庫服務(wù),總不能讓A部門搜到B部門的內(nèi)部文檔吧?數(shù)據(jù)安全直接出問題。
我剛開始做的時候也踩過坑:最開始想給每個租戶單獨建一張向量表,租戶少的時候還好,后來租戶多到上百個,光維護表結(jié)構(gòu)就要瘋掉,查詢性能也差得不行。
踩過坑之后,我整理了一套生產(chǎn)可用的共享表+行級權(quán)限過濾方案,今天把完整實現(xiàn)代碼分享出來,看完就能直接用。
核心思路:為什么要這么做?
先講清楚兩種常見方案的對比:
表格
方案 優(yōu)點 缺點 適用場景
每個租戶一張向量表 隔離徹底,實現(xiàn)簡單 租戶多了運維爆炸,性能差 租戶數(shù)量少、單租戶數(shù)據(jù)量大
共享表+行級過濾 維護簡單,資源利用率高 需要額外加過濾邏輯 大多數(shù)企業(yè)級/SaaS場景
顯然,絕大多數(shù)場景下,共享表+行級過濾都是更優(yōu)解。我們今天就基于Spring AI + PgVector(最常用的向量庫組合)來實現(xiàn)這套方案,核心就兩步:
? ? 存文檔時,給每個文檔塊打上租戶ID標(biāo)記
? ? 檢索時,自動加上租戶ID過濾條件,只搜當(dāng)前租戶的文檔
具體實現(xiàn),代碼直接抄
第一步:給向量表加租戶字段
Spring AI默認(rèn)的PgVector表只存了文檔內(nèi)容和向量,我們只需要加一個tenant_id字段,再建個索引提升過濾速度:
sql
-- 給默認(rèn)向量表添加租戶ID字段
ALTER TABLE vector_store ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(64);
-- 給租戶ID加索引,過濾速度提升N倍
CREATE INDEX IF NOT EXISTS idx_vector_store_tenant_id ON vector_store(tenant_id);
如果是新建表,直接把字段加上就行,不用改表結(jié)構(gòu)。
第二步:用ThreadLocal保存租戶上下文
我們一般會從請求頭或者Token里解析出租戶ID,用ThreadLocal存在當(dāng)前請求上下文里,不用每個方法都傳參數(shù):
java
@Component
public class TenantContextHolder {
? ? private static final ThreadLocal<String> TENANT_ID = new ThreadLocal<>();
? ? // 設(shè)置當(dāng)前租戶ID
? ? public static void set(String tenantId) {
? ? ? ? TENANT_ID.set(tenantId);
? ? }
? ? // 獲取當(dāng)前租戶ID
? ? public static String get() {
? ? ? ? return TENANT_ID.get();
? ? }
? ? // 請求結(jié)束后清理,避免內(nèi)存泄漏
? ? public static void clear() {
? ? ? ? TENANT_ID.remove();
? ? }
}
一般我們會寫一個攔截器,在請求進來的時候解析租戶ID,結(jié)束之后清理:
java
@Component
public class TenantInterceptor implements HandlerInterceptor {
? ? @Override
? ? public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
? ? ? ? String tenantId = request.getHeader("X-Tenant-Id");
? ? ? ? if (tenantId != null) {
? ? ? ? ? ? TenantContextHolder.set(tenantId);
? ? ? ? }
? ? ? ? return true;
? ? }
? ? @Override
? ? public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
? ? ? ? TenantContextHolder.clear();
? ? }
}
第三步:擴展PgVectorStore,實現(xiàn)自動權(quán)限過濾
Spring AI的PgVectorStore本身支持?jǐn)U展,我們只需要繼承它,重寫添加文檔和檢索兩個方法,加上租戶邏輯就行:
java
public class TenantPgVectorStore extends PgVectorStore {
? ? private final JdbcTemplate jdbcTemplate;
? ? public TenantPgVectorStore(
? ? ? ? ? ? JdbcTemplate jdbcTemplate,
? ? ? ? ? ? EmbeddingClient embeddingClient,
? ? ? ? ? ? PgVectorStoreConfig config
? ? ) {
? ? ? ? super(jdbcTemplate, embeddingClient, config);
? ? ? ? this.jdbcTemplate = jdbcTemplate;
? ? }
? ? // 添加文檔時,自動給每個文檔打上租戶ID標(biāo)記
? ? @Override
? ? public void add(List<Document> documents) {
? ? ? ? String tenantId = TenantContextHolder.get();
? ? ? ? if (tenantId == null) {
? ? ? ? ? ? throw new IllegalArgumentException("文檔導(dǎo)入失?。何传@取到租戶信息");
? ? ? ? }
? ? ? ? // 給每個文檔元數(shù)據(jù)加上租戶ID,同時寫入數(shù)據(jù)庫字段
? ? ? ? documents.forEach(doc -> doc.getMetadata().put("tenant_id", tenantId));
? ? ? ? super.add(documents);
? ? }
? ? // 檢索時,自動加上租戶過濾條件
? ? @Override
? ? public List<Document> similaritySearch(String query, int topK) {
? ? ? ? String tenantId = TenantContextHolder.get();
? ? ? ? if (tenantId == null) {
? ? ? ? ? ? return Collections.emptyList();
? ? ? ? }
? ? ? ? // 生成查詢文本的向量
? ? ? ? float[] queryEmbedding = this.embeddingClient.embed(query);
? ? ? ? // 自定義帶租戶過濾的查詢SQL
? ? ? ? String sql = """
? ? ? ? ? ? ? ? SELECT id, content, (embedding <-> ?) AS distance
? ? ? ? ? ? ? ? FROM %s
? ? ? ? ? ? ? ? WHERE tenant_id = ?
? ? ? ? ? ? ? ? ORDER BY distance
? ? ? ? ? ? ? ? LIMIT ?
? ? ? ? ? ? ? ? """.formatted(getTableName());
? ? ? ? // 執(zhí)行查詢,轉(zhuǎn)換為Document對象返回
? ? ? ? return jdbcTemplate.query(
? ? ? ? ? ? ? ? sql,
? ? ? ? ? ? ? ? (rs, row) -> {
? ? ? ? ? ? ? ? ? ? Document doc = new Document(rs.getString("content"));
? ? ? ? ? ? ? ? ? ? doc.getMetadata().put("distance", rs.getFloat("distance"));
? ? ? ? ? ? ? ? ? ? doc.getMetadata().put("tenant_id", tenantId);
? ? ? ? ? ? ? ? ? ? return doc;
? ? ? ? ? ? ? ? },
? ? ? ? ? ? ? ? new PgVector(queryEmbedding),
? ? ? ? ? ? ? ? tenantId,
? ? ? ? ? ? ? ? topK
? ? ? ? );
? ? }
}
就這么簡單!核心就是檢索SQL加了一句WHERE tenant_id = ?,只查當(dāng)前租戶的文檔,從根源上實現(xiàn)了權(quán)限隔離。
第四步:替換默認(rèn)VectorStore配置
最后我們把自定義的租戶感知VectorStore注冊到Spring容器,替換默認(rèn)實現(xiàn):
java
@Configuration
public class TenantVectorConfig {
? ? @Bean
? ? public VectorStore tenantVectorStore(
? ? ? ? ? ? JdbcTemplate jdbcTemplate,
? ? ? ? ? ? EmbeddingClient embeddingClient,
? ? ? ? ? ? PgVectorStoreProperties properties
? ? ) {
? ? ? ? PgVectorStoreConfig config = PgVectorStoreConfig.builder()
? ? ? ? ? ? ? ? .withDimension(properties.getDimension())
? ? ? ? ? ? ? ? .withIndexName(properties.getIndexName())
? ? ? ? ? ? ? ? .build();
? ? ? ? return new TenantPgVectorStore(jdbcTemplate, embeddingClient, config);
? ? }
}
整個項目里注入的VectorStore都是帶權(quán)限控制的了,原來的業(yè)務(wù)代碼完全不用改,對代碼侵入性極低。
擴展:支持部門/角色級權(quán)限
很多公司不是簡單的多租戶,而是同一個租戶下有多個部門,用戶只能看自己部門的文檔,怎么擴展?其實非常簡單,加個過濾條件就行:
? ? 向量表再加一個department_id字段:
sql
ALTER TABLE vector_store ADD COLUMN IF NOT EXISTS department_id VARCHAR(64);
CREATE INDEX idx_vector_store_dept ON vector_store(tenant_id, department_id);
? ? 檢索SQL改成多條件過濾:
sql
SELECT id, content, embedding <-> ? AS distance
FROM vector_store
WHERE tenant_id = ?
? AND department_id IN (?) -- 匹配用戶所屬的所有部門
ORDER BY distance
LIMIT ?
如果是更細(xì)粒度的用戶級權(quán)限,再加個user_id過濾就行,思路完全一樣。
性能優(yōu)化小技巧
加了過濾條件會不會影響檢索速度?只要做好這兩點,性能幾乎沒影響:
? ? 建對索引:把常用過濾字段(tenant_id、department_id)加上組合索引,過濾速度非??欤疫@邊100萬文檔量,單次檢索延遲不到10ms;
? ? 大小租戶分層存儲:超大租戶(百萬級文檔)單獨分表,中小租戶共享表,兼顧性能和維護成本,不用一上來就所有租戶都分表。
寫在最后
企業(yè)級RAG的多租戶權(quán)限控制其實真的不難,核心思路就是共享表+行級過濾,利用Spring AI的擴展點,幾十行代碼就能搞定,比給每個租戶建表好維護太多了。
這套方案我已經(jīng)在生產(chǎn)環(huán)境跑了快半年,支撐了上百個租戶,運行非常穩(wěn)定,大家可以直接拿去用。
后續(xù)我會持續(xù)更新Spring AI的進階實戰(zhàn)內(nèi)容:
?? Spring AI怎么對接本地部署的Llama 3/Qwen大模型?
?? 多Agent協(xié)作系統(tǒng)怎么搭?
?? AI應(yīng)用怎么優(yōu)化延遲、降低Token成本?
感興趣的朋友可以關(guān)注我。