MQ第10篇:RAG与消息队列实战:Java构建AI客服系统的检索增强生成完整架构

摘要:本文深入RAG(检索增强生成)的工程实现,用Spring AI的VectorStore + RetrievalAugmentationAdvisor + RocketMQ构建完整的AI客服系统架构。从"不是让AI记住所有知识,而是开卷考试"的核心原理讲起,拆解知识库异步更新管道和用户请求异步处理链路,用RocketMQ LiteTopic实现多租户SaaS下的会话级隔离。全文配架构图、时序图、可直接运行的Spring Boot代码,以及5个生产环境必知的核心决策点。适合正在构建企业级AI客服、智能问答系统的Java开发者。

一、从一个AI客服"胡说八道"的事故说起

先讲一个真实的场景。某SaaS企业的AI客服上线后,客户问"我的订单为什么还没发货",AI回复:"根据我们的政策,订单将在3-5个工作日内发货,感谢您的耐心等待。"

回答看起来很专业,但问题是------用户根本没有下过订单。AI完全在"幻觉"编造。

这不是模型能力不够,而是模型根本不知道你的业务数据。GPT-4、DeepSeek这些大模型的训练数据来自公开互联网,它们不知道你的商品信息、你的订单状态、你的售后政策。当用户问一个模型没有见过的问题时,模型会用"看起来合理"的方式编造一个答案。

解决这个问题的核心技术就是RAG------检索增强生成。它的核心思想可以用一句话概括:不是让AI记住所有知识,而是让AI"开卷考试" 。用户提问时,先从你的知识库中检索相关文档,然后把问题和检索到的文档一起交给大模型,让它基于真实资料生成回答。
#mermaid-svg-UEmzmjPWn45gJJu7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UEmzmjPWn45gJJu7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UEmzmjPWn45gJJu7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UEmzmjPWn45gJJu7 .error-icon{fill:#552222;}#mermaid-svg-UEmzmjPWn45gJJu7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UEmzmjPWn45gJJu7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UEmzmjPWn45gJJu7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UEmzmjPWn45gJJu7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UEmzmjPWn45gJJu7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UEmzmjPWn45gJJu7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UEmzmjPWn45gJJu7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UEmzmjPWn45gJJu7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UEmzmjPWn45gJJu7 .marker.cross{stroke:#333333;}#mermaid-svg-UEmzmjPWn45gJJu7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UEmzmjPWn45gJJu7 p{margin:0;}#mermaid-svg-UEmzmjPWn45gJJu7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UEmzmjPWn45gJJu7 .cluster-label text{fill:#333;}#mermaid-svg-UEmzmjPWn45gJJu7 .cluster-label span{color:#333;}#mermaid-svg-UEmzmjPWn45gJJu7 .cluster-label span p{background-color:transparent;}#mermaid-svg-UEmzmjPWn45gJJu7 .label text,#mermaid-svg-UEmzmjPWn45gJJu7 span{fill:#333;color:#333;}#mermaid-svg-UEmzmjPWn45gJJu7 .node rect,#mermaid-svg-UEmzmjPWn45gJJu7 .node circle,#mermaid-svg-UEmzmjPWn45gJJu7 .node ellipse,#mermaid-svg-UEmzmjPWn45gJJu7 .node polygon,#mermaid-svg-UEmzmjPWn45gJJu7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UEmzmjPWn45gJJu7 .rough-node .label text,#mermaid-svg-UEmzmjPWn45gJJu7 .node .label text,#mermaid-svg-UEmzmjPWn45gJJu7 .image-shape .label,#mermaid-svg-UEmzmjPWn45gJJu7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-UEmzmjPWn45gJJu7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UEmzmjPWn45gJJu7 .rough-node .label,#mermaid-svg-UEmzmjPWn45gJJu7 .node .label,#mermaid-svg-UEmzmjPWn45gJJu7 .image-shape .label,#mermaid-svg-UEmzmjPWn45gJJu7 .icon-shape .label{text-align:center;}#mermaid-svg-UEmzmjPWn45gJJu7 .node.clickable{cursor:pointer;}#mermaid-svg-UEmzmjPWn45gJJu7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UEmzmjPWn45gJJu7 .arrowheadPath{fill:#333333;}#mermaid-svg-UEmzmjPWn45gJJu7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UEmzmjPWn45gJJu7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UEmzmjPWn45gJJu7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UEmzmjPWn45gJJu7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UEmzmjPWn45gJJu7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UEmzmjPWn45gJJu7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UEmzmjPWn45gJJu7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UEmzmjPWn45gJJu7 .cluster text{fill:#333;}#mermaid-svg-UEmzmjPWn45gJJu7 .cluster span{color:#333;}#mermaid-svg-UEmzmjPWn45gJJu7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UEmzmjPWn45gJJu7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UEmzmjPWn45gJJu7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-UEmzmjPWn45gJJu7 .icon-shape,#mermaid-svg-UEmzmjPWn45gJJu7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UEmzmjPWn45gJJu7 .icon-shape p,#mermaid-svg-UEmzmjPWn45gJJu7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UEmzmjPWn45gJJu7 .icon-shape .label rect,#mermaid-svg-UEmzmjPWn45gJJu7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UEmzmjPWn45gJJu7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UEmzmjPWn45gJJu7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UEmzmjPWn45gJJu7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 有RAG架构
相关文档
基于事实
用户提问
检索知识库
大模型
准确回答
无RAG架构
幻觉编造
用户提问
大模型
错误回答

但RAG的工程实现远不止"检索+生成"两个步骤。在SaaS客服系统中,你还需要解决:知识库文档的异步更新(新商品上架、政策变更)、多租户知识库的严格隔离、检索和生成两个耗时环节的异步化。这些正是消息队列的用武之地。

二、RAG核心原理与Spring AI模块化架构

2.1 RAG的完整流程

RAG系统的检索质量直接决定生成质量。下图展示了用户请求从进入到返回的完整链路:
#mermaid-svg-qVQKENnQhvDhSz20{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qVQKENnQhvDhSz20 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qVQKENnQhvDhSz20 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qVQKENnQhvDhSz20 .error-icon{fill:#552222;}#mermaid-svg-qVQKENnQhvDhSz20 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qVQKENnQhvDhSz20 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qVQKENnQhvDhSz20 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qVQKENnQhvDhSz20 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qVQKENnQhvDhSz20 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qVQKENnQhvDhSz20 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qVQKENnQhvDhSz20 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qVQKENnQhvDhSz20 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qVQKENnQhvDhSz20 .marker.cross{stroke:#333333;}#mermaid-svg-qVQKENnQhvDhSz20 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qVQKENnQhvDhSz20 p{margin:0;}#mermaid-svg-qVQKENnQhvDhSz20 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qVQKENnQhvDhSz20 .cluster-label text{fill:#333;}#mermaid-svg-qVQKENnQhvDhSz20 .cluster-label span{color:#333;}#mermaid-svg-qVQKENnQhvDhSz20 .cluster-label span p{background-color:transparent;}#mermaid-svg-qVQKENnQhvDhSz20 .label text,#mermaid-svg-qVQKENnQhvDhSz20 span{fill:#333;color:#333;}#mermaid-svg-qVQKENnQhvDhSz20 .node rect,#mermaid-svg-qVQKENnQhvDhSz20 .node circle,#mermaid-svg-qVQKENnQhvDhSz20 .node ellipse,#mermaid-svg-qVQKENnQhvDhSz20 .node polygon,#mermaid-svg-qVQKENnQhvDhSz20 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qVQKENnQhvDhSz20 .rough-node .label text,#mermaid-svg-qVQKENnQhvDhSz20 .node .label text,#mermaid-svg-qVQKENnQhvDhSz20 .image-shape .label,#mermaid-svg-qVQKENnQhvDhSz20 .icon-shape .label{text-anchor:middle;}#mermaid-svg-qVQKENnQhvDhSz20 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qVQKENnQhvDhSz20 .rough-node .label,#mermaid-svg-qVQKENnQhvDhSz20 .node .label,#mermaid-svg-qVQKENnQhvDhSz20 .image-shape .label,#mermaid-svg-qVQKENnQhvDhSz20 .icon-shape .label{text-align:center;}#mermaid-svg-qVQKENnQhvDhSz20 .node.clickable{cursor:pointer;}#mermaid-svg-qVQKENnQhvDhSz20 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qVQKENnQhvDhSz20 .arrowheadPath{fill:#333333;}#mermaid-svg-qVQKENnQhvDhSz20 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qVQKENnQhvDhSz20 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qVQKENnQhvDhSz20 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qVQKENnQhvDhSz20 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qVQKENnQhvDhSz20 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qVQKENnQhvDhSz20 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qVQKENnQhvDhSz20 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qVQKENnQhvDhSz20 .cluster text{fill:#333;}#mermaid-svg-qVQKENnQhvDhSz20 .cluster span{color:#333;}#mermaid-svg-qVQKENnQhvDhSz20 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qVQKENnQhvDhSz20 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qVQKENnQhvDhSz20 rect.text{fill:none;stroke-width:0;}#mermaid-svg-qVQKENnQhvDhSz20 .icon-shape,#mermaid-svg-qVQKENnQhvDhSz20 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qVQKENnQhvDhSz20 .icon-shape p,#mermaid-svg-qVQKENnQhvDhSz20 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qVQKENnQhvDhSz20 .icon-shape .label rect,#mermaid-svg-qVQKENnQhvDhSz20 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qVQKENnQhvDhSz20 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qVQKENnQhvDhSz20 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qVQKENnQhvDhSz20 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 在线阶段
离线阶段
业务文档

商品/政策/FAQ
文档分块

Chunking
Embedding模型

向量化
VectorStore

向量数据库
用户提问
Query向量化
相似度检索

Top-K
文档过滤/重排序
组装Prompt

问题+上下文
LLM生成
返回回答

在这个过程中,检索环节的质量决定了生成的上限。如果检索到的文档与问题不相关,即使模型再强大,也只能基于错误信息生成回答。

2.2 Spring AI的模块化RAG架构

Spring AI 2.0的RetrievalAugmentationAdvisor实现了模块化RAG架构,每个阶段都是可插拔的组件:

阶段 Spring AI接口 职责 典型实现
预检索 QueryTransformer 重写查询 RewriteQueryTransformer
预检索 QueryExpander 扩展多查询 MultiQueryExpander
检索 DocumentRetriever 获取文档 VectorStoreDocumentRetriever
检索 DocumentJoiner 合并结果 ConcatenationDocumentJoiner
后检索 DocumentPostProcessor 过滤/重排 JevDocumentFilter
生成 QueryAugmenter 组装Prompt ContextualQueryAugmenter

为什么需要预检索阶段? 用户不会像搜索引擎那样输入查询词。他们输入的是"我上个月买的东西怎么还没到"------这种自然语言句子直接Embedding会引入大量噪声。RewriteQueryTransformer会用LLM把用户问题改写为"订单发货延迟 原因 处理方式"这样的检索友好查询,显著提升检索命中率。

为什么需要后检索阶段? 向量检索返回的是"主题相似"的文档,但相似不等于有用。一份参考文献列表提到"RAG"五次,相似度得分很高,但对回答问题毫无帮助。后检索阶段的过滤和重排就是用来剔除这些"高分低能"文档的。

三、Spring AI VectorStore:向量存储的统一抽象

Spring AI的VectorStore接口是RAG架构的存储基石。它提供了清晰的读写分离设计:VectorStoreRetriever是只读接口,仅暴露检索能力;VectorStore继承并扩展了写入能力(add/delete)。

为什么读写分离很重要? 在SaaS多租户场景中,不同的组件需要不同的权限。RAG查询服务只需要检索能力,不应该有写入权限;而知识库更新服务需要写入能力。读写分离从接口层面强制了这个权限约束。

3.1 向量数据库选型

Spring AI支持多种VectorStore实现,底层可以切换而业务代码不变:

数据库 类型 适用场景 Spring AI实现
pgvector PostgreSQL扩展 已有PG体系,中小规模 PgVectorStore
Milvus 独立向量库 大规模企业知识库,亿级向量 MilvusVectorStore
Qdrant 独立向量库 RAG实时检索,高性能过滤 QdrantVectorStore
Elasticsearch 搜索引擎扩展 混合检索(向量+全文) ElasticsearchVectorStore
Redis 内存数据库 低延迟、小规模 RedisVectorStore

选型建议 :对于大多数SaaS客服系统,pgvector是性价比最高的选择。它不需要引入新的基础设施组件,而且事务支持和多租户隔离(通过schema或RLS)都非常成熟。只有当向量数据量超过千万级、或需要独立的向量检索性能优化时,才考虑迁移到Milvus或Qdrant。

在向量数据库的性能评估中,Recall@K(Top-K中找到真实近邻的比例)是最值得关注的指标,远比单纯的QPS更重要。因为RAG的最终质量取决于检索到的文档是否真正相关,而不是检索速度有多快。

3.2 配置VectorStore

yaml 复制代码
spring:
  ai:
    vectorstore:
      pgvector:
        index-type: HNSW
        distance-type: COSINE_DISTANCE
        dimensions: 1536
        initialize-schema: true

HNSW索引 是当前RAG场景下最主流的近似最近邻(ANN)索引类型。它在召回率和查询延迟之间取得了良好的平衡,适合大多数业务场景。COSINE_DISTANCE是文本Embedding最常用的距离度量------它衡量的是向量方向的相似度,不受向量长度影响。

四、RAG知识库异步更新管道

RAG系统的第一个消息队列应用场景是知识库更新。在SaaS客服系统中,知识库文档会频繁变化:新商品上架、售后政策调整、FAQ更新。每次变更都需要重新分块、Embedding、写入向量数据库。

4.1 为什么知识库更新需要异步化?

如果知识库更新是同步的,会出现两个问题。问题一 :批量上传100个文档时,API层需要等待所有文档处理完毕才能返回,用户界面长时间卡住。问题二:Embedding模型的调用可能耗时数秒甚至超时,如果同步处理,任何一个文档失败都会导致整批任务失败。

异步化的方案是:API层接收文档后立即返回,文档通过消息队列投递给Worker异步处理。每个文档独立处理,单个失败不影响其他文档。
#mermaid-svg-TSMc8W76yvZtU3d8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-TSMc8W76yvZtU3d8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-TSMc8W76yvZtU3d8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-TSMc8W76yvZtU3d8 .error-icon{fill:#552222;}#mermaid-svg-TSMc8W76yvZtU3d8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-TSMc8W76yvZtU3d8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-TSMc8W76yvZtU3d8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-TSMc8W76yvZtU3d8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-TSMc8W76yvZtU3d8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-TSMc8W76yvZtU3d8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-TSMc8W76yvZtU3d8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-TSMc8W76yvZtU3d8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-TSMc8W76yvZtU3d8 .marker.cross{stroke:#333333;}#mermaid-svg-TSMc8W76yvZtU3d8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-TSMc8W76yvZtU3d8 p{margin:0;}#mermaid-svg-TSMc8W76yvZtU3d8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-TSMc8W76yvZtU3d8 .cluster-label text{fill:#333;}#mermaid-svg-TSMc8W76yvZtU3d8 .cluster-label span{color:#333;}#mermaid-svg-TSMc8W76yvZtU3d8 .cluster-label span p{background-color:transparent;}#mermaid-svg-TSMc8W76yvZtU3d8 .label text,#mermaid-svg-TSMc8W76yvZtU3d8 span{fill:#333;color:#333;}#mermaid-svg-TSMc8W76yvZtU3d8 .node rect,#mermaid-svg-TSMc8W76yvZtU3d8 .node circle,#mermaid-svg-TSMc8W76yvZtU3d8 .node ellipse,#mermaid-svg-TSMc8W76yvZtU3d8 .node polygon,#mermaid-svg-TSMc8W76yvZtU3d8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-TSMc8W76yvZtU3d8 .rough-node .label text,#mermaid-svg-TSMc8W76yvZtU3d8 .node .label text,#mermaid-svg-TSMc8W76yvZtU3d8 .image-shape .label,#mermaid-svg-TSMc8W76yvZtU3d8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-TSMc8W76yvZtU3d8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-TSMc8W76yvZtU3d8 .rough-node .label,#mermaid-svg-TSMc8W76yvZtU3d8 .node .label,#mermaid-svg-TSMc8W76yvZtU3d8 .image-shape .label,#mermaid-svg-TSMc8W76yvZtU3d8 .icon-shape .label{text-align:center;}#mermaid-svg-TSMc8W76yvZtU3d8 .node.clickable{cursor:pointer;}#mermaid-svg-TSMc8W76yvZtU3d8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-TSMc8W76yvZtU3d8 .arrowheadPath{fill:#333333;}#mermaid-svg-TSMc8W76yvZtU3d8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-TSMc8W76yvZtU3d8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-TSMc8W76yvZtU3d8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TSMc8W76yvZtU3d8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-TSMc8W76yvZtU3d8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TSMc8W76yvZtU3d8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-TSMc8W76yvZtU3d8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-TSMc8W76yvZtU3d8 .cluster text{fill:#333;}#mermaid-svg-TSMc8W76yvZtU3d8 .cluster span{color:#333;}#mermaid-svg-TSMc8W76yvZtU3d8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-TSMc8W76yvZtU3d8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-TSMc8W76yvZtU3d8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-TSMc8W76yvZtU3d8 .icon-shape,#mermaid-svg-TSMc8W76yvZtU3d8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TSMc8W76yvZtU3d8 .icon-shape p,#mermaid-svg-TSMc8W76yvZtU3d8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-TSMc8W76yvZtU3d8 .icon-shape .label rect,#mermaid-svg-TSMc8W76yvZtU3d8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TSMc8W76yvZtU3d8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-TSMc8W76yvZtU3d8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-TSMc8W76yvZtU3d8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Worker层
API层
上传文档
保存原始文件
发送消息到

knowledge.ingest Topic
返回202
消息消费者
文档分块

Chunking
Embedding

向量化
写入VectorStore
更新文档状态

4.2 文档分块策略:RAG检索质量的第一个关卡

文档分块(Chunking)是RAG工程中最容易被忽视但影响最大的环节。分块太大,检索到的文档包含太多无关信息,浪费Token并降低生成质量;分块太小,可能丢失跨块的语义上下文。

当前主流的四种分块策略:

分块策略 原理 优点 缺点
固定大小 按固定Token数切分 实现简单,成本低 破坏语义边界
递归分割 按段落→句子→字符逐级切分 保留结构语义 块大小不固定
语义分块 计算相邻句子的语义相似度 语义连贯性最好 实现复杂,计算开销大
结构感知 按标题/表格/代码块等结构切分 保留文档结构 依赖文档格式

推荐策略 :对于SaaS客服系统的知识库文档,推荐使用结构感知分块+递归分割的组合。先用标题和章节边界做粗粒度切分,再对超长章节用递归分割做细粒度切分。每个Chunk控制在300-500 Token,overlap设为50-100 Token。

踩过的坑:overlap不是越大越好。过大的overlap会导致同一个内容在多个Chunk中重复出现,检索时返回大量冗余结果,浪费Token。实践中发现50-100 Token的overlap已经足够保持上下文连续性。

4.3 知识库更新消费者

java 复制代码
@Component
@RocketMQMessageListener(
    topic = "knowledge.ingest",
    consumerGroup = "knowledge-ingest-worker",
    consumeMode = ConsumeMode.CONCURRENTLY
)
@Slf4j
public class KnowledgeIngestConsumer implements RocketMQListener<KnowledgeIngestMsg> {

    @Autowired
    private VectorStore vectorStore;
    @Autowired
    private DocumentChunker chunker;
    @Autowired
    private KnowledgeDocRepository docRepo;

    @Override
    public void onMessage(KnowledgeIngestMsg msg) {
        String docId = msg.getDocId();
        String tenantId = msg.getTenantId();

        log.info("开始处理知识库文档:docId={}, tenantId={}", docId, tenantId);

        // 1. 幂等校验
        if (docRepo.isProcessed(docId)) {
            log.info("文档已处理,跳过:docId={}", docId);
            return;
        }

        try {
            // 2. 读取原始文档内容
            String content = docRepo.getContent(docId);

            // 3. 文档分块
            List<Document> chunks = chunker.chunk(content);

            // 4. 关键:为每个Chunk添加租户元数据
            for (Document chunk : chunks) {
                chunk.getMetadata().put("tenantId", tenantId);
                chunk.getMetadata().put("docId", docId);
                chunk.getMetadata().put("source", msg.getFileName());
            }

            // 5. 批量写入VectorStore(自动完成Embedding)
            vectorStore.add(chunks);

            // 6. 更新文档状态
            docRepo.markProcessed(docId, chunks.size());
            log.info("知识库文档处理完成:docId={}, chunks={}",
                    docId, chunks.size());

        } catch (Exception e) {
            log.error("知识库文档处理失败:docId={}", docId, e);
            docRepo.markFailed(docId, e.getMessage());
            throw new RuntimeException("文档处理失败", e);
        }
    }
}

为什么要在每个Chunk的metadata中写入tenantId? 这是多租户RAG隔离的基础。检索时通过metadata filter确保只返回当前租户的文档,这是第六节要详细展开的内容。

为什么用vectorStore.add(chunks)批量写入而不是逐条写入? 批量写入可以复用同一次Embedding API调用(Spring AI的VectorStore内部会批量调用Embedding模型),显著降低API调用次数和成本。假设一个文档分了50个Chunk,批量写入只需要1-2次Embedding调用,逐条写入则需要50次。

五、RAG用户请求异步处理链路

RAG系统的第二个消息队列应用场景是用户请求的异步处理。这和第九篇的大模型异步任务队列一脉相承,但RAG场景有其特殊性:检索和生成是两个独立的耗时环节,需要不同的降级策略。

5.1 完整架构设计

大模型 VectorStore RAG Worker RocketMQ API层 用户 大模型 VectorStore RAG Worker RocketMQ API层 用户 #mermaid-svg-jQuHBCKxPQje6S85{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jQuHBCKxPQje6S85 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jQuHBCKxPQje6S85 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jQuHBCKxPQje6S85 .error-icon{fill:#552222;}#mermaid-svg-jQuHBCKxPQje6S85 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jQuHBCKxPQje6S85 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jQuHBCKxPQje6S85 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jQuHBCKxPQje6S85 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jQuHBCKxPQje6S85 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jQuHBCKxPQje6S85 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jQuHBCKxPQje6S85 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jQuHBCKxPQje6S85 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jQuHBCKxPQje6S85 .marker.cross{stroke:#333333;}#mermaid-svg-jQuHBCKxPQje6S85 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jQuHBCKxPQje6S85 p{margin:0;}#mermaid-svg-jQuHBCKxPQje6S85 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jQuHBCKxPQje6S85 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-jQuHBCKxPQje6S85 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-jQuHBCKxPQje6S85 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-jQuHBCKxPQje6S85 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-jQuHBCKxPQje6S85 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-jQuHBCKxPQje6S85 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-jQuHBCKxPQje6S85 .sequenceNumber{fill:white;}#mermaid-svg-jQuHBCKxPQje6S85 #sequencenumber{fill:#333;}#mermaid-svg-jQuHBCKxPQje6S85 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-jQuHBCKxPQje6S85 .messageText{fill:#333;stroke:none;}#mermaid-svg-jQuHBCKxPQje6S85 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jQuHBCKxPQje6S85 .labelText,#mermaid-svg-jQuHBCKxPQje6S85 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-jQuHBCKxPQje6S85 .loopText,#mermaid-svg-jQuHBCKxPQje6S85 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-jQuHBCKxPQje6S85 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-jQuHBCKxPQje6S85 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-jQuHBCKxPQje6S85 .noteText,#mermaid-svg-jQuHBCKxPQje6S85 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-jQuHBCKxPQje6S85 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jQuHBCKxPQje6S85 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jQuHBCKxPQje6S85 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jQuHBCKxPQje6S85 .actorPopupMenu{position:absolute;}#mermaid-svg-jQuHBCKxPQje6S85 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-jQuHBCKxPQje6S85 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jQuHBCKxPQje6S85 .actor-man circle,#mermaid-svg-jQuHBCKxPQje6S85 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-jQuHBCKxPQje6S85 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt检索成功检索失败 提问生成task_id发送消息到rag.jobs Topic返回202 + task_id投递消息幂等校验向量检索(带租户过滤)返回Top-K文档Prompt + 上下文生成结果降级:直接用LLM回答保存结果ACK轮询 /task/{id}返回结果

检索失败和生成失败的降级策略不同。检索失败(向量数据库不可用)可以降级为直接调用LLM回答------虽然质量下降,但至少能给出回复。生成失败(LLM不可用)则没有降级空间------重试是唯一的选择。

5.2 RAG Worker的核心实现

java 复制代码
@Component
@RocketMQMessageListener(
    topic = "rag.jobs",
    consumerGroup = "rag-worker",
    maxReconsumeTimes = 3
)
@Slf4j
public class RagJobConsumer implements RocketMQListener<RagJobMessage> {

    @Autowired
    private VectorStore vectorStore;
    @Autowired
    private ChatClient chatClient;
    @Autowired
    private RagTaskRepository taskRepo;

    @Override
    public void onMessage(RagJobMessage msg) {
        String taskId = msg.getTaskId();
        String tenantId = msg.getTenantId();
        String question = msg.getQuestion();

        try {
            // 1. 租户隔离的向量检索
            List<Document> documents = retrieveDocuments(tenantId, question);

            // 2. 组装Prompt
            String context = documents.stream()
                    .map(Document::getContent)
                    .collect(Collectors.joining("\n\n---\n\n"));

            String prompt = String.format("""
                    请根据以下参考资料回答用户问题。
                    如果参考资料中没有相关信息,请如实告知,不要编造。

                    【参考资料】
                    %s

                    【用户问题】
                    %s
                    """, context, question);

            // 3. 调用大模型
            String answer = chatClient.prompt()
                    .user(prompt)
                    .call()
                    .content();

            // 4. 保存结果(先保存再返回/提交位点)
            taskRepo.complete(taskId, answer,
                    documents.stream().map(Document::getId).toList());

            log.info("RAG任务完成:taskId={}, retrievedDocs={}",
                    taskId, documents.size());

        } catch (VectorStoreException e) {
            // 检索失败:降级为直接LLM回答
            log.warn("向量检索失败,降级为直接LLM回答:taskId={}", taskId, e);
            String fallbackAnswer = chatClient.prompt()
                    .user(question + "\n\n(注意:当前无法访问知识库,请基于通用知识回答)")
                    .call().content();
            taskRepo.complete(taskId, fallbackAnswer, Collections.emptyList());
        }
    }

    private List<Document> retrieveDocuments(String tenantId, String question) {
        // 关键:通过metadata filter实现租户隔离
        SearchRequest request = SearchRequest.builder()
                .query(question)
                .topK(5)
                .similarityThreshold(0.7)
                .filterExpression("tenantId == '" + tenantId + "'")
                .build();

        return vectorStore.similaritySearch(request);
    }
}

为什么将filterExpression放在检索请求中而不是在应用层过滤? 这是多租户RAG隔离的核心原则------隔离必须在检索层完成。如果先检索所有租户的文档,再在应用层过滤,A租户的问题可能会从B租户的文档中匹配到高相似度的结果,这些结果已经被加载到了内存中,存在数据泄露风险。更严重的是,B租户的文档可能"占用"了Top-K的位置,导致A租户真正相关的文档被挤出结果集。

为什么similarityThreshold设为0.7? 这个阈值决定了检索结果的最低相似度要求。设得太低(如0.5),会返回大量不相关的文档,干扰LLM生成;设得太高(如0.9),可能返回空结果,导致RAG退化为纯LLM回答。0.7是一个经验值,实际项目中需要根据Embedding模型和业务文档特征进行调整。

六、多租户RAG隔离的完整方案

在SaaS客服系统中,多租户隔离是安全底线。A租户的客户不能看到B租户的知识库内容。Spring AI的VectorStore通过SearchRequest的filterExpression提供了灵活的metadata过滤能力。

6.1 三层隔离方案对比

隔离级别 实现方式 隔离强度 适用场景
独立集合 每租户独立Collection/Index 最强 金融/政务级SaaS
metadata过滤 共享Collection + tenantId过滤 中等 大多数SaaS
应用层过滤 检索后过滤 最弱 内部系统

metadata过滤是大多数SaaS系统的推荐方案 。它的实现方式是:所有租户的Chunk存储在同一个Collection中,每个Chunk的metadata中包含tenantId,检索时通过filterExpression过滤。

为什么不用应用层过滤? 除了前面说的安全风险之外,还有一个性能问题:应用层过滤意味着向量数据库需要返回所有租户的Top-K结果,然后应用层再过滤。假设有100个租户,每个租户需要返回5个结果,那么向量数据库需要返回500个结果,其中95%会被丢弃。这浪费了大量的计算资源和网络带宽。

6.2 为什么需要多层幂等保障?

第09篇我们讲了Redis SETNX + 数据库状态校验的两层幂等方案。在RAG场景中,幂等同样重要------重复调用LLM不仅浪费Token成本,还可能因为模型输出的随机性导致用户看到不一致的答案。

java 复制代码
@Component
@Slf4j
public class RagIdempotentService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * RAG任务的幂等校验
     * 两层保障:Redis快速去重 + 数据库状态兜底
     */
    public boolean tryProcess(String taskId, Duration expire) {
        String key = "rag:idempotent:" + taskId;
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(key, "1", expire);
        return Boolean.TRUE.equals(success);
    }
}

6.3 租户隔离的完整验证清单

在生产环境中,建议对多租户RAG隔离做以下验证:

验证一:跨租户检索测试。 用租户A的账号提问,确认返回的文档全部属于租户A。可以在测试环境中创建两个租户的"金丝雀文档"------每个文档包含唯一的标记词,然后验证检索结果中不会出现另一个租户的标记词。

验证二:filterExpression注入测试。 如果filterExpression是动态拼接的(如"tenantId == '" + tenantId + "'"),需要验证tenantId参数不能被SQL注入。建议对tenantId做白名单校验或使用参数化查询。

验证三:Embedding模型一致性。 如果不同租户使用了不同的Embedding模型或维度,需要确保在同一个Collection中不能混用。Spring AI的VectorStore在创建时需要指定固定的维度,维度不一致的向量写入会失败。

七、5个生产环境必知的核心决策点

决策一:知识库更新的增量vs全量策略。 如果知识库文档频繁小改(如FAQ条目更新),应该只重新处理被修改的文档,而不是全量重建向量索引。全量重建的成本极高------一个10万文档的知识库,全量Embedding可能需要数小时和数百美元的API调用费用。建议在数据库中用updateTime字段做增量识别。

决策二:Embedding模型的选型与成本。 OpenAI的text-embedding-3-small(1536维)是最常用的Embedding模型,成本约$0.02/百万Token。对于中文场景,可以考虑智谱的embedding-2或阿里的text-embedding-v3,中文语义理解更好且成本更低。Embedding模型一旦选定,不能随意更换------不同模型的向量空间不兼容,更换模型意味着全量重新Embedding所有文档。

决策三:检索结果的缓存策略。 RAG检索的结果可以缓存------如果两个用户问了相似的问题,检索到的文档可能相同。使用Redis缓存"问题向量 → Top-K文档ID"的映射,可以避免重复的向量检索开销。缓存的过期时间建议设置为知识库更新周期的1/2------如果知识库每天更新一次,缓存过期时间设为12小时。

决策四:流式响应的异步化。 如果AI客服需要打字机效果,用户提问后应该先返回一个SSE连接,RAG Worker在处理过程中将每个Token推送到结果队列,API层订阅结果队列并转发给SSE连接。这比同步等待完整结果再返回的体验好得多,但实现复杂度也高得多。

决策五:RAG评估的自动化。 RAG系统的质量评估不能只靠人工。建议建立自动化评估管道:准备一组"问题-标准答案"对,每次知识库更新后自动运行评估,计算检索的Recall@K和生成的BLEU/ROUGE分数。如果评估分数下降超过阈值,自动触发告警。

八、总结与下一篇预告

这篇我们从RAG的核心原理出发,用Spring AI的VectorStore + RetrievalAugmentationAdvisor + RocketMQ构建了一个完整的AI客服系统架构。核心设计可以浓缩为三句话:

  • 知识库更新走异步管道:API层接收文档后立即返回,Worker异步完成分块、Embedding、入库
  • 用户请求走异步链路:检索和生成分离,检索失败降级为直接LLM回答
  • 多租户隔离在检索层完成:通过metadata filter确保租户A的提问永远不会检索到租户B的文档

但RAG只是AI客服系统的一部分。在更复杂的场景中,一个用户提问可能需要多个Agent协作完成------查天气的Agent、查订单的Agent、查政策的Agent各自独立工作,最后汇总结果。这就是Multi-Agent协作。下一篇将深入RocketMQ LiteTopic如何驱动Multi-Agent异步协作,解决传统消息模型在AI场景中的"队头阻塞"问题。

📖 下一篇预告:Multi-Agent异步协作------RocketMQ LiteTopic驱动AI工作流。

相关推荐
Evelvnk1 小时前
计算机学习资料
开发语言·人工智能·计算机·php
用户8314550980311 小时前
如一 Agent 架构解读(十九):错误的修养——工具报错是 Agent 唯一的自纠教材
人工智能·llm
招财牛猫1 小时前
当 AI 公司用 AI 抓内鬼:从 OpenAI 会议室窃听案看内部威胁检测的技术实现
人工智能
中伟视界1 小时前
边缘AI目标检测在矿区场景的工程化部署:量化、裁剪与推理优化
人工智能·目标检测·目标跟踪
IvorySQL1 小时前
PostgreSQL 日报| pg_vectorsearch 开源向量搜索发布(10 月 14 日)
数据库·人工智能·postgresql·开源·区块链
Summer-Bright1 小时前
深度 | AI 早期融资的估值前置:种子轮买人,A 轮买营收,安全边际在消失
人工智能·安全
会灭火的程序员1 小时前
大模型部署硬件资源计算指南:从显存估算到显卡选型
人工智能·算法
cheems95271 小时前
从统一响应到 Swagger:一篇讲清微服务接口文档设计未命名
java·微服务·架构·状态模式