为什么做这个项目问题很具体要给多个客户做知识库问答。每个客户有自己的文档、自己的模型账号、自己的向量库配置。直接调大模型不行——模型不知道客户的私有文档而且不能把 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没有静默降级。调用方长这样EmbeddingModelmodelrouter.get(tenantCode,ResourceType.EMBEDDING_MODEL,pipeline.getEmbeddingModelKey(),EmbeddingModel.class);EmbeddingStoreTextSegmentstorerouter.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.tableRegistrar 加载时维护已见键集合遇到同键配置后者直接拒绝构建并记错误日志先加载者胜。这个检查在启动期就做不留到运行时。第三层访问语义业务代码取资源必须显式带租户编码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里SELECTa.atttypmodFROMpg_attribute aJOINpg_class cONa.attrelidc.oidWHEREc.relnameembeddings_tenant_aANDa.attnameembedding;-- 对 vector(1024) 列返回 1024于是 Provider 建完 store 后开一次性 JDBC 连接读这个值和配置维度对不上就在加载期失败。把运行期错误提前到启动期是整个基座的基本策略。另外实测确认了打分语义score (2 − 余弦距离) / 2范围 [−1, 1]。后续检索的分数阈值都按这个来。摄取管线化切片索引侧的闭环是摄取加切片。这里有个关键取舍粗细双粒度不是硬编码两条流水线而是租户内任意多条命名摄取管线的自然结果。单独一张ingestion_pipeline表每条管线声明切片策略、token 上限默认 500、重叠默认 50、目标向量存储 key、嵌入模型 key。配两条管线指向两张表就是粗细粒度。IngestionService.ingest的流程算内容的 SHA-256 指纹相同则 no-op数据不动不同则 version1逐条管线执行。PipelineExecutor是单管线执行器核心流程很短clearOldChunks(documentId,pipelineId,store);// 先清后写删旧 chunk 行和旧向量ListTextSegmentsegmentssplit(pipeline,document,content);// 递归切片 打四键 metadataResponseListEmbeddingembeddingsmodel.embedAll(segments);writtenIdsstore.generateIds(segments.size());store.addAll(writtenIds,embeddings.content(),segments);// 先定 id写入后回链chunkService.saveBatch(toChunks(document,pipeline,segments,writtenIds));替换语义、切片、metadata、批量向量化都在这十几行里。自编排的原因就在generateIdsaddAll(ids, ...)这两步——langchain4j 官方的EmbeddingStoreIngestor不返回写入的 id用它就做不了 embedding id 与业务表的回链。管线之间没有共享事务可能跨库物理上没法单事务。单管线失败只 best-effort 清掉本 run 已写的向量其他管线不受影响。文档状态汇总为 INGESTED / PARTIAL / FAILED每次管线执行都有ingestion_run记录。进程重启时StaleIngestionRecovery把滞留 INGESTING 超 5 分钟的标记为 interrupted防止状态误导重试。token 计量用 tiktokenOpenAiTokenCountEstimator对中文是子词近似不精确但可接受。真要中文专精计量IngestionTokenizer就是替换点。目前的进展做完了的项目骨架、多租户资源基座、pgvector 向量存储、文档摄取与双粒度切片全部有集成测试覆盖无外部凭据可跑。没做的检索和问答只有设计稿没有实现。没有 HTTP 接口摄取靠进程内调用。没有重排、没有混合检索——设计里把它们定为扩展点混合检索默认不启用是因为中文分词的配置成本还没评估。单实例前提锁是 JVM 内的多实例要换分布式锁。只支持纯文本和 MarkdownPDF 解析留了 SPI 没做。切片策略只有 RECURSIVE。实测数据方面向量写入/检索的隔离性、加载期校验有测试背书但端到端的检索质量命中率、召回没有任何数据——链路还没通没法测。500/50 的切片默认值是拍的经验区间没经过真实语料校准。下一步就是检索服务、问答接口、全链路观测Micrometer 指标加阶段化 trace 记录观测模型会把摄取执行记录一起纳入。