CH 05RAG:检索增强生成
RAG(Retrieval-Augmented Generation)是企业级大模型应用的绝对主力技术。一句话概括它的价值:让模型基于你提供的资料回答,而不是基于它的记忆胡猜。第 4 章我们准备好了「向量」,这一章把向量、检索、提示词、生成串成一条能用的流水线。本章全部代码按「自建」口径手把手带你从空工程搭起来——不依赖任何现成 lab 代码,每一步都能独立复制执行。
教学设计说明(教师参考):本章是课程难度的一个台阶,建议用「先跑通再讲透」的节奏:第 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 推荐 |
把知识放在外部库,回答前先检索相关片段塞进提示词 | 数据可随时更新 能给出引用出处 权限可控(谁能看哪些文档) 成本低 |
增加一次检索延迟 效果依赖检索质量 |
知识点:三种「让模型懂你」的方式怎么选
除了微调与 RAG,还有第三种常被拿来比较的思路——长上下文(把整本文档直接塞进上下文窗口)。三者不是互斥的,理解取舍才能用对地方:
| 维度 | RAG(检索增强) | 微调(Fine-tuning) | 长上下文(超长窗口) |
|---|---|---|---|
| 注入的是 | 「事实/资料」 | 「风格/能力」 | 「原始上下文」 |
| 知识更新 | 重灌索引即可,秒级 | 要重新训练,天级/钱级 | 每次请求都带上,靠输入 |
| 能否溯源 | ✅ 能,带出处 | ❌ 不能 | ⚠️ 理论能,但模型常不标 |
| 成本 | 低(只送相关片段) | 训练贵、推理省 | 高(每次都烧大量 token) |
| 适合场景 | 企业知识库、客服、政策问答 | 统一文风、特定任务格式 | 单篇长文精读、一次性分析 |
类比帮助记忆:RAG 像「开卷考试带索引的笔记本」——考前把重点抄成便签(灌库),考试时翻到相关那一页(检索)再答题;微调像「把知识点背进脑子」,考场上不翻书但忘得快、还改不了;长上下文像「把整本书摊在桌上」,看得全但费桌子(token 贵)且容易看花眼(注意力被稀释)。
RAG 能解决的三个真实痛点
- 模型不知道你的私有数据:公司制度、产品手册、客户合同,模型训练时根本没见过。
- 模型的知识有截止日期:昨天的政策变更,模型不可能知道。
- 模型会一本正经地胡说(幻觉):RAG 让它在有据可依的情况下回答,并要求「资料没有就说没有」。
为什么不直接把整份文档塞进 Prompt?读者可能会想:既然要让模型看资料,为什么不把整个 PDF 粘进提示词?原因有三:① 模型上下文窗口有限,一本书塞不进去;② 即使塞进去了,大段无关内容会「稀释」模型的注意力,反而答不准;③ 你无法控制「只检索相关片段」,也就无法做权限隔离和成本控制。RAG 的本质,是在调用模型前,先把「最该被看到的几段」精准找出来。
5.2RAG 的九步全链路(务必背下来)
RAG 看起来复杂,但拆解成九步就清晰了。这九步分两大阶段:离线阶段(①②③④,灌库,通常一次或定期执行)和在线阶段(⑤⑥⑦⑧⑨,每次用户提问都执行)。把这条链路背下来,后面所有代码都是在实现其中的某几步。
每一步在 Spring AI 里都有对应的接口/类,记住这份映射对你理解代码极有帮助:① DocumentReader(如 MarkdownDocumentReader);② DocumentTransformer(如 TokenTextSplitter);③ EmbeddingModel;④ VectorStore;⑤ VectorStore.similaritySearch 或 VectorStoreDocumentRetriever;⑦⑧ 由 Advisor 或手工拼提示词完成;⑨ 从检索结果里取元数据。
知识点:离线 vs 在线(为什么分两阶段)
| 阶段 | 执行频率 | 耗时 | 出错影响 |
|---|---|---|---|
| 离线(①②③④) | 灌库/更新时一次 | 相对慢(要调 embedding) | 只影响「以后」的检索质量 |
| 在线(⑤~⑨) | 每次提问都跑 | 要求快(用户体验) | 直接决定本次回答对错 |
POST /reindex),在线只做检索+生成。
5.3第①②步:文档加载与切分
离线阶段的前两步:先把各种格式的文档读进来,再切成合适大小的块。这两步的质量,直接决定了后面检索的上限——切得不好,后面怎么调都救不回来。
动手前置:加两个 Reader 依赖
各格式 Reader 分散在独立 starter 中,先把它加进工程的 pom.xml。注意不同 2.0.x 小版本 artifactId 命名可能微调,以官方文档为准。
-
在 IDEA 左侧 Project 面板,展开工程根目录,找到并双击打开
pom.xml。 -
滚动到
<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> -
按
Ctrl+S保存;等待右下角 Maven 同步完成(出现依赖红线说明坐标写错或版本不匹配,回去核对)。
DocumentReader:把各种格式读成 Document
下面这段代码演示四种常见 Reader。先别急着跑,按步骤建一个测试类把它跑通、验证「读取」这一步本身没问题。
-
在 IDEA 里右键包
com.example.springai.ch05→ New → Java Class;Name 输入ReaderDemo,回车创建。 -
把下面整段代码复制粘贴覆盖自动生成的骨架,按
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 都能吐出非空文本(不需要向量库也能验证读取,所以放在最前面先过关):
-
在
ReaderDemo里临时加两行(或写个main/ JUnit 测试):System.out.println("docs2 块数=" + docs2.size()); System.out.println("首块前80字=" + docs2.get(0).getText().substring(0, 80)); -
运行该类(IDEA 右键 → Run)。
预期输出(pre 示例,数字随文档而异):
docs2 块数=12 首块前80字=# 退款政策 本政策适用于所有在平台购买商品的用户。满足以下条件可申请退款……看到什么算通过:四种
docs*.size()都大于 0,且getText()打印出的是可读中文而非空串 / 乱码 / 一堆方框。这一步通过,说明「加载」没问题,后面再接切分和向量化。
docs*.size() 为 0 或 getText() 是空串/乱码。
- 路径错:
classpath:docs/guide.md要求文件在src/main/resources/docs/下;用FileSystemResource则要相对项目根目录的真实路径。先用new File("docs/guide.md").exists()打印确认文件找得到。 - 扫描版 PDF 读出来是空的——里面是图片不是文字,Tika 不做 OCR,需先 OCR 或换文本版。
- 中文乱码:多数 Reader 默认 UTF-8;若源文件是 GBK,先用编辑器转 UTF-8 再灌。
排查步骤:① 先用任意阅读器确认文档是否可选中文字(不可选 = 扫描版,需 OCR);② 打印 docs4.size() 和每块的 getText() 前 200 字,确认不是空串或被切断;③ 表格类文档改用 MarkdownDocumentReader 手动整理后再灌。
TokenTextSplitter:把长文切成合适的块
为什么必须切分?① 模型上下文有限,整本书塞不进去;② 检索要的是「精准片段」,一整章里只有一段相关,会把无关信息也带进去;③ 块太小则失去上下文,块太大则检索精度下降——切分粒度是 RAG 调优的第一杠杆。
-
在同一测试类里追加下面代码,对上面读到的
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() + " 块"); -
运行,预期输出形如
原文档 1 个,切分后 24 块;看到什么算通过:块数明显多于原文数,且每块长度大致在 350~800 中文字区间。
KeepSeparator 与块边界的取舍问题。
修复:① 优先改用按结构切(MarkdownDocumentReader 的标题层级切),比固定长度切语义更完整;② 若必须固定长度,调大 withMinChunkSizeChars 减少碎片;③ 切完人工抽查 10 块是必做项,不是可选——见下方 tip。
切分策略对比:没有最好的,只有最合适的
| 策略 | 做法 | 适用 |
|---|---|---|
| 固定长度 | 每 N 个字符/token 切一刀(TokenTextSplitter) | 通用默认;实现简单 |
| 按结构切 | 按标题、段落、--- 分隔(MarkdownDocumentReader) | ✅ 制度文档、技术手册(效果最好) |
| 按语义切 | 用模型判断语义边界再切 | 高价值长文,成本高 |
| 父文档 / 子块 | 用小块检索,返回它所属的大块给模型 | ✅ 解决「块太小缺上下文」的经典方案 |
chunkSize 怎么定?一个实用的起始规则:
- 知识库是 FAQ / 短问答:一条就是一块,别切。
- 知识库是 制度 / 手册:按章节切,400~800 token。
- 知识库是 长篇小说 / 访谈:800~1200 token,并保留重叠。
- 切完之后一定要人工抽查 10 块——看看有没有把一句话切断、有没有把标题和正文分离。这一步能提前发现 80% 的检索问题。
5.4最简 RAG:一个 Advisor 就搞定
Spring AI 提供了 QuestionAnswerAdvisor,它把第⑤⑥⑦步全部封装好了。你只需要挂上它,剩下的交给框架。这是学生第一次跑通 RAG 的捷径——先看到效果,再理解原理。
前置:把模型与向量库配好(自建必做)
本章全程自建,先确认 application.yml 同时配好「聊天模型」和「嵌入模型」。关键红线:聊天默认用 DeepSeek;但 DeepSeek 没有 embedding 接口,嵌入必须走 OpenAI 兼容供应商或本地 Ollama;且嵌入配置没有 .options 层级,只有 chat 才有。
-
在 IDEA 里右键
src/main/resources→ New → File;FileName 输入application.yml(若已存在则双击打开),回车。 -
把下面内容粘贴进去(密钥一律用
${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 -
按
Ctrl+S保存。改完 yml 必须重启应用才会生效。
400/404 或「model not found」类错误,且日志指向 embedding。
- 把 embedding 配到了 DeepSeek 的 base-url:DeepSeek 不提供 embedding 接口,会返回 404。确认
spring.ai.openai.embedding.base-url指向有 embedding 的供应商(如 OpenAI)。 - 错写成
spring.ai.openai.embedding.options.model:嵌入配置没有.options层级,这样写会被忽略、回退到默认模型导致不匹配。改成spring.ai.openai.embedding.model。 - 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 代码(标准答案,零语义改动)
-
在 IDEA 里右键包
com.example.springai.ch05→ New → Java Class;Name 输入SimpleRagDemo,回车。 -
把下面整段复制粘贴覆盖骨架(其中
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();
{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);
application.yml 里把 logging.level.org.springframework.ai: debug 打开,
你会看到完整的、包含检索片段的 Prompt。这是理解 RAG 最快的方式——
强烈建议在第 5 章至少认真读一遍这条日志。
动手:把最简 RAG 跑起来(建议课堂第 1 个实验)
-
在
src/main/resources/下建docs/目录,放一份refund-policy.md测试文档(随便写几句「7 天内可申请退款,审核后 3 个工作日到账」之类的内容)。 -
新建一个测试类,用上面的快捷方法做检索 + 灌库 + 提问。先灌库(把 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的相似度检索能返回内容(下一步验证)。 -
调用上面的快捷方法提问
"退款要几天到账?"。用 main 直接System.out.println(answer)打印。预期输出:返回一段依据文档的回答,而不是模型凭空编的。
依据《refund-policy.md》,订单完成后 7 天内可申请退款,审核通过后 3 个工作日内退回原支付账户。 [来源:refund-policy.md]看到什么算通过:答案里出现你文档里写的事实(如「7 天」「3 个工作日」),并带
[来源:...]。若返回「资料中未提及」,说明检索没命中——回到 5.3 节检查切分与阈值,或把similarityThreshold临时调到0.0验证。
- 阈值太高:
similarityThreshold(0.5)把相关片段全过滤了。先临时改成0.0,能命中就说明是阈值问题,再慢慢往上调到 0.4~0.6。 - 占位符名字写错:模板里必须是
{question_answer_context}(框架约定),写成{context}等自定义名,检索结果进不了提示词,模型当然看不到资料。 - 没真的灌库:上一步
vectorStore.add抛了异常被吞掉;确认控制台没有报错,且文档路径正确。 - 文档和提问语言/用词差距大:口语化问法命中低,见 5.5 节查询改写。
QuestionAnswerAdvisor 一行把「检索→拼提示→生成」三步走完;模板占位符必须是 {question_answer_context},检索对你透明,但你也拿不到中间结果。5.5进阶:模块化 RAG(RetrievalAugmentationAdvisor)
QuestionAnswerAdvisor 够简单,但不够灵活:不能做查询改写、不能做重排、不能做多路检索。这时候用「模块化 RAG」——它把 RAG 拆成了可插拔的组件。当你的需求超出「检索→拼提示→生成」三板斧时,就该上它了。
知识点:QuestionAnswerAdvisor vs 模块化 RAG 怎么选
| 维度 | QuestionAnswerAdvisor | RetrievalAugmentationAdvisor |
|---|---|---|
| 上手难度 | 极低,几行搞定 | 中,要理解各组件 |
| 检索为空时 | 默认直接进提示词(无空上下文控制) | 可配 allowEmptyContext |
| 查询改写 | ❌ 不支持 | ✅ 支持 |
| 重排/多路检索 | ❌ | ✅ 支持 |
| 多租户过滤 | 需自己包一层 | ✅ filterExpression 原生支持 |
| 适用阶段 | 先跑通、验证想法 | 生产级、需求复杂时 |
① 最常用的配置:检索 + 空上下文策略
-
新建类
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(); -
运行验证:用上面客户端提问一个知识库里没有的问题(如「公司年会在哪天」)。预期输出:模型按提示词规则回复「资料中未提及」之类,不再抛 500。看到什么算通过:接口返回 200 且 answer 是「资料不足」类表述,而不是异常页。
allowEmptyContext 的默认值是 false,这会让很多新手一脸懵。
当检索不到任何结果时,默认行为是抛出异常,你看到的是 500 错误,而不是「资料中未提及」。
生产环境建议设成 true,并在提示词里写清楚「资料为空时怎么回答」。
排查步骤:① 若接口报 500 且日志出现 EmptyContextException 类异常,先确认是否忘了设 allowEmptyContext(true);② 确认向量库确实有数据(查库或打日志);③ 确认相似度阈值没高到把所有结果过滤掉(可临时设 0 验证)。
② 加查询改写:解决「多轮对话中的指代问题」
真实场景:用户先问「2024001 这个订单」,接着问「它什么时候发货?」第二句单独拿去检索,向量库里根本没有「它」的信息——检索必然失败。查询改写就是解决这个的:
-
在
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(); -
运行验证:开 debug 日志,先问「订单 2024001 的物流状态」,再问「它什么时候发货」。预期输出:日志里能看到改写后的查询变成「订单 2024001 什么时候发货」。看到什么算通过:第二问能正确命中第一问所指的订单文档,而不是因「它」二字检索落空。
③ 元数据过滤 + 多租户隔离(企业项目必需)
-
把下面片段并入你的 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(); -
运行验证:用租户 A 的 id 灌一批文档、租户 B 灌另一批,分别用 A/B 的 filter 检索。预期输出:A 只拿到 A 的文档。看到什么算通过:打印检索结果的
source元数据,确认没有混入 B 的文档名。
filterExpression,让别人的数据根本查不出来。
排查步骤:① 若某租户能搜到别的租户文档,先打印实际执行的 filterExpression 字符串,确认变量真的拼接进去了;② 确认灌库时每块都写了 tenantId 元数据(没写 → 过滤不到 → 全量返回);③ 确认向量库支持该过滤语法(不同库语法略有差异,以官方文档为准)。
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,手工做「检索 → 组装 → 生成」,这样每一步都能拿到数据、都能打日志、都能做实验:
-
在 IDEA 里右键包
com.example.springai.ch05→ New → Java Class;Name 输入ManualRagService,回车创建。 -
把下面整段复制粘贴覆盖骨架,
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) {} } -
运行验证:注入
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% 准确。
[1][2] 序号——否则来源必错。5.7完整示例:企业知识库问答
把①②③④(离线)和⑤⑥⑦⑧⑨(在线)拼成完整可运行的服务。这一节是本章的「总装车间」,请照着把文件建出来并跑通。
① 离线索引服务:一键灌库
-
IDEA 中右键包
com.example.springai.ch05→ New → Java Class;Name 输入KnowledgeIndexService,回车。 -
把下面整段复制粘贴覆盖骨架,
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); } } } -
运行验证(灌库):启动应用后,用 curl 调重建索引接口(接口定义见 ③)。预期输出:
{"chunks":18,"tenantId":"default"}(数字随文档规模)。看到什么算通过:控制台打印「索引重建完成:2 个文件,18 个片段」且无 embedding 报错。
- embedding 供应商 key 无效/未设:确认
OPENAI_API_KEY环境变量已设且重启了 IDEA;用echo $OPENAI_API_KEY(或 PowerShellecho $env:OPENAI_API_KEY)确认非空。 - embedding 与 chat 共用了一个 base-url:DeepSeek 无 embedding,
spring.ai.openai.embedding.base-url必须指向有 embedding 的供应商。 - 文档路径匹配不到:
classpath:docs/*.md要求 md 在resources/docs/且被构建进去;确认target/classes/docs/下有文件。
② 接口层
-
同理新建类
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) {} } -
把 5.6 节的
ManualRagService改名为KnowledgeQaService(或保留原名并把 Controller 里的引用改回ManualRagService),保证编译通过。文末「完整版」给的是统一命名为KnowledgeQaService的版本。
③ 验证:完整的 RAG 闭环
-
启动应用(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看是否打印「跳过不支持的文件」。
- 返回
-
用口语化、不含原文关键词的问法提问:
# 注意:问题里没有"退款""到账"等原文词,故意用口语,验证语义检索 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非空但答案跑偏 → 问题在生成层,检查提示词与上下文组装。 -
问一个知识库里没有的问题——验证「不瞎编」:
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 但还是说无法确定 → 资料里真没有 / 或生成层没用好。
KnowledgeIndexService(读→切→灌)、在线 KnowledgeQaService(检→组→生→溯)、KnowledgeQaController 暴露 HTTP;灵魂测试是「知识库外问题必须回答无法确定,绝不编造」。5.8效果优化与排错
这一节是本章的急救箱。RAG 出问题时,最怕的不是难,而是在错误的地方反复试——改了提示词、换了模型、调了 temperature,折腾一小时,结果问题出在切分上。下面五步请严格按顺序走:先定位是哪一层坏了 → 再按顺序排查检索层 → 再按症状查表 → 再谈优化 → 最后才做召回率专项提升。
第一步:先分层定位(30 秒,决定往哪个方向查)
九步链路可以粗暴切成三层,每层有各自的「责任」。出问题先问一句:坏的是哪一段?
| 层 | 包含步骤 | 判据(怎么知道它坏了) | 典型病灶 |
|---|---|---|---|
| 检索层 | ①加载 ②切分 ③向量化 ④存储 ⑤检索 | citations 为空,或答案要的事实压根不在 excerpt 里 |
忘了重建索引 / 阈值太高 / 问法与文档表述不匹配 / 切分把关键句切碎 / 向量被污染失真 |
| 生成层 | ⑦组装上下文 ⑧生成 | citations 非空且含正确答案,但 answer 没写对、或跑题 |
提示词没约束「只依据资料」/ 上下文过长关键信息被淹没 / 模型能力不足 |
| 溯源层 | ⑨引用构造 | 答案是对的,但 index 与 source 对不上号 |
把引用序号交给模型自由生成(违反 5.6 铁律) |
citations 空不空,就是检索层与生成层的分界线。空 → 往上查检索(第②步);非空 → 往下查生成(提示词 / 上下文)。
配合服务端日志
【RAG检索】命中=N 一起看,这一条能砍掉一半的无效折腾。
第二步:检索层的排查顺序(严格按这个顺序,不要乱试)
-
先看检索结果(90% 的问题在这里)。打开日志确认:
· 检索到的片段里到底有没有正确答案?没有 → 继续第 2 步。
· 有,但排在第 4、5 位 → 加大 topK,或降低 threshold。 - 检查切分质量。把 chunk 打印出来看:是不是把关键的一句切成两半了?是不是标题和正文分离了?换成按结构切(Markdown 标题 / 段落)。
-
检查向量模型。中文语料用了英文优化的模型?换
text-embedding-v3或BAAI/bge-large-zh重灌。 - 检查用户问法。「钱啥时候回来」这种口语化问题,检索表现通常差。开启查询改写(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 天然就是「口语问法 ↔ 答案」的成对语料,用户的口语问句能直接命中标题里的同义表述。但有两个必须知道的点:
- 只有
.md和.docx/.doc/.pdf/.txt会被索引——.json格式的 FAQ 会被静默跳过(只在日志里打一行「跳过不支持的文件」)。所以 FAQ 一定要写成.md。 - 写完必须重新调
/reindex,看到chunks数字变大,才算真的进去了。
招 2:放宽检索参数 + 一个对照实验
把 KnowledgeQaService 里的 similarityThreshold(0.5) 改成 0.35、topK(5) 改成 topK(8),然后用同一组问题跑改造前/改造后两遍,把命中条数与最高分记录下来:
| 问句 | 改造前 命中 / 最高分 | 改造后 命中 / 最高分 | 看什么 |
|---|---|---|---|
| 退款要多久到账(含关键词) | 4 / 0.66 | __ / __ | 自己填。预期规律:含关键词的问题变化不大;口语化、短问句的命中数会上升;但知识库外的问题可能开始捞到无关片段——那就要回调阈值 |
| 我买的东西不想要了,钱什么时候能退给我?(口语) | 5 / 0.67 | __ / __ | |
| 我的名字是什么(极短、无重叠词) | 0 / — | __ / __ | |
| 公司年会在哪一天举办?(知识库外) | 0 / — | __ / __ |
招 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);
「我的名字是什么」会被改写成什么?大致是「个人身份信息」「姓名 档案」「用户个人信息」这类检索友好的表述——原来一条都召不回的,现在有机会命中。记得把 [查询扩展] 日志打出来,那是判断改写质量的第一现场;如果它把问题改跑偏了(比如把「退款」改成了「退货」),说明提示词需要收窄。
ConcatenationDocumentJoiner只拼接、不去重——同一个片段可能被多条查询命中而重复出现,引用列表里会出现重复项。生产代码要按document.getId()去重后再组装上下文。- 每次问答多一次 LLM 调用(延迟约 +1~2 秒)。想省的话,用只改写一次的
RewriteQueryTransformer替代(同样是builder().chatClientBuilder(...).build())。别在没有评测集之前就全量上线,先用一组固定问题做前后对比。
现在不要做的:混合检索与重排
网上教程常推荐「向量 + BM25 混合检索」「召回 20 条再 Rerank」,这两招确实有效,但Spring AI 2.0.1 没有内置实现(DocumentPostProcessor 只是个接口,没有可用的重排实现类;也没有内置 BM25),要自己接第三方重排 API 或写 SQL 全文检索。本章阶段先把招 1~3 做扎实——它们覆盖了绝大多数模糊提问场景,而且每一项都能用「命中条数」量化验证;混合检索与重排留到第 10 章有了评测集再上,否则你根本无法判断它到底有没有变好。
[1] [2] 这种序号,本质上是把「引用和来源的映射关系」交给模型记忆,模型中途一定会搞错。正确做法是:让模型只管生成答案,引用列表由你的代码根据检索结果直接构造(就是我们 5.6 节方法二里的 citations)。这样引用 100% 准确。
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%,但成本和延迟翻倍,未必值得。
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 租户的文档。
- 能默写 RAG 九步,并指出每步对应哪个 Java 类/接口(如 Reader / Splitter / VectorStore)。
- 能独立完成「加载 md → 切分 → 灌库 → 检索 → 生成 → 溯源」的完整链路,并返回带出处的答案。
- 口语化提问(不含原文关键词)能命中正确片段;知识库外问题能正确回答「无法确定」,不编造。
- 能说出至少四种提升检索效果的手段,并解释为什么「多租户隔离必须做在检索条件里」。
文末完整版(自建可直接照抄)
下面把本章所有文件按「统一命名 + 可直接编译」整理成一份。配置基线: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) {}
}
src/main/resources/docs/ 放 refund-policy.md 等测试文档,设好 DEEPSEEK_API_KEY 与 OPENAI_API_KEY 环境变量后启动,按 5.7 节 ③ 的 curl 命令即可跑通完整 RAG 闭环。
5.11动手清单与自查表(照着核对)
本章是全课程第一个"完整业务闭环":离线灌库 + 在线问答 + 引用溯源三段拼在一起。哪一段没做对,表现都是同一个——回答变成「资料中未提及」。所以这里的自查表重点解决一件事:出现这个症状时,怎么在 30 秒内判断坏在哪一层。
① 你要建的文件(建一个,勾一个)
| 文件(相对工程根) | 对应小节 | 怎么确认建对了 |
|---|---|---|
.../ch05/ReaderDemo.java | 5.3 | 含 main 方法,可单独运行;四种 Reader 各有一段 |
.../ch05/SimpleRagDemo.java | 5.4 + 5.6 | QuestionAnswerAdvisor 的模板里占位符是 {question_answer_context}(写错这个=永远检索不到);内层挂了 SimpleLoggerAdvisor |
.../ch05/ModularRagDemo.java | 5.5 | 三个 client 分别演示空上下文策略 / 查询改写 / 多租户过滤 |
.../ch05/Citation.java | 5.6 | record + 静态工厂 of(index, doc);共享给两个溯源入口 |
.../ch05/RagAnswer.java | 5.6 | record:answer + citations |
.../ch05/KnowledgeQaService.java | 5.6 / 5.7 | 手工链路:检索 → 组装 → 生成 → 溯源;检索为空时直接返回固定话术,不调模型 |
.../ch05/KnowledgeIndexService.java | 5.7 | 按扩展名分发 Reader(.md 走 Markdown、其余走 Tika);灌库前先按 tenantId 删;不支持的扩展名自动跳过 |
.../ch05/KnowledgeQaController.java | 5.7 | 两个端点:POST /api/v1/knowledge/ask(在线)、POST /api/v1/knowledge/reindex(离线管理) |
.../ch05/Ch05DemoRunner.java | 5.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 就是编码没生效。
MetadataMode.EMBED 会把文档的 全部 metadata 拼进向量化输入,于是同一段文本因为 tenantId / indexedAt 不同,相似度分数每次都不一样,甚至直接掉出阈值。修复只需一行配置:
spring:
ai:
openai:
embedding:
metadata-mode: NONE # 只向量化纯文本,不让元数据污染相似度
验证方法:同一段文本连续灌 3 次,看检索分数是否完全一致(修复后应到小数点后十几位都一样)。
② 分步验收:从零到跑通(每步都有预期输出)
-
前置自检(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 都要有三条缺一条,后面的失败都无从归因——这也是为什么本章的排错要先分层。
-
灌库(离线,改过文档就必须重跑):
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会被跳过)。 -
语义问答(口语化提问,故意不含原文关键词):
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 就得是那句话的出处)。 -
灵魂测试(本章最重要的验收,也是最容易被当成"坏了"的一步):
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的日志必须保留。没有它,你分不清"库里没有"和"没查到",就会跑去改提示词、调温度——白忙半天。先看日志里的命中数,再决定往哪查。 -
多格式验证(可选但强烈建议):把一个
.docx(例如个人简历)放进docs/,重新reindex(chunks应增加),然后就文档里的具体事实提问。预期:能命中 docx 解析出来的内容并正确回答。
注意:docx/pdf 经 Tika 解析后是无结构长文本,按 token 盲切,一块里可能混多个主题。所以要问"单一意图"的问题("就读院校和专业是什么");把两个不相干的问题拼在一起问,正确块很可能被稀释出 topK——这是策略问题,不是 bug(改进方向见 5.8 节第五步)。
-
做一个阈值反例:把
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:0 | 5.7 节 ① | 文档位置或扩展名不对(.json 会被跳过) |
| 引用来源错乱 / 序号对不上 | 5.6 节铁律 | 引用列表必须由代码构造,绝不让模型编序号 |
| 多轮对话里"它"指代不明导致检索错 | 5.5 节 ② | 开查询改写(Compression)先消解指代 |
| A 租户搜到 B 租户的文档 | 5.5 节 ③ | 隔离必须做在 filterExpression 里,提示词约束不算隔离 |
| 模糊提问("我的名字是什么")查不到 | 5.8 节第五步 | 四招按成本递增:补 FAQ 语料 → 放宽参数 → 多查询扩展 |
| 复合提问答不出,拆开问就对 | 5.8 节第三步 | 打印命中块,看是不是一块里混了多个主题 |
| 依赖坐标 404 | 5.4 节 | 旧坐标 spring-ai-advisors-vector-store 已下架,用 spring-ai-vector-store-advisor |
reindex 的 chunks > 0;② 口语化提问能命中正确片段,且引用序号与正文标号严格对应;③ 知识库外的问题能正确说"无法确定"、citations 为空;④ 能对着 5.8 节的五步急救流程,说出「citations 空 → 查检索层,非空 → 查生成层」这条分界线。这四条里第 ③ 条最容易被跳过,但它恰恰是 RAG 系统的"灵魂"——一个系统好不好,不只看它能答什么,更看它知不知道自己什么时候答不了。