从零搭一个多租户 RAG:UniRAG 的设计与取舍

为什么做这个项目

问题很具体:要给多个客户做知识库问答。每个客户有自己的文档、自己的模型账号、自己的向量库配置。直接调大模型不行------模型不知道客户的私有文档,而且不能把 A 客户的内容答给 B 客户。

把所有文档塞进提示词也不行,上下文窗口装不下,成本也扛不住。标准解法是 RAG:文档切片、向量化入库,提问时先检索相关片段,拼进提示词再让模型回答。但现成开源方案大多是单租户示例,改造成多租户要处理的东西比想象多:每个租户的嵌入模型不同,向量维度都可能不一样,向量表要隔离,配置互不相同。UniRAG 就是为这个场景写的,技术栈是 Spring Boot 3.5 + Java 21 + langchain4j + PostgreSQL/pgvector。

架构:先搭资源基座

核心决策是:多租户不靠代码里写 if-else,而是"每个租户一份资源配置,启动时实例化"。配置存 PostgreSQL 的 tenant_resource_config 表,一条记录四要素:租户、资源类型(LLM / EMBEDDING_MODEL / EMBEDDING_STORE)、资源 key、JSON 配置体。

启动时 TenantResourceRegistrar(一个 ApplicationRunner)读全部启用的配置,按资源类型分发给 Provider SPI 构建,注册成命名 Bean,命名规则 {resourceType}_{tenantCode}_{key}。业务代码统一走 TenantResourceRouter.get 取,取不到抛 TenantResourceNotFoundException,没有静默降级。调用方长这样:

java 复制代码
EmbeddingModel model = router.get(tenantCode, ResourceType.EMBEDDING_MODEL,
        pipeline.getEmbeddingModelKey(), EmbeddingModel.class);
EmbeddingStore<TextSegment> store = router.get(tenantCode, ResourceType.EMBEDDING_STORE,
        pipeline.getEmbeddingStoreKey(), EmbeddingStore.class);

Router 内部就是按 (tenantCode, type, key) 索引到 Bean 名再从容器取,索引在启动时建好,运行期零反射零扫描。

这个设计有三个亮点:

Provider SPI 把"怎么建"和"建什么"分开。Registrar 只管通用流程(读配置、去重、构建、注册),每种资源类型一个 Provider 声明 beanType、实现构建逻辑、可选实现 collisionKey。以后加新资源类型(比如重排模型)就是新增一个 Provider,主流程一行不改。

命名 Bean 充当轻量服务目录。不引入额外注册中心,Spring 容器本身就是租户资源的索引。同名 key 可共存------租户 A 和租户 B 都可以有叫 default 的嵌入模型,因为 Bean 名里带了租户前缀。

构建失败即跳过,不阻塞启动。一条配置错了只记日志,其他租户照常加载,空配置也能启动。多租户平台里一个客户配错不该拖死所有人。

租户隔离:分了四层

隔离是这个项目投入设计精力最多的一块,分了四层,各管一段。

第一层,物理隔离:每个租户一张独立向量表。没有把所有租户的数据混在一张大表里靠 tenant_id 过滤,那种方案只要某个查询忘了写过滤条件就跨租户泄漏。表即边界,检索代码想串都串不了。因为表已经是边界,向量 metadata 里干脆不放 tenant 字段,少一处冗余、少一处不一致的可能。

第二层,配置防撞:两个租户配了同一张物理表是最危险的事故。Provider 的 collisionKey 钩子返回向量存储的物理标识(host:port:database:schema.table),Registrar 加载时维护已见键集合,遇到同键配置,后者直接拒绝构建并记错误日志,先加载者胜。这个检查在启动期就做,不留到运行时。

第三层,访问语义:业务代码取资源必须显式带租户编码,Router.get(tenantCode, type, key),取不到抛异常。不存在"拿个默认的先顶着"这种路径------借别人的模型答自己的问题,这类事故都被这个签名挡住了。

第四层,数据回链:chunk 业务表带 tenant_id,文档按 (tenant_id, doc_name) 唯一约束,并发摄取按 (tenant, docName) 分段锁串行化。锁粒度到租户,不同租户的摄取互不等待。

四层合起来的一句话:物理上隔离,配置上防撞,访问上显式,数据上可回溯。

向量存储的三个坑

pgvector 落地时每个租户一张向量表,这部分坑最多。

第一个坑:langchain4j 的 PgVectorEmbeddingStore 每次拿连接都会执行 CREATE EXTENSION IF NOT EXISTS vector。等于每个连接都跑一次系统级 DDL,还要求应用账号有高权限。解法是构建 store 时强制 skipCreateVectorExtension(true),扩展创建交给 Flyway 迁移统一做,应用账号不需要超级用户权限。

第二个坑:表名是字符串拼接进 DDL 的,langchain4j 没做任何转义。配置来自数据库,算受信数据但可能错。加了一层白名单正则校验(EmbeddingStoreTableVerifier),不合法直接构建失败。这个校验同时也是隔离的一部分------畸形表名可能拼出越界语句。

第三个坑:建表用 CREATE TABLE IF NOT EXISTS,表已存在时静默跳过,不校验维度。维度不匹配要到写入时才报 SQL 错误,运维定位成本高。实测 pgvector 列维度存在 pg_attribute.atttypmod 里:

sql 复制代码
SELECT a.atttypmod
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
WHERE c.relname = 'embeddings_tenant_a' AND a.attname = 'embedding';
-- 对 vector(1024) 列返回 1024

于是 Provider 建完 store 后开一次性 JDBC 连接读这个值,和配置维度对不上就在加载期失败。把运行期错误提前到启动期,是整个基座的基本策略。

另外实测确认了打分语义:score = (2 − 余弦距离) / 2,范围 −1, 1。后续检索的分数阈值都按这个来。

摄取:管线化切片

索引侧的闭环是摄取加切片。这里有个关键取舍:粗细双粒度不是硬编码两条流水线,而是"租户内任意多条命名摄取管线"的自然结果。单独一张 ingestion_pipeline 表,每条管线声明切片策略、token 上限(默认 500)、重叠(默认 50)、目标向量存储 key、嵌入模型 key。配两条管线指向两张表,就是粗细粒度。

IngestionService.ingest 的流程:算内容的 SHA-256 指纹,相同则 no-op,数据不动;不同则 version+1,逐条管线执行。

PipelineExecutor 是单管线执行器,核心流程很短:

java 复制代码
clearOldChunks(documentId, pipelineId, store);          // 先清后写:删旧 chunk 行和旧向量

List<TextSegment> segments = split(pipeline, document, content);   // 递归切片 + 打四键 metadata
Response<List<Embedding>> embeddings = model.embedAll(segments);
writtenIds = store.generateIds(segments.size());
store.addAll(writtenIds, embeddings.content(), segments);          // 先定 id,写入后回链

chunkService.saveBatch(toChunks(document, pipeline, segments, writtenIds));

替换语义、切片、metadata、批量向量化都在这十几行里。自编排的原因就在 generateIds + addAll(ids, ...) 这两步------langchain4j 官方的 EmbeddingStoreIngestor 不返回写入的 id,用它就做不了 embedding id 与业务表的回链。

管线之间没有共享事务(可能跨库,物理上没法单事务)。单管线失败只 best-effort 清掉本 run 已写的向量,其他管线不受影响。文档状态汇总为 INGESTED / PARTIAL / FAILED,每次管线执行都有 ingestion_run 记录。进程重启时 StaleIngestionRecovery 把滞留 INGESTING 超 5 分钟的标记为 interrupted,防止状态误导重试。

token 计量用 tiktoken(OpenAiTokenCountEstimator),对中文是子词近似,不精确但可接受。真要中文专精计量,IngestionTokenizer 就是替换点。

目前的进展

做完了的:项目骨架、多租户资源基座、pgvector 向量存储、文档摄取与双粒度切片,全部有集成测试覆盖,无外部凭据可跑。

没做的:检索和问答只有设计稿,没有实现。没有 HTTP 接口,摄取靠进程内调用。没有重排、没有混合检索------设计里把它们定为扩展点,混合检索默认不启用,是因为中文分词的配置成本还没评估。单实例前提,锁是 JVM 内的,多实例要换分布式锁。只支持纯文本和 Markdown,PDF 解析留了 SPI 没做。切片策略只有 RECURSIVE。

实测数据方面:向量写入/检索的隔离性、加载期校验有测试背书,但端到端的检索质量(命中率、召回)没有任何数据------链路还没通,没法测。500/50 的切片默认值是拍的经验区间,没经过真实语料校准。

下一步就是检索服务、问答接口、全链路观测(Micrometer 指标加阶段化 trace 记录),观测模型会把摄取执行记录一起纳入。

相关推荐
老A的AI实验室1 小时前
赛博月刊 #2026年9月
大数据·人工智能·深度学习·ai·llm
FII工业富联科技服务1 小时前
2026工业AI智能体架构全景:从单Agent到多Agent协同的工厂级闭环实践
人工智能·ai·机器人·制造
zhanghaha13141 小时前
AI Agent_6 AI 底层架构
ai·agent
abigalexy2 小时前
图解AI应用架构设计
人工智能·ai·架构·系统架构·aigc
Summer-Bright2 小时前
深度 | 谷歌Gemini 4 Argon单次输出100万Token:长输出是智能体刚需,先给防御者不给攻击者才是新玩法
人工智能·安全·ai
汤姆yu2 小时前
GPT‑6 Astra大模型综述
gpt·ai·大模型
Ai-_Man2 小时前
您您这可以把Google AI Studio的多个会话比如说。左侧的多个会话一次性导出吗?不是单条会话里面的多次会对话。AI导出鸭
javascript·人工智能·ai·小程序·电脑
Martina_03213 小时前
AI生成3D场景第一次进入总卡一下?用6步排查Shader编译、纹理上传与预加载
图像处理·人工智能·游戏·3d·ai·自然语言处理·游戏策划
宋哥转AI3 小时前
AgentScope Java 实战 05:Spring Boot 整合——11 个官方 starter 的自动装配路线
人工智能·ai·ai编程