从你准备自己做企业级 AI 应用那天起多租户就是绕不过去的坎。我最近基于此前的知识库项目重构了一个内部框架起名 UniRAG核心就干一件事在统一的 RAG 服务里把几十个租户的数据、权限、检索、计费全部隔离干净同时保留对不同知识库类型的支持。这篇文章把 UniRAG 的设计过程和关键取舍完整摊开包括隔离粒度怎么选、向量库如何选型、知识库如何做路由、检索瓶颈怎么拆解以及我踩过的坑。如果你正在把 RAG 从一个 demo 变成一个多租户产品希望对你有点用。1. 明确问题多租户 RAG 到底难在哪1.1 从单租户到多租户变化的不只是接口先说说我最初的核心痛点。早年搭单体 RAG 很简单一个知识库、一个向量集合、一个检索接口全系统只有一个知识空间。可用在了真实的 B 端服务里客户 A 和客户 B 都要上传各自的业务文档都要基于各自的知识资产获得答案。这时候如果架构还停留在单租户的思路上哪怕只是照着 demo 多加几个库也会立刻出现灵魂拷问客户 A 的检索请求凭什么保证不命中客户 B 的文档客户 A 上传的文件被谁看到了算力成本和 token 成本到底该算在谁的头上所以多租户 RAG 的难点从来不是多接几个账号多加几张表而是隔离策略、数据链路设计、检索上下文约束这几件事。任何一步不到位线上必然出事故而且是客户能直接感知的那种事故——A 公司的销售问我们上一季度的报价单,模型居然回答了 B 公司的报价内容。这类事故一出现口碑就没了在 To B 场景里甚至可能触发保密争议。1.2 UniRAG 的定位自研而不是二次开发做 UniRAG 之前我认真评估过 dify 社区版 1.10。dify 的多租户能力确实在变强工作流编排、知识库管理都有现成的界面。但我最终放弃了二次开发原因有三第一dify 的重心是应用编排和对话体验而我需要的是一个偏底层的知识服务引擎要能嵌入到我们自己的业务后台和计费系统里第二dify 的隔离模型是工作空间级别的而我们需要更细粒度的隔离控制同一个租户下还要能区分部门、项目、甚至是单份文档的可见性第三多租户系统的很多核心行为比如元数据过滤、检索融合、图谱查询我需要能随时改逻辑、加策略而不是在庞大的开源框架里挖洞打补丁。所以 UniRAG 的定位很明确一个面向开发者的多租户 RAG 服务层。外部通过 REST API 暴露统一接口内部把租户隔离、知识库管理、文档处理、检索增强、权限过滤、成本统计都做进框架里。你可以称它为基于 PostgreSQL 向量引擎 对象存储自研的 RAG 中间层。一点建议如果你只是做个内部效率工具单体 RAG 完全够用但凡是给外部客户提供知识服务多租户隔离从第一天就要按线上标准设计别等数据出问题再重构。2. 整体架构设计与核心取舍2.1 三层架构接入层、编排层、存储层UniRAG 的整体架构我分成三层接入层负责租户鉴权、接口路由、请求限流。每个请求必须携带租户凭证凭证解析后注入请求上下文贯穿整个调用链。编排层这是整个系统的核心负责编排 RAG pipeline。包括知识库类型路由、文档解析、文本切分、向量化、检索召回、重排融合、大模型生成。每个环节都接收租户上下文确保每一步只操作当前租户的数据。存储层统一使用 PostgreSQL 作为主库存储租户信息、知识库元数据、文档元数据、切分块信息向量数据放在 PostgreSQL 的 pgvector 扩展中通过 JSONB 类型的元数据字段完成租户过滤原始文件存放在对象存储里数据库只存文件路径和哈希值。这个分层最大的好处是让租户上下文成为贯穿全链路的一等公民。我不会在业务代码里到处传 tenant_id而是把它放在一个请求级别的 context 对象里pipeline 的每个节点都能拿到。这样隔离逻辑就不是散落在各处的条件判断而是每一层都默认生效的基础约束。2.2 租户隔离方案的选择唯一的真问题设计多租户系统时第一个要拍的板就是隔离粒度。我列了三个可选方案共享向量库 元数据过滤所有租户的向量存在同一个 collection 或同一张表里每条向量带上 tenant_id 标签检索时通过过滤条件限定范围。独享 Collection / 独立分区每个租户拥有独立的向量集合或独立的分区键逻辑上互相隔离。独立实例每个租户部署一套完整的 RAG 服务包括独立存储、独立向量库、甚至独立模型配置。三个方案的取舍很直接。独立实例隔离性最强但运维成本是灾难性的几十个客户就意味着几十套待升级、待监控的服务更不要提硬件开销。独享 Collection 方案隔离性不错但建库和管理的复杂度随租户数量线性增长且单租户数据量很小时完全是资源浪费。共享库 元数据过滤的隔离性最弱需要更严谨的过滤逻辑但运维最简单、资源利用率最高。我的选择是折中的共享 PostgreSQL 和共享 pgvector 集合但强制使用租户感知的分区键 双层过滤。具体做法是在向量的元数据里同时写入 tenant_id 和 knowledge_base_id检索时 SQL 固定拼接WHERE tenant_id $1条件这属于硬过滤逻辑不管上层怎么传参数底层都会校验。同时为了避免业界常见的那种filter 失效导致数据串库问题我会在服务层面额外做一次校验检索结果返回后逐条检查结果的 tenant_id 是否与当前请求上下文一致不一致直接丢弃并记录错误日志。双保险的思路不算优雅但在多租户这种容不得数据泄露的场景里防御纵深比代码简洁更有价值。2.3 为什么选 PostgreSQL 当主库而不是 MongoDB有人可能会问RAG 系统的非结构化信息很多为什么不用 MongoDB我的看法是MongoDB 在文档存储上确实有优势但 UniRAG 的元数据结构其实非常规整租户表、知识库表、文档表、切分块表字段固定、关系明确。用 PostgreSQL 做主库配合 JSONB 字段存储可变的元数据既能享受关系型事务和复杂查询的能力又能保留一定的灵活性。另外pgvector 直接挂在 PostgreSQL 上意味着向量数据和关系数据不需要跨数据库同步备份、事务、权限体系都是统一的。对中小规模的多租户 RAG 来说这比MongoDB 存文档 Milvus 存向量 Redis 做缓存这套多组件组合更容易维护。不过这里有个代价PostgreSQL pgvector 的向量检索能力在千万级以上规模时不如专用向量数据库表现好。我在设计 UniRAG 时对这个点做了妥协先把单租户的数据规模上限控制在 200 万向量以下超过这个量级的用户后续再提供专属实例选项。这个设计也体现了 UniRAG 的核心取舍——绝大多数租户用不到海量向量检索的能力与其一开始就把架构搞得极其复杂不如先把 90% 的场景做扎实。3. 知识对象设计向量库、结构化库、图谱库怎么共存3.1 三种知识库的本质区别别再混为一谈热搜里有个词条特别典型kg知识库、rag知识库和结构知识库区分以及应用场景。这个问题我在实际设计 UniRAG 知识对象模型时也认真梳理过。这三类知识库名称相近本质上完全不同带来的检索策略和适用场景差异极大。RAG 向量知识库面向非结构化文本比如 PDF、Word、网页内容。把文本切块后用 embedding 模型向量化检索时做语义相似度匹配。优势是处理海量文档效率高劣势是对精确数值、规范性名词、多跳推理的支撑较弱。结构化知识库面向表格、数据库、Excel 这类有明确 schema 的数据。查询走 SQL靠条件和聚合直接拿到精确结果。优势是准确、可控劣势是灵活性低没法处理意思相近但表达不同的自然语言问题。图谱知识库KG面向实体关系数据。核心是节点、边、属性和多跳关系。比如某产品属于某事业部事业部负责人是谁该负责人管辖哪些客户。图谱适合回答关系型、路径型问题在 ontology 建模之后还能做更深层的逻辑推理。强调一点这三者不是互相排斥的。一个真正的业务问题往往需要混合检索才能回答得更好。举个例子客户问华东区去年合同总额超过 500 万的客户有哪些他们的主营业务是什么这样的问题如果只有向量知识库模型只能从文档里模糊拼凑但如果在 UniRAG 里同时配置了结构化知识库合同表和图谱知识库客户-行业关系就可以先用结构化查询锁定客户名单再由图谱补齐行业信息最后用向量知识库补充每个客户的公开背景三层结果一起送给大模型。3.2 UniRAG 的知识库路由设计为了让三种知识库共存并协同工作我设计了一个知识库路由模块。每个知识库在创建时就要声明类型vector、structured、graph也可以声明为hybrid。当用户提问进来路由模块会做一次意图预判规则大致是问题包含明显的筛选条件、聚合词如超过平均名单时路由到结构化知识库为主问题包含明显的实体关系词如谁负责属于哪个和谁有关时路由到图谱为主其余问题走向量知识库。意图预判不可能完美所以我在设计上留了后手每条检索结果都附带来源类型标签如果结构化查询返回为空而向量检索命中率很高系统会把来源权重自动调过去。这个动态路由 兜底的组合在真实场景里比单纯的意图分类器稳定得多。3.3 图谱值得上吗ontology RAG 的务实边界热词里还有一个 ontology rag我的理解是带本体建模的图谱增强检索。关于图谱我持冷静态度。图谱知识库的建模成本非常高且不是所有问题都需要多跳推理。如果一个知识库的内容本来就是独立的 FAQ 文档强行建图只会让 pipeline 变得臃肿还拖慢响应速度。UniRAG 对图谱的使用是有边界的只有当租户的数据本身具备明显的实体关联结构时才推荐启用图谱模式。典型场景包括组织架构类数据部门、负责人、下属员工、产品体系类数据产品线、SKU、事业部、上下游关系类数据供应商、客户、合同。在这些场景下图谱查询相比向量检索有压倒性优势因为它能做确定性的路径遍历而不是靠文本相似度猜。但如果您的场景就是一堆手册文档做问答那不用建图谱向量知识库足矣。4. 核心数据链路从上传到检索的实现细节4.1 文件解析与清洗脏数据是效果杀手多租户 RAG 系统里每个租户上传的文件格式千奇百怪PDF 里可能有扫描图片Word 里可能嵌套了表格Excel 可能有多级 Sheet。UniRAG 的解析流程在进入切分之前先做一次清洗这一步非常关键但很多 RAG 项目恰恰会忽略。文件解析后的清洗包含三件事统一转码所有文本统一转成 UTF-8避免中文乱码去噪剔除页眉页脚、重复的目录页、水印文字、大段空行结构保留对表格和列表保留原有的结构化标记例如用竖线分隔符保留表格行不要让切分器把表格撕裂。以 PDF 为例我遇到过一个真实案例客户上传的产品手册里有 30 多页全是产品参数表格如果直接按文本流切分每条 chunk 里参数表都被切得七零八落检索时一问额定电压是多少模型拿到的是半截表格给出的答案自然不稳定。后来我要求解析器识别表格区域在切分前把表格单独提取成结构化块效果立刻就稳了。4.2 切分策略RAG 瓶颈的第一来源切分是 RAG 系统被吐槽最多的环节之一也是热词rag瓶颈里最容易忽略的根因。切分切不好再强的 embedding 模型和再强的生成模型都救不回来。UniRAG 里我实践过三种切分策略。固定窗口切分设定 chunk_size 为 512 字符、overlap 为 64 字符。实现简单但对语义边界的破坏你没法控制经常一个完整的段落被腰斩。语义切分按标题、段落、句子边界切分。缺点是依赖文档本身的排版质量碰到排版混乱的文档时表现差。父子分块这是最终主力方案。大块父块保留完整语义小块子块精确检索。检索时先在子块里找出得分最高的候选再映射回父块把整个父块内容作为上下文送给生成模型。这样既保证了检索精度又保证了上下文完整性。如果只是简单场景切分可以直接先用固定窗口。但一旦你的系统用于生产强烈建议一步到位用父子分块。它的实现成本并不高本质上就是做两张表一张存父块一张存子块并记录父块 ID检索阶段多一次 join 而已。但带来的效果提升绝对值回票价。4.3 元数据与索引隔离的第一道防线UniRAG 在 pgvector 表里的向量字段结构大致是这个样子CREATE TABLE doc_chunks ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, kb_id BIGINT NOT NULL, doc_id BIGINT NOT NULL, parent_chunk_id BIGINT, chunk_type VARCHAR(16), content TEXT, meta JSONB NOT NULL DEFAULT {}, vector vector(768) ); CREATE INDEX idx_chunks_tenant ON doc_chunks (tenant_id, kb_id); CREATE INDEX idx_chunks_meta ON doc_chunks USING GIN (meta);检索时我要求所有查询必须带上tenant_id条件results await vector_store.query( embeddingquery_embedding, filter{ tenant_id: context.tenant_id, kb_id: knowledge_base_id, }, top_k20, )思路很简单但我要提醒一个容易被忽略的细节meta 字段里的 tenant_id 必须是后端注入的不能相信客户端传来的任何参数。比如客户端可能传一个filters参数来限定检索范围如果你不审核就把这个 filter 拼进查询恶意用户完全可以传一个tenant_id999把别人的数据检索出来。这是我在安全评审时坚持的底线租户信息只允许来自鉴权 token 解析业务接口的 filter 参数里即使出现了 tenant_id服务端也要覆盖为当前上下文的值而不是直接使用。5. 检索与生成突破召回瓶颈的组合拳5.1 纯向量检索的局限如果你只在向量知识库里跑语义检索很快会碰到瓶颈。典型问题有三类专有名词召回差比如光刻机双工件台这类精确术语embedding 模型可能把它拆成模糊的语义导致召回文档不精准。数字与精确条件失灵用户问合同金额大于 500 万的有哪些向量检索对这种精确数字条件基本无能为力。短查询/长文档的语义鸿沟用户输入很短的关键词而文档是很长的段落向量相似度可能拉不高导致排序太靠后。所以我在 UniRAG 里给向量召回叠了一层 hybrid 检索而不是只靠向量。5.2 混合检索与 RRF 融合UniRAG 的检索链路是BM25 关键词检索 向量语义检索 RRF 排序融合。我不直接对两路得分做加权平均而是使用 RRFReciprocal Rank Fusion算法把两路结果的排名转换成倒数分数再相加避开了不同检索方式得分分布不一致的问题。RRF 的好处是稳定、无需调权重且实现不过十几行代码。融合逻辑大致如下def rrf_fusion(vec_results: list, bm25_results: list, k: int 60) - list: scores: dict {} for rank, item in enumerate(vec_results): scores[item.id] scores.get(item.id, 0) 1 / (k rank) for rank, item in enumerate(bm25_results): scores[item.id] scores.get(item.id, 0) 1 / (k rank) return sorted(scores.items(), keylambda x: x[1], reverseTrue)这层融合的效果在生产环境里非常明显。以我们客户上传的混合型文档包含技术参数、销售话术、常见问题为例纯向量检索的命中的准确率大约在 70%加上混合检索后实测对话中答非所问率下降了约 15%。如果您也已经跑通了基础 RAG我强烈建议升级一下检索链路这个改动的性价比很高。5.3 重排与生成长度的取舍是否上重排模型我的取舍是对高价值租户或者意图复杂的问题启用对高频低难度问题直接跳过。原因很简单重排模型每一次调用都会增加大约 0.3 到 0.8 秒的延迟而且本地小参数重排模型的效果并不一定比 RRF 融合好多少。UniRAG 的做法是把重排配置做成知识库级参数由租户按自己的场景选择。默认情况下系统先走混合检索 RRF如果评测显示回答质量不达标再打开重排选项而不是一上来就把性能搞砸。生成端的策略也有取舍。我把送入大模型的上下文限制控制在 8 个语块以内也就是大约 4000 到 5000 token 的规模。不要一个劲儿地往提示词里塞检索块上下文越长模型注意力越容易被分散反而更容易出幻觉和废话。我实测下来8 个语块以内时答案相关度最高超过 12 个语块后答案质量开始出现回落。这和信息太多等于信息污染的道理一致。6. 部署、性能与成本实测6.1 部署形态与组件清单UniRAG 的部署形态我尽量控制在单机可跑、集群可扩。一台 16 核 64G 的服务器上可以同时跑 PostgreSQL、pgvector、对象存储用 MinIO 或云厂商 OSS、嵌入模型服务本地部署小型 embedding 模型或调用外部 API、UniRAG 服务本身。全部采用 Docker Compose 编排一键启动。生产环境建议拆成三组一组跑 PostgreSQL 主从、一组跑向量检索集群如果需要更高吞吐、一组跑 API Worker。UniRAG 服务是无状态的任何节点都可以被随时摘除和扩容所以扩并发时只加 worker 就行不需要改业务代码。6.2 性能实测数据说一组我这边的实测数据在 2 万份文档、约 120 万个向量块的规模下单租户的写入吞吐大约是每秒 120 个 chunk全文索引写入耗时可接受检索链路混合检索 RRF 融合的平均延迟在 120 到 180 毫秒加上大模型生成时间一次完整问答的 P95 延迟稳定在 2.8 秒左右。这个指标基本可以满足常见客服问答和内部知识助手的体验要求。并发方面我用 200 个并发请求压过单节点数据库连接池和 pgvector 的查询能力在压测中能稳定支撑CPU 保持在 80% 以下。超过这个并发阈值时瓶颈首先出现在 embedding 服务的吞吐上所以如果你预期会有大量并发建议优先对 embedding 服务做水平扩展。6.3 成本优化的三个角度多租户系统不仅要算总账更要把成本核算到租户维度。UniRAG 的成本控制做了三件事embedding 批处理文档入库时如果逐条调用 embedding API成本高且慢。我把待向量化的文本攒成 64 条一组的批次统一调用既降低 API 调用次数也提高吞吐。在同等数据量下这一步能让向量化成本直接下降约 40%。缓存高频向量化结果对短文本和频繁访问的文档块做 embedding 缓存。同一个文档被重复上传到不同知识库时直接命中缓存不重复计费。检索结果缓存对高频问题的检索结果做短时缓存默认 TTL 为 60 秒到期自动失效。客服场景里同一批问题经常被反复询问缓存能显著降低 Embedding 调用和向量查询的压力。以上优化都能在代码层落地属于多租户 RAG 系统里只赚不亏的基础操作。7. 常见问题排查与避坑实录7.1 串数据怎么查到了别人的文档这是多租户系统里最严重的事故。排查链路从三个方向入手。第一检查查询 SQL 是否真的带上了 tenant_id 条件这看着简单但在 ORM 拼接复杂查询时非常容易漏条件。第二检查向量库的 filter 是否被后续代码覆盖。第三检查是否涉及跨库关联查询比如 join 的时候把两个租户的数据混在一起。我们的经验是在数据库层面创建视图时就把 tenant_id 当作默认过滤条件业务层只查询视图让数据库层的强制约束兜底。7.2 检索结果为空或答非所问检索结果为空八成是切分块太碎导致查询和任何一块都对不上或者元数据过滤条件太严格。答非所问则通常是上下文不足或检索到了不相关的块。我的排查顺序是先看检索结果的相关性再做查询改写。UniRAG 里加了一个自动查询扩写模块。用户问去年华为的销售额是多少如果检索不到直接信息系统会改写为华为 2023 年营收数据重新检索。这个改写逻辑可以由大模型驱动但为了稳定性和成本我优先使用规则和同义词表大模型扩写只作为 fallback。7.3 分块与检索效果反复横跳这是调优过程中最折磨人的阶段。我会告诉你至少先把 chunk_size 和 overlap 一变再在固定测试集上跑 20 个典型问题得到基线然后换成父子分块在同样的测试集上跑一遍。在实践中效果反复横跳通常不是参数问题而是文档本身的结构问题也就是文档原本排版混乱或者语义粒度不一致。与其反复调参不如先做文档清洗和结构重建。7.4 图谱查询不生效很多人在设计图谱时踩的一个坑是实体别名没有归一化。比如客户导入的合同里写华为另一些文档里写华为技术有限公司图谱里就会出现两个节点查询华为的合作伙伴有哪些时只查到一半数据。UniRAG 在处理图谱导入时会做实体归一化通过别名表把不同写法映射到同一个实体 ID。这是图谱查询不生效时最先要检查的地方。其次是检查建模粒度关系太细会导致查询路径复杂关系太粗又会让多跳推理失效建模粒度需要根据实际查询语料反推。7.5 检索整体变慢如果整体检索变慢最常见的根因不是向量检索本身而是 metadata 过滤条件没法走索引。比如你把租户 ID 存成了 JSONB 里面的一个普通字段却没有对这个字段建 GIN 索引那么每次检索都要扫描整个集合后做过滤性能自然下滑。解决方式有两个一是在建表时对 JSONB 建 GIN 索引二是把 tenant_id 单独提取成独立列并且建普通 B-tree 索引。后者效果更直接也是我在 UniRAG 里推荐的首选方案。另一个可能因素是 embedding 服务变慢这个需要通过监控指标去确认。最后说点实在的做多租户 RAG 系统真正难的不是某个单一算法而是隔离思维要贯穿全链路。从鉴权解析、元数据注入、SQL 拼接、向量过滤、图谱查询到返回结果校验每一环都必须意识到这笔数据属于谁。UniRAG 的取舍也体现了这一点与其追求顶级检索指标不如先把隔离性和稳定性做到位。对于大多数场景而言稳定不出事故、能按租户算清成本比多几个点的准确率重要得多。如果你也想自己搭一个类似框架我的建议是别一上来就堆技术栈。先想清楚隔离方案、知识库类型路由、检索融合这三个核心问题技术选型自然迎刃而解。先把主链路跑通再加上图谱、重排、缓存这些增强功能你会发现一个多租户 RAG 系统并没有想象中那么复杂但需要保持耐心和专注。
阅读完成 · 觉得有帮助?