Spring AI 2.0 学习指南企业级智能应用开发实践 环境准备 速查 ⌂ 全部教程
首页 / Spring AI 指南 / 第 5 章

CH 05RAG:检索增强生成

RAG(Retrieval-Augmented Generation)是企业级大模型应用的绝对主力技术。 一句话概括它的价值:让模型基于你提供的资料回答,而不是基于它的记忆胡猜。 第 4 章我们准备好了「向量」,这一章把向量、检索、提示词、生成串成一条能用的流水线。

学习目标
掌握 RAG 全链路工程实现
前置要求
第 3、4 章
本章产出
知识库问答(回答带出处)
建议学时
6–8 小时

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

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

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

RAG 能解决的三个真实痛点

  1. 模型不知道你的私有数据:公司制度、产品手册、客户合同,模型训练时根本没见过。
  2. 模型的知识有截止日期:昨天的政策变更,模型不可能知道。
  3. 模型会一本正经地胡说(幻觉):RAG 让它在有据可依的情况下回答,并要求「资料没有就说没有」。

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

【离线阶段 —— 灌库,通常一次或定期执行】 ① 加载 DocumentReader 把 PDF/Word/MD/网页读成纯文本 → Document ② 切分 DocumentTransformer 把长文切成 300~1000 字的小块(chunk) ③ 向量化 EmbeddingModel 把每块文本变成向量 ④ 存储 VectorStore 把「向量 + 文本 + 元数据」写进向量库 【在线阶段 —— 每次用户提问都执行】 ⑤ 检索 把用户问题向量化,在库里找出最相似的 TopK 片段 ⑥ 重排 对候选片段做二次排序/过滤(提高精度) ⑦ 组装 把片段塞进提示词的 {context} 占位符 ⑧ 生成 调用模型,要求它「只依据 context 回答」 ⑨ 溯源 把引用的来源(文件名 + 页码)一并返回给用户 用户问题 ──▶ ⑤检索 ──▶ ⑥重排 ──▶ ⑦组装提示 ──▶ ⑧模型生成 ──▶ 带引用的回答 ▲ │ └────────── ④向量库 ◀── ①②③ 离线灌库 ◀──┘(知识来源)
调试 RAG 的黄金法则:出问题时,先打印第 ⑤ 步的检索结果。 90% 的「回答不对」都是「检索到的片段不对」,而不是模型不行。 所以本章的示例代码里,每一处都会把检索结果打到日志里——这是刻意设计的,请不要删。

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

DocumentReader:把各种格式读成 Document

需要额外依赖(在 0.4.7 的 pom 里已经有了):

<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>
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 textReader = new TextReader(new FileSystemResource("docs/policy.txt"),
        Map.of("source", "policy.txt"));
List<Document> docs1 = textReader.get();

// ---------- ② Markdown(按标题层级自动切成多个 Document,很适合技术文档) ----------
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 jsonReader = new JsonReader(new ClassPathResource("docs/faq.json"),
        "question",      // 用哪个字段作为正文
        "answer");       // 可以传多个字段,会被拼接
List<Document> docs3 = jsonReader.get();

// ---------- ④ PDF / Word / PPT / HTML(Tika 万能解析) ----------
TikaDocumentReader tikaReader =
        new TikaDocumentReader(new FileSystemResource("docs/manual.pdf"));
List<Document> docs4 = tikaReader.get();
Tika 读 PDF 的坑(非常常见):
  1. 扫描版 PDF 读出来是空的——因为里面是图片不是文字。需要先做 OCR,Tika 不做 OCR。
  2. 表格被拆散——Tika 会把表格按行拉成文本,列对不上。财务/参数类文档建议先转成 Markdown 表格再灌。
  3. 中文断行错乱——PDF 里每个字符都带定位信息,Tika 可能把一句话断成多行。可以后处理:把「行尾无标点」的行合并。

TokenTextSplitter:把长文切成合适的块

为什么必须切分?① 模型上下文有限,整本书塞不进去;② 检索要的是「精准片段」,一整章里只有一段相关,会把无关信息也带进去。

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() + " 块");

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

策略做法适用
固定长度每 N 个字符/token 切一刀(TokenTextSplitter)通用默认;实现简单
按结构切按标题、段落、--- 分隔(MarkdownDocumentReader)✅ 制度文档、技术手册(效果最好)
按语义切用模型判断语义边界再切高价值长文,成本高
父文档 / 子块用小块检索,返回它所属的大块给模型✅ 解决「块太小缺上下文」的经典方案
chunkSize 怎么定?一个实用的起始规则:
  • 知识库是 FAQ / 短问答:一条就是一块,别切。
  • 知识库是 制度 / 手册:按章节切,400~800 token。
  • 知识库是 长篇小说 / 访谈:800~1200 token,并保留重叠。
  • 切完之后一定要人工抽查 10 块——看看有没有把一句话切断、有没有把标题和正文分离。这一步能提前发现 80% 的检索问题。

5.4最简 RAG:一个 Advisor 就搞定

Spring AI 提供了 QuestionAnswerAdvisor,它把第⑤⑥⑦步全部封装好了。你只需要挂上它,剩下的交给框架。

<!-- 依赖(pom 里已有) -->
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-vector-store-advisor</artifactId>
</dependency>
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 上
ChatClient ragClient = ChatClient.builder(chatModel)
        .defaultAdvisors(qaAdvisor)
        .build();

// ④ 用起来和普通对话一模一样,检索是透明的
String answer = ragClient.prompt()
        .user("退款要几天到账?")
        .call()
        .content();
它是怎么工作的?执行顺序是:Advisor 拦截请求 → 取出用户问题 → 向量化 → 检索 topK → 把片段拼成文本填入 {question_answer_context} → 得到最终的 Prompt → 交给模型。 所以「检索」这一步对你完全透明,但也意味着你不能在业务代码里直接拿到检索结果—— 如果需要在接口里返回引用来源,就得用 5.6 节的方法。

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

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

5.5进阶:模块化 RAG(RetrievalAugmentationAdvisor)

QuestionAnswerAdvisor 够简单,但不够灵活:不能做查询改写、不能做重排、不能做多路检索。 这时候用「模块化 RAG」——它把 RAG 拆成了可插拔的组件。

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

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

import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;

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

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

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

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

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

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

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

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

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

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();

// 取出检索到的文档列表
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,手工做「检索 → 组装 → 生成」,这样每一步都能拿到数据、都能打日志、都能做实验:

@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());
        }

        // ---------- 第⑦步:组装上下文 ----------
        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 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) {}
}
为什么推荐「方法二」?因为 RAG 是一个需要反复调优的链路: 今天调 topK,明天调阈值,后天换切分粒度。自己控制检索,你才能在每一环插入日志、收集指标、做 A/B 对比。 Advisor 适合快速验证想法,生产代码建议自己写——第 11 章的综合项目就是这么做的。

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 # 技术文档(用来验证分类过滤)

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

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();

    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);
        }
    }
}

② 接口层

@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) {}
}

③ 验证:完整的 RAG 闭环

# ① 重建索引(把 docs/ 下的 md 灌进向量库)
curl -X POST "http://localhost:8080/api/v1/knowledge/reindex?tenantId=default"

# 期望:{"chunks":18,"tenantId":"default"}

# ② 提问——用口语化、不含原文关键词的问法
curl -X POST http://localhost:8080/api/v1/knowledge/ask \
  -H "Content-Type: application/json" \
  -d '{"question":"我买的东西不想要了,钱什么时候能退给我?","tenantId":"default"}'

# 期望输出结构:
# {
#   "answer": "订单完成后的 7 天内可以提交退款申请[1];审核通过后 3 个工作日内退回原支付账户[2]。",
#   "citations": [
#      {"index":1,"source":"refund-policy.md","score":0.78,"excerpt":"..."},
#      {"index":2,"source":"refund-policy.md","score":0.74,"excerpt":"..."}
#   ]
# }

# ③ 问一个知识库里没有的问题——验证"不瞎编"
curl -X POST http://localhost:8080/api/v1/knowledge/ask \
  -H "Content-Type: application/json" \
  -d '{"question":"公司年会在哪一天举办?","tenantId":"default"}'

# 期望:answer 为"根据现有资料无法确定…",citations 为空数组
# 如果它编了一个日期出来 → 你的提示词约束不够强,或阈值太低捞进了无关片段
第 ③ 步是 RAG 的「灵魂测试」。很多同学把 RAG 做出来就完事, 结果上线后被问到一个知识库外的问题就胡说八道,用户立刻失去信任。 一个 RAG 系统好不好,不看它能答什么,而看它知不知道自己在什么时候答不了。

5.8效果优化与排错

RAG 效果差的排查顺序(重要!按这个顺序查,不要乱试)

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

六个高频优化手段

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

排错对照表

现象原因解决
永远回答"资料中未提及"阈值太高 / 检索为空 / 占位符名字写错把 threshold 先设 0 测试;确认占位符是 {question_answer_context}
allowEmptyContext=false 时抛异常检索结果为空时的默认行为设成 true,并在提示词里定义"资料为空"的回答
回答里出现了知识库里没有的信息提示词没禁止编造,或 topK 太小导致模型用先验知识补全加「只依据资料,不足则说无法确定」;把 temperature 降到 0.1~0.3
回答正确但引用来源是错的模型只是模仿了 [1][2] 的格式,序号和实际来源不对应不要把序号交给模型自由发挥;改用「我们必须程序化生成引用列表」(5.6 节方法二)
同一个问题换成不同问法,答案差很多检索不稳定开查询改写;或对同一问题做多查询检索后合并
灌库后检索不到新文档忘了重建索引,或灌错了租户调 /reindex;检查 tenantId 是否一致
响应特别慢(> 10s)topK 太大、片段太长、查询改写多一次调用压小 topK;对片段做摘要;单轮对话关闭改写
成本突然暴涨上下文里塞了太多片段统计每次调用的 promptTokens(第 3 章那个 Advisor),把 topK × 平均片段长度 控制住
关于「引用来源错乱」的补充说明:让模型输出 [1] [2] 这种序号, 本质上是把「引用和来源的映射关系」交给模型记忆,模型中途一定会搞错。 正确做法是:让模型只管生成答案,引用列表由你的代码根据检索结果直接构造 (就是我们 5.6 节方法二里的 citations)。这样引用 100% 准确。

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 九步,并指出每步对应哪个 Java 类/接口。
  • 能独立完成「加载 md → 切分 → 灌库 → 检索 → 生成 → 溯源」的完整链路。
  • 口语化提问(不含原文关键词)能命中正确片段。
  • 知识库外的问题能正确回答「无法确定」,不编造。
  • 返回结构里带引用来源,且来源与答案真实对应。
  • 能说出至少四种提升检索效果的手段。