
1. 为什么我要自己搭一套多租户 RAG而不是直接用现成方案去年下半年我手上同时压着三个内部知识库项目一个是给售后团队用的产品故障排查库一个是给法务团队用的合同条款检索库还有一个是给研发团队用的接口文档问答库。三个库的数据源、访问权限、更新频率完全不一样但老板给的预算只够买一套商业方案。我第一反应是找现成的开源项目改dify 社区版、FastGPT、RAGFlow 都试了一圈最后发现一个绕不开的问题多租户隔离。现成方案大多默认你是一个团队用一套系统租户概念要么没有要么做得很浅——比如只做了数据表层面的tenant_id过滤但向量检索、缓存、文件存储、模型调用配额这些地方全是共享的。这意味着 A 租户的一次全量重建索引可能把 B 租户的检索延迟从 200ms 拉到 3 秒。更麻烦的是权限法务的合同条款和售后的故障记录如果落在同一个 collection 里靠元数据过滤来隔离一旦过滤条件写错就是数据泄露。所以我决定从零搭一套核心目标就三个租户级物理隔离、检索链路可插拔、单机能跑起来。技术选型上我选了 langchain4j 做编排层pgvector 做向量存储Ollama 跑本地 embedding 和生成模型。这套组合的好处是全部能在本地 Docker 里跑通不依赖外部 API成本可控而且 langchain4j 的EmbeddingStore和RetrievalAugmentor抽象做得比较干净方便我按租户维度做定制。这篇文章我会把 UniRAG 这套东西的设计思路、关键取舍、踩过的坑完整讲一遍。适合两类人看一是正在做多租户 SaaS 知识库的工程师二是想用 langchain4j pgvector 搭 RAG 但不知道从哪下手的人。我不会只给代码重点讲为什么这么设计因为多租户 RAG 的坑基本都在设计层面代码反而是最简单的部分。2. 多租户隔离到底该隔离在哪一层2.1 三种隔离粒度的真实成本对比多租户隔离不是加个 tenant_id 就完事它有三个层次成本差异巨大隔离层级实现方式隔离强度资源开销适用场景逻辑隔离所有租户共用表靠 tenant_id 过滤低最低内部工具租户间无敏感数据Schema 隔离每个租户一个 PostgreSQL schema中中等SaaS 产品租户数据需强隔离物理隔离每个租户独立数据库实例高最高金融、医疗等强合规场景我一开始想用逻辑隔离因为 pgvector 的 HNSW 索引是全局的加WHERE tenant_id ?就能过滤看起来最省事。但实测下来有两个致命问题第一HNSW 索引在过滤条件下会退化成近似暴力搜索因为图结构是按全局向量构建的过滤后候选集变小召回率明显下降第二tenant_id过滤是在向量检索之后做的意味着每次查询都要扫描大量不属于该租户的向量租户越多越慢。我做了个压测10 个租户每个租户 5 万条向量共 50 万条。逻辑隔离下单租户查询 P99 延迟是 480ms改成 schema 隔离后同样数据量降到 90ms。差距来自两点schema 隔离下每个租户的 HNSW 索引是独立构建的图结构只包含本租户向量召回率和速度都更好而且 PostgreSQL 的 schema 级权限控制天然支持租户隔离不用担心过滤条件写错。2.2 为什么我最终选了 schema 隔离而不是物理隔离物理隔离最安全但运维成本我扛不住。三个租户就是三个 PostgreSQL 实例备份、迁移、监控都要乘以三。而且我的租户规模不大单租户数据量在 10 万条以内schema 隔离完全够用。PostgreSQL 的 schema 是轻量级的创建和删除都很快CREATE SCHEMA tenant_xxx毫秒级完成适合租户动态开通的场景。具体实现上我在 langchain4j 的EmbeddingStore外面包了一层TenantAwareEmbeddingStore核心逻辑是根据当前请求的租户 ID动态切换search_path让 pgvector 的查询落到对应 schema。这里有个细节要注意pgvector 的vector扩展是数据库级的不是 schema 级的所以每个 schema 下建表时要显式引用public.vector类型否则会报类型找不到。-- 为租户创建独立 schema CREATE SCHEMA IF NOT EXISTS tenant_abc; -- 在租户 schema 下建向量表注意 vector 类型要带 public 前缀 CREATE TABLE tenant_abc.embeddings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), content TEXT NOT NULL, metadata JSONB, embedding public.vector(768) ); -- 每个租户独立建 HNSW 索引 CREATE INDEX idx_tenant_abc_embedding ON tenant_abc.embeddings USING hnsw (embedding public.vector_cosine_ops);注意vector_cosine_ops也要带public前缀否则在非 public schema 下会找不到操作符类。这个坑我踩了整整一个下午报错信息是 operator class does not exist但实际是 schema 搜索路径的问题。2.3 租户上下文怎么在请求链路里传递租户 ID 从 HTTP 请求进来要一路传到向量检索层中间经过 Controller、Service、langchain4j 的RetrievalAugmentor最后到EmbeddingStore。最直接的做法是方法参数层层传递但这样侵入性太强每个方法都要加tenantId参数。我用了ThreadLocal 拦截器的方案在 Spring MVC 的HandlerInterceptor里从请求头解析租户 ID存入TenantContext一个ThreadLocalString然后在TenantAwareEmbeddingStore里读取。这个方案在同步链路下没问题但 langchain4j 的流式响应StreamingChatLanguageModel会切换线程ThreadLocal会丢。解决办法是用TransmittableThreadLocal阿里开源的 TTL它能在ExecutorService提交任务时自动传递上下文。public class TenantContext { private static final TransmittableThreadLocalString CURRENT new TransmittableThreadLocal(); public static void set(String tenantId) { CURRENT.set(tenantId); } public static String get() { return CURRENT.get(); } public static void clear() { CURRENT.remove(); } }提示TransmittableThreadLocal需要配合TtlExecutors.getTtlExecutorService()包装线程池才生效直接 new 一个ThreadPoolExecutor是不行的。这个在 langchain4j 的异步检索场景下特别重要。3. langchain4j 的检索链路怎么按租户拆开3.1 langchain4j 的 RetrievalAugmentor 到底做了什么langchain4j 的 RAG 核心是RetrievalAugmentor接口它把整个检索增强流程拆成了几个可插拔的组件QueryTransformer查询改写、QueryRouter查询路由、ContentRetriever内容检索、ContentAggregator内容聚合。默认实现是DefaultRetrievalAugmentor走的是查询改写 → 路由 → 检索 → 聚合 → 注入 Prompt这条链路。多租户场景下我需要在ContentRetriever这一层做租户隔离。langchain4j 提供了EmbeddingStoreContentRetriever它内部调用EmbeddingStore.search()。我的做法是继承EmbeddingStoreContentRetriever重写retrieve()方法在调用search()之前根据TenantContext.get()切换 schema。public class TenantAwareContentRetriever implements ContentRetriever { private final EmbeddingStoreTextSegment embeddingStore; private final EmbeddingModel embeddingModel; private final int maxResults; Override public ListContent retrieve(Query query) { String tenantId TenantContext.get(); if (tenantId null) { throw new IllegalStateException(租户上下文缺失); } // 切换 schema 后执行检索 Embedding queryEmbedding embeddingModel.embed(query.text()).content(); ListEmbeddingMatchTextSegment matches embeddingStore.search( EmbeddingSearchRequest.builder() .queryEmbedding(queryEmbedding) .maxResults(maxResults) .minScore(0.7) .build() ).matches(); return matches.stream() .map(m - Content.from(m.embedded().text(), m.embedded().metadata())) .toList(); } }这里有个取舍minScore设多少合适我一开始设 0.8结果召回太少很多相关问题答不上来设 0.5 又引入太多噪声生成答案时模型容易被无关内容带偏。实测下来 0.7 是个比较平衡的值但不同 embedding 模型的分数分布不一样。Ollama 的nomic-embed-text分数普遍偏高0.7 可能偏松OpenAI 的text-embedding-3-small分数分布更分散0.7 可能偏紧。建议先用一批标注数据跑一遍看召回率和准确率的曲线再定。3.2 多路召回在 langchain4j 里怎么落地热词里有人问 langchain4j 多路召回这确实是 RAG 效果提升的关键。单路向量召回的问题是语义相似但关键词不匹配的内容容易漏掉比如用户问登录报 401文档里写的是认证失败返回未授权状态码向量相似度可能不高但关键词401和未授权是强匹配的。我的方案是向量召回 全文召回双路并行然后做 RRFReciprocal Rank Fusion融合。langchain4j 本身没有内置 RRF但ContentAggregator接口可以自定义。我实现了RrfContentAggregator把两路结果的排名做倒数加权public class RrfContentAggregator implements ContentAggregator { private static final int K 60; // RRF 平滑参数 Override public ListContent aggregate(MapQuery, ListListContent results) { MapString, Double scoreMap new HashMap(); MapString, Content contentMap new HashMap(); for (ListListContent queryResults : results.values()) { for (ListContent routeResults : queryResults) { for (int rank 0; rank routeResults.size(); rank) { Content c routeResults.get(rank); String key c.textSegment().text(); scoreMap.merge(key, 1.0 / (K rank 1), Double::sum); contentMap.putIfAbsent(key, c); } } } return scoreMap.entrySet().stream() .sorted(Map.Entry.String, DoublecomparingByValue().reversed()) .limit(10) .map(e - contentMap.get(e.getKey())) .toList(); } }全文召回用 PostgreSQL 的tsvector实现每个租户 schema 下建一个tsvector列和 GIN 索引。这里要注意中文分词问题PostgreSQL 默认的simple配置对中文是按字切分的效果一般。我用了zhparser扩展但它在某些 PostgreSQL 版本上编译有问题后来换成了在应用层用 HanLP 分词后再写入tsvector虽然多了一步但更可控。3.3 租户级模型配额怎么控制多租户系统里模型调用是最贵的资源。如果不做配额一个租户疯狂调用 embedding 接口其他租户就得排队。我在EmbeddingModel外面包了一层QuotaAwareEmbeddingModel用 Redis 做租户级令牌桶。public class QuotaAwareEmbeddingModel implements EmbeddingModel { private final EmbeddingModel delegate; private final RedisTemplateString, String redis; private final int qpsLimit; Override public ResponseEmbedding embed(String text) { String tenantId TenantContext.get(); String key quota:embedding: tenantId : currentSecond(); Long count redis.opsForValue().increment(key); if (count 1) { redis.expire(key, Duration.ofSeconds(2)); } if (count qpsLimit) { throw new QuotaExceededException(租户 tenantId embedding 配额超限); } return delegate.embed(text); } }这个方案简单但有效。qpsLimit我按租户等级配置免费租户 5 QPS付费租户 50 QPS。注意 Redis 的increment是原子操作不用担心并发问题。过期时间设 2 秒是为了防止时钟边界问题实际统计窗口是 1 秒。4. pgvector 在生产环境里的性能调优与踩坑4.1 HNSW 索引参数怎么调才不翻车pgvector 支持两种索引IVFFlat 和 HNSW。IVFFlat 建索引快但召回率依赖lists参数和查询时的probes参数调优麻烦HNSW 建索引慢但查询快、召回率高我最终选了 HNSW。HNSW 有两个关键参数m和ef_construction。m是每个节点的最大连接数越大图越密、召回率越高但内存占用越大ef_construction是建索引时的候选集大小越大索引质量越好但建索引越慢。我的实测数据10 万条 768 维向量单租户mef_construction建索引耗时索引大小召回率10查询 P99166445s320MB0.9212ms321282min580MB0.9718ms482005min850MB0.9825ms最终我选了m32, ef_construction128召回率和延迟比较平衡。查询时的ef_search参数也重要默认是 40我调到 100 后召回率从 0.97 提到 0.99延迟只增加了 5ms。-- 建索引时指定参数 CREATE INDEX idx_embeddings_hnsw ON tenant_abc.embeddings USING hnsw (embedding public.vector_cosine_ops) WITH (m 32, ef_construction 128); -- 查询时调整 ef_search SET hnsw.ef_search 100;注意hnsw.ef_search是会话级参数不是全局的。在连接池场景下每次从池里拿连接都要重新 SET否则可能用到默认值。我是在TenantAwareEmbeddingStore的search()方法里拿到连接后先执行SET LOCAL hnsw.ef_search 100LOCAL关键字保证只在当前事务生效不影响连接池里的其他请求。4.2 向量维度选择和 embedding 模型匹配pgvector 的vector类型需要指定维度建表时就要定死。Ollama 的nomic-embed-text是 768 维OpenAI 的text-embedding-3-small是 1536 维text-embedding-3-large是 3072 维。维度越高存储和计算开销越大但语义表达能力越强。我的选择是 768 维原因有三第一本地 Ollama 跑 768 维 embedding 在 CPU 上大约 50ms/条1536 维要 120ms批量索引时差距明显第二768 维在 10 万条规模下HNSW 索引大小约 320MB单机内存扛得住第三实测下来 768 维和 1536 维在中文技术文档检索上的召回率差距不到 2 个百分点性价比不高。但这里有个坑不同 embedding 模型的向量不能混用。我一开始想省事历史数据用 OpenAI 的 1536 维新数据用 Ollama 的 768 维结果检索时维度对不上直接报错。后来统一成 768 维历史数据全部重新 embedding 了一遍。所以建表前一定要定好模型中途换模型意味着全量重建。4.3 批量写入时的连接池和事务陷阱索引构建阶段要批量写入向量我一开始用单条 INSERT10 万条跑了 20 分钟。后来改成批量 INSERT每批 1000 条降到 3 分钟。但批量写入有个坑PostgreSQL 的max_allowed_packet和 JDBC 的reWriteBatchedInserts参数。// JDBC URL 加上 reWriteBatchedInsertstrue // jdbc:postgresql://localhost:5432/unrag?reWriteBatchedInsertstrue // 批量写入 String sql INSERT INTO tenant_abc.embeddings (content, metadata, embedding) VALUES (?, ?::jsonb, ?::public.vector); jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { Override public void setValues(PreparedStatement ps, int i) throws SQLException { ps.setString(1, contents.get(i)); ps.setString(2, metadataList.get(i)); ps.setObject(3, vectors.get(i).toString()); // 向量转字符串 } Override public int getBatchSize() { return contents.size(); } });reWriteBatchedInsertstrue会让 JDBC 把多条 INSERT 重写成一条多值 INSERT性能提升 3-5 倍。但注意这个参数在 PostgreSQL 9.5 以下不支持而且如果批量里有错误整批都会回滚。我的做法是每批 1000 条失败后降级为单条重试定位到具体哪条有问题。提示向量转字符串时pgvector 期望的格式是[0.1,0.2,0.3]不是 Java 的toString()默认格式。float[]的toString()会输出[F1a2b3c必须手动拼接。我写了个工具方法VectorUtils.toPgVector(float[] v)专门处理这个。5. 文档解析和分块RAG 效果的上限在这里5.1 为什么分块策略比模型选择更重要很多人搭 RAG 时把精力全花在选模型上但实际决定效果上限的是文档解析和分块。我做过对比实验同一批文档用同样的 embedding 模型和检索参数只改分块策略召回率从 0.65 到 0.93差距比换模型大得多。我的分块策略经历了三代第一代是固定长度分块每 500 字符一刀切。问题是经常把一句话切成两半或者把标题和正文分开检索出来的片段语义不完整。第二代是按段落分块遇到空行就切。好一些但技术文档里经常有长段落一个段落 2000 字embedding 后语义被稀释检索精度下降。第三代是递归字符分块 语义边界检测。优先按标题切其次按段落切再按句子切最后才按字符切。同时检测代码块、表格、列表这些结构保证它们不被切断。public class SemanticChunker { private static final int MAX_CHUNK_SIZE 800; private static final int OVERLAP 100; public ListTextSegment chunk(String text, MapString, Object metadata) { ListTextSegment segments new ArrayList(); // 先按 Markdown 标题切 String[] sections text.split((?^#{1,3}\\s)); for (String section : sections) { if (section.length() MAX_CHUNK_SIZE) { segments.add(TextSegment.from(section, metadata)); } else { // 超长段落按句子切带重叠 segments.addAll(splitBySentence(section, metadata)); } } return segments; } }OVERLAP设 100 字符是为了防止边界信息丢失。比如一个概念的定义跨了两个 chunk没有重叠的话两个 chunk 都只包含半截定义检索时都不完整。重叠让边界处的信息在两个 chunk 里都出现提高召回概率。5.2 图片和表格怎么处理热词里有人问 rag 知识库能存储图片嘛答案是能但要看怎么存。纯向量检索对图片无能为力因为 embedding 模型处理的是文本。我的方案是图片单独存储在文本 chunk 里插入图片的引用标记如检索到包含引用的 chunk 时把图片一起返回给前端展示。表格的处理更麻烦。Markdown 表格直接 embedding 效果很差因为表格的语义依赖行列结构线性化后信息丢失严重。我的做法是把表格转成自然语言描述再 embedding// 原始表格 // | 参数 | 默认值 | 说明 | // | m | 16 | 连接数 | // 转换后 // HNSW 索引参数 m 的默认值是 16说明是连接数。这个转换用 LLM 做最准但成本高。我写了个规则引擎处理常见的两列和三列表格复杂表格才调 LLM。实测下来规则引擎能覆盖 80% 的场景成本降低很多。5.3 增量更新和索引重建的取舍知识库不是一次性的文档会更新。全量重建索引最简单但 10 万条数据重建一次要 5 分钟期间检索服务不可用。我的方案是双缓冲索引维护embeddings_active和embeddings_building两张表增量更新写入building表更新完成后原子切换。-- 增量更新只处理变更的文档 INSERT INTO tenant_abc.embeddings_building (content, metadata, embedding) SELECT ... FROM new_documents; -- 切换在事务里完成 BEGIN; ALTER TABLE tenant_abc.embeddings RENAME TO embeddings_old; ALTER TABLE tenant_abc.embeddings_building RENAME TO embeddings; COMMIT; DROP TABLE tenant_abc.embeddings_old;这个方案的问题是内存占用翻倍因为两张表同时存在。对于 10 万条规模多占 320MB 内存可以接受。如果数据量更大可以考虑用 PostgreSQL 的分区表按时间分区只重建变更的分区。注意ALTER TABLE ... RENAME会获取排他锁切换瞬间的查询会阻塞。我的做法是在业务低峰期切换并且切换前先SET lock_timeout 5s避免长时间阻塞。6. 实测中的意外情况和排查记录6.1 租户 schema 切换导致的连接池污染上线后遇到一个诡异问题A 租户的查询偶尔会返回 B 租户的数据。排查了两天才定位到原因我在TenantAwareEmbeddingStore里用SET search_path切换 schema但连接池HikariCP复用连接时search_path是会话级参数上一个请求设置的search_path会保留到下一个请求。// 错误做法直接 SET污染连接池 jdbcTemplate.execute(SET search_path TO tenant_ tenantId); // 正确做法用 SET LOCAL事务结束自动恢复 jdbcTemplate.execute(SET LOCAL search_path TO tenant_ tenantId);SET LOCAL只在当前事务生效事务提交或回滚后自动恢复。但前提是每个请求都要在事务里执行我后来在 Service 层加了Transactional注解。这个坑的教训是连接池场景下任何会话级参数都要用 LOCAL 或显式重置。6.2 Ollama 并发调用时的模型加载抖动Ollama 默认只加载一个模型实例并发请求时会排队。我一开始用nomic-embed-text做 embeddingqwen2.5:7b做生成两个模型切换时 Ollama 要重新加载每次加载 3-5 秒。表现就是第一个请求很快第二个请求卡 5 秒第三个又很快。解决办法是设置OLLAMA_MAX_LOADED_MODELS2让 Ollama 同时保留两个模型。但内存占用翻倍7B 模型约 5GBembedding 模型约 500MB总共 5.5GB。如果内存不够可以改用更小的生成模型或者把 embedding 和生成拆到两个 Ollama 实例。# 启动 Ollama 时设置环境变量 OLLAMA_MAX_LOADED_MODELS2 OLLAMA_NUM_PARALLEL4 ollama serveOLLAMA_NUM_PARALLEL4允许每个模型同时处理 4 个请求吞吐量提升明显。但注意这个参数和OLLAMA_MAX_LOADED_MODELS是乘法关系4 并行 × 2 模型 8 个并发槽位内存要留够。6.3 中文分块时的标点符号陷阱中文分块时我按。切句子但遇到英文缩写就出问题。比如 使用 pgvector 的 HNSW 索引。m 参数默认 16。 按。切没问题但 版本号是 v1.2.3。下一个版本 会把 v1.2.3 里的点也当成句子边界。我的修复方案是用正则匹配中文标点并且要求标点后面跟空格或中文字符// 只匹配中文句号、问号、感叹号且后面是空白或中文 private static final Pattern SENTENCE_BOUNDARY Pattern.compile((?[。])(?\\s|[\\u4e00-\\u9fa5]));这个正则的意思是在中文标点之后且后面是空白或中文字符的位置切分。这样 v1.2.3 里的点不会被误切因为它是英文点且后面跟数字。7. 这套架构的边界和后续可以怎么扩展UniRAG 目前跑在单机 Docker 上支撑 10 个租户、每个租户 10 万条以内的向量规模P99 检索延迟在 100ms 左右。这个规模下 schema 隔离 HNSW 索引完全够用。但如果租户数涨到 100 个或者单租户数据量到百万级就需要考虑分片了。一个自然的扩展方向是按租户哈希分库租户 ID 哈希到不同的 PostgreSQL 实例每个实例承载 20-30 个租户。langchain4j 的EmbeddingStore抽象让这个切换很容易只需要在TenantAwareEmbeddingStore里根据租户 ID 选择不同的DataSource。另一个方向是引入 ontology 做结构化知识融合。热词里有人问 ontology rag 和 kg 知识库、rag 知识库和结构知识库区分这确实是 RAG 的进阶方向。纯向量检索擅长非结构化文本但对某产品的某个型号的某个参数这种结构化查询向量检索不如 SQL 精确。我的想法是在 RAG 前面加一层意图识别结构化查询走 SQL非结构化查询走向量检索两者结果融合后交给 LLM 生成。不过这些扩展我还没动手因为当前规模下收益不明显。我的原则是先跑通再优化不要为了架构而架构。多租户 RAG 最核心的隔离问题解决了剩下的都是锦上添花。最后分享一个我在实际使用中的小技巧给每个租户的检索结果加一个来源可信度权重。比如官方文档权重 1.0用户手册 0.8社区帖子 0.5。在 RRF 融合时把这个权重乘上去能明显提升答案质量。这个权重存在租户配置里不同租户可以自己调比全局统一参数灵活得多。