Spring AI 应用开发实践课程教导站 · Spring AI 2.0 课程首页 速查
课程首页 / 第 5 章

CH 05RAG:检索增强生成

RAG(Retrieval-Augmented Generation)是企业级大模型应用的绝对主力技术。一句话概括它的价值:让模型基于你提供的资料回答,而不是基于它的记忆胡猜。第 4 章我们准备好了「向量」,这一章把向量、检索、提示词、生成串成一条能用的流水线。本章全部代码按「自建」口径手把手带你从空工程搭起来——不依赖任何现成 lab 代码,每一步都能独立复制执行。

教学目标
独立完成「加载→切分→灌库→检索→生成→溯源」RAG 全链路
教学重点
QuestionAnswerAdvisor 与模块化 RAG、引用溯源、检索质量调优
教学难点
检索质量调优、多租户隔离、引用准确生成
建议学时
4 学时(2 学时讲 + 2 学时练)

教学设计说明(教师参考):本章是课程难度的一个台阶,建议用「先跑通再讲透」的节奏:第 1 学时让学生照 5.4 节把最简 RAG 跑起来(先有体感),第 2 学时再回头讲九步链路和切分原理;易掉队点是「检索不到正确片段」和「引用来源错乱」,务必用 5.6 节方法二和 5.8 节排错表反复演示;课堂检查方式:让学生现场用口语化问题提问,验证能否命中正确片段、能否正确回答「不知道」。

本章新增易掉队点(务必在课前/课中反复强调):

  • DeepSeek 没有 embedding 接口。聊天用 DeepSeek,但「向量化」必须走 OpenAI 兼容供应商(如 OpenAI text-embedding-3-small)或本地 Ollama,两者在 application.yml 里是两套独立配置,学生极易把 embedding 也配到 DeepSeek 的 base-url 上导致 404。
  • 嵌入配置没有 .options 层级。spring.ai.openai.embedding.* 下直接写 model,只有 chat 才有 options;写成 spring.ai.openai.embedding.options.model 是无效配置,会静默回退到默认值。
  • 改了 application.yml 必须重启应用,运行中的进程不会热加载,典型症状:配了新 key/新模型却毫无变化。
  • 阈值 similarityThreshold 太高 = 永远「资料中未提及」;占位符名字写错(必须是 {question_answer_context})也会让检索结果进不了提示词。
  • 引用来源错乱:把 [1][2] 序号交给模型自由发挥必然出错,必须按 5.6 节方法二「用代码程序化生成引用列表」。
  • 多租户隔离只能做在检索条件(filterExpression)里,做在提示词里等于没做,数据已经泄露。

5.1RAG 是什么:给模型「开卷考试」的权利

闭卷考试靠记忆,开卷考试查资料。大模型的两种「知识注入」方式正好对应这两种考试:

方式原理优点缺点
微调
Fine-tuning
用你的数据继续训练模型,把知识「写进」参数 推理时无需额外检索,延迟低 成本高
数据一变就要重训
无法溯源
易产生灾难性遗忘
RAG
推荐
把知识放在外部库,回答前先检索相关片段塞进提示词 数据可随时更新
能给出引用出处
权限可控(谁能看哪些文档)
成本低
增加一次检索延迟
效果依赖检索质量
什么时候两个都要用?当你要让模型学会一种说话风格/输出格式(比如「用我们公司的公文口吻写通知」),微调更合适;当你要让模型知道具体事实(比如「2026 年 A 产品的价格是多少」),RAG 更合适。实践中 90% 的企业需求是后者——所以本课程只讲 RAG。

知识点:三种「让模型懂你」的方式怎么选

除了微调与 RAG,还有第三种常被拿来比较的思路——长上下文(把整本文档直接塞进上下文窗口)。三者不是互斥的,理解取舍才能用对地方:

维度RAG(检索增强)微调(Fine-tuning)长上下文(超长窗口)
注入的是「事实/资料」「风格/能力」「原始上下文」
知识更新重灌索引即可,秒级要重新训练,天级/钱级每次请求都带上,靠输入
能否溯源✅ 能,带出处❌ 不能⚠️ 理论能,但模型常不标
成本低(只送相关片段)训练贵、推理省高(每次都烧大量 token)
适合场景企业知识库、客服、政策问答统一文风、特定任务格式单篇长文精读、一次性分析

类比帮助记忆:RAG 像「开卷考试带索引的笔记本」——考前把重点抄成便签(灌库),考试时翻到相关那一页(检索)再答题;微调像「把知识点背进脑子」,考场上不翻书但忘得快、还改不了;长上下文像「把整本书摊在桌上」,看得全但费桌子(token 贵)且容易看花眼(注意力被稀释)。

RAG 能解决的三个真实痛点

  1. 模型不知道你的私有数据:公司制度、产品手册、客户合同,模型训练时根本没见过。
  2. 模型的知识有截止日期:昨天的政策变更,模型不可能知道。
  3. 模型会一本正经地胡说(幻觉):RAG 让它在有据可依的情况下回答,并要求「资料没有就说没有」。
一个判断 RAG 是否适用的小技巧:问自己两个问题——① 这个答案会不会随时间变化?(会 → RAG 优于微调);② 用户需不需要知道「这个答案从哪来的」?(需要 → 必须 RAG,因为只有 RAG 能溯源)。两个问题里只要有一个是「是」,就优先用 RAG。

为什么不直接把整份文档塞进 Prompt?读者可能会想:既然要让模型看资料,为什么不把整个 PDF 粘进提示词?原因有三:① 模型上下文窗口有限,一本书塞不进去;② 即使塞进去了,大段无关内容会「稀释」模型的注意力,反而答不准;③ 你无法控制「只检索相关片段」,也就无法做权限隔离和成本控制。RAG 的本质,是在调用模型前,先把「最该被看到的几段」精准找出来。

一句话记忆:RAG = 开卷考试,把「最该看的几段」在调用模型前精准找出来塞进提示词;它注入事实、能溯源、随更随用,是企业知识问答的首选。

5.2RAG 的九步全链路(务必背下来)

RAG 看起来复杂,但拆解成九步就清晰了。这九步分两大阶段:离线阶段(①②③④,灌库,通常一次或定期执行)和在线阶段(⑤⑥⑦⑧⑨,每次用户提问都执行)。把这条链路背下来,后面所有代码都是在实现其中的某几步。

【离线阶段 —— 灌库,通常一次或定期执行】 ① 加载 DocumentReader 把 PDF/Word/MD/网页读成纯文本 → Document ② 切分 DocumentTransformer 把长文切成 300~1000 字的小块(chunk) ③ 向量化 EmbeddingModel 把每块文本变成向量 ④ 存储 VectorStore 把「向量 + 文本 + 元数据」写进向量库 【在线阶段 —— 每次用户提问都执行】 ⑤ 检索 把用户问题向量化,在库里找出最相似的 TopK 片段 ⑥ 重排 对候选片段做二次排序/过滤(提高精度) ⑦ 组装 把片段塞进提示词的 {context} 占位符 ⑧ 生成 调用模型,要求它「只依据 context 回答」 ⑨ 溯源 把引用的来源(文件名 + 页码)一并返回给用户 用户问题 ──▶ ⑤检索 ──▶ ⑥重排 ──▶ ⑦组装提示 ──▶ ⑧模型生成 ──▶ 带引用的回答 ▲ │ └────────── ④向量库 ◀── ①②③ 离线灌库 ◀──┘(知识来源)

每一步在 Spring AI 里都有对应的接口/类,记住这份映射对你理解代码极有帮助:① DocumentReader(如 MarkdownDocumentReader);② DocumentTransformer(如 TokenTextSplitter);③ EmbeddingModel;④ VectorStore;⑤ VectorStore.similaritySearch 或 VectorStoreDocumentRetriever;⑦⑧ 由 Advisor 或手工拼提示词完成;⑨ 从检索结果里取元数据。

知识点:离线 vs 在线(为什么分两阶段)

阶段执行频率耗时出错影响
离线(①②③④)灌库/更新时一次相对慢(要调 embedding)只影响「以后」的检索质量
在线(⑤~⑨)每次提问都跑要求快(用户体验)直接决定本次回答对错
为什么这个区分重要?离线阶段可以慢、可以批量、可以半夜跑;在线阶段必须快、必须稳定。很多新手把「灌库」写在每次请求里,导致用户问一个问题要等几十秒——这是架构错误。灌库做成独立的管理接口(如 POST /reindex),在线只做检索+生成。
调试 RAG 的黄金法则:出问题时,先打印第 ⑤ 步的检索结果。90% 的「回答不对」都是「检索到的片段不对」,而不是模型不行。所以本章的示例代码里,每一处都会把检索结果打到日志里——这是刻意设计的,请不要删。
一句话记忆:九步链路 = 离线四步(加载→切分→向量化→存储)一次性灌库,在线五步(检索→重排→组装→生成→溯源)每次提问跑;背不下链路就写不出代码。

5.3第①②步:文档加载与切分

离线阶段的前两步:先把各种格式的文档读进来,再切成合适大小的块。这两步的质量,直接决定了后面检索的上限——切得不好,后面怎么调都救不回来。

动手前置:加两个 Reader 依赖

各格式 Reader 分散在独立 starter 中,先把它加进工程的 pom.xml。注意不同 2.0.x 小版本 artifactId 命名可能微调,以官方文档为准。

  1. 在 IDEA 左侧 Project 面板,展开工程根目录,找到并双击打开 pom.xml。

  2. 滚动到 <dependencies> 段落,把下面两段依赖粘贴到已有依赖的后面、</dependencies> 之前。

    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-tika-document-reader</artifactId>   <!-- 万能格式:PDF/Word/PPT/HTML/… -->
    </dependency>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-markdown-document-reader</artifactId>
    </dependency>
  3. 按 Ctrl+S 保存;等待右下角 Maven 同步完成(出现依赖红线说明坐标写错或版本不匹配,回去核对)。

依赖归属说明:Spring AI 2.0 的各 Reader 分散在独立 starter 中,不同 2.0.x 小版本里 artifactId 的命名可能微调;如果你的工程里没有这些包,先到官方文档核对当前版本对应的坐标,以官方文档为准,不要照搬网上的旧坐标。

DocumentReader:把各种格式读成 Document

下面这段代码演示四种常见 Reader。先别急着跑,按步骤建一个测试类把它跑通、验证「读取」这一步本身没问题。

  1. 在 IDEA 里右键包 com.example.springai.ch05 → New → Java Class;Name 输入 ReaderDemo,回车创建。

  2. 把下面整段代码复制粘贴覆盖自动生成的骨架,按 Ctrl+S 保存。

    import org.springframework.ai.reader.TextReader;
    import org.springframework.ai.reader.JsonReader;
    import org.springframework.ai.reader.markdown.MarkdownDocumentReader;
    import org.springframework.ai.reader.markdown.config.MarkdownDocumentReaderConfig;
    import org.springframework.ai.reader.tika.TikaDocumentReader;
    import org.springframework.core.io.FileSystemResource;
    import org.springframework.core.io.ClassPathResource;
    import java.util.List;
    
    // ---------- ① 纯文本 ----------
    // 为什么用 TextReader:最简单,适合 .txt / .md 之外的裸文本;
    // Map.of("source", ...) 是给每个 Document 加的元数据,后面溯源、过滤都要靠它。
    TextReader textReader = new TextReader(new FileSystemResource("docs/policy.txt"),
            Map.of("source", "policy.txt"));
    List<Document> docs1 = textReader.get();   // get() 返回读到的文档列表
    
    // ---------- ② Markdown(按标题层级自动切成多个 Document,很适合技术文档) ----------
    // 为什么单独讲 Markdown:制度/手册/技术文档大多是 md,按标题切比按长度切语义更完整。
    MarkdownDocumentReaderConfig mdConfig = MarkdownDocumentReaderConfig.builder()
            .withHorizontalRuleCreateDocument(true)     // 遇到 --- 分隔线也切分
            .withIncludeCodeBlock(false)                 // 代码块不要(通常对问答无用,还占 Token)
            .withIncludeBlockquote(true)                  // 保留引用块
            .withAdditionalMetadata("category", "技术文档") // 额外元数据,便于按分类过滤
            .build();
    MarkdownDocumentReader mdReader =
            new MarkdownDocumentReader("classpath:docs/guide.md", mdConfig);
    List<Document> docs2 = mdReader.get();
    
    // ---------- ③ JSON(适合结构化知识库:FAQ、产品参数表) ----------
    // JSON 形如 [{"question":"如何退款","answer":"7天内提交..."}, ...]
    // 为什么用 JsonReader:一行数据就是一条知识,天然适合问答;
    // 把 question 和 answer 两个字段拼接成正文,检索时整条命中。
    JsonReader jsonReader = new JsonReader(new ClassPathResource("docs/faq.json"),
            "question",      // 用哪个字段作为正文
            "answer");       // 可以传多个字段,会被拼接
    List<Document> docs3 = jsonReader.get();
    
    // ---------- ④ PDF / Word / PPT / HTML(Tika 万能解析) ----------
    // 为什么用 Tika:一套 API 吃遍所有常见办公格式,不用为每种格式找解析库。
    TikaDocumentReader tikaReader =
            new TikaDocumentReader(new FileSystemResource("docs/manual.pdf"));
    List<Document> docs4 = tikaReader.get();

运行验证:读取这一步本身对不对

在测试类里加一段打印,确认四种 Reader 都能吐出非空文本(不需要向量库也能验证读取,所以放在最前面先过关):

  1. 在 ReaderDemo 里临时加两行(或写个 main / JUnit 测试):

    System.out.println("docs2 块数=" + docs2.size());
    System.out.println("首块前80字=" + docs2.get(0).getText().substring(0, 80));
  2. 运行该类(IDEA 右键 → Run)。

    预期输出(pre 示例,数字随文档而异):

    docs2 块数=12
    首块前80字=# 退款政策
    本政策适用于所有在平台购买商品的用户。满足以下条件可申请退款……

    看到什么算通过:四种 docs*.size() 都大于 0,且 getText() 打印出的是可读中文而非空串 / 乱码 / 一堆方框。这一步通过,说明「加载」没问题,后面再接切分和向量化。

排查:运行后 docs*.size() 为 0 或 getText() 是空串/乱码。
  1. 路径错:classpath:docs/guide.md 要求文件在 src/main/resources/docs/ 下;用 FileSystemResource 则要相对项目根目录的真实路径。先用 new File("docs/guide.md").exists() 打印确认文件找得到。
  2. 扫描版 PDF 读出来是空的——里面是图片不是文字,Tika 不做 OCR,需先 OCR 或换文本版。
  3. 中文乱码:多数 Reader 默认 UTF-8;若源文件是 GBK,先用编辑器转 UTF-8 再灌。

排查步骤:① 先用任意阅读器确认文档是否可选中文字(不可选 = 扫描版,需 OCR);② 打印 docs4.size() 和每块的 getText() 前 200 字,确认不是空串或被切断;③ 表格类文档改用 MarkdownDocumentReader 手动整理后再灌。

TokenTextSplitter:把长文切成合适的块

为什么必须切分?① 模型上下文有限,整本书塞不进去;② 检索要的是「精准片段」,一整章里只有一段相关,会把无关信息也带进去;③ 块太小则失去上下文,块太大则检索精度下降——切分粒度是 RAG 调优的第一杠杆。

  1. 在同一测试类里追加下面代码,对上面读到的 docs4 做切分并打印块数。

    import org.springframework.ai.transformer.splitter.TokenTextSplitter;
    
    // 默认配置:chunkSize=800 token,适合大多数场景
    TokenTextSplitter defaultSplitter = new TokenTextSplitter();
    
    // 精细配置(推荐按文档类型调整)
    TokenTextSplitter splitter = TokenTextSplitter.builder()
            // 每块目标大小(token 数)。中文大约 1 字 ≈ 0.7~1 token,800 token ≈ 500~700 中文字
            .withChunkSize(800)
            // 最小块字符数:小于它的块会尝试与前一块合并,避免产生"碎片"
            .withMinChunkSizeChars(350)
            // 太短的块(少于此 token)直接丢弃,通常是页眉页脚之类的噪音
            .withMinChunkLengthToEmbed(5)
            // 单文档最多切多少块,防止超大文档把内存打爆
            .withMaxNumChunks(10000)
            // 是否保留分隔符(换行、句号)。建议 true,能保住语义边界
            .withKeepSeparator(true)
            .build();
    
    // 用法:把 reader 读出来的 Document 列表直接喂进去
    List<Document> chunks = splitter.apply(docs4);
    System.out.println("原文档 " + docs4.size()
            + " 个,切分后 " + chunks.size() + " 块");
  2. 运行,预期输出形如 原文档 1 个,切分后 24 块;看到什么算通过:块数明显多于原文数,且每块长度大致在 350~800 中文字区间。

切分后某一块是「半句话」被切断了。这是 KeepSeparator 与块边界的取舍问题。

修复:① 优先改用按结构切(MarkdownDocumentReader 的标题层级切),比固定长度切语义更完整;② 若必须固定长度,调大 withMinChunkSizeChars 减少碎片;③ 切完人工抽查 10 块是必做项,不是可选——见下方 tip。

切分策略对比:没有最好的,只有最合适的

策略做法适用
固定长度每 N 个字符/token 切一刀(TokenTextSplitter)通用默认;实现简单
按结构切按标题、段落、--- 分隔(MarkdownDocumentReader)✅ 制度文档、技术手册(效果最好)
按语义切用模型判断语义边界再切高价值长文,成本高
父文档 / 子块用小块检索,返回它所属的大块给模型✅ 解决「块太小缺上下文」的经典方案

chunkSize 怎么定?一个实用的起始规则:

  • 知识库是 FAQ / 短问答:一条就是一块,别切。
  • 知识库是 制度 / 手册:按章节切,400~800 token。
  • 知识库是 长篇小说 / 访谈:800~1200 token,并保留重叠。
  • 切完之后一定要人工抽查 10 块——看看有没有把一句话切断、有没有把标题和正文分离。这一步能提前发现 80% 的检索问题。
一句话记忆:加载用各种 Reader 把文档变成 Document,切分用 TokenTextSplitter 把长文切成 350~800 字的小块;切分粒度是 RAG 调优第一杠杆,切完必须人工抽查 10 块。

5.4最简 RAG:一个 Advisor 就搞定

Spring AI 提供了 QuestionAnswerAdvisor,它把第⑤⑥⑦步全部封装好了。你只需要挂上它,剩下的交给框架。这是学生第一次跑通 RAG 的捷径——先看到效果,再理解原理。

前置:把模型与向量库配好(自建必做)

本章全程自建,先确认 application.yml 同时配好「聊天模型」和「嵌入模型」。关键红线:聊天默认用 DeepSeek;但 DeepSeek 没有 embedding 接口,嵌入必须走 OpenAI 兼容供应商或本地 Ollama;且嵌入配置没有 .options 层级,只有 chat 才有。

  1. 在 IDEA 里右键 src/main/resources → New → File;FileName 输入 application.yml(若已存在则双击打开),回车。

  2. 把下面内容粘贴进去(密钥一律用 ${XXX_API_KEY:} 环境变量占位符,绝不硬编码):

    server:
      port: 8080
    
    spring:
      ai:
        openai:
          # ===== 聊天:默认走 DeepSeek(OpenAI 兼容协议)=====
          chat:
            api-key: ${DEEPSEEK_API_KEY:}        # 环境变量占位符,启动前已 setx/export
            base-url: https://api.deepseek.com  # DeepSeek 的兼容网关
            options:                            # 注意:只有 chat 才有 options 层级
              model: deepseek-chat
          # ===== 嵌入:DeepSeek 没有 embedding,走 OpenAI 兼容供应商 =====
          # 重要:embedding 下【没有】.options 层级,model 直接写在这里
          embedding:
            api-key: ${OPENAI_API_KEY:}         # 换成你有 embedding 权限的供应商 key
            base-url: https://api.openai.com/v1
            model: text-embedding-3-small
        # 打开 Spring AI 调试日志,能看到拼进提示词的检索片段(强烈建议 5 章至少看一次)
        logging:
          level:
            org.springframework.ai: debug
  3. 按 Ctrl+S 保存。改完 yml 必须重启应用才会生效。

启动报 400/404 或「model not found」类错误,且日志指向 embedding。
  1. 把 embedding 配到了 DeepSeek 的 base-url:DeepSeek 不提供 embedding 接口,会返回 404。确认 spring.ai.openai.embedding.base-url 指向有 embedding 的供应商(如 OpenAI)。
  2. 错写成 spring.ai.openai.embedding.options.model:嵌入配置没有 .options 层级,这样写会被忽略、回退到默认模型导致不匹配。改成 spring.ai.openai.embedding.model。
  3. key 没读到:运行中的进程不刷新环境变量,setx/export 后必须重启 IDEA 再启动应用。

动手前自检:三条命令确认环境就绪(能省掉一半的报错)

上面的配置最容易「看起来对、其实没生效」。正式写代码前先花 3 分钟把这三项确认掉——本章后面八成的报错都出在这三处:

检查项怎么查不对会怎样
① 密钥被程序读到了 PowerShell 里执行 echo $env:DEEPSEEK_API_KEY;CMD 里执行 echo %DEEPSEEK_API_KEY%。必须打印出以 sk- 开头的值。用 IDEA 启动的话,设完环境变量要完全退出 IDEA 再重开(运行中的进程不会刷新环境变量)。 启动不报错,但每次提问都是 401 / Authentication Fails
② 向量库能连上
(跟课程用内存版的可跳过)
用 pgvector 时:psql -U ai -d vectordb -c "SELECT count(*) FROM vector_store;"——能返回一个数字(哪怕是 0)就说明库、表、扩展都就绪。
用内存版 SimpleVectorStore 的话跳过这条,但必须记住:应用一重启,向量全部清空,必须重新灌库。
reindex 报 500;或检索结果永远是空
③ 维度与嵌入模型一致 yml 里 spring.ai.vectorstore.pgvector.dimensions 必须等于嵌入模型的输出维度:OpenAI text-embedding-3-small = 1536;智谱 embedding-3 = 1024(可选 256/512/1024/2048)。两者必须相等,而且表建好之后不能再改——换了嵌入模型,就得删表重建。 灌库直接报维度不匹配;或侥幸灌进去了,却怎么都检索不出来
顺序很重要:先 ①,再 ②,最后 ③。它们分别对应「调不通模型」「连不上库」「灌不进数据」三类完全不同的故障——跳过自检直接写代码,你会把三种症状混在一起查,非常耗时间。

同时确认 pom.xml 里已有聊天 starter 与向量库(本章用内存版 SimpleVectorStore 免装数据库):

<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-model-openai</artifactId>   <!-- 提供 ChatModel + EmbeddingModel -->
</dependency>
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-vector-store-advisor</artifactId>   <!-- QuestionAnswerAdvisor 所在模块 -->
</dependency>

QuestionAnswerAdvisor 属于 Spring AI 核心模块,引入任意模型 starter 即可使用,一般无需额外坐标;具体归属以官方文档为准。

最简 RAG 代码(标准答案,零语义改动)

  1. 在 IDEA 里右键包 com.example.springai.ch05 → New → Java Class;Name 输入 SimpleRagDemo,回车。

  2. 把下面整段复制粘贴覆盖骨架(其中 chatModel 与 vectorStore 由 Spring 自动注入,见文末完整版配置),Ctrl+S 保存。

    import org.springframework.ai.chat.client.ChatClient;
    import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
    import org.springframework.ai.vectorstore.SearchRequest;
    import org.springframework.ai.vectorstore.VectorStore;
    
    // ① 一个会被"粘贴"进提示词的模板:{question_answer_context} 是框架约定的占位符
    //    这个占位符会被替换成检索到的片段拼接结果,千万别写错名字。
    //    为什么单独写模板:我们想强制模型"只依据资料",这是防幻觉的关键约束。
    String qaTemplate = """
            请仅依据下面 <参考资料> 中的内容回答问题。
    
            <参考资料>
            {question_answer_context}
            </参考资料>
    
            回答规则:
            1. 如果参考资料中没有相关信息,直接回答"资料中未提及",不要编造。
            2. 引用资料时,用 [来源:文件名] 的形式标注出处。
            3. 用简体中文回答,条理清晰。
    
            问题:{query}
            """;
    
    // ② 构建 Advisor:把向量库 + 检索参数 + 提示模板绑在一起
    QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
            .searchRequest(SearchRequest.builder()
                    .topK(5)                          // 取 5 个片段:太少可能漏,太多会稀释注意力
                    .similarityThreshold(0.5)         // 过滤弱相关内容(第 4 章讲过,非常重要)
                    .build())
            .promptTemplate(new PromptTemplate(qaTemplate))
            .build();
    
    // ③ 挂到 ChatClient 上:defaultAdvisors 表示每次调用都自动走检索
    //    注意:ChatClient 是带状态的"装配好的客户端",这里把记忆/检索等能力预先挂好。
    ChatClient ragClient = ChatClient.builder(chatModel)
            .defaultAdvisors(qaAdvisor)
            .build();
    
    // ④ 用起来和普通对话一模一样,检索是透明的
    String answer = ragClient.prompt()
            .user("退款要几天到账?")
            .call()
            .content();
它是怎么工作的?执行顺序是:Advisor 拦截请求 → 取出用户问题 → 向量化 → 检索 topK → 把片段拼成文本填入 {question_answer_context} → 得到最终的 Prompt → 交给模型。 所以「检索」这一步对你完全透明,但也意味着你不能在业务代码里直接拿到检索结果—— 如果需要在接口里返回引用来源,就得用 5.6 节的方法。

只想快速验证效果?用 ChatClient 的快捷方法

如果你只是想 5 分钟验证「RAG 链路通不通」,可以不用单独声明 Advisor 变量,直接在调用链上挂:

// 最简版本:几行代码跑通 RAG,适合做完第 4 章后立刻验证
// 为什么这样写:ChatClient.create(chatModel) 直接造一个临时客户端,
// .advisors(...) 内联挂上 QuestionAnswerAdvisor,零配置即可跑。
String answer = ChatClient.create(chatModel)
        .prompt()
        .advisors(QuestionAnswerAdvisor.builder(vectorStore).build())
        .user("退款要几天到账?")
        .call()
        .content();
System.out.println(answer);
打开 debug 日志看它到底发了什么。在 application.yml 里把 logging.level.org.springframework.ai: debug 打开, 你会看到完整的、包含检索片段的 Prompt。这是理解 RAG 最快的方式—— 强烈建议在第 5 章至少认真读一遍这条日志。

动手:把最简 RAG 跑起来(建议课堂第 1 个实验)

  1. 在 src/main/resources/ 下建 docs/ 目录,放一份 refund-policy.md 测试文档(随便写几句「7 天内可申请退款,审核后 3 个工作日到账」之类的内容)。

  2. 新建一个测试类,用上面的快捷方法做检索 + 灌库 + 提问。先灌库(把 md 读进来并灌进向量库):

    // 把 md 读进来并灌进向量库(第 4 章的 VectorStore.add)
    MarkdownDocumentReader reader = new MarkdownDocumentReader(
            "classpath:docs/refund-policy.md",
            MarkdownDocumentReaderConfig.builder()
                    .withAdditionalMetadata("source", "refund-policy.md")
                    .build());
    vectorStore.add(reader.get());

    运行验证:写一个临时 main 或测试跑这段,预期输出:控制台无报错,日志里能看到 embedding 调用成功。看到什么算通过:没有异常堆栈,且 vectorStore 的相似度检索能返回内容(下一步验证)。

  3. 调用上面的快捷方法提问 "退款要几天到账?"。用 main 直接 System.out.println(answer) 打印。

    预期输出:返回一段依据文档的回答,而不是模型凭空编的。

    依据《refund-policy.md》,订单完成后 7 天内可申请退款,审核通过后
    3 个工作日内退回原支付账户。 [来源:refund-policy.md]

    看到什么算通过:答案里出现你文档里写的事实(如「7 天」「3 个工作日」),并带 [来源:...]。若返回「资料中未提及」,说明检索没命中——回到 5.3 节检查切分与阈值,或把 similarityThreshold 临时调到 0.0 验证。

提问返回「资料中未提及」或答非所问。
  1. 阈值太高:similarityThreshold(0.5) 把相关片段全过滤了。先临时改成 0.0,能命中就说明是阈值问题,再慢慢往上调到 0.4~0.6。
  2. 占位符名字写错:模板里必须是 {question_answer_context}(框架约定),写成 {context} 等自定义名,检索结果进不了提示词,模型当然看不到资料。
  3. 没真的灌库:上一步 vectorStore.add 抛了异常被吞掉;确认控制台没有报错,且文档路径正确。
  4. 文档和提问语言/用词差距大:口语化问法命中低,见 5.5 节查询改写。
一句话记忆:QuestionAnswerAdvisor 一行把「检索→拼提示→生成」三步走完;模板占位符必须是 {question_answer_context},检索对你透明,但你也拿不到中间结果。

5.5进阶:模块化 RAG(RetrievalAugmentationAdvisor)

QuestionAnswerAdvisor 够简单,但不够灵活:不能做查询改写、不能做重排、不能做多路检索。这时候用「模块化 RAG」——它把 RAG 拆成了可插拔的组件。当你的需求超出「检索→拼提示→生成」三板斧时,就该上它了。

RetrievalAugmentationAdvisor ├── QueryTransformer 查询转换:改写 / 翻译 / 压缩多轮对话 ├── DocumentRetriever 检索器:从向量库捞候选 ├── DocumentJoiner 多路结果合并(向量检索 + 关键词检索) ├── DocumentPostProcessor 后处理:重排 / 去重 / 过滤 └── QueryAugmenter 把检索结果组装进提示词(含"空上下文"策略)

知识点:QuestionAnswerAdvisor vs 模块化 RAG 怎么选

维度QuestionAnswerAdvisorRetrievalAugmentationAdvisor
上手难度极低,几行搞定中,要理解各组件
检索为空时默认直接进提示词(无空上下文控制)可配 allowEmptyContext
查询改写❌ 不支持✅ 支持
重排/多路检索❌✅ 支持
多租户过滤需自己包一层✅ filterExpression 原生支持
适用阶段先跑通、验证想法生产级、需求复杂时

① 最常用的配置:检索 + 空上下文策略

  1. 新建类 ModularRagDemo(包 com.example.springai.ch05,New → Java Class),粘贴下面代码并保存。

    import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
    import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
    import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
    
    // 为什么用模块化 RAG:我们要显式控制"检索不到时怎么办",这是 QuestionAnswerAdvisor 做不到的。
    RetrievalAugmentationAdvisor ragAdvisor = RetrievalAugmentationAdvisor.builder()
            .documentRetriever(
                    VectorStoreDocumentRetriever.builder()
                            .vectorStore(vectorStore)
                            .similarityThreshold(0.5)
                            .topK(5)
                            .build())
            .queryAugmenter(
                    ContextualQueryAugmenter.builder()
                            // ★ 关键配置:检索为空时怎么办
                            // false(默认)= 直接抛异常,阻止调用模型
                            // true          = 允许空上下文,让模型按提示词里的"资料不足"规则回复
                            .allowEmptyContext(true)
                            .build())
            .build();
    
    ChatClient ragClient = ChatClient.builder(chatModel)
            .defaultAdvisors(ragAdvisor)
            .build();
  2. 运行验证:用上面客户端提问一个知识库里没有的问题(如「公司年会在哪天」)。预期输出:模型按提示词规则回复「资料中未提及」之类,不再抛 500。看到什么算通过:接口返回 200 且 answer 是「资料不足」类表述,而不是异常页。

allowEmptyContext 的默认值是 false,这会让很多新手一脸懵。 当检索不到任何结果时,默认行为是抛出异常,你看到的是 500 错误,而不是「资料中未提及」。 生产环境建议设成 true,并在提示词里写清楚「资料为空时怎么回答」。

排查步骤:① 若接口报 500 且日志出现 EmptyContextException 类异常,先确认是否忘了设 allowEmptyContext(true);② 确认向量库确实有数据(查库或打日志);③ 确认相似度阈值没高到把所有结果过滤掉(可临时设 0 验证)。

② 加查询改写:解决「多轮对话中的指代问题」

真实场景:用户先问「2024001 这个订单」,接着问「它什么时候发货?」第二句单独拿去检索,向量库里根本没有「它」的信息——检索必然失败。查询改写就是解决这个的:

  1. 在 ModularRagDemo 中追加下面代码(注意多一次模型调用,单轮问答可不加):

    import org.springframework.ai.rag.preretrieval.query.transformation.CompressionQueryTransformer;
    import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer;
    
    // 方案 A:压缩式改写——把「多轮对话 + 当前问题」压成一个独立完整的查询
    // 它会把 "它什么时候发货" + 上文 "订单 2024001" → 改写成 "订单 2024001 什么时候发货"
    // 为什么用压缩式:保留上下文但去掉寒暄/废话,检索更精准。
    CompressionQueryTransformer compression = CompressionQueryTransformer.builder()
            .chatClientBuilder(ChatClient.builder(chatModel))
            .build();
    
    // 方案 B:改写式——把口语化的问法改写成更适合检索的表述
    // "钱啥时候能回来啊" → "退款到账时间"
    // 为什么用改写式:口语词和文档里的书面词向量距离远,直接检索命中低。
    RewriteQueryTransformer rewriter = RewriteQueryTransformer.builder()
            .chatClientBuilder(ChatClient.builder(chatModel))
            // 可以指定用便宜的小模型做改写,省成本
            .build();
    
    RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
            .queryTransformers(compression)      // ★ 挂上改写器
            .documentRetriever(VectorStoreDocumentRetriever.builder()
                    .vectorStore(vectorStore)
                    .topK(5)
                    .similarityThreshold(0.5)
                    .build())
            .queryAugmenter(ContextualQueryAugmenter.builder()
                    .allowEmptyContext(true)
                    .build())
            .build();
  2. 运行验证:开 debug 日志,先问「订单 2024001 的物流状态」,再问「它什么时候发货」。预期输出:日志里能看到改写后的查询变成「订单 2024001 什么时候发货」。看到什么算通过:第二问能正确命中第一问所指的订单文档,而不是因「它」二字检索落空。

查询改写会让延迟和成本上升(多一次模型调用)。所以它适合多轮对话场景,不适合单轮问答。工程上的折中:只在对话轮次 > 1 时才启用改写,或者用本地小模型做改写。

③ 元数据过滤 + 多租户隔离(企业项目必需)

  1. 把下面片段并入你的 Advisor 构建(注意 filterExpression 字符串拼接来自请求上下文):

    // 关键:把租户 ID 从请求上下文里带进来,限制检索范围
    // 这是"防止 A 公司查到 B 公司文档"的最后一道防线
    String tenantFilter = "tenantId == '" + currentTenantId + "'";
    
    RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
            .documentRetriever(
                    VectorStoreDocumentRetriever.builder()
                            .vectorStore(vectorStore)
                            .similarityThreshold(0.5)
                            .topK(5)
                            .filterExpression(tenantFilter)   // ★ 租户隔离
                            .build())
            .build();
  2. 运行验证:用租户 A 的 id 灌一批文档、租户 B 灌另一批,分别用 A/B 的 filter 检索。预期输出:A 只拿到 A 的文档。看到什么算通过:打印检索结果的 source 元数据,确认没有混入 B 的文档名。

多租户隔离必须做在「检索条件」里,不能做在「提示词」里。在提示词里写「只回答本公司文档」是完全无效的——模型根本没有能力阻止你检索到别人的数据,而且资料早就被检索出来塞进提示词了,数据已经泄露。正确的做法是在向量库层面加 filterExpression,让别人的数据根本查不出来。

排查步骤:① 若某租户能搜到别的租户文档,先打印实际执行的 filterExpression 字符串,确认变量真的拼接进去了;② 确认灌库时每块都写了 tenantId 元数据(没写 → 过滤不到 → 全量返回);③ 确认向量库支持该过滤语法(不同库语法略有差异,以官方文档为准)。

一句话记忆:模块化 RAG(RetrievalAugmentationAdvisor)把检索拆成可插拔组件;allowEmptyContext(true) 让「查不到」优雅降级,多租户隔离必须写进 filterExpression 而不是提示词。

5.6引用溯源:让回答带上出处

企业场景里,「答案是什么」和「答案从哪来」同样重要。财务、法务、医疗类应用如果没有引用,用户根本无法验证真伪,也就不会信任它。溯源不是锦上添花,是 RAG 上生产的硬要求。

方法一:从 Advisor 上下文里取回检索结果

QuestionAnswerAdvisor 在内部检索后会把命中的文档放进响应上下文,用一个固定常量 key 存取。适合「在接口里顺带返回引用」的轻量场景:

import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;

// QuestionAnswerAdvisor 会把检索到的文档放进响应的 context,key 是固定的常量
ChatClientResponse response = ragClient.prompt()
        .user("退款要几天到账?")
        .call()
        .chatClientResponse();

// 取出检索到的文档列表(类型转换:context 里存的是 Object)
List<Document> retrieved = (List<Document>) response.context()
        .get(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS);

// 组装成引用列表返回给前端
List<Citation> citations = retrieved == null ? List.of() : retrieved.stream()
        .map(d -> new Citation(
                String.valueOf(d.getMetadata().get("source")),
                String.valueOf(d.getMetadata().get("page")),
                d.getScore(),
                // 只回传 200 字摘要,不要整段回传(可能很长)
                d.getText().length() > 200 ? d.getText().substring(0, 200) + "…" : d.getText()))
        .toList();

System.out.println("回答:" + response.chatResponse().getResult().getOutput().getText());
System.out.println("引用:" + citations);

方法二:自己控制检索(推荐,最灵活)

不依赖 Advisor,手工做「检索 → 组装 → 生成」,这样每一步都能拿到数据、都能打日志、都能做实验:

  1. 在 IDEA 里右键包 com.example.springai.ch05 → New → Java Class;Name 输入 ManualRagService,回车创建。

  2. 把下面整段复制粘贴覆盖骨架,Ctrl+S 保存。

    @Service
    public class ManualRagService {
    
        private final VectorStore vectorStore;
        private final ChatClient chatClient;
    
        /** 提示词模板:{context} 是我们自己定义的占位符 */
        private static final String TEMPLATE = """
                你是企业知识库助手。请严格依据下方【参考资料】回答用户问题。
    
                【参考资料】
                {context}
    
                回答要求:
                1. 只使用参考资料中的信息;资料不足时回答"根据现有资料无法确定"。
                2. 在每个结论后用 [序号] 标注它来自哪条参考资料。
                3. 不要复述参考资料原文,用你自己的话组织答案。
                4. 回答控制在 300 字以内。
    
                用户问题:{question}
                """;
    
        public RagAnswer ask(String question, String tenantId) {
            // ---------- 第⑤步:检索 ----------
            // 为什么自己写检索:我们要拿到命中的文档对象,用于后面构造引用列表。
            List<Document> docs = vectorStore.similaritySearch(
                    SearchRequest.builder()
                            .query(question)
                            .topK(5)
                            .similarityThreshold(0.5)
                            .filterExpression("tenantId == '" + tenantId + "'")
                            .build());
    
            // ★ 调试必看:把检索结果打出来,这是排查 RAG 问题的第一现场
            log.info("【RAG检索】问题=「{}」命中={}条", question, docs.size());
            for (int i = 0; i < docs.size(); i++) {
                log.info("  [{}] score={} source={} 内容前80字={}",
                        i + 1, docs.get(i).getScore(),
                        docs.get(i).getMetadata().get("source"),
                        docs.get(i).getText().substring(0, Math.min(80, docs.get(i).getText().length())));
            }
    
            // 检索为空:直接返回,不浪费一次模型调用(省成本、响应快)
            if (docs.isEmpty()) {
                return new RagAnswer("根据现有资料无法确定,建议联系人工客服。", List.of());
            }
    
            // ---------- 第⑦步:组装上下文 ----------
            // 把每块编号 + 来源 + 正文拼成一个大字符串,后面 {context} 占位符会被它替换。
            StringBuilder ctx = new StringBuilder();
            for (int i = 0; i < docs.size(); i++) {
                Document d = docs.get(i);
                ctx.append("[%d] 来源:%s  内容:%s\n\n".formatted(
                        i + 1,
                        d.getMetadata().get("source"),
                        d.getText()));
            }
    
            // ---------- 第⑧步:生成 ----------
            // 用 .param 把我们拼好的 context 和原始问题填进模板占位符。
            String answer = chatClient.prompt()
                    .user(u -> u.text(TEMPLATE)
                               .param("context", ctx.toString())
                               .param("question", question))
                    .call()
                    .content();
    
            // ---------- 第⑨步:溯源 ----------
            // 关键:引用列表由"代码"根据检索结果构造,而不是让模型自由编序号。
            List<Citation> citations = new java.util.ArrayList<>();
            for (int i = 0; i < docs.size(); i++) {
                Document d = docs.get(i);
                citations.add(new Citation(
                        i + 1,
                        String.valueOf(d.getMetadata().get("source")),
                        String.valueOf(d.getMetadata().getOrDefault("page", "-")),
                        d.getScore(),
                        d.getText()));
            }
            return new RagAnswer(answer, citations);
        }
    
        public record RagAnswer(String answer, List<Citation> citations) {}
        public record Citation(int index, String source, String page,
                                   Double score, String excerpt) {}
    }
  3. 运行验证:注入 ManualRagService 后调用 ask("退款要几天到账?", "default"),打印返回值。预期输出:RagAnswer 的 answer 含文档事实,citations 列表非空且 source 指向 refund-policy.md。看到什么算通过:日志先打印「命中=N 条」及每条 score/source,再返回带引用的答案;且回答里编的 [1][2] 序号与 citations 列表顺序一致。

引用来源是错的(序号和真实出处对不上)。这是把 [1][2] 序号交给模型自由发挥的典型后果——模型只是模仿了格式,序号和实际来源并不对应。

修复:不要依赖模型输出序号。改用方法二——引用列表由你的代码根据检索结果直接构造(就是上面 citations 那段),模型只管生成答案,引用 100% 准确。详见下方 note。

关于「引用来源错乱」的补充说明:让模型输出 [1] [2] 这种序号,本质上是把「引用和来源的映射关系」交给模型记忆,模型中途一定会搞错。正确做法是:让模型只管生成答案,引用列表由你的代码根据检索结果直接构造(就是我们方法二里的 citations)。这样引用 100% 准确。
为什么推荐「方法二」?因为 RAG 是一个需要反复调优的链路:今天调 topK,明天调阈值,后天换切分粒度。自己控制检索,你才能在每一环插入日志、收集指标、做 A/B 对比。Advisor 适合快速验证想法,生产代码建议自己写——第 11 章的综合项目就是这么做的。
一句话记忆:溯源是企业 RAG 上生产的硬要求;引用列表必须由代码按检索结果程序化生成,绝不能让模型自由编 [1][2] 序号——否则来源必错。

5.7完整示例:企业知识库问答

把①②③④(离线)和⑤⑥⑦⑧⑨(在线)拼成完整可运行的服务。这一节是本章的「总装车间」,请照着把文件建出来并跑通。

src/main/java/com/example/springai/ch05/ ├── KnowledgeIndexService.java # 离线:读取 docs/ 目录 → 切分 → 灌库 ├── KnowledgeQaService.java # 在线:检索 → 组装 → 生成 → 溯源(5.6 节) └── KnowledgeQaController.java # HTTP 接口 src/main/resources/docs/ ├── refund-policy.md # 售后政策(你会用来测试的文档) └── technical-guide.md # 技术文档(用来验证分类过滤)

① 离线索引服务:一键灌库

  1. IDEA 中右键包 com.example.springai.ch05 → New → Java Class;Name 输入 KnowledgeIndexService,回车。

  2. 把下面整段复制粘贴覆盖骨架,Ctrl+S 保存。

    package com.example.springai.ch05;
    
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.ai.document.Document;
    import org.springframework.ai.reader.markdown.MarkdownDocumentReader;
    import org.springframework.ai.reader.markdown.config.MarkdownDocumentReaderConfig;
    import org.springframework.ai.transformer.splitter.TokenTextSplitter;
    import org.springframework.ai.vectorstore.VectorStore;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.core.io.Resource;
    import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
    import org.springframework.stereotype.Service;
    import java.util.ArrayList;
    import java.util.List;
    import java.util.Map;
    
    @Slf4j
    @Service
    public class KnowledgeIndexService {
    
        /** 文档目录,可在 yml 里配置:knowledge.docs-path=classpath:docs/*.md */
        @Value("${knowledge.docs-path:classpath:docs/*.md}")
        private String docsPathPattern;
    
        private final VectorStore vectorStore;
    
        /** 切分器:按第 5.3 节的建议参数配置 */
        private final TokenTextSplitter splitter = TokenTextSplitter.builder()
                .withChunkSize(600)
                .withMinChunkSizeChars(200)
                .withMinChunkLengthToEmbed(5)
                .withKeepSeparator(true)
                .build();
    
        // 构造注入:Spring 会把容器里的 VectorStore bean 注入进来
        public KnowledgeIndexService(VectorStore vectorStore) {
            this.vectorStore = vectorStore;
        }
    
        /**
         * 全量重建索引。
         * 建议做成一个管理接口(POST /admin/reindex),知识库更新时手动触发。
         */
        public int rebuildIndex(String tenantId) {
            try {
                // ① 加载:用通配符匹配目录下所有 md 文件
                Resource[] resources = new PathMatchingResourcePatternResolver()
                        .getResources(docsPathPattern);
    
                List<Document> allChunks = new ArrayList<>();
    
                for (Resource res : resources) {
                    String fileName = res.getFilename();
                    log.info("正在处理:{}", fileName);
    
                    // 按结构切:Markdown 的标题层级天然是语义边界
                    MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
                            .withHorizontalRuleCreateDocument(true)
                            .withIncludeCodeBlock(false)
                            .withAdditionalMetadata("source", fileName)
                            .withAdditionalMetadata("tenantId", tenantId)   // ★ 多租户
                            .withAdditionalMetadata("docType", "knowledge")
                            .build();
    
                    List<Document> raw = new MarkdownDocumentReader(res, config).get();
    
                    // ② 切分:结构切完后如果某块还太大,再按 token 二次切
                    List<Document> chunks = splitter.apply(raw);
    
                    // ③ 补元数据:chunkIndex 便于调试时定位是哪一块
                    for (int i = 0; i < chunks.size(); i++) {
                        chunks.get(i).getMetadata().put("chunkIndex", i);
                    }
                    allChunks.addAll(chunks);
                }
    
                // ④ 灌库前的幂等处理:清掉本租户的旧数据,避免重复累积
                vectorStore.delete("tenantId == '" + tenantId + "'");
    
                // ⑤ 写入向量库(内部自动向量化)
                vectorStore.add(allChunks);
    
                log.info("索引重建完成:{} 个文件,{} 个片段", resources.length, allChunks.size());
                return allChunks.size();
    
            } catch (Exception e) {
                log.error("索引重建失败", e);
                throw new IllegalStateException("索引重建失败:" + e.getMessage(), e);
            }
        }
    }
  3. 运行验证(灌库):启动应用后,用 curl 调重建索引接口(接口定义见 ③)。预期输出:{"chunks":18,"tenantId":"default"}(数字随文档规模)。看到什么算通过:控制台打印「索引重建完成:2 个文件,18 个片段」且无 embedding 报错。

灌库接口报 500,日志出现 embedding 相关 4xx/5xx。
  1. embedding 供应商 key 无效/未设:确认 OPENAI_API_KEY 环境变量已设且重启了 IDEA;用 echo $OPENAI_API_KEY(或 PowerShell echo $env:OPENAI_API_KEY)确认非空。
  2. embedding 与 chat 共用了一个 base-url:DeepSeek 无 embedding,spring.ai.openai.embedding.base-url 必须指向有 embedding 的供应商。
  3. 文档路径匹配不到:classpath:docs/*.md 要求 md 在 resources/docs/ 且被构建进去;确认 target/classes/docs/ 下有文件。

② 接口层

  1. 同理新建类 KnowledgeQaController(包 com.example.springai.ch05),粘贴下面代码并保存(注意它引用 5.6 节的 KnowledgeQaService,下一段给出)。

    @RestController
    @RequestMapping("/api/v1/knowledge")
    public class KnowledgeQaController {
    
        private final KnowledgeQaService qaService;        // 5.6 节的 ManualRagService
        private final KnowledgeIndexService indexService;
    
        public KnowledgeQaController(KnowledgeQaService qa, KnowledgeIndexService i) {
            this.qaService = qa;
            this.indexService = i;
        }
    
        /** 问答:返回答案 + 引用来源 */
        @PostMapping("/ask")
        public KnowledgeQaService.RagAnswer ask(@RequestBody AskRequest req) {
            // 真实项目里 tenantId 应从登录态取,这里为演示放在请求体
            return qaService.ask(req.question(), req.tenantId());
        }
    
        /** 重建索引:管理接口,生产上要加权限控制 */
        @PostMapping("/reindex")
        public Map<String, Object> reindex(@RequestParam String tenantId) {
            int n = indexService.rebuildIndex(tenantId);
            return Map.of("chunks", n, "tenantId", tenantId);
        }
    
        public record AskRequest(String question, String tenantId) {}
    }
  2. 把 5.6 节的 ManualRagService 改名为 KnowledgeQaService(或保留原名并把 Controller 里的引用改回 ManualRagService),保证编译通过。文末「完整版」给的是统一命名为 KnowledgeQaService 的版本。

③ 验证:完整的 RAG 闭环

  1. 启动应用(IDEA 点主类旁绿色箭头,或命令行 mvn spring-boot:run)。先重建索引(把 docs/ 下的 md 灌进向量库):

    # 重建索引:把资源目录下的 md 全部切分灌库;tenantId 用于多租户隔离
    curl -X POST "http://localhost:8080/api/v1/knowledge/reindex?tenantId=default"

    预期输出:{"chunks":15,"tenantId":"default"}。

    看到什么算通过:chunks 是大于 0 的数字。示例库有 4 个文件(3 个 md + 1 个 docx),切出 15 块——你的数字必然不同,取决于文档数量与长度,只判「大于 0」即可。

    对不上怎么查:

    • 返回 404 → @RequestMapping 路径写错(确认是 /api/v1/knowledge/reindex)。
    • 返回 500 → 看控制台第一行异常:向量库没初始化、embedding 密钥为空、或维度不匹配(见 5.8 的环境自检)。
    • 返回 {"chunks":0} → 一个文件都没读到。检查 docs/ 是否在 src/main/resources/ 下、扩展名是否受支持(.md/.docx/.doc/.pdf/.txt,.json 会被静默跳过)。
    • 返回 chunks 明显偏小 → 有文件被跳过,把日志级别调到 debug 看是否打印「跳过不支持的文件」。
  2. 用口语化、不含原文关键词的问法提问:

    # 注意:问题里没有"退款""到账"等原文词,故意用口语,验证语义检索
    curl -X POST http://localhost:8080/api/v1/knowledge/ask \
      -H "Content-Type: application/json" \
      -d '{"question":"我买的东西不想要了,钱什么时候能退给我?","tenantId":"default"}'

    预期输出结构(实测样例,措辞每次会变、分数会波动):

    {
      "answer": "能否退款、何时到账,取决于您的订单情况: 1. 申请时限:订单完成后 7 天内可提交退款申请,超期需联系客服人工审核 [1][4]。 2. 退款到账:审核通过后 3 个工作日内退回原支付账户。",
      "citations": [
         {"index":1,"source":"guide.md","score":0.6656,"excerpt":"..."},
         {"index":2,"source":"guide.md","score":0.6473,"excerpt":"..."},
         {"index":3,"source":"refund-policy.md","score":0.6295,"excerpt":"..."}
      ]
    }

    看到什么算通过(三条逐条对):

    • citations 非空,条数在 1~5 之间(等于 topK 上限);
    • 每条 score 在 0.5~0.7 区间——不要盯着小数去对齐,它随文档与问法浮动;低于 similarityThreshold(默认 0.5)的条目不会被返回,所以只要非空,分数必然 ≥ 阈值;
    • answer 正文里的 [1][4] 序号,都能在 citations 里找到对应的 index,且 source 是售后政策类文档(不该是技术文档)。

    对不上怎么查:citations 为空 → 问题在检索层,跳到 5.8 的「第一步:先分层定位」;citations 非空但答案跑偏 → 问题在生成层,检查提示词与上下文组装。

  3. 问一个知识库里没有的问题——验证「不瞎编」:

    curl -X POST http://localhost:8080/api/v1/knowledge/ask \
      -H "Content-Type: application/json" \
      -d '{"question":"公司年会在哪一天举办?","tenantId":"default"}'

    预期输出:answer 为「根据现有资料无法确定,建议联系人工客服。」,citations 为空数组。

    看到什么算通过:返回 200、明确说「无法确定」、没有编出任何日期。如果它编了一个日期出来 → 你的提示词约束不够强,或阈值太低捞进了无关片段。

    ⚠️ 同一句话,两种完全不同的含义——必须学会区分。

    「无法确定」有两个来源,而且响应体长得一模一样,学生极容易混淆:

    来源真实含义怎么确认
    正确的拒答检索到了资料,但资料里确实没有这个信息——这是好现象服务端日志 【RAG检索】命中=N,N > 0
    检索失败压根没检索到任何片段,模型连判断的机会都没有——这是故障日志 命中=0,且响应里 citations 为空

    新手最常见的误判就在这里:把「检索失败」当成「知识库里没有」,于是跑去改提示词、调 temperature、换模型,白忙半天。你随手问一句「我的名字是什么」,返回的也是这句「无法确定」——但它属于检索失败,因为问法与文档里的表述(「姓名:某某」)语义距离太远,压根没召回。

    唯一的判据是服务端日志里的命中条数,所以下面这行日志必须保留,不要图省事删掉:

    log.info("【RAG检索】问题=「{}」命中={}条", question, docs.size());
    for (int i = 0; i < docs.size(); i++) {
        log.info("  [{}] score={} source={}", i + 1, docs.get(i).getScore(),
                docs.get(i).getMetadata().get("source"));
    }

    有了它,任何一次「答不出来」都能在 5 秒内定性:命中=0 → 检索层问题(5.8 第一步);命中>0 但还是说无法确定 → 资料里真没有 / 或生成层没用好。

第 ③ 步是 RAG 的「灵魂测试」。很多同学把 RAG 做出来就完事,结果上线后被问到一个知识库外的问题就胡说八道,用户立刻失去信任。一个 RAG 系统好不好,不看它能答什么,而看它知不知道自己在什么时候答不了。
一句话记忆:总装三件套 = 离线 KnowledgeIndexService(读→切→灌)、在线 KnowledgeQaService(检→组→生→溯)、KnowledgeQaController 暴露 HTTP;灵魂测试是「知识库外问题必须回答无法确定,绝不编造」。

5.8效果优化与排错

这一节是本章的急救箱。RAG 出问题时,最怕的不是难,而是在错误的地方反复试——改了提示词、换了模型、调了 temperature,折腾一小时,结果问题出在切分上。下面五步请严格按顺序走:先定位是哪一层坏了 → 再按顺序排查检索层 → 再按症状查表 → 再谈优化 → 最后才做召回率专项提升。

第一步:先分层定位(30 秒,决定往哪个方向查)

九步链路可以粗暴切成三层,每层有各自的「责任」。出问题先问一句:坏的是哪一段?

层包含步骤判据(怎么知道它坏了)典型病灶
检索层 ①加载 ②切分 ③向量化 ④存储 ⑤检索 citations 为空,或答案要的事实压根不在 excerpt 里 忘了重建索引 / 阈值太高 / 问法与文档表述不匹配 / 切分把关键句切碎 / 向量被污染失真
生成层 ⑦组装上下文 ⑧生成 citations 非空且含正确答案,但 answer 没写对、或跑题 提示词没约束「只依据资料」/ 上下文过长关键信息被淹没 / 模型能力不足
溯源层 ⑨引用构造 答案是对的,但 index 与 source 对不上号 把引用序号交给模型自由生成(违反 5.6 铁律)
一条判据切两半:citations 空不空,就是检索层与生成层的分界线。
空 → 往上查检索(第②步);非空 → 往下查生成(提示词 / 上下文)。
配合服务端日志 【RAG检索】命中=N 一起看,这一条能砍掉一半的无效折腾。

第二步:检索层的排查顺序(严格按这个顺序,不要乱试)

  1. 先看检索结果(90% 的问题在这里)。打开日志确认:
    · 检索到的片段里到底有没有正确答案?没有 → 继续第 2 步。
    · 有,但排在第 4、5 位 → 加大 topK,或降低 threshold。
  2. 检查切分质量。把 chunk 打印出来看:是不是把关键的一句切成两半了?是不是标题和正文分离了?换成按结构切(Markdown 标题 / 段落)。
  3. 检查向量模型。中文语料用了英文优化的模型?换 text-embedding-v3 或 BAAI/bge-large-zh 重灌。
  4. 检查用户问法。「钱啥时候回来」这种口语化问题,检索表现通常差。开启查询改写(5.5 节)或做同义词扩充。
  5. 最后才怀疑模型。检索明明是对的,模型还是答错——这时检查提示词是不是太弱(没说「只依据资料」)、上下文是不是太长(关键信息被淹没)。

第三步:按症状查表(覆盖三层,含真实踩坑)

表里前 4 条是真实项目里踩过的坑,共同特点是「症状看起来像提示词问题,实际是检索层故障」——这类坑最耗时间,所以每一条都给了定位方法。

现象根因怎么定位(关键)解决
永远回答「资料中未提及」 检索为空,或占位符名字写错 看日志 【RAG检索】命中=N;N=0 就是检索层 按第二步顺序查;确认占位符是 {question_answer_context}(框架约定,写错则检索结果进不了提示词)
★ 检索能力整体下滑,答案频繁「无法确定」,且库里数据全部失真 Java 17 中文 Windows 默认 file.encoding=GBK,UTF-8 文档被按 GBK 解码 → 乱码(U+FFFD 替换符)入库 → 向量失真,分数集体下跌(实测 0.5693 → 0.5084) 进库里查有没有替换符:
SELECT count(*) FROM vector_store WHERE content LIKE '%\ufffd%';
结果 > 0 就是它
在 spring-boot-maven-plugin 里加 <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>,然后重新 reindex(必须重灌,旧数据已坏)
★ 同一段文本、同一个问题,分数却每次不一样;加了元数据后分数刚好掉出阈值 OpenAiEmbeddingModel 默认按 MetadataMode.EMBED 把全部 metadata 也拼进 embedding 输入。于是加一个 tenantId,回退款文档分数就从 0.5693 掉到 0.4821(掉出 0.5 阈值);含时间戳的文档每次重灌分数都变(0.5464/0.5441/0.5478) 把同一段文本连续灌 3 次,比对检索分数:
· 三次完全一致 → 正常
· 每次不同 → 就是 metadata 污染
spring.ai.openai.embedding.metadata-mode: NONE(只向量化纯文本,metadata 仍可用于过滤/展示)。修复后同文本重灌三次分数在小数点后 15 位一致
复合提问(「教育背景是什么?获得过哪些奖项?」)答「无法确定」,但分开问就都对 docx/pdf 经 Tika 解析成无结构长文本后按 token 盲切,一块里混了好几个主题(含教育背景的那块,前 500 字全是技术能力描述);复合问题的向量是多个意图的平均,中心被稀释,正确块掉出 topK/阈值 把命中块的正文打印出来看:是不是「前半段在讲别的」;再把它拆成单意图问题分别问,若都能答 → 确诊 教学阶段先用聚焦提问(单一意图)绕过;治本三条:更小的 chunk、查询侧多意图拆分、检索后重排(详见下方「模糊提问的召回率优化」)
同一个问题换种问法,答案差很多 检索不稳定(阈值卡在分数边缘) 把几种问法的命中数与 score 列出来对比 降低阈值;或开查询改写 / 多查询检索后合并(见下节)
返回 {"chunks":0},或新文档检索不到 文档没被读到;或改了文档忘了重建索引;或租户不一致 看 reindex 返回的 chunks;看日志有没有「跳过不支持的文件」 确认 docs/ 在 src/main/resources 下且扩展名受支持(.json 会被跳过);改完文档必须重新调 /reindex;检查 tenantId 一致
allowEmptyContext=false 时,以为会抛异常 2.0.1 的实际语义与直觉相反,旧资料常写错 直接跑一次空检索场景看结果 以实测为准:false = 用 emptyContextPromptTemplate 替换问题(不抛异常);true = 原样放行、让模型自由发挥。两个分支都不抛异常,别照着旧教程去「修异常」
回答里出现了知识库里没有的信息 提示词没禁止编造,或 topK 太小导致模型用先验知识补全 看 citations:为空却给了具体事实 → 编造 加「只依据资料,不足则说无法确定」;把 temperature 降到 0.1~0.3
回答正确但引用来源是错的 模型只是模仿了 [1][2] 的格式,序号和实际来源不对应 拿 answer 里的 [n] 去比对 citations[n-1].source 不要把序号交给模型自由发挥;改用「引用列表由代码按检索结果构造」(5.6 节方法二)
响应特别慢(> 10s)/ 成本突然暴涨 topK 太大、片段太长、查询改写多了一次调用;上下文塞了太多片段 统计每次调用的 promptTokens(第 3 章那个 Advisor),算 topK × 平均片段长度 压小 topK;对片段做摘要;单轮对话关闭改写

第四步:六个高频优化手段(先知道什么时候上哪一招)

手段怎么做收益
混合检索向量检索 + 关键词检索(BM25)各取 20 条,合并去重后重排专有名词、编号类查询提升明显
重排(Rerank)先召回 20 条,再用重排模型精排取前 5精度大幅提升,增加一次调用延迟
父块检索用小块做匹配,把小块所属的大块给模型解决「片段太小丢上下文」
元数据预过滤按部门/时间/文档类型先缩范围免费且显著,强烈推荐
上下文压缩把检索到的长片段先摘要,再放进提示词省 Token、降低干扰
多查询检索把一个问题改写成 3 个不同角度的查询,分别检索后合并召回率提升,成本翻倍

第五步:模糊提问查不到?四招提升召回率(可照做)

「我的名字是什么」「钱啥时候回来」这类提问的特点是:口语化、字数少、不含文档里的关键词。它们失败的原因不是「阈值太高」这一个参数,而是查询的表述与文档的表述不在同一个语义区域——文档里写的是「姓名:某某」,用户问的是「我的名字」。所以只能从语料侧或查询侧解决,改 temperature、换模型统统无效。

优先级手段怎么做成本适合什么情况
1语料侧补口语问法把常见口语问法写成 FAQ 块放进知识库零代码、零调用问题集中在几个高频说法上——性价比最高,先做这个
2放宽检索参数阈值 0.5 → 0.35,topK 5 → 8改两个数字分数卡在阈值边缘、时好时坏
3多查询扩展把 1 个问句扩成 3 条检索式,分别检索后合并每次多 1 次 LLM 调用(约 +1~2s)问法千变万化、无法穷举——通用性最好
4意图拆分 + 重排复合问题拆成单意图;宽召回后精排需自建/外部 API复合提问(见第三步踩坑第 4 条)——留到第 10 章评测阶段

招 1:语料侧补口语问法(零代码,先做这个)

这是最被低估的一招。既然用户会这样问,那就让文档里也存在这样的句子。在 docs/ 下新建 faq.md:

## 我的名字是什么 / 我是谁
本知识库中的个人档案属于某某某,就读于某某大学某某专业。

## 钱什么时候能退给我
见《退款政策》:订单完成后 7 天内可申请,审核通过后 3 个工作日退回原账户。

## 东西还没发货怎么办
订单超过 48 小时未发货,可在订单页点击「催发货」或联系人工客服。

为什么有效:FAQ 天然就是「口语问法 ↔ 答案」的成对语料,用户的口语问句能直接命中标题里的同义表述。但有两个必须知道的点:

招 2:放宽检索参数 + 一个对照实验

把 KnowledgeQaService 里的 similarityThreshold(0.5) 改成 0.35、topK(5) 改成 topK(8),然后用同一组问题跑改造前/改造后两遍,把命中条数与最高分记录下来:

问句改造前
命中 / 最高分
改造后
命中 / 最高分
看什么
退款要多久到账(含关键词)4 / 0.66__ / __自己填。预期规律:含关键词的问题变化不大;口语化、短问句的命中数会上升;但知识库外的问题可能开始捞到无关片段——那就要回调阈值
我买的东西不想要了,钱什么时候能退给我?(口语)5 / 0.67__ / __
我的名字是什么(极短、无重叠词)0 / —__ / __
公司年会在哪一天举办?(知识库外)0 / —__ / __
看结果时注意一个反直觉现象:降低阈值后,「知识库外的问题」也可能开始捞到无关片段——于是它不再说「无法确定」,而是拿着无关资料硬答。召回率上升的代价,是精确率下降。所以第 4 行必须盯着看:如果它开始编答案,说明阈值降过头了,把 0.35 回调到 0.4 再试一次。灵魂测试永远是那条不可逾越的红线。

招 3:多查询扩展(通用性最好,代码可直接抄)

思路:让模型把你的口语问题改写成几条「检索友好」的查询,各自去检索,最后合并结果——一条问法没命中的,另一条可能就命中了。这个能力 Spring AI 已经内置(在 spring-ai-rag 模块里,随 spring-ai-vector-store-advisor 一并引入,无需新增依赖坐标):

// ① 声明扩展器:ChatClient.Builder 由自动配置提供,加进构造参数注入即可
private final MultiQueryExpander expander;

public KnowledgeQaService(VectorStore vectorStore,
                          ChatClient chatClient,
                          ChatClient.Builder chatClientBuilder) {   // ★ 新增这个参数
    this.vectorStore = vectorStore;
    this.chatClient = chatClient;
    this.expander = MultiQueryExpander.builder()
            .chatClientBuilder(chatClientBuilder)
            .numberOfQueries(3)       // 扩成 3 条检索式
            .includeOriginal(true)    // 保留原问句,防止改写跑偏
            .build();
}

// ② 把原来的「一次检索」改成「扩展 → 逐条检索 → 合并」
List<Query> queries = expander.expand(Query.builder().text(question).build());
for (Query q : queries) {
    log.info("[查询扩展] {}", q.text());       // ★ 打印出来,看模型改写成了什么
}
Map<Query, List<List<Document>>> hits = new LinkedHashMap<>();
for (Query q : queries) {
    hits.put(q, List.of(vectorStore.similaritySearch(SearchRequest.builder()
            .query(q.text())
            .topK(5)
            .similarityThreshold(0.35)
            .filterExpression("tenantId == '" + tenantId + "'")
            .build())));
}
List<Document> docs = new ConcatenationDocumentJoiner().join(hits);

「我的名字是什么」会被改写成什么?大致是「个人身份信息」「姓名 档案」「用户个人信息」这类检索友好的表述——原来一条都召不回的,现在有机会命中。记得把 [查询扩展] 日志打出来,那是判断改写质量的第一现场;如果它把问题改跑偏了(比如把「退款」改成了「退货」),说明提示词需要收窄。

用之前必须知道的两个坑:
  1. ConcatenationDocumentJoiner 只拼接、不去重——同一个片段可能被多条查询命中而重复出现,引用列表里会出现重复项。生产代码要按 document.getId() 去重后再组装上下文。
  2. 每次问答多一次 LLM 调用(延迟约 +1~2 秒)。想省的话,用只改写一次的 RewriteQueryTransformer 替代(同样是 builder().chatClientBuilder(...).build())。别在没有评测集之前就全量上线,先用一组固定问题做前后对比。
什么时候别用招 3:你的问答对延迟敏感(要求 < 3 秒),或问题本身已经很明确(用户在问具体条款)。多查询扩展是「兜底手段」,不是万能药。

现在不要做的:混合检索与重排

网上教程常推荐「向量 + BM25 混合检索」「召回 20 条再 Rerank」,这两招确实有效,但Spring AI 2.0.1 没有内置实现(DocumentPostProcessor 只是个接口,没有可用的重排实现类;也没有内置 BM25),要自己接第三方重排 API 或写 SQL 全文检索。本章阶段先把招 1~3 做扎实——它们覆盖了绝大多数模糊提问场景,而且每一项都能用「命中条数」量化验证;混合检索与重排留到第 10 章有了评测集再上,否则你根本无法判断它到底有没有变好。

一句话记忆:模糊提问查不到,本质是「查询的表述」与「文档的表述」不匹配——四招按顺序上:补 FAQ 语料(零成本)→ 放宽阈值 → 多查询扩展 → 拆意图 + 重排;每改完一项,都用同一组问题跑前后对照、看命中条数,别凭感觉判断好坏。
关于「引用来源错乱」的补充说明:让模型输出 [1] [2] 这种序号,本质上是把「引用和来源的映射关系」交给模型记忆,模型中途一定会搞错。正确做法是:让模型只管生成答案,引用列表由你的代码根据检索结果直接构造(就是我们 5.6 节方法二里的 citations)。这样引用 100% 准确。
一句话记忆:RAG 调优黄金顺序 = 先看检索结果 → 再查切分 → 再查向量模型 → 再查问法 → 最后才怀疑模型;「响应慢/成本高」多半是 topK 太大或片段太长。而「明明库里有却答不出来」,先看 citations 空不空——空就是检索层,别去改提示词。

5.9自测题

Q1. RAG 和微调各自适合什么场景?为什么企业知识问答首选 RAG?

微调适合注入「风格/格式/能力」,RAG 适合注入「事实知识」。企业知识问答首选 RAG 的原因:① 数据频繁更新,RAG 只需重灌索引;② 必须能给出引用出处,微调做不到;③ 可以做细粒度权限控制(谁能检索哪些文档);④ 成本低、见效快、可解释。

Q2. 完整的 RAG 有哪九个步骤?哪些是离线的,哪些是在线的?

离线:① 加载(Reader)② 切分(Splitter)③ 向量化(EmbeddingModel)④ 存储(VectorStore)。在线:⑤ 检索 ⑥ 重排 ⑦ 组装上下文 ⑧ 生成 ⑨ 溯源。离线步骤只在灌库/更新时执行,在线步骤每次提问都执行。

Q3. 检索明明是对的,模型却答错了,可能的三个原因是什么?

① 提示词没约束「只依据资料」,模型用自己的先验知识覆盖了资料;② 上下文太长,关键信息淹没在中间(模型对上下文中部信息注意力较弱);③ 检索到的片段中包含相互矛盾的信息(例如新旧两版制度都在库里),模型选错了。第 ③ 点需要在灌库时做版本管理,只保留最新版本。

Q4. 为什么「多租户隔离」必须做在检索条件里?

因为检索发生在模型调用之前。如果检索阶段就把别的租户的文档捞了出来,数据已经进入了 Prompt 上下文,再让模型「不要说出来」是掩耳盗铃——稍微巧妙的提问就能诱导它泄露。必须在向量库层面用 filterExpression 物理隔离。

Q5.【动手题】把本节的 topK=5 / threshold=0.5 改成三组不同参数,各跑同一批 10 个问题,统计「正确命中数」和「平均耗时」,选出一组最优参数。

这是第 10 章「评测」的雏形。步骤:固定问题集 → 对每组参数跑一遍 → 人工或模型判定答案是否正确 → 记录命中率与延迟 → 做折中决策。注意:不要只盯命中率,topK 从 5 调到 20 可能命中率提升 3%,但成本和延迟翻倍,未必值得。

一句话记忆:九步链路张口就来、RAG 与微调取舍清晰、多租户隔离在检索条件、引用由代码生成——这是本章自测的五道关。

5.10实操作业与本章验收

课堂练习(课上完成,约 1 学时):

  • 照 5.4 节把最简 RAG 跑起来,用口语化问题「我不想要了钱啥时候退」提问,截图验证能命中 refund-policy.md 的片段。
  • 故意把 similarityThreshold 调成 0.95,观察回答退化为「资料中未提及」,再调回 0.5——体会阈值对召回的影响。
  • 两人一组互换:A 给 B 一个知识库外的问题,B 验证自己的系统能正确回答「无法确定」而不是编造。

课后作业:

  • 把 5.7 节完整示例敲出来并跑通,提交三张截图:① /reindex 返回;② 命中问题的回答+引用;③ 知识库外问题的「无法确定」回答。
  • 选一个你熟悉的领域(课程介绍/社团章程/产品说明书),准备 2 个 md 文档灌库,写 300 字说明:你如何确定 chunkSize、如何设计元数据以支持按分类过滤。
  • (选做)把 5.5 节的多租户隔离加上,构造两个租户,验证 A 租户搜不到 B 租户的文档。
一句话记忆:作业验收四条 = 会背九步、能跑通带出处全链路、口语化命中且不编造、能讲清四种优化与多租户隔离原理。

文末完整版(自建可直接照抄)

下面把本章所有文件按「统一命名 + 可直接编译」整理成一份。配置基线:Spring AI 2.0.1 + Spring Boot 4.1.x + Java 17;聊天走 DeepSeek,嵌入走 OpenAI 兼容供应商(DeepSeek 无 embedding)。具体 API 以官方文档为准。

① pom.xml 关键依赖

<dependencies>
  <!-- Spring Boot 基础 -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>

  <!-- Spring AI BOM 统一管版本(2.0.1)-->
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-bom</artifactId>
        <version>2.0.1</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <!-- 聊天(ChatModel) + 嵌入(EmbeddingModel),OpenAI 兼容协议 -->
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
  </dependency>

  <!-- QuestionAnswerAdvisor 所在模块 -->
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-vector-store-advisor</artifactId>
  </dependency>

  <!-- 文档读取:Markdown / Tika(万能格式) -->
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-markdown-document-reader</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-tika-document-reader</artifactId>
  </dependency>
</dependencies>

② application.yml

server:
  port: 8080

spring:
  ai:
    openai:
      # 聊天:DeepSeek(OpenAI 兼容)
      chat:
        api-key: ${DEEPSEEK_API_KEY:}
        base-url: https://api.deepseek.com
        options:                 # 只有 chat 才有 options 层级
          model: deepseek-chat
      # 嵌入:DeepSeek 无此接口,走 OpenAI 兼容供应商;embedding 下无 .options
      embedding:
        api-key: ${OPENAI_API_KEY:}
        base-url: https://api.openai.com/v1
        model: text-embedding-3-small

logging:
  level:
    org.springframework.ai: debug   # 能看到拼进提示词的检索片段

③ 启动类 SpringaiLabApplication.java

package com.example.springai;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringaiLabApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringaiLabApplication.class, args);
    }
}

④ 向量库配置 RagConfig.java(内存版 SimpleVectorStore,免装数据库)

package com.example.springai.ch05;

import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RagConfig {
    // EmbeddingModel 由 spring.ai.openai.embedding.* 自动装配
    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel) {
        // 具体 builder API 以官方文档为准
        return SimpleVectorStore.builder(embeddingModel).build();
    }
}

⑤ 离线索引服务 KnowledgeIndexService.java

package com.example.springai.ch05;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.markdown.MarkdownDocumentReader;
import org.springframework.ai.reader.markdown.config.MarkdownDocumentReaderConfig;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

@Slf4j
@Service
public class KnowledgeIndexService {

    @Value("${knowledge.docs-path:classpath:docs/*.md}")
    private String docsPathPattern;

    private final VectorStore vectorStore;

    private final TokenTextSplitter splitter = TokenTextSplitter.builder()
            .withChunkSize(600)
            .withMinChunkSizeChars(200)
            .withMinChunkLengthToEmbed(5)
            .withKeepSeparator(true)
            .build();

    public KnowledgeIndexService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public int rebuildIndex(String tenantId) {
        try {
            Resource[] resources = new PathMatchingResourcePatternResolver()
                    .getResources(docsPathPattern);

            List<Document> allChunks = new ArrayList<>();

            for (Resource res : resources) {
                String fileName = res.getFilename();
                log.info("正在处理:{}", fileName);

                MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
                        .withHorizontalRuleCreateDocument(true)
                        .withIncludeCodeBlock(false)
                        .withAdditionalMetadata("source", fileName)
                        .withAdditionalMetadata("tenantId", tenantId)
                        .withAdditionalMetadata("docType", "knowledge")
                        .build();

                List<Document> raw = new MarkdownDocumentReader(res, config).get();
                List<Document> chunks = splitter.apply(raw);

                for (int i = 0; i < chunks.size(); i++) {
                    chunks.get(i).getMetadata().put("chunkIndex", i);
                }
                allChunks.addAll(chunks);
            }

            vectorStore.delete("tenantId == '" + tenantId + "'");
            vectorStore.add(allChunks);

            log.info("索引重建完成:{} 个文件,{} 个片段", resources.length, allChunks.size());
            return allChunks.size();

        } catch (Exception e) {
            log.error("索引重建失败", e);
            throw new IllegalStateException("索引重建失败:" + e.getMessage(), e);
        }
    }
}

⑥ 在线问答服务 KnowledgeQaService.java(5.6 方法二,统一命名)

package com.example.springai.ch05;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;

@Slf4j
@Service
public class KnowledgeQaService {

    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    private static final String TEMPLATE = """
            你是企业知识库助手。请严格依据下方【参考资料】回答用户问题。

            【参考资料】
            {context}

            回答要求:
            1. 只使用参考资料中的信息;资料不足时回答"根据现有资料无法确定"。
            2. 在每个结论后用 [序号] 标注它来自哪条参考资料。
            3. 不要复述参考资料原文,用你自己的话组织答案。
            4. 回答控制在 300 字以内。

            用户问题:{question}
            """;

    public KnowledgeQaService(VectorStore vectorStore, ChatClient.Builder builder) {
        this.vectorStore = vectorStore;
        this.chatClient = builder.build();
    }

    public RagAnswer ask(String question, String tenantId) {
        List<Document> docs = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(question)
                        .topK(5)
                        .similarityThreshold(0.5)
                        .filterExpression("tenantId == '" + tenantId + "'")
                        .build());

        log.info("【RAG检索】问题=「{}」命中={}条", question, docs.size());
        for (int i = 0; i < docs.size(); i++) {
            Document d = docs.get(i);
            log.info("  [{}] score={} source={} 内容前80字={}",
                    i + 1, d.getScore(), d.getMetadata().get("source"),
                    d.getText().substring(0, Math.min(80, d.getText().length())));
        }

        if (docs.isEmpty()) {
            return new RagAnswer("根据现有资料无法确定,建议联系人工客服。", List.of());
        }

        StringBuilder ctx = new StringBuilder();
        for (int i = 0; i < docs.size(); i++) {
            Document d = docs.get(i);
            ctx.append("[%d] 来源:%s  内容:%s\n\n".formatted(
                    i + 1, d.getMetadata().get("source"), d.getText()));
        }

        String answer = chatClient.prompt()
                .user(u -> u.text(TEMPLATE)
                           .param("context", ctx.toString())
                           .param("question", question))
                .call()
                .content();

        List<Citation> citations = new ArrayList<>();
        for (int i = 0; i < docs.size(); i++) {
            Document d = docs.get(i);
            citations.add(new Citation(
                    i + 1,
                    String.valueOf(d.getMetadata().get("source")),
                    String.valueOf(d.getMetadata().getOrDefault("page", "-")),
                    d.getScore(),
                    d.getText()));
        }
        return new RagAnswer(answer, citations);
    }

    public record RagAnswer(String answer, List<Citation> citations) {}
    public record Citation(int index, String source, String page, Double score, String excerpt) {}
}

⑦ 接口层 KnowledgeQaController.java

package com.example.springai.ch05;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;

@RestController
@RequestMapping("/api/v1/knowledge")
public class KnowledgeQaController {

    private final KnowledgeQaService qaService;
    private final KnowledgeIndexService indexService;

    public KnowledgeQaController(KnowledgeQaService qa, KnowledgeIndexService i) {
        this.qaService = qa;
        this.indexService = i;
    }

    @PostMapping("/ask")
    public KnowledgeQaService.RagAnswer ask(@RequestBody AskRequest req) {
        return qaService.ask(req.question(), req.tenantId());
    }

    @PostMapping("/reindex")
    public Map<String, Object> reindex(@RequestParam String tenantId) {
        int n = indexService.rebuildIndex(tenantId);
        return Map.of("chunks", n, "tenantId", tenantId);
    }

    public record AskRequest(String question, String tenantId) {}
}
完整版使用说明:把以上 7 个文件放入工程,在 src/main/resources/docs/ 放 refund-policy.md 等测试文档,设好 DEEPSEEK_API_KEY 与 OPENAI_API_KEY 环境变量后启动,按 5.7 节 ③ 的 curl 命令即可跑通完整 RAG 闭环。

5.11动手清单与自查表(照着核对)

本章是全课程第一个"完整业务闭环":离线灌库 + 在线问答 + 引用溯源三段拼在一起。哪一段没做对,表现都是同一个——回答变成「资料中未提及」。所以这里的自查表重点解决一件事:出现这个症状时,怎么在 30 秒内判断坏在哪一层。

① 你要建的文件(建一个,勾一个)

文件(相对工程根)对应小节怎么确认建对了
.../ch05/ReaderDemo.java5.3含 main 方法,可单独运行;四种 Reader 各有一段
.../ch05/SimpleRagDemo.java5.4 + 5.6QuestionAnswerAdvisor 的模板里占位符是 {question_answer_context}(写错这个=永远检索不到);内层挂了 SimpleLoggerAdvisor
.../ch05/ModularRagDemo.java5.5三个 client 分别演示空上下文策略 / 查询改写 / 多租户过滤
.../ch05/Citation.java5.6record + 静态工厂 of(index, doc);共享给两个溯源入口
.../ch05/RagAnswer.java5.6record:answer + citations
.../ch05/KnowledgeQaService.java5.6 / 5.7手工链路:检索 → 组装 → 生成 → 溯源;检索为空时直接返回固定话术,不调模型
.../ch05/KnowledgeIndexService.java5.7按扩展名分发 Reader(.md 走 Markdown、其余走 Tika);灌库前先按 tenantId 删;不支持的扩展名自动跳过
.../ch05/KnowledgeQaController.java5.7两个端点:POST /api/v1/knowledge/ask(在线)、POST /api/v1/knowledge/reindex(离线管理)
.../ch05/Ch05DemoRunner.java5.4 ~ 5.6按 run54→run55→run56 顺序;脚手架,可随时删
src/main/resources/docs/*.md(≥1 个)5.3 / 5.7用 UTF-8 保存;文件名尽量英文(中文名也能用,但排查时更麻烦)
src/main/resources/docs/*.docx(可选)5.7验证多格式接入(Tika 解析);放进去后必须重新 reindex
pom.xml(改动)5.3 / 5.4含 Reader 依赖(markdown / tika)与 spring-ai-vector-store-advisor(注意不是已下架的旧坐标 spring-ai-advisors-vector-store)
本章必须写进 pom.xml 的一行 JVM 参数(否则整章白做):Java 17 的中文 Windows 默认编码是 GBK,而你的 .md 是 UTF-8。不指定编码读出来就是乱码,入库的是替换符(U+FFFD),向量因此失真、检索分数集体下滑——症状是"文档明明有,却总说资料中未提及"。
<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <configuration>
    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
  </configuration>
</plugin>
验证方法:入库后用 psql 查有没有替换符——SELECT count(*) FROM vector_store WHERE content LIKE '%\ufffd%'; 结果不为 0 就是编码没生效。
第二处"隐形杀手":embedding 会把元数据一起向量化。默认 MetadataMode.EMBED 会把文档的 全部 metadata 拼进向量化输入,于是同一段文本因为 tenantId / indexedAt 不同,相似度分数每次都不一样,甚至直接掉出阈值。修复只需一行配置:
spring:
  ai:
    openai:
      embedding:
        metadata-mode: NONE      # 只向量化纯文本,不让元数据污染相似度
验证方法:同一段文本连续灌 3 次,看检索分数是否完全一致(修复后应到小数点后十几位都一样)。

② 分步验收:从零到跑通(每步都有预期输出)

  1. 前置自检(30 秒,能省掉一半无效排查)——三条一起看:

    检查项命令 / 做法
    ① 应用在跑curl -i http://localhost:8080/actuator/health(或看启动日志)
    ② 向量库在跑psql -U postgres -h 127.0.0.1 -d vectordb -c "SELECT 1;"
    ③ 密钥非空chat 用 DEEPSEEK_API_KEY、embedding 用的那家 key 都要有

    三条缺一条,后面的失败都无从归因——这也是为什么本章的排错要先分层。

  2. 灌库(离线,改过文档就必须重跑):

    curl -s -X POST "http://localhost:8080/api/v1/knowledge/reindex?tenantId=default"

    预期:返回形如 {"chunks":15,"tenantId":"default"}。

    看到什么算通过:chunks 必须大于 0。等于 0 是最隐蔽的失败——说明文档一个都没被读到,常见原因:文件没放进 src/main/resources/docs/(放到了工程根目录 docs/),或扩展名不在支持列表(.json 会被跳过)。

  3. 语义问答(口语化提问,故意不含原文关键词):

    curl -s -X POST http://localhost:8080/api/v1/knowledge/ask \
      -H "Content-Type: application/json" \
      -d '{"question":"我买的东西不想要了,钱什么时候能退给我?","tenantId":"default"}'

    预期:answer 分点作答、且正文里带 [1] [2] 之类序号;citations 非空。

    看到什么算通过(可精确断言):① citations 里必须出现 refund-policy.md;② score 在 0.5 以上;③ 引用序号与正文标号严格对应(正文写 [1],citations 第 1 条的 source 就得是那句话的出处)。

  4. 灵魂测试(本章最重要的验收,也是最容易被当成"坏了"的一步):

    curl -s -X POST http://localhost:8080/api/v1/knowledge/ask \
      -H "Content-Type: application/json" \
      -d '{"question":"公司年会在哪一天举办?","tenantId":"default"}'

    预期:{"answer":"根据现有资料无法确定,建议联系人工客服。","citations":[]}。

    看到什么算通过:明确说"无法确定",没有编出任何日期。这一条过了,才说明你的 RAG 是"可靠的"而不只是"能答的"。

    ★ 同一个响应体,两种完全相反的含义——新手最容易在这里走错路:
    表面表现服务端日志真实含义
    回答"资料中未提及"命中 > 0正确的拒答——资料里确实没有,好事
    回答"资料中未提及"命中 = 0检索失败——压根没召回到,是故障
    所以:服务端那行 【RAG检索】命中=N 的日志必须保留。没有它,你分不清"库里没有"和"没查到",就会跑去改提示词、调温度——白忙半天。先看日志里的命中数,再决定往哪查。
  5. 多格式验证(可选但强烈建议):把一个 .docx(例如个人简历)放进 docs/,重新 reindex(chunks 应增加),然后就文档里的具体事实提问。

    预期:能命中 docx 解析出来的内容并正确回答。

    注意:docx/pdf 经 Tika 解析后是无结构长文本,按 token 盲切,一块里可能混多个主题。所以要问"单一意图"的问题("就读院校和专业是什么");把两个不相干的问题拼在一起问,正确块很可能被稀释出 topK——这是策略问题,不是 bug(改进方向见 5.8 节第五步)。

  6. 做一个阈值反例:把 similarityThreshold 改成 0.95 重启,重发第 3 步的提问。

    预期:回答退化成"资料中未提及"、citations 变空。

    再由 0.95 调回 0.5 左右,恢复正常。这一步的目的:让你分清"阈值太高"和"没灌库"两种症状——它们表现相似,但调完阈值就能立刻区分。

③ 卡住了去哪查(症状 → 定位)

症状先去哪一节一句话提示
不知道该往哪查5.8 节第一步「分层定位」先看 citations 空不空——空查检索层,非空查生成层
回答"资料中未提及",库里明明有5.8 节第二步 + 本节 warn先看日志命中数;命中=0 就查编码/阈值/问法
检索分数集体偏低、分数每次不同本节 warn(metadata-mode)元数据污染向量,配 metadata-mode: NONE 重灌
库里有替换符、文档乱码本节 warn(file.encoding)加 -Dfile.encoding=UTF-8,清库重灌
reindex 返回 chunks:05.7 节 ①文档位置或扩展名不对(.json 会被跳过)
引用来源错乱 / 序号对不上5.6 节铁律引用列表必须由代码构造,绝不让模型编序号
多轮对话里"它"指代不明导致检索错5.5 节 ②开查询改写(Compression)先消解指代
A 租户搜到 B 租户的文档5.5 节 ③隔离必须做在 filterExpression 里,提示词约束不算隔离
模糊提问("我的名字是什么")查不到5.8 节第五步四招按成本递增:补 FAQ 语料 → 放宽参数 → 多查询扩展
复合提问答不出,拆开问就对5.8 节第三步打印命中块,看是不是一块里混了多个主题
依赖坐标 4045.4 节旧坐标 spring-ai-advisors-vector-store 已下架,用 spring-ai-vector-store-advisor
本章「做完」的判据(四条同时成立):① reindex 的 chunks > 0;② 口语化提问能命中正确片段,且引用序号与正文标号严格对应;③ 知识库外的问题能正确说"无法确定"、citations 为空;④ 能对着 5.8 节的五步急救流程,说出「citations 空 → 查检索层,非空 → 查生成层」这条分界线。这四条里第 ③ 条最容易被跳过,但它恰恰是 RAG 系统的"灵魂"——一个系统好不好,不只看它能答什么,更看它知不知道自己什么时候答不了。