RAG
快来分享你的内容吧~
05-18 15:28- 04-23 11:57·后端AI 总是一本正经地胡说八道?RAG 让大模型先搜再答,从此告别幻觉。本文一次性讲透 Naive RAG、Hybrid Search、GraphRAG、Agentic RAG 等 16 种主流方案,附代码示例 + 选型对照表,程序员面试和 AI 编程实战必备。查看全文加油鸭:太棒了!16种RAG方案全景解析,干货密度拉满,逻辑清晰、案例生动、代码贴心,连小白都能看懂。这份深度与诚意,真的让人眼前一亮!51483分享
我完成了一个最新的 RAG 项目 —— 从零构建企业级知识库平台的完整历程
# 我完成了一个最新的 RAG 项目 —— 从零构建企业级知识库平台的完整历程 ## 写在前面 大家好。过去几个月,我利用业余时间完成了一个让我自己都感到惊喜的项目——**Argus(百眼巨人)**,一个基于 Spring AI Alibaba 从零构建的 RAG 知识库平台。 项目地址:https://github.com/DevYangJC/Argus 说实话,在开始这个项目之前,我对 RAG 的理解还停留在"就是给 GPT 外挂一个向量数据库"的层面。但当我真正深入进去,才发现这条路上有太多值得探索的技术细节——从文档解析到向量索引,从混合检索到 Agent 工具编排,每一步都让我对 AI 应用开发有了全新的认识。 今天,我想把这段从 0 到 1 的完整历程分享出来,希望能给同样在 AI 应用开发道路上探索的朋友们一些启发。     --- ## 一、为什么选择 RAG + Spring AI Alibaba? ### 1.1 一个真实的困惑 在 2025 年初,我开始思考一个问题:大语言模型确实很强大,但当一个企业想把自己的私有文档(产品手册、技术规范、运维指南)交给 AI 来回答问题时,会发生什么? 答案让人沮丧——LLM 会"编造"答案。它不知道你的文档里写了什么,只能基于训练数据中的通用知识来推测,结果往往是看起来很专业、实际上完全错误的内容。这就是 AI 领域著名的**幻觉问题**。 而 RAG(Retrieval-Augmented Generation,检索增强生成)正是解决这个问题的关键方案。它的核心思想非常简单: > 在 LLM 回答之前,先从你的私有文档中检索出最相关的内容,然后让 LLM **基于这些真实文档**来生成回答。 这样一来,AI 就不再是"凭空想象",而是"有据可查"。 ### 1.2 为什么选择 Spring AI Alibaba? 确定了 RAG 的技术方向后,接下来面临的是技术选型问题。作为一个 Java 开发者,我自然希望能用 Spring Boot 生态来构建这个项目。当时我考察了几个方案: | 方案 | 优点 | 缺点 | |------|------|------| | **LangChain + Python** | 生态最成熟,社区资源丰富 | 需要学习 Python 生态,与我现有的 Java 技术栈不兼容 | | **Spring AI + OpenAI** | Java 原生,Spring 生态无缝集成 | 国内访问 OpenAI API 困难,延迟高 | | **Spring AI Alibaba** | Java 原生 + 国内大模型深度集成 | 文档相对较新,社区尚在成长中 | 最终我选择了 **Spring AI Alibaba**,理由非常务实: - **DashScope 原生集成**:通义千问在国内的访问速度和稳定性都非常好,API 延迟远低于海外服务 - **Chat/Embedding 分离架构**:Chat 走 DashScope 原生 API,Embedding 走 OpenAI 兼容模式——各取所长 - **ReactAgent 图执行引擎**:这是 Spring AI Alibaba Agent Framework 提供的核心能力,让我在后来的 V4.0 阶段能够构建出真正的 Agent 对话系统 - **Spring Boot 生态兼容**:MyBatis-Plus、Spring Security、Spring Retry 等成熟组件可以无缝接入 ### 1.3 项目定位与命名 我给这个项目取名为 **Argus**——希腊神话中的百眼巨人。传说 Argus 即使睡着,身上的眼睛也始终保持警惕。这个名字完美契合了平台的愿景: > **全面洞察你的私有知识资产,让每一次提问都有据可查。** 项目采用**渐进式迭代**的开发方式,分四个版本逐步构建,每个版本聚焦一个核心主题: ```mermaid graph LR V1[V1.0<br/>基础设施<br/>认证 + 群组] --> V2[V2.0<br/>文档引擎<br/>上传 + ETL + 检索] V2 --> V3[V3.0<br/>RAG 问答<br/>查询规划 + 混合检索] V3 --> V4[V4.0<br/>AI Agent<br/>多轮对话 + 短期记忆] ``` --- ## 二、V1.0:万丈高楼平地起 —— 用户认证与群组协作 ### 2.1 为什么从认证开始? 说实话,刚开始我也犹豫过——是不是应该先从"炫酷"的 AI 对话功能开始?但仔细想想,一个企业级平台最基础的要求是什么?是**安全**和**隔离**。 如果不能让不同用户的数据相互隔离,不能让不同团队在各自的知识库空间中协作,那后面所有的 AI 能力都无从谈起。所以 V1.0 我选择从最基础的认证和群组系统开始。 ### 2.2 JWT 双令牌认证机制 在认证方案上,我没有选择传统的 Session 模式,而是采用了 **JWT 双令牌机制**: ```mermaid sequenceDiagram participant Client as 客户端 participant Server as 后端服务 participant DB as PostgreSQL Client->>Server: POST /api/auth/login {username, password} Server->>Server: BCrypt 验证密码 Server->>Server: 生成 Access Token (JWT, 15min) Server->>Server: 生成 Refresh Token (随机字符串) Server->>DB: 存储 Refresh Token (含过期时间) Server-->>Client: Access Token (Body) + Refresh Token (httpOnly Cookie) Note over Client,Server: ... 15分钟后,Access Token 过期 ... Client->>Server: POST /api/auth/refresh (Cookie 中的 Refresh Token) Server->>DB: 验证 Refresh Token Server->>DB: 删除旧 Token + 存储新 Token (Rotation) Server-->>Client: 新的 Access Token + 新的 Refresh Token ``` 这个设计有几个关键考量: **Access Token 短期化(15 分钟)**:即使 Access Token 被泄露,攻击者也只有 15 分钟的操作窗口。相比某些系统动辄 24 小时的 Token 有效期,这是一个更加保守但更安全的选择。 **Refresh Token 存储于 httpOnly Cookie + 数据库**:httpOnly 意味着 JavaScript 无法读取,XSS 攻击无法窃取。同时,Refresh Token 在数据库中也有记录,每次刷新时会进行 **Rotation**(旧 Token 删除、新 Token 生成),这样即使某个 Refresh Token 被盗用,使用一次后就会失效。 **BCrypt 密码加密**:用户密码在数据库中存储的是 BCrypt 哈希值,即使数据库被拖库,攻击者也无法还原明文密码。 ### 2.3 三级角色权限体系 权限设计上,我实现了三个层级的角色控制: | 角色 | 权限范围 | |------|---------| | **Admin** | 系统管理员,可以管理所有用户、查看所有群组 | | **Group Owner** | 群组所有者,可以邀请成员、审批申请、上传文档、删除文档 | | **Group Member** | 群组成员,可以查看群组文档、在知识库中提问 | 权限校验的实现采用了"多层防御"策略——先经过 JWT 认证过滤器,再经过角色校验,最后在数据查询层面还会附加 `groupId` 过滤条件。这样即使某一层出现了漏洞,后续的防御层仍然能够保护数据安全。 ### 2.4 群组协作机制 群组协作支持两种加入方式: - **邀请制**:群组 Owner 生成邀请码,被邀请者通过邀请码直接加入 - **申请制**:用户主动申请加入群组,Owner 审批通过后成为成员 这两种机制覆盖了不同的协作场景——邀请制适合小团队,申请制适合开放的知识社区。 --- ## 三、V2.0:打通数据链路 —— 文档管理与 ETL 流水线 ### 3.1 挑战:大文件上传怎么搞? V2.0 是整个项目中最"硬核"的一个版本。这个阶段要做的事情是:让用户能够上传文档,然后系统自动把文档解析、切片、向量化、建立索引,最终变成可检索的知识。 听起来简单?实际上光"上传"这一个环节就让我头疼了好几天。 在网络环境下上传大文件(比如几百 MB 的 PDF),最直接的问题是:**如果网断了怎么办?** 用户辛辛苦苦传了 95%,网络一抖,全部从头再来——这种体验简直是灾难。 ### 3.2 三阶段分片上传协议 为了解决这个问题,我设计了一个**三阶段分片上传协议**: ```mermaid sequenceDiagram participant Client as 客户端 participant API as 上传接口 participant DB as PostgreSQL participant MinIO as MinIO 对象存储 Note over Client,MinIO: === 阶段一:初始化 === Client->>API: POST /upload/init {fileName, fileSize, fileHash, chunkSize} API->>DB: 查询是否存在相同 fileHash 的 READY 文档 alt 秒传命中 API-->>Client: {type: "INSTANT", documentId: 42} Note over Client: 无需上传,直接完成! else 检查可复用会话 API->>DB: 查询未过期的上传会话 alt 断点续传 API-->>Client: {type: "UPLOAD_SESSION", uploadedChunks: [0,1,3]} else 新建会话 API->>DB: 创建上传会话 (uploadId, expires_at=now+24h) API-->>Client: {type: "UPLOAD_SESSION", uploadedChunks: []} end end Note over Client,MinIO: === 阶段二:分片上传 === loop 每个分片 Client->>API: POST /upload/chunks {uploadId, chunkIndex, chunkData} API->>MinIO: 上传分片对象 API->>DB: 记录分片元数据 (upsert 幂等) API-->>Client: {uploadedCount, totalCount} end Note over Client,MinIO: === 阶段三:完成合并 === Client->>API: POST /upload/{uploadId}/complete API->>MinIO: composeObject 服务端合并分片 API->>DB: 创建 DocumentEntity (status=UPLOADED) API->>DB: 发布 IngestionRequestedEvent API-->>Client: {documentId: 42} ``` 这个协议解决了三个核心问题: **秒传(Instant Upload)**:如果同一个群组内已经存在相同 SHA-256 哈希的文档,系统直接返回已有文档的 ID,完全不占用带宽和存储空间。 **断点续传**:上传会话有 24 小时的有效期。如果上传中断,客户端重新初始化时会收到 `uploadedChunks` 列表(已上传的分片序号),只需要上传缺失的部分即可。 **幂等安全**:分片记录使用 PostgreSQL 的 `ON CONFLICT ... DO UPDATE`(upsert)语法,即使客户端重复上传同一个分片,也不会产生脏数据。 ### 3.3 ETL 异步流水线 上传完成后,接下来是文档的"消化"过程——也就是 ETL(Extract-Transform-Load)流水线。我采用 **Spring Event + @Async** 的异步机制来驱动这个过程,这样上传接口可以立即返回,不会让用户等待: ```mermaid graph TB UPLOAD[文档上传完成] -->|Spring Event| LISTENER LISTENER[Async Listener<br/>@TransactionalEventListener<br/>AFTER_COMMIT] LISTENER -->|发布 IngestionJob| WORKER WORKER[Async Worker<br/>@Retryable 3次重试] WORKER --> STEP1 STEP1[Step 1: 文档解析<br/>PDFBox / POI / MD / TXT] STEP1 --> STEP2 STEP2[Step 2: 文本清洗<br/>控制字符过滤 / 空白压缩] STEP2 --> STEP3 STEP3[Step 3: 结构感知切片<br/>标题边界 + 段落边界 + Token预算] STEP3 --> STEP4 STEP3 --> STEP5 STEP4[Step 4: 向量嵌入<br/>text-embedding-v3 → PGvector HNSW] STEP5[Step 5: 关键词索引<br/>IK分词 → Elasticsearch] STEP4 --> STEP6 STEP5 --> STEP6 STEP6[Step 6: 状态更新<br/>DocumentStatus → READY] WORKER -.->|失败重试| RETRY RETRY[SpringRetry<br/>退避 2s/4s/8s] RETRY -.->|3次全失败| RECOVER RECOVER["@Recover兜底<br/>status → FAILED"] ``` 这里有几个设计细节值得展开: **为什么用 Spring Event 而不是消息队列?** 对于教学项目来说,引入 RabbitMQ 或 Kafka 会增加运维复杂度。Spring Event + `@Async` 的组合在单机部署场景下完全够用——事务提交后异步触发 ETL,不影响 HTTP 响应时间。而且 `@TransactionalEventListener(AFTER_COMMIT)` 保证了只有在数据库事务成功提交后才会触发处理,避免了"文档还没落库就开始处理"的竞态问题。 **结构感知切片是什么?** 简单的文本切片按固定字符数切割,会破坏文档的语义结构(可能在段落中间切断)。我的实现会先识别 Markdown 标题层级和段落边界,优先在标题或段落边界处切割,确保每个切片都是一个相对完整的语义单元。切片过小的就合并到上一个,过大的则递归向下拆分。 **PGvector HNSW 索引参数的选择** 向量索引使用了 HNSW(Hierarchical Navigable Small World)算法,参数 `m=16, ef_construction=200`。这不是随便选的——`m` 控制每个节点的最大连接数,越大检索越快但构建越慢;`ef_construction` 控制构建时的候选集大小,越大索引质量越高但构建耗时越长。对于百万级以内的数据集,`m=16, ef_construction=200` 是质量和速度的平衡点。 --- ## 四、V3.0:让 AI 真正理解你的文档 —— RAG 问答系统 ### 4.1 从检索到回答,中间还差什么? V2.0 完成后,我已经有了两路检索能力:PGvector 的向量语义检索和 Elasticsearch 的关键词全文检索。理论上,拿到检索结果后喂给 LLM,就能生成回答了。 但实际试了一下,发现效果远不如预期。问题出在哪里? **问题一:用户的问题千奇百怪** 用户问"这玩意儿怎么搞?",直接拿去检索,向量相似度很低;但如果改写为"文档上传流程和操作方法",检索效果就好很多。这就是**查询规划(Query Planning)**要解决的问题。 **问题二:两路检索结果怎么融合?** 向量检索返回的相似度分数是 0 到 1 的浮点数,ES 返回的 BM25 分数可能是 0 到几十——两种分数不在同一个尺度上,没法直接比较。这就是**RRF 融合排序**要解决的问题。 **问题三:检索到的证据够不够?** 有时检索回来的内容跟问题其实关系不大,如果强行让 LLM 回答,它还是会"编"。这就是**证据评估**要解决的问题。 ### 4.2 RAG 问答的完整流程 ```mermaid sequenceDiagram participant User as 用户 participant QA as QaService participant QP as QueryPlanningService participant LLM as 大模型 (DashScope) participant Vector as PGvector 向量检索 participant ES as Elasticsearch 关键词检索 participant RRF as RRF 融合引擎 participant Eval as 证据评估器 User->>QA: 提问:"这玩意儿怎么搞?" QA->>QP: 查询规划 Note over QP,LLM: LLM 分析问题,决定策略 QP->>LLM: 分析问题类型 LLM-->>QP: 策略: REWRITE<br/>原问题 + "文档上传流程和操作方法" Note over QP: 并行双通道检索 (最多3条查询) par 向量语义检索 QP->>Vector: search("文档上传流程和操作方法", topK=50) Vector-->>QP: 50条语义匹配结果 (COSINE_DISTANCE) and 关键词全文检索 QP->>ES: search("文档上传流程和操作方法", topK=50) ES-->>QP: 50条关键词匹配结果 (BM25) end QP->>RRF: 100条候选 → RRF 融合排序 Note over RRF: 1/(k+rank) 统一评分<br/>类簇聚合 + 邻居窗口扩展 RRF-->>Eval: 证据束 (合并后的文档片段) Eval->>Eval: 四级证据评估 alt 证据不足 (NONE) Eval-->>User: "抱歉,未找到相关文档,无法回答此问题。" else 证据有限 (WEAK) Eval->>LLM: 生成回答 (标注"依据有限") LLM-->>User: 回答 + 引用 (标注局限性) else 证据充分 (SUFFICIENT) Eval->>LLM: 生成回答 (基于证据) LLM-->>User: 回答 + 引用溯源列表 end ``` ### 4.3 RRF 融合排序:让向量和关键词"握手" RRF(Reciprocal Rank Fusion)是一个优雅的算法。它的公式简单到只有一行: \[ RRF\_score(d) = \sum_{c \in channels} \frac{1}{k + rank_c(d)} \] 其中 `k=60` 是一个平滑参数。这个公式的妙处在于: - 它在两个通道的排名上做文章,而不是原始分数——这就完美解决了分数尺度不统一的问题 - 某个文档在向量检索中排第 1、在关键词检索中排第 10,它的 RRF 分数是 `1/(60+1) + 1/(60+10) = 0.0164 + 0.0143 = 0.0307` - 另一个文档在向量检索中排第 3、在关键词检索中排第 3,它的 RRF 分数是 `1/(60+3) + 1/(60+3) = 0.0317` 可以看到,双通道都排名靠前的文档最终得分更高——这正是我们想要的:**两个通道"交叉验证"过的结果更可信**。 融合之后还有两步优化: - **类簇聚合**:同一个文档中连续的几个切片如果都命中了,就合并为一个"证据单元",提供更完整的上下文 - **邻居窗口扩展**:每个命中的切片向前后各扩展 1 个切片,补充上下文避免碎片化 ### 4.4 四级证据评估:AI 的"自知之明" 这是整个 RAG 系统中我最喜欢的设计。在传统的"搜索 + GPT"方案中,LLM 总是会尝试回答——即使检索到的内容完全不相关,它也会"编"一个听起来合理的答案。 我设计了一个**四级证据评估**机制: | 等级 | 触发条件 | 回答策略 | |------|---------|---------| | **NONE** | 检索结果为空 | 直接拒答,不调用 LLM(省钱!) | | **WEAK** | 仅单通道命中 + 文档数 < 2 | 生成回答但标注"依据有限,仅供参考" | | **PARTIAL** | 双通道命中 OR 文档数 ≥ 2 | 正常回答,但标注覆盖不足的方面 | | **SUFFICIENT** | 文档数 ≥ 2 AND (双通道命中 OR 最高分≥0.95) | 正常回答,禁止臆测 | 这个设计有两个关键考量: **文档数量门槛**:单文档证据即使高相关也可能是文档本身的偏向性——比如一篇产品宣传文可能过度夸大某个功能。至少 2 个不同文档的切片命中,交叉验证的可信度才足够高。 **NONE 级别直接拒答**:这是成本控制的关键。在 NONE 的情况下,LLM 根本不会被调用,既节省了 API 费用,也避免了"一本正经胡说八道"的尴尬。 ### 4.5 结构化输出与引用溯源 LLM 的输出格式不稳定是一个众所周知的痛点。我的解决方案是:通过精心设计的 System Prompt 要求 LLM 输出 JSON 格式: ```json { "answered": true, "answer": "文档上传流程分为三个阶段...", "reasonCode": null, "reasonMessage": null } ``` 同时准备了一个**回退解析器**——如果 LLM 输出的 JSON 解析失败(偶尔会发生),就用正则表达式从原始文本中尝试提取 `answered` 字段和 `answer` 内容。这确保了系统在 LLM 输出异常时也能优雅降级,而不是直接报错。 每条回答都会附带引用列表(Citations),包含来源文档名、切片序号、相关性评分。这让用户可以追溯到回答的依据——"这条信息来自哪个文档的哪一段"。 --- ## 五、V4.0:让 AI 拥有"记忆" —— AI 助手 Agent ### 5.1 从"一次性问答"到"多轮对话" V3.0 的 RAG 问答虽然强大,但有一个明显的局限:**每次提问都是独立的**。你问"什么是 RAG?",AI 回答了;你再问"那它有什么优势?",AI 不知道"它"指的是 RAG。这就像每次对话都在跟一个失忆的人聊天。 V4.0 的目标就是解决这个问题——让 AI 助手具备**上下文感知**的多轮对话能力。 ### 5.2 ReactAgent:统一对话引擎 在技术选型上,我选择了 Spring AI Alibaba 的 **ReactAgent 图执行引擎**。这是一个基于图(Graph)的执行框架,支持"思考→行动→观察→再思考"的循环模式: ```mermaid graph TB START([START]) --> BEFORE_HOOK[BEFORE_MODEL Hook<br/>注入会话上下文] BEFORE_HOOK --> AGENT_MODEL[Agent 模型推理] AGENT_MODEL -->|CHAT 模式| AGENT_FINISH{判断} AGENT_MODEL -->|KB_SEARCH 模式| TOOL_DECISION{需要调用工具?} TOOL_DECISION -->|是| TOOL_EXEC[执行知识库检索工具<br/>QaRetrievalService] TOOL_EXEC --> TOOL_OBSERVE[工具结果观察] TOOL_OBSERVE --> AGENT_MODEL TOOL_DECISION -->|否| AGENT_FINISH AGENT_FINISH -->|递归次数 < 10| AGENT_MODEL AGENT_FINISH -->|完成 / 超限| END_NODE([END]) style BEFORE_HOOK fill:#e1f5fe style TOOL_EXEC fill:#fff3e0 style AGENT_MODEL fill:#e8f5e9 ``` 这里有一个有意思的设计决策:即使是纯对话模式(CHAT),我也让它走 ReactAgent 的图执行引擎,只是不装配任何工具。这样做的好处是: - 两种模式共用同一套 Hook、流式处理、错误处理逻辑 - 代码复用度高,不需要维护两套对话处理流程 - 虽然 CHAT 模式走 Agent 图有一定的微小开销(图节点调度、MemorySaver checkpoint),但在 LLM 调用时延(数秒级)面前可以忽略不计 ### 5.3 短期记忆三级压缩策略 多轮对话面临的核心矛盾是:**LLM 的上下文窗口有限(即使是最新的模型也有上限),但对话历史会无限增长**。 简单的"滑动窗口"方案(只保留最近 N 条消息)在长对话中会丢失早期的关键信息。比如用户在对话开始时说"我在做一个电力行业的项目",30 轮对话后如果你忘了这个背景,AI 的回答可能就完全跑偏了。 我设计了一个**三级渐进压缩**策略: ```mermaid graph LR subgraph 消息层 M1[msg 1-10] -->|超过 20 条消息<br/>或 8000 token| SUM[summary_text<br/>简明摘要] M2[msg 11-15] M3[msg 16-20] end subgraph 摘要层 SUM -->|增量 LLM 摘要<br/>每 +4 条消息触发| MEM[session_memory<br/>会话记忆] M2 --> MEM end subgraph 记忆层 MEM -->|超过 6500 token<br/>更精细压缩| COMPACT[compact_summary<br/>紧凑摘要] end subgraph 运行时 COMPACT -->|超过 50000 token<br/>最后防线| TRUNCATE[Runtime Compact<br/>只保留末尾3条] end style SUM fill:#fff9c4 style MEM fill:#ffcc80 style COMPACT fill:#ef9a9a style TRUNCATE fill:#f44336,color:#fff ``` **第一级:summary_text(会话摘要)** 当会话消息数超过 20 条或 token 估算超过 8000 时触发。规则很简单:保留最近 N 条原始消息,将更早的消息压缩为"用户问了什么,助手回答了什么"的格式文本。摘要可复用——如果 7 天内没有新消息,直接使用已有的摘要。 **第二级:session_memory(会话记忆)** 这是最精妙的一级。每当新增 4 条消息或新增 token 超过 1200 时,调用 LLM 进行**增量更新**——不是重新摘要全部历史,而是把新消息"合并"到已有记忆中。Prompt 模板引导 LLM 保留关键事实、用户偏好和重要决策,丢弃临时性的寒暄和重复内容。 例如,用户可能在对话中多次提到"我在电力行业工作"、"我们的巡检手册要求..."——这些信息会被 session_memory 保留下来,而"好的"、"谢谢"、"明白了"之类的废话会被丢弃。 **第三级:compact_summary(紧凑摘要)** 当会话总 token 超过 6500 时触发。这是对 session_memory 的进一步压缩——基于"现有 compact_summary + session_memory + 待压缩消息"生成更精炼的版本。Prompt 引导 LLM 只保留最核心的信息。 **运行时压缩(最后防线)** 如果前面的压缩机制全部失效(理论上不应该发生),还有一个硬编码的 50000 token 阈值。超过时直接截断消息列表,只保留末尾 3 条。这是一个"逃生舱",确保系统永远不会因为上下文溢出而崩溃。 ### 5.4 BEFORE_MODEL Hook:无侵入的上下文注入 你可能会问:这些摘要和记忆是怎么"喂"给模型的? 这就用到了 ReactAgent 框架提供的 **MessagesModelHook** 机制。我实现了一个 `BEFORE_MODEL` Hook,在每次模型调用之前自动执行: 1. 从 `RunnableConfig` 的 metadata 中读取 `userId`、`sessionId`、`toolMode`、`groupId` 2. 从数据库中加载该会话的 `compactSummary`、`sessionMemory`、最近消息 3. 按顺序组装消息列表: ``` [compact summary 作为系统消息] → [session memory 作为系统消息] → [历史消息 1] → [历史消息 2] → ... → [工具调用结果(如果有)] → [当前用户问题] ``` 4. 使用 **REPLACE 模式**完全替换 Agent 框架默认的消息列表 这整个过程中,Agent 的业务代码完全不需要关心上下文是怎么组装的——Hook 在框架层面自动完成了全部工作。这种"无侵入"的设计让代码保持了很高的内聚性。 ### 5.5 SSE 流式输出与 Delta 去重 流式输出听起来简单——模型生成一个字就推送一个字——但实际上有一个坑:某些模型后端在流式模式下返回的不是**增量 delta**,而是**截至当前的全文**。如果直接透传给前端,用户会看到不断重复的前缀文字。 我的解决方案是用一个 `StringBuilder` 持续累积已推送的文本。每次收到新文本时,检查它是否以已累积的文本为前缀——如果是,就裁掉前缀,只把真正的增量推送给前端: ``` 第 1 次收到: "RAG" → 推送 "RAG" 第 2 次收到: "RAG(检索增强" → 推送 "(检索增强" 第 3 次收到: "RAG(检索增强生成)是一种" → 推送 "生成)是一种" ``` 同时还有一个 `AGENT_MODEL_FINISHED` 兜底路径——某些模型不走逐字流式通道,而是直接在 finished 节点返回全文。此时如果 `finalReply` 为空,就将完整文本作为一次性 delta 发送。 --- ## 六、前后端分离与实时通信 ### 6.1 前端技术选型 前端我选择了 **Vue 3 + TypeScript + Element Plus** 的组合。选择 Vue 3 的理由很简单:它的 Composition API 让组件逻辑的组织更加清晰,TypeScript 的类型系统能在编译期就发现大量潜在问题。 状态管理使用了 **Pinia**(Vue 3 官方推荐的状态管理库),相比 Vuex 更加轻量且 TypeScript 支持更好。Markdown 渲染使用了 **marked** 库,支持 GFM(GitHub Flavored Markdown)语法。 ### 6.2 流式对话的前端实现 SSE 流式对话的前端实现使用 `fetch` API + `ReadableStream`: ```typescript const response = await fetch('/api/assistant/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ sessionId, message, toolMode, groupId }) }) const reader = response.body!.getReader() const decoder = new TextDecoder() while (true) { const { done, value } = await reader.read() if (done) break // 解析 SSE 事件:event:delta\ndata:{"delta":"新文本"}\n\n // 将增量文本追加到显示缓冲区,实现打字机效果 } ``` 前端收到 `delta` 事件后,将文本增量追加到消息显示区域,实现逐字打印的打字机效果。`done` 事件到达后,将完整消息保存到 Pinia store 中。 --- ## 七、未来发展规划 项目目前已经完成了 V4.0,但这只是开始。我计划在后续版本中逐步增加以下能力: ### V4.1 — 体验优化 - **会话记忆可视化**:在前端展示压缩摘要内容,让用户了解 AI "记住了什么" - **消息分页加载**:当前只支持加载最近 N 条消息,长会话需要分页支持 - **会话归档与恢复**:将不活跃的会话归档,需要时再恢复 ### V4.2 — 工具扩展 - **更多 Agent 工具**:文档管理工具(列出文档、搜索文档)、群组管理工具(查看成员、查看统计) - **多工具协作**:Agent 可以在同一轮对话中调用多个工具,处理更复杂的用户请求 - **工具调用可视化**:在前端展示 Agent 的"思考过程"——它调用了哪些工具、得到了什么结果 ### V4.3 — 对话增强 - **对话分支**:从任意消息节点创建分支对话,探索不同的回答方向 - **消息编辑与重新生成**:编辑已发送的消息,让 AI 基于修改后的内容重新回答 - **Prompt 版本管理**:支持不同版本的 System Prompt,方便 A/B 测试 ### V5.0 — 重大升级 - **多模态支持**:除了文本文档,支持图片、表格等多模态内容的检索与问答 - **WebSocket 升级**:将 SSE 替换为 WebSocket,支持双向实时通信 - **前端管理控制台全面升级**:文档管理、群组管理、问答历史、数据统计等功能的完整控制台 - **消息队列迁移**:将 Spring Event 异步机制升级为 RabbitMQ/Kafka,支持分布式 Worker 调度 ```mermaid gantt title Argus 版本演进路线图 dateFormat YYYY-MM axisFormat %Y-%m section 已完成 V1.0 基础设施 :done, v1, 2025-12, 2026-01 V2.0 文档引擎 :done, v2, 2026-01, 2026-02 V3.0 RAG问答 :done, v3, 2026-02, 2026-03 V4.0 AI Agent :done, v4, 2026-03, 2026-04 section 计划中 V4.1 体验优化 :active, v41, 2026-05, 2026-06 V4.2 工具扩展 :v42, 2026-06, 2026-07 V4.3 对话增强 :v43, 2026-07, 2026-08 V5.0 重大升级 :v5, 2026-08, 2026-10 ``` --- ## 八、学习心得与经验总结 ### 8.1 从文档出发,而不是从教程出发 整个开发过程中,我最大的感受是:**官方文档是最好的学习资料**。 Spring AI Alibaba 的官方文档写得很用心——不仅告诉你 API 怎么用,还解释了背后的设计理念。比如关于 Chat/Embedding 分离提供者的说明,让我理解了为什么 Embedding 要走 OpenAI 兼容模式而不是 DashScope 原生 API(因为 Spring AI 的 OpenAI embedding 客户端更成熟稳定)。 相比之下,网上的很多教程往往只给代码不给原理,看完之后知其然不知其所以然。遇到稍微复杂一点的需求,就束手无策了。 ### 8.2 渐进式迭代的力量 这个项目分了四个版本,每个版本聚焦一个主题。这种方式让我在每个阶段都能保持专注,不会被过多未完成的功能分散注意力。 更重要的是,每个版本的交付物都是**可用的**——V1.0 有可用的认证系统,V2.0 有可用的文档上传和检索,V3.0 有可用的 RAG 问答,V4.0 有可用的 Agent 对话。这种"每一步都有交付"的开发节奏不仅给了我持续的正反馈,也让我在每个阶段都能进行完整的测试和验证。 ### 8.3 遇到的技术挑战与解决思路 **挑战一:Markdown 预览变成纯文本** 在开发文档预览功能时,我发现 MD 文件预览只显示纯文本,没有任何格式。排查后发现,后端 `MdDocumentParser.stripMarkdown()` 方法会主动剥离所有 Markdown 语法(`#`、`**`、列表标记等),返回的是处理后的纯文本。修改方案是让 MD 文件绕过解析器,直接从 MinIO 读取原始内容返回给前端,由前端的 `marked.js` 完成渲染。 **教训**:调试时要沿着完整的数据链路排查——从前端请求到后端处理再到数据存储,任何一个环节都可能是问题所在。 **挑战二:SSE 流式输出的 Delta 去重** 前面提到过,某些模型后端返回的是全文而不是增量。这个问题花了我不少时间排查——一开始我以为是前端解析 SSE 事件的逻辑有 bug,后来才发现是后端推送的内容本身就是重复的。 **教训**:不要假设第三方组件的行为一定符合预期。即使文档上说"流式推送",实际行为也可能因模型后端的不同而有差异。 **挑战三:短期记忆的并发写入冲突** 在多轮对话中,用户发送消息后,`BEFORE_MODEL Hook` 和 `AFTER_AGENT` 回调都可能触发记忆更新。如果两个操作同时尝试更新 `assistant_session_contexts` 表,就会产生并发冲突。我的解决方案是使用**乐观锁**——在更新 SQL 的 WHERE 条件中加入 `context_version = #{expectedVersion}`,更新失败(影响行数为 0)时抛出异常回滚事务。 **教训**:在涉及状态变更的系统中,并发控制是一个必须从一开始就考虑的问题。 ### 8.4 给想入门 RAG 开发的建议 如果你也想从零开始构建一个 RAG 应用,我的建议是: 1. **先理解原理,再动手写代码**。搞清楚 Embedding 是什么、向量检索怎么工作、RAG 的完整链路是怎样的——这些基础知识会让你在遇到问题时更容易定位原因。 2. **从最简单的实现开始**。先用最直接的方式跑通"文档上传 → 向量检索 → LLM 回答"这个核心链路,然后再逐步优化检索质量、增加 Agent 能力、引入记忆管理。 3. **Spring Boot + Spring AI Alibaba 是一个很好的起点**。如果你有 Java 基础,这个组合让你可以在熟悉的生态中快速构建 AI 应用,不需要额外学习 Python 或 LangChain。 4. **记录你的踩坑过程**。我在开发过程中养成了记录遇到的问题和解决方案的习惯,这不仅帮助我自己理清思路,也让我在写这篇文章时能够回顾当时的思考过程。 --- ## 写在最后 从一行代码都没有,到最终交付一个包含认证授权、文档管理、ETL 流水线、混合检索、RAG 问答、Agent 对话、短期记忆管理的完整平台,这段旅程让我深刻体会到:**AI 应用开发不是"调 API"那么简单,它需要你对检索、存储、并发、架构等基础工程能力有扎实的理解。** 但正是这种"全栈"的挑战,让整个过程充满了乐趣和成就感。 如果你对这个项目感兴趣,欢迎访问 [GitHub 仓库](https://github.com/DevYangJC/Argus) 查看完整源码,也欢迎提 Issue 和 PR 一起讨论改进。 **让每一次提问都有据可查 —— 这是 Argus 的初心,也是我对 AI 应用开发的信念。** --- *本文同步发布于掘金、知乎、CSDN等平台。转载请联系作者。*
RAG 是什么?16 种 RAG 方案一次讲清!AI 应用开发必学 | 万字干货
大家好,我是程序员鱼皮。 最近这两年,只要你接触过 AI 编程,大概率听过一个词,RAG(Retrieval-Augmented Generation)。 但很多小伙伴对 RAG 都只是一知半解,导致面试的时候只能说出 “检索增强生成” 这六个字,面试官再多问一点,就只能 “阿巴阿巴”。 而这篇文章,是对 RAG 技术的一个全景科普,从最初的 Naive RAG、到现在最主流的 Agentic RAG,总共 16 种主流 RAG 方案,我会一次性给大家讲清楚。以后开发 AI 应用的过程中,无论遇到什么场景,都能选择最合适的 RAG 方案。  干货比较多,建议先点赞收藏,再慢慢往下看~ ## 什么是 RAG? AI 大模型有一些硬伤,比如: 1. 知识有截止日期 2. 会一本正经地胡说八道,也就是我们常说的幻觉 3. 缺乏私有知识,了解不到内部的文档写了什么 比如问 DeepSeek:程序员鱼皮的最新项目是什么? 结果它给我扯了个两年半以前的项目出来,技术栈也完全不对!  解决这个问题就可以用 RAG。RAG 的核心思想是 **先搜再答**,让大模型在回答之前先去搜一遍相关资料,再基于搜到的知识来组织答案。 就跟考试的时候偷偷翻书一样,遇到不会的先翻一翻书,再根据书里的知识答题。  还是问 AI 同样的问题,我们主动给 AI 一些参考资料,他的回答就会准确一些:  这个思路听起来简单,但在实际工程上 RAG 已经演化出了很多种不同的实现方法,从最初的「切块 → 搜索 → 生成」,到让 AI Agent 自主决策检索策略的 Agentic RAG,复杂度和能力天差地别。 有朋友可能会问:现在的大模型不是已经支持百万 token 的上下文窗口了,还需要 RAG 吗? 答案是:**需要,而且用得比以前更多了!** 因为把所有文档塞进上下文窗口,既贵又不靠谱。上下文越长 token 费用越高,而且大模型普遍存在 “Lost in the Middle” 问题,顾名思义,就是对超长上下文中间部分的注意力会明显下降。 这个也不难理解,就像听别人说话一样,我们对开头和结尾的印象会相对深刻一些,中间的总是容易忘记。 不过呢,RAG 和长上下文也不是互斥的关系,现在一般的最佳实践是先用 RAG 给 AI 提供相对精确的资料,再利用长上下文窗口进行有针对性的分析推理,两者互补。 好,背景交代完了。下面我们正式进入主题,挨个讲讲每种 RAG 方案。 我们先从最基础的 RAG 讲起,如果你之前完全没接触过 RAG,看完这一节就能理解它的核心原理。 ## 主流 RAG 方案 ### 标准 RAG 及变体 #### Naive RAG Naive RAG 这个词听起来就很牛对不对? 但其实,Naive 是朴素的意思,Naive RAG 是最基本的 RAG 实现方案。 假设你有一份 200 页的公司员工手册,怎么让 AI 基于里面的内容回答员工的提问呢? 最简单粗暴的做法就是每次提问都把整本手册塞给 AI 大模型。 但是一本手册可能几十万字,全塞进去又贵又慢,而且上下文一长,大模型还容易犯前面提到的 “Lost in the Middle” 的毛病,导致回答质量下降。 所以更合理的思路是:员工问什么,我们就只把相关的几段内容塞给 AI。 那问题又来了,怎么定位到相关的那几段呢? 如果用关键词匹配,很容易出现问题和文档里的关键词不一致的问题,比如员工问 “老板不批假怎么办”,文档里写的是 “请假审批流程”,关键词对不上,就搜不到。 这就需要用到 **向量** 了。 所谓向量,就是把一段文字用一串数字表示出来,让计算机可以比较语义上的相似度。 举几个例子感受一下: | 文本 | 向量(简化示意) | | ------------ | ----------------------- | | 我喜欢吃鱼 | [0.21, 0.85, 0.13, ...] | | 我爱吃海鲜 | [0.23, 0.82, 0.15, ...] | | 今天天气真好 | [0.88, 0.12, 0.41, ...] | 语义越接近的句子,它们的向量在数学空间里离得越近。 负责把文字转成向量的模型,叫 Embedding 模型;存储这些向量并支持快速相似度搜索的数据库,叫向量数据库,比如 Milvus、Chroma、Qdrant 等。 理解了向量,Naive RAG 的做法就很好理解了,主要分为两步。 第一步是离线索引: 1. 把文档切成小块(chunk),每块几百字 2. 用 Embedding 模型把每个小块转成向量 3. 把向量和对应原文都存进向量数据库  第二步是在线查询问答: 1. 把用户问题也用 Embedding 模型转成向量 2. 去向量库里搜最相似的几个文档块(比如 Top 5) 3. 把这几个块和用户问题拼成 Prompt,交给大模型生成回答 回到开头那份 200 页的员工手册。我们先把它切成几百个小块并向量化入库,当员工问 “年假有多少天?” 的时候,RAG 的执行流程是这样的: 1. 系统把问题转成向量 2. 在向量库里找到最相似的 5 个文档块(比如某块写着 “入职满一年享有 10 天年假,满三年 15 天…”) 3. 把这 5 个块连同问题一起交给大模型 4. 大模型回答:“入职满一年享有 10 天年假”  不过 Naive RAG 也有一些比较明显的问题: - 切块方式粗暴,可能把一段完整的语义从中间截断 - 检索质量完全依赖 embedding 模型,搜不到就没辙 - 搜到了垃圾文档也不管,导致输出错误答案 这些局限,就是后面所有进阶方法要解决的问题。 下面我用伪代码来帮助大家理解,不熟悉编程的同学可以跳过,不影响后续的学习~ 1)离线索引阶段: ```python # 1. 把文档切成小块,chunk_size=500 表示每块 500 字, # chunk_overlap=50 表示相邻块之间重叠 50 字,避免关键信息被切断 chunks = split_into_chunks(documents, chunk_size=500, chunk_overlap=50) # 2. 把每个小块转成向量,连同原文一起存入向量数据库 for each chunk in chunks: vector = embedding_model.encode(chunk) # 调用 Embedding 模型编码 vector_store.insert(vector, chunk) # 向量和原文一起入库 ``` 2)在线查询阶段: ```python # 1. 用同一个 Embedding 模型把用户问题也转成向量 query_vector = embedding_model.encode(user_query) # 2. 在向量库里搜最相似的 Top 5 文档块 top_k_chunks = vector_store.search(query_vector, k=5) # 3. 把检索到的文档块拼进 Prompt,作为参考资料 prompt = "基于以下参考资料回答问题:\n" + join(top_k_chunks) + "\n问题:" + user_query # 4. 交给大模型生成最终回答 answer = LLM.generate(prompt) ``` #### Multi-Query RAG 用户提问的方式千奇百怪。比如同样是想知道公司报销流程,有人会问怎么报销,有人会问费用审批流程是什么,还有人会问花了钱怎么找公司要回来。 但文档里可能只写了 “报销申请流程” 这个表述,如果用户的措辞和文档差距太大,向量检索就可能搜不到正确的内容。 Multi-Query 的思路就是:既然一种问法搜不全,那就让大模型把原始问题改成多种不同的表述,分别去搜,最后把结果合并去重。  这种方法的代价就是每次提问要多调用一次 LLM 做改写,再多跑 N 次向量检索,延迟和成本都会增加。 而且如果 LLM 改写出的问题方向跑偏,会把无关文档也带进来,影响答案质量。 所以它比较适合 **面向普通用户的客服、电商等场景**,用户表述口语化、和文档术语差距大,多花这点成本还是有必要的。但是在术语规范的专业领域,Multi-Query 的收益就很有限了。 Multi-Query RAG 的代码实现如下: ```python # 1. 让 LLM 把原问题改写成多个表述 queries = LLM.generate("请将以下问题改写成 3 个不同的表述:" + user_query) // 例如 AI 返回: ["报销流程是什么", "费用审批怎么操作", "如何提交报销申请"] # 2. 每个表述分别走一次向量检索 all_results = [] for each query in queries: results = vector_store.search(embed(query), k=5) all_results.append(results) # 3. 合并去重,得到覆盖面更广的候选文档 merged_chunks = deduplicate(all_results) # 4. 把合并后的文档 + 原始问题交给大模型生成回答 answer = LLM.generate(merged_chunks + user_query) ``` #### HyDE AI 回复的效果不好,可能不是因为用户的问法不好,而是用户的问题和文档的语义空间不一致。 用户的提问往往很短,比如 “KV Cache 是什么?”,就这么几个字。 但文档里关于 KV Cache 的描述可能是一大段技术解释。一短一长,在 embedding 空间中可能离得很远,就检索不到了。 HyDE 的做法就是让大模型凭空写一个答案(不必完全准确),然后用这段假答案的向量去检索。因为假答案和真文档的文体更接近,两者在向量空间中离得也更近。 还是上面的例子,用户问:“KV Cache 是什么?” LLM 先编一段假答案:“KV Cache 是一种在 Transformer 推理过程中缓存 Key 和 Value 矩阵的优化技术,可以避免重复计算…” 这段假答案虽然不一定完全准确,但它的向量和真实文档里关于 KV Cache 的那段描述非常接近,所以能精准命中正确的文档。  不过 HyDE 也有一个风险,如果 LLM 编的假答案方向完全跑偏了(比如把 KV Cache 理解成了 Redis 缓存),那检索结果就会更差。所以它比较适合 LLM 对问题领域有基本认知的场景,冷门领域或企业私有术语慎用。 HyDE 的代码示例如下: ```python # 1. 让 LLM 先凭空生成一段“假答案”(不必完全准确) hypothetical_answer = LLM.generate("请回答:" + user_query) # 2. 用假答案的向量去检索,因为它的文体更接近真实文档 hyp_vector = embedding_model.encode(hypothetical_answer) top_k_chunks = vector_store.search(hyp_vector, k=5) # 3. 把检索到的真实文档 + 原始问题交给大模型生成最终回答 answer = LLM.generate(top_k_chunks + user_query) ``` 上面这三种方法,解决的是「能不能搜到」的问题。但搜到之后,资料的质量好不好呢?这就是下面要处理的了。 ### 提升检索质量 #### 语义分块(Semantic Chunking) Naive RAG 里最粗暴的一步就是切块,每 500 个字切一刀,管你是不是正好切在一句话中间。 比如某块文本是 “员工请假需要提前 3 天申请,超过 5 天需要” 刚好在这里被截断了,下一个块从 “部门经理审批” 开始。 这两个块单独看都不完整,检索和 AI 的理解都会打折扣。 虽然可以通过 chunk_overlap 设置相邻块之间重复内容的长度,但是效果相对有限。 语义分块的做法是先把文档按句子拆开,计算相邻句子的 embedding 相似度,当相似度突然下降时,说明话题变了,就在这里切一刀,尽量保证每个句子的语义都是完整的:  其余的步骤就跟 Naive RAG 一致了。 不过这种方式代价也不小,首先要为每一句话都算一次 embedding,成本和耗时比按字数切分要高。 而且相似度阈值非常难调,所以它比较适合结构松散、话题变化比较快的文档,像会议纪要、访谈记录这些。 对于本身就有清晰章节结构的技术手册、产品说明文档,直接按标题切效果也不差,还更便宜。 语义分块的伪代码示例如下: ```python # 1. 把文档拆成单句 sentences = split_into_sentences(document) chunks = [] current_chunk = [sentences[0]] # 2. 遍历相邻句子,计算 embedding 相似度 for i from 1 to len(sentences) - 1: similarity = cosine_similarity(embed(sentences[i-1]), embed(sentences[i])) # 相似度骤降,话题切换 if similarity < threshold: chunks.append(join(current_chunk)) current_chunk = [] current_chunk.append(sentences[i]) ``` #### 层级索引(Parent-Child Retrieval) 切块这件事有一个天然矛盾。切得小吧,检索精度高但上下文不足;切得大吧,上下文丰富但噪声也大。 Parent-Child Retrieval 的策略则是 **两层都要**。 先把文档切成大块,再把每个大块细分成小块。检索时用小块匹配,命中后返回它所属的大块。 相当于在书里搜到了某一句话时,读的时候把它所在的整个章节都拿过来看:  学过数据库的朋友们应该对这种思路不陌生吧? 这种方案就很适合长文档场景,比如技术手册、法律合同、产品文档这些内容,往往需要连带上下文一起看才能理解。 层级索引的伪代码实现如下: ```python # 1. 离线阶段:文档先切大块,再在大块内切小块 for each document: parent_chunks = split_into_sections(document) for each parent in parent_chunks: child_chunks = split_into_paragraphs(parent) # 只对小块建向量索引,但保留它属于哪个大块 for each child in child_chunks: index.insert(embed(child), child, parent_id=parent.id) # 2. 在线阶段:用小块做精确匹配 matched_children = index.search(embed(query), k=5) # 3. 但返回的是小块所属的大块,保证上下文完整 parent_ids = unique([child.parent_id for child in matched_children]) context = [get_parent_chunk(pid) for pid in parent_ids] # 4. 把大块作为上下文交给大模型 answer = LLM.generate(context + query) ``` #### Hybrid Search 混合检索 纯向量检索有一个缺点,就是无法精确术语匹配。 比如用户问:“ERROR_CODE_4012 是什么意思?” 向量检索会去找语义相似的内容。 但这种编码本身没什么语义,向量搜索可能找到一堆讲错误处理的段落,就是找不到那个精确提到 4012 的段落。 而传统的关键词搜索擅长精确匹配,但不理解语义。 比如搜 “如何退款”,就搜不到写着 “退货及返还货款流程” 的文档,类似数据库里的 like 操作。 Hybrid Search 就是同时使用两种搜索,然后合并排序。向量搜索负责语义理解,BM25 负责精确匹配,通过 RRF(Reciprocal Rank Fusion 倒数排序融合)算法,把两边的结果合并成一个排序。  几乎所有生产环境都建议用 Hybrid Search 替代纯向量搜索,尤其是像技术文档、医疗、法律等术语密集的领域。 Hybrid Search 混合检索的实现代码如下: ```python # 1. 同时跑两路检索,各自召回 Top 20 semantic_results = vector_store.search(embed(query), k=20) keyword_results = bm25_index.search(query, k=20) # 2. 用 RRF 算法融合两路结果,公式:1 / (60 + 排名),分数越高越相关 for each doc in (semantic_results ∪ keyword_results): score = 0 if doc in semantic_results: score += 1 / (60 + rank_in_semantic) if doc in keyword_results: score += 1 / (60 + rank_in_keyword) # 3. 按融合后的分数排序,取 Top 5 交给大模型 final_results = sort_by_score(all_docs, top_k=5) answer = LLM.generate(final_results + query) ``` #### Reranking 精排 不管是向量搜索还是 Hybrid Search,检索回来的候选文档里总会混着一些看起来相关但实际没用的噪声。 Reranking 的做法是在检索和生成之间加一个精排步骤:用 Reranker 模型给每对 (query, doc) 重新打分。 前面提到的 embedding 模型是分别给 query 和 doc 算向量再比较距离,快但粗糙,Reranker 是把它们拼在一起送进模型打分,慢但精准。 在实际生产中,如果语料库有十万级以上的 chunk,一般会采用级联检索方案,分层筛选:  为什么分这么多层呢? 如果使用粗检索,只捞 20 个内容,在大型语料库里很容易漏掉关键文档;但如果一次性捞 150 个,全送进 Cross-Encoder 交叉编码器来精确计算两个文本片段的相关性分数,算力又扛不住。 分层筛选则是在召回率和计算成本之间找平衡。当语料库里的 chunk 比较多时,Reranking 的效果提升会非常显著。 可以这么理解,粗检索负责不遗漏,精排负责不掺假,跟推荐系统里的粗排和精排很像。 分层筛选的实现代码如下: ```python # 第一层:粗检索,保证召回率 candidates = hybrid_search(query, k=150) # 第二层:轻量 Reranker 初筛 semi_final = lightweight_reranker.rank(query, candidates, top_k=20) # 第三层:Cross-Encoder 精排,只处理 20 个 scored = [] for each doc in semi_final: score = cross_encoder.score(query, doc) scored.append((doc, score)) top_docs = top_k(scored, k=5) answer = LLM.generate(top_docs + query) ``` 到这里,我们已经能搭出一个相当不错的 RAG 系统了,语义分块 + Hybrid Search + Reranking,这三板斧组合起来,就是大多数生产级 RAG 系统的基础配置。 前面的方法都在优化怎么搜得更准,但有一个更根本的问题没解决:如果搜到的全是垃圾,大模型还是会一本正经地基于这些垃圾内容生成答案。 这就像开卷考试带错了书,还照着抄了上去…… 所以接下来我们要聊一聊,怎么让 RAG 学会自我纠错。 ### RAG 反思机制 #### Corrective RAG(CRAG) 不管我们怎么优化检索,总会有搜不到或者搜歪了的情况,这时候大模型如果还硬着头皮拿这些内容去回答,那就避免不了一本正经地胡说八道。 而 CRAG 就是 **把搜到的资料过滤一遍** 来解决这个问题的。 具体做法就是在检索和生成之间插一个质检员,逐个审查检索到的文档是否和问题相关,然后根据审查结果走不同的分支。 - 打分高的,说明资料靠谱,直接喂给大模型生成答案 - 打分低的,说明内部知识库里压根没找着相关内容,干脆回退到 Web 搜索兜底 - 打分模糊的,就两边的结果合一起送进去  Corrective RAG 的示例实现代码如下: ```python # 1. 先跑一遍常规检索 docs = retriever.search(query, k=5) # 2. 逐个让 LLM 判断文档是否和问题相关 relevant_docs = [] for each doc in docs: score = LLM.judge("这段内容和问题相关吗?", query, doc) if score > threshold: relevant_docs.append(doc) # 3. 根据审查结果走不同分支 if len(relevant_docs) > 0: answer = LLM.generate(relevant_docs + query) else: new_query = LLM.rewrite(query) web_results = web_search(new_query) answer = LLM.generate(web_results + query) ``` 最关键的是那个 `else` 分支,如果内部知识库完全搜不到有用信息,CRAG 会自动回退到 Web 搜索(当然也可以是其他策略)。 #### Self-RAG CRAG 在检索阶段做了质检,但是生成阶段呢? 大模型完全有可能拿到了正确的参考资料,但回答的时候夹带私货,在答案里掺入了参考资料中根本没提到的内容。这就是生成阶段的幻觉。 Self-RAG 的思路是在整个流程中设置四个检查点,每一步都让模型自我审视: - 这个问题需要检索吗(Retrieve)? - 检索到的文档相关吗(IsRel)? - 我的回答有文档支撑吗(IsSup)? - 这个答案对用户有用吗(IsUse)?  Self-RAG 的示例实现代码如下: ```python # 1. 判断这个问题需不需要检索 need_retrieval = LLM.judge("这个问题需要外部知识吗?", query) if not need_retrieval: return LLM.generate(query) # 2. 检索 + 逐个过滤相关文档 docs = retriever.search(query, k=5) relevant_docs = filter(docs, where LLM.judge("和问题相关吗?") == true) # 3. 生成初步答案 answer = LLM.generate(relevant_docs + query) # 4. 检查答案的每个论断是否都有文档依据 is_supported = LLM.judge("答案中的每个论断都能在文档中找到依据吗?", answer, relevant_docs) if not is_supported: answer = LLM.regenerate(relevant_docs + query + "请严格基于参考资料回答") ``` 一般来说第三个检查点的价值相对较高,能在一定程度上避免 AI 的幻觉。 #### Adaptive RAG CRAG 和 Self-RAG 都在给 RAG 加流程来解决问题,但这样多了好几次 LLM 调用,增加了成本。 如果用户问的是 “你好” 或者 “今天星期几”,还要跑一遍完整的检索 + 质检 + 生成流程,有点开着坦克去买菜的意思,纯属浪费。 Adaptive RAG 在最前面加了一个路由器(分类器),先判断问题的复杂度,然后决定走哪条路线:  Adaptive RAG 的实现代码很简单: ```python complexity = classifier.predict(query) # 简单问题直接答 if complexity == "simple": answer = LLM.generate(query) # 一般问题检索一次 else if complexity == "moderate": docs = retriever.search(query) answer = LLM.generate(docs + query) # 复杂问题采用 crag else: answer = run_full_crag_pipeline(query) ``` 这个分类器可以是一个微调的小模型,也可以用 LLM 的 few-shot 少样本提示来实现,关键是让简单问题和复杂问题分别处理。 这种方案适合流量混杂的场景,比如既有 “公司地址在哪” 这种一句话能答的问题,又有 “对比 A 和 B 两个方案的优缺点” 这种需要多文档综合分析的复杂问题。 ------ 上面这些方法处理的都是非结构化文本,但如果我们的数据是关系网络或者表格呢?那就需要下面的方法了。 ### 结构化知识增强 #### GraphRAG 前面这些方法都有一个共同的问题,就是答案必须落在某一个文档块里。 但现实里经常有一类问题:答案散落在多个文档中,需要串起来推理。 举个例子,假设文档库里有两段话。 - 文档 A 写着 “张三是 AI 部门的负责人” - 文档 B 写着 “AI 部门属于技术中心” 用户问:“张三属于哪个中心?” 传统向量检索大概率只能搜到文档 A,但要回答这个问题,必须把 A 和 B 连起来推理:张三 → AI 部门 → 技术中心。 这种跨文档的多跳推理,纯向量搜索就很难搞定了。 GraphRAG 就是用来解决这种问题的,这是 Microsoft Research 在 2024 年提出的方法。  它的思路是先把文档变成知识图谱,再基于图谱来检索和推理。 具体做法分为 3 步: 1. 用 LLM 逐篇读文档,抽取里面的实体(人、部门、产品等)和关系,构建成一张图谱 2. 用 Leiden 算法对图谱做社区划分,把关联紧密的实体聚成一团,然后让 LLM 为每个社区生成一段摘要 3. 提问时先定位到相关实体,沿着关系拿到子图  根据微软的评测,在全局语义理解类问题上,GraphRAG 答案的全面性和多样性显著优于传统向量 RAG。 但对于简单的事实查询,两者效果差不多,就没必要用了。 需要注意的是,GraphRAG 要用 LLM 逐篇抽实体关系,图谱构建成本比向量索引高得多,查询延迟也更大,所以使用 GraphRAG 之前一定要评估是否必要! GraphRAG 的伪代码实现如下: ```python # 1. 离线阶段:用 LLM 从每篇文档里抽取实体和关系,构建知识图谱 for each document: entities, relations = LLM.extract("请抽取文中的实体和关系", document) knowledge_graph.add(entities, relations) # 2. 用 Leiden 算法做社区划分,并为每个社区生成摘要(用于回答全局性问题) communities = leiden_algorithm(knowledge_graph) for each community: summary = LLM.summarize(community.entities, community.relations) # 3. 在线阶段:先定位相关实体,沿关系遍历 2 跳拿子图 relevant_entities = knowledge_graph.search("张三") subgraph = knowledge_graph.traverse(relevant_entities, hops=2) # 4. 把子图和社区摘要作为上下文交给大模型生成回答 answer = LLM.generate(subgraph + community_summaries + query) ``` #### Text-to-SQL RAG 如果我们的数据本身就是结构化的表格,比如销售数据、用户行为日志、财务报表这些,就不合适传统的 RAG 了,因为对表格数据做 embedding 是非常低效的。 用户问:“上个月销售额最高的产品是哪个?” 这本质上就是一条 SQL,向量搜索对这种聚合、排序、筛选类的需求完全没招。 Text-to-SQL RAG 的做法是让 LLM 直接把自然语言翻译成 SQL,执行查询,再把查询结果作为上下文来回答:  这个方案适合所有数据分析类需求,比如 BI 看板问答、数据库运维助手、财务报表查询等等,本质上是用 LLM 替代了手写 SQL。 不过要特别提醒,生产环境中绝对不能让 LLM 生成的 SQL 直接执行,必须配备只读权限控制、SQL 语法审计、沙盒隔离等安全措施,防止 SQL 注入。 Text-to-SQL RAG 的伪代码实现很简单: ```python # 1. 准备表结构(schema)作为提示,让 LLM 知道有哪些表、字段 schema = "表 sales: product(产品名), amount(金额), month(月份), region(地区)" # 2. 让 LLM 把自然语言问题翻译成 SQL sql = LLM.generate("根据以下表结构,将问题转为 SQL:\n" + schema + "\n问题:" + query) # 3. 在数据库中执行 SQL,拿到结构化结果 result = database.execute(sql) # 4. 把查询结果喂给 LLM,让它用自然语言组织成最终回答 answer = LLM.generate("查询结果:" + result + "\n请用自然语言回答:" + query) ``` 到这里,我们已经讲了很多种 RAG 方法了。有的小伙伴可能已经注意到一个问题:前面提到的大多数方法都是预定义好的 pipeline,流程是死的,不管什么问题进来,处理方式都差不多。 但现实世界的问题千变万化。有的需要搜向量库,有的该查数据库,有的应该搜 Web,甚至有的根本不需要检索。 好复杂啊…… 有没有一种方法能让系统自己判断该怎么做? 答案就是 Agentic RAG。 ### 智能体驱动 RAG #### Agentic RAG 前面每种方法都有自己擅长的场景:Hybrid Search 擅长术语密集的文档,GraphRAG 擅长多跳推理,Text-to-SQL 擅长结构化数据。 但在一个真实的系统中,这些场景可能同时存在。 比如用户问 “张三上个月的考勤记录” 得查数据库,问 “公司的远程办公政策” 得搜文档,问 “张三属于哪个部门的哪个中心” 得走知识图谱,但是为每种问题硬编码一条 pipeline 太麻烦了。 Agentic RAG 的做法是让一个 AI Agent 来自动调度,根据问题自主决定每一步该怎么做。给这个 Agent 配备一组检索工具,它会先搜搜看 → 看看结果够不够 → 不够就换个方式 / 换个关键词再搜 → 结果够了就生成回答。  Agentic RAG 虽然是很灵活的方案,但是代码实现很简单,核心是 Agent Loop 循环: ```python # 1. 给 Agent 配一组工具(向量检索、Web 搜索、SQL 查询、图谱遍历……) tools = { "vector_search": query -> vector_store.search(embed(query)), "web_search": query -> search_engine.search(query), "sql_query": query -> database.execute(LLM.to_sql(query)), "graph_traverse": query -> knowledge_graph.traverse(query), } # 2. 进入 ReAct 循环:思考 → 行动 → 观察,直到信息足够为止 context = [] while true: # 让 LLM 基于已知信息,决定下一步用哪个工具,或者直接回答 thought = LLM.reason("问题:" + query + "\n已知信息:" + context + "\n我应该使用哪个工具?还是信息已经足够可以回答了?") # 决定作答,跳出循环 if thought.action == "answer": return LLM.generate(context + query) # 否则调用对应工具,把结果追加到上下文,进入下一轮 result = tools[thought.tool](thought.tool_input) context.append(result) ``` 以前 Agent 的概念刚火起来的时候,大家还在争论 “Agent 自主决策靠不靠谱”。 到了今天,Agentic RAG 已经是比较主流的生产范式了,知名的 AI 编程工具 Cursor 用的就是这种方式,AI 自主决定使用什么方式来搜集信息:  #### Multi-Agent RAG 单个 Agent 处理复杂任务时,可能有个问题:当它要同时兼顾理解意图、选择策略、验证质量、生成答案时,Prompt 变得又长又复杂,决策质量就会下降。这就像一个人又当程序员、又教人打篮球、又当说唱歌手,忙不过来。 Multi-Agent RAG 的做法是拆分为多个专职 Agent,各自负责一些任务。 比如 Router Agent 负责分发、各 RAG Agent 负责对应领域的检索和推理、Verification Agent 负责质检、Generation Agent 负责润色输出。  这种方案适合数据源多、权限复杂、语言多样的企业级知识库。每个环节可以独立优化和扩展,不会牵一发而动全身。 Multi-Agent RAG 的代码示例如下: ```python # 1. Router Agent 先识别问题意图,决定分给哪个专职 Agent intent = router_agent.analyze(query) # 2. 各领域 Agent 各管一摊,独立完成检索和推理 if intent == "document_qa": raw_answer = doc_agent.run(query) else if intent == "data_analysis": raw_answer = sql_agent.run(query) else: raw_answer = graph_agent.run(query) # 3. Verifier Agent 做质检,发现问题则提出修改建议 verified = verifier_agent.check(query, raw_answer) if not verified.passed: raw_answer = verifier_agent.suggest_fix(raw_answer) # 4. Writer Agent 做最终润色,统一输出风格 final_answer = writer_agent.polish(query, raw_answer) ``` ### RAG 能力扩展 #### 多模态 RAG(Multimodal RAG) 传统 RAG 只处理文本,但现实中的企业文档里面充斥着大量的图表、流程图、架构图、产品照片等。 如果用纯文本 RAG 去处理一份真实文档,那些流程图、架构图里的信息就全丢了。 多模态 RAG 的做法是把图片、表格和文本统一到一个向量空间里,这样检索时就能跨模态匹配: 最后一步生成阶段,需要用视觉语言模型来处理混合模态的上下文,因为普通的纯文本 LLM 看不懂图片。  多模态 RAG 的代码实现比较复杂: ```python // 离线:文本和图片统一编码到同一个向量空间 for each page in document: text_chunks = extract_text(page) images = extract_images(page) tables = extract_tables(page) for each chunk in text_chunks: index.insert(text_encoder.encode(chunk), chunk, type="text") for each image in images: index.insert(vision_encoder.encode(image), image, type="image") for each table in tables: index.insert(table_encoder.encode(table), table, type="table") // 在线:统一检索,跨模态匹配 results = index.search(text_encoder.encode(query), k=5) // results 中可能混合了文本块、图片、表格 answer = vision_LLM.generate(results + query) // 用视觉语言模型处理混合内容 ``` #### Speculative RAG 普通的 RAG 还有一个问题:如果把检索到的所有文档都塞进同一个 Prompt,不仅增加了推理延迟、响应速度,而且如果某个文档是噪声,整个生成都会被带偏。 Speculative RAG(假设性检索增强生成)借鉴了推测性解码(Speculative Decoding)的思想,核心目标是降低延迟,把检索到的文档分成多个子集,用多个专家小模型从每个子集 **并行** 生成候选草稿,最后由一个更强的大模型做一次验证,选出最佳答案。  这就有点像团队共同做个大项目,多位前端和后端开发一起干活和仔细验证,最后产品经理只需要简单验证就好,能大幅缩短工作总时长,有问题也更容易发现。 Speculative RAG 的实现代码如下: ```python # 1. 检索阶段召回较多的候选文档 docs = retriever.search(query, k=15) # 2. 把候选文档拆成 n 个子集,准备并行处理 subsets = split_into_subsets(docs, n=5) # 3. 多个小模型并行生成候选草稿,每个草稿只看一部分文档 drafts = parallel_run( for each subset in subsets: draft = small_LM.generate(subset + query) return { draft, subset, confidence } ) # 4. 大模型做一次验证,从多个草稿中选出最佳答案 best = large_LM.verify_and_select(drafts, query) return best.answer ``` ## 方法选择 看到这里,你可能已经被这十几种 RAG 方案搞晕了。 我到底该用哪一种啊啊啊啊?! 没事,我给大家梳理了一张表格,直接根据自己的项目情况来选方案: | 你的情况 | 推荐方案 | | ---------------------------- | ----------------------------- | | 标准文本知识库,追求基本可用 | Naive RAG | | 用户提问风格多变、口语化 | Multi-Query RAG 或 HyDE | | 生产环境,追求检索质量 | Hybrid Search + Reranking | | 对准确率要求高,不能容忍幻觉 | Corrective RAG 或 Self-RAG | | 查询复杂度差异大 | Adaptive RAG 路由 | | 需要跨文档多跳推理 | GraphRAG | | 数据以结构化表格为主 | Text-to-SQL RAG | | 文档包含大量图表/图片 | 多模态 RAG | | 多数据源、多类型混合 | Agentic RAG / Multi-Agent RAG | | 延迟敏感 | Speculative RAG | 对于正在动手搭建 RAG 系统的初学者来说,我建议 **从简单开始,逐步完善。** 先跑通 Naive RAG,发现哪个环节出了问题,就针对性地选用前面的 RAG 方案。 千万别一上来就搞 Multi-Agent + GraphRAG + 多模态全家桶,不仅实现成本高,效果也不一定更好。  那怎么知道 RAG 系统效果好不好呢? 其实有个评估框架 RAGAS,就是用来做这个的。它有四个核心指标: - 忠实度(回答有没有瞎编) - 答案相关性(答的是不是你问的) - 上下文精确率(搜到的有多少是有用的) - 上下文召回率(该搜到的搜到了吗) 这样你就能先评估效果再优化,而不是仅凭感觉调参数。 除此之外,RAG 相关的主流技术还有编排框架 LangChain / LangGraph、LlamaIndex、一体化平台 Dify 、RAGFlow、向量数据库 Chroma、Milvus、Qdrant 等,大家感兴趣的话可以自行学习了解一下~ ------ OK,这篇文章写了快 1 万字,把 16 种 RAG 的实现和优化方案都讲完了,希望对大家有所帮助。 我是鱼皮,持续分享 AI 编程干货,这篇文章也会收录到我免费开源的 [《AI 编程零基础入门教程》](https://ai.codefather.cn/vibe),GitHub Star 数已经破万,从零开始带你学会用 AI 开发上线自己的产品。 开源仓库:https://github.com/liyupi/ai-guide  学会的话欢迎点赞收藏关注哦,也欢迎评论区聊聊:你用过 RAG 么?最喜欢那种 RAG 方案?
