用 Spring AI + PGVector 构建多租户 RAG 知识库:权限隔离、引用溯源与评测实战 本文定位Java 后端实战 / RAG 工程化 / 多租户安全示例环境Java 21、Spring Boot 3.3、Spring AI 1.0.x、PostgreSQL 16、pgvector 0.7.x。版本号用于复现实验实际项目上线前应锁定依赖并重新验证 API。摘要很多 RAG 教程只演示“文档切分—向量化—相似度检索—生成回答”四个步骤真正接入企业系统后最容易出问题的却是租户隔离、权限过滤、文档版本、引用溯源和效果评测。一个知识库回答得越准确如果它把 A 公司文档返回给 B 公司风险反而越大。本文以 Java 后端视角设计一个可落地的多租户 RAG。重点不是把模型 API 调通而是把租户身份、文档归属、检索条件、引用信息和评测数据串成一条闭环链路。文章给出表结构、核心代码、查询策略、验证用例和上线前的边界说明读者可以据此改造成内部制度问答、售后知识库或故障诊断助手。一、先定义问题知识库为什么不能只做“向量搜索”一个最小 RAG 的伪流程通常是接受问题生成 Query Embedding查询最相似的几个 Chunk把文本拼进 Prompt再让大模型回答。这个流程在单用户 Demo 中成立但企业场景至少多出六个约束请求必须绑定可信的tenantId不能让前端通过参数自由指定租户。用户只能检索自己有权限访问的文档部门权限和个人权限需要在数据库层过滤。同一份文档可能有多个版本旧版本不能因为向量相似度高而被召回。回答要能指出依据来自哪份文档、哪一页、哪一段方便复核和追责。检索与生成的效果需要有离线评测集不能只凭“感觉变好了”。数据库、Embedding 服务或大模型不可用时需要给出可理解的降级结果。因此本文的目标不是“让模型知道更多”而是让系统在正确的租户边界内找到可验证的依据。二、总体架构与数据流用户请求认证与租户上下文问题改写/意图识别带权限条件的混合检索上下文裁剪与引用编号大模型生成结构化输出校验回答与引用检索日志用户反馈与离线评测集建议把链路拆成五个边界身份边界从 JWT、网关或服务间令牌中解析租户不信任请求体里的租户字段。数据边界Chunk 记录租户、文档、版本、可见范围和删除状态。检索边界向量相似度只是排序信号权限和状态必须是过滤条件。生成边界Prompt 明确“只能依据上下文回答”并要求输出引用编号。审计边界保存请求摘要、召回文档 ID、Prompt 版本、模型版本、校验结果和反馈。三、表结构设计把权限字段放进可检索的数据行生产系统不建议把权限判断放在检索之后再过滤。原因是“先召回、后过滤”可能导致上下文不足也可能在日志、缓存或调试接口里意外暴露越权文档。更稳妥的方式是让过滤条件参与数据库查询。CREATEEXTENSIONIFNOTEXISTSvector;CREATETABLEkb_document(id bigserialPRIMARYKEY,tenant_idvarchar(64)NOTNULL,document_keyvarchar(128)NOTNULL,version_nointegerNOTNULL,titlevarchar(256)NOTNULL,source_uritextNOTNULL,content_hashvarchar(64)NOTNULL,statusvarchar(32)NOTNULL,created_at timestamptzNOTNULLDEFAULTnow(),UNIQUE(tenant_id,document_key,version_no));CREATETABLEkb_chunk(id bigserialPRIMARYKEY,tenant_idvarchar(64)NOTNULL,document_idbigintNOTNULLREFERENCESkb_document(id),chunk_nointegerNOTNULL,page_nointeger,contenttextNOTNULL,embedding vector(1536)NOTNULL,acl_group_idstext[]NOTNULLDEFAULT{},activebooleanNOTNULLDEFAULTtrue,metadata jsonbNOTNULLDEFAULT{});CREATEINDEXidx_kb_chunk_tenant_activeONkb_chunk(tenant_id,active);CREATEINDEXidx_kb_chunk_embeddingONkb_chunkUSINGhnsw(embedding vector_cosine_ops);tenant_id出现在 Document 和 Chunk 两张表中看起来有冗余但它能让检索 SQL 直接完成租户过滤避免每一次查询都依赖复杂 Join。content_hash用来实现幂等导入同一个文件重复上传时系统可以识别为同一版本而不是产生两份相同向量。四、文档入库解析、切分、向量化必须可重跑入库不是一次性脚本而应该是一个可以暂停、重试和继续执行的任务。建议任务状态至少包含PENDING、PROCESSING、SUCCESS、FAILED四种状态并记录失败原因与重试次数。publicrecordDocumentContext(StringtenantId,longdocumentId,StringdocumentKey,intversion,SetStringgroupIds){}publicrecordChunkDraft(intchunkNo,IntegerpageNo,Stringcontent,MapString,Objectmetadata){}publicListKbChunkbuildChunks(DocumentContextctx,ListChunkDraftdrafts,Listfloat[]vectors){if(drafts.size()!vectors.size()){thrownewIllegalArgumentException(文档片段与向量数量不一致);}returnIntStream.range(0,drafts.size()).mapToObj(i-{ChunkDraftdraftdrafts.get(i);returnnewKbChunk(ctx.tenantId(),ctx.documentId(),draft.chunkNo(),draft.pageNo(),draft.content(),vectors.get(i),ctx.groupIds(),true,draft.metadata());}).toList();}切分策略不能只写一个固定长度数字。制度文档、故障手册和代码文档的结构不同制度文档应尽量保留条款编号故障手册要保留“现象—原因—处理步骤”闭环代码文档则不应把方法签名和参数说明拆开。实际项目可先按标题、段落、表格和页码分层再在单个语义单元过长时进行二次切分。一个实用的切分检查表如下检查项建议失败表现语义完整性每个 Chunk 能独立说明一个小问题召回后只有半句话重叠长度约 10%20%按语义边界调整上下文重复、成本上升元数据保存标题、页码、章节、版本、权限无法引用和追责版本状态只检索当前有效版本旧制度被回答幂等键租户 文档键 版本 Chunk 序号重复向量、结果漂移五、检索权限是过滤条件不是提示词假设用户属于group_a请求上下文已经由认证层构造完成检索 SQL 可以写成下面的形式。真正项目中还需要防止数组字段过大并根据数据量评估是否拆成关联表。SELECTc.id,c.document_id,d.title,d.version_no,c.page_no,c.content,1-(c.embedding:query_embedding)ASsimilarityFROMkb_chunk cJOINkb_document dONd.idc.document_idWHEREc.tenant_id:tenant_idANDc.activetrueANDd.statusPUBLISHEDANDc.acl_group_idsCAST(:group_idsAStext[])ORDERBYc.embedding:query_embeddingLIMIT:candidate_limit;不要把“该用户只能看某些文档”写进 Prompt让模型自己判断权限。Prompt 不是安全边界模型可能复述上下文中的内容也不能保证它始终遵守权限规则。权限必须在数据访问层完成Prompt 只负责约束回答方式。如果召回结果质量不稳定可以采用“关键词 向量”的混合检索关键词负责精确命中编号、错误码和产品型号向量负责处理自然语言表达。初始候选集可以取 3050 条再通过 Rerank 选择 58 条进入上下文。候选集不要无限扩大因为噪声越多模型越容易把相近但不适用的内容混在一起。六、生成与引用让回答能够被复核建议把上下文编号并要求模型返回结构化结果。下面的 Prompt 不是安全机制但有助于让回答更稳定你是企业知识库助手。只能依据 CONTEXT 中的内容回答。 如果 CONTEXT 没有足够依据必须回答“当前知识库没有足够信息”不得凭常识补全。 每个关键结论后附 [C#] 引用编号如果多个结论来自不同片段分别引用。 不要输出任何租户、权限组或系统内部字段。 CONTEXT: [C1] 文档设备告警手册版本3页码18 内容当错误码 E102 连续出现三次时先检查采集链路再重启采集服务。生成后还要做一次程序校验引用编号是否存在、是否属于本次召回集合、回答是否超过长度限制、是否出现禁止字段。校验失败时可以重试一次但不要无限重试第二次仍失败应降级为“展示检索证据 请人工确认”。publicAnswervalidate(Answeranswer,SetStringvalidCitationIds){if(answer.text()null||answer.text().isBlank()){returnAnswer.needsHuman(模型没有返回有效回答);}if(!validCitationIds.containsAll(answer.citationIds())){returnAnswer.needsHuman(回答包含无效引用);}if(answer.text().length()4000){returnAnswer.needsHuman(回答超过安全长度需要人工确认);}returnanswer;}七、如何评测不要只看“回答像不像人说的”建议先建立 50200 条小规模评测集每条包含问题、标准答案要点、应该引用的文档、禁止引用的租户文档、是否允许“无法回答”。评测至少分为四组命中测试问题能否召回正确文档。组合测试答案是否能综合多个 Chunk而不是只复述一条。拒答测试知识库没有答案时是否拒绝编造。越权测试改变用户租户和权限组后结果是否仍然隔离。可以记录RecallK、引用准确率、拒答准确率、越权拦截率、平均延迟和单次成本。一个低成本的回归脚本可以把每次模型或切分策略变化前后的指标做成 CSV放进 CI 里。当引用准确率下降超过阈值时不允许直接发布。指标含义初始目标示例Recall5正确证据是否出现在前 5 条≥ 0.85Citation Precision引用是否真正支持结论≥ 0.90拒答准确率无依据时能否拒答≥ 0.95越权拦截率非法文档是否全部不出现100%P95 延迟高峰请求的尾延迟按业务设定这些目标不是通用标准而是项目初始门槛。不同业务的错误代价不同故障诊断、财务制度和客服问答不能使用同一套阈值。八、性能、成本与故障降级第一版不要急着引入复杂缓存。先分别记录 Embedding、数据库检索、Rerank、模型生成四个阶段的耗时和 Token 数再决定优化位置。常见优化顺序是减少无效上下文、限制最大候选数、对相同问题做短期缓存、采用流式输出、最后再考虑更换模型。故障降级建议按用户可理解的方式设计Embedding 服务超时提示“暂时无法完成语义检索”可尝试关键词检索。向量库不可用不允许直接让模型凭空回答可返回服务不可用和工单入口。大模型超时保留已召回的引用证据提供“仅查看资料”模式。引用校验失败转人工而不是把未校验文本当作正常答案。所有降级都要写入日志并带上traceId、租户、请求类型和失败阶段但不要记录完整敏感文档。日志脱敏和访问控制同样属于 RAG 的安全边界。九、常见错误与改进方式1. 只在前端传 tenantId前端参数可以被修改必须以认证令牌中的租户身份为准并在服务端拒绝不一致的字段。2. 先查 20 条再在 Java 代码里过滤权限这会造成结果不足也可能让调试日志先拿到越权内容。权限条件应尽量下推到 SQL 或检索引擎。3. 所有文档统一切成 500 字固定长度方便实现但会破坏标题、表格和步骤关系。应该先识别文档结构再按最大 Token 数做兜底。4. 把所有召回结果都塞进 Prompt大上下文不等于高质量。噪声会增加成本也会让模型错配依据。保留少量高相关候选并让引用可校验。5. 没有“无法回答”样本没有拒答样本系统会被鼓励“总要给答案”。评测集一定要包含知识库外问题和相似但不适用的问题。十、总结多租户 RAG 的核心不是向量数据库而是权限、版本、引用和评测组成的工程闭环。Spring AI 可以帮助 Java 应用接入模型和向量检索但不能替代业务系统的身份认证、数据隔离、审计和降级设计。如果只记住三句话第一租户权限必须进入检索条件第二模型回答必须携带可验证引用第三任何效果优化都要通过评测集和指标验证。做到这三点知识库才从“能回答”走向“敢上线”。读者讨论你的知识库更关心“多租户权限隔离”还是更关心“召回准确率和回答速度”如果已经遇到过越权、旧版本命中或引用不准确的问题欢迎把现象和数据整理出来继续讨论。