Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话

0 阅读13分钟

Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话

作者:鱼宵 | Spring AI 实战精通营 · 第 8 篇

上周帮朋友公司搭了个内部问答机器人,把员工手册喂进去,问"年假怎么请",它张口就来——我低头一对手册,它说"年假 10 天起",手册里白纸黑字写的是"入职满 1 年享 5 天"。要命的是它语气特别自信,跟真的一样。这就是大模型的老毛病:记性不好,但文笔太好了,不知道的事它也敢一本正经地编,行话叫"幻觉"。

怎么治?这节课我们不给它"闭卷背书",改成"开卷考试"——回答之前先从你的知识库里把相关资料捞出来,压在问题下面让它照着答。代码全在仓库的 lesson-08/ 目录里,clone 下来照着命令跑一遍,你会看到两件事:问文档里有的,回答和手册逐字对得上;问文档里没有的,它居然老老实实说"不知道,请联系人工"。

一、核心原理:给记性不好的实习生开卷考试

一句话:RAG(检索增强生成)= 回答前先检索你的知识库,把命中的资料拼进提示词,让模型照着资料答,而不是凭记忆瞎编。

1. RAG 五步流水线

别被英文名唬住,拆成五步就是:

步骤干什么类比(开卷考试)
① 加载 Load把文件读成纯文本把《入职 FAQ》拿到考场
② 切分 Split按块切成小片段把书按章节折好角
③ 嵌入 Embed每块文本变成向量坐标给每章做张"语义地图坐标"
④ 入库 Store向量存进向量库坐标索引好,方便秒查
⑤ 检索增强提问时捞最像的几块喂给模型考试先翻到相关那几页,压在题下面答

前四步启动时做一次(把书备好),第五步每次提问时实时发生。

2. 为什么非得切分?整块文档不行吗?

很多人第一反应:我把整个 FAQ 文件扔进去不就行了?不行。检索是"按块找相似",6 条 FAQ 存成一整块,你问"年假",它就把 6 条全塞给模型——又贵,还稀释注意力。切成小块后,"年假"这个问题能精确命中年假那一块,报销、社保那些不相干的就不带进来了。chunkSize(每块多大)就是 RAG 调参的第一课,后面挑战题你亲手试。

3. QuestionAnswerAdvisor:Controller 零 RAG 代码的秘密

Advisor(顾问) 是夹在"用户提问"和"发给模型"之间的一道工序。QuestionAnswerAdvisor 这道工序专门干一件事:拿你的问题去向量库检索,把命中的片段自动拼进提示词。

类比机场安检:裸 ChatClient 是直接登机;挂上 Advisor 像过安检通道,你的问题在交给模型前,先被"资料台"塞了两张参考页进去。Controller 里那行代码和第 1 课长得一模一样,但回答已经"开卷"了。

4. 压幻觉两件套

幻觉就是模型一本正经地编。本课两招一起上:

  1. 检索增强:给它真资料,让它有据可依;
  2. 系统提示词 + 低温:yml 里 temperature: 0.0(不让它自由发挥),提示词里写死"资料里没有就说不知道"。

敢说不知道,比乱答强一百倍——第四节你会亲眼看到它怎么"拒答"。

二、动手:十分钟跑起来

环境:JDK 17 + Maven 3.9+,环境变量 DEEPSEEK_API_KEY 已配。本课端口 8100。

第 1 步:检查环境。

java -version                              # 期望 True
[bool][Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY')        # 期望 True
Get-NetTCPConnection -LocalPort 8100 -State Listen -ErrorAction SilentlyContinue   # 无输出=空闲

第 2 步:编译 + 启动。(注意要在 lesson-08 目录下启动,知识库文件是相对路径读的)

cd spring-ai-journey\lesson-08
$env:JAVA_HOME="C:\Program Files\Java\jdk-17"
mvn clean install -DskipTests        # 看到 BUILD SUCCESS
mvn spring-boot:run                  # 看到 Tomcat started on port 8100 即成功

第 3 步:提问(中文先 URL 编码)。

[Console]::OutputEncoding=[System.Text.Encoding]::GetEncoding(936)
$q = [uri]::EscapeDataString('年假怎么请')
Invoke-RestMethod "http://localhost:8100/ask?q=$q"

浏览器直接开 http://localhost:8100/ask?q=年假怎么请 也行,浏览器会自动编码。

三、关键代码:四个类,看清 RAG 怎么挂上去

真正要看的就四块:yml(模型+本地嵌入配置)、RagConfig(挂顾问)、FaqDataLoader(启动建库)、FaqAssistantController(/ask 接口)。

第一段:application.yml——聊天模型在线,嵌入模型本地。

server:
  port: 8100                          # 本课端口:lesson-08 = 8100
spring:
  ai:
    openai:
      base-url: ${LLM_BASE_URL:https://api.deepseek.com}   # 默认 DeepSeek(兼容 OpenAI 协议)
      api-key: ${DEEPSEEK_API_KEY}                          # Key 只从环境变量读,文件里没有明文
      chat:
        options:
          model: ${LLM_MODEL:deepseek-chat}
          max-tokens: 400        # 演示控成本
          temperature: 0.0       # 事实问答:低温照本宣科,防自由发挥(压幻觉关键)
    embedding:
      transformer:
        onnx:
          model-uri: file:./models/model.onnx        # 本地 ONNX 嵌入模型(不上传文本)
        tokenizer:
          uri: file:./models/tokenizer.json
        cache:
          enabled: false                             # 模型在本地,关远程缓存

两个点值得盯:temperature: 0.0 是 RAG 事实问答的灵魂;spring.ai.embedding.transformer.* 是 1.0.9 的真实前缀,写错不报错、只是不生效(坑见第八节)。

第二段:RagConfig.java——把问答顾问挂到 ChatClient 上。

package com.springai.lesson08;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
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;

/**
 * RAG 装配:本地嵌入 + 内存向量库 + 问答顾问,拼成一条流水线。
 */
@Configuration
public class RagConfig {

    // 向量库:内存实现 + 本地 ONNX 嵌入(生产换 Redis/PgVector 只改依赖,业务代码不动)
    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }

    // 带 RAG 顾问的对话入口:以后每次提问都自动"检索→拼资料→交模型"
    @Bean
    public ChatClient ragChatClient(ChatClient.Builder builder, VectorStore vectorStore) {
        // 问答顾问:自动拿问题去这个向量库检索,命中片段拼进提示词
        QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore).build();
        // 系统提示词:限定人设 + 防幻觉兜底规则
        String systemPrompt = """
                你是"星辰科技"的入职知识助手。
                规则:
                1. 只能依据参考资料回答,不要编造制度、金额、电话、日期;
                2. 如果资料里没有答案,必须回答:
                   "这个问题知识库中暂无相关信息,建议您联系人工客服(400-800-8888)。"
                3. 回答简洁,两三句话说清即可。
                """;
        // defaultSystem 全局限定人设;defaultAdvisors 全局挂顾问
        return builder.defaultSystem(systemPrompt)
                .defaultAdvisors(qaAdvisor)
                .build();
    }
}

看出门道了吗?Controller 一行 RAG 代码都不用写,因为检索+拼资料全被 defaultAdvisors(qaAdvisor) 这道工序承包了。

第三段:FaqDataLoader.java——启动时跑完 RAG 前四步。

package com.springai.lesson08;

import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.io.FileSystemResource;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * 启动时自动把 docs/faq.md 走完:加载→切分→嵌入→入库。
 * ApplicationRunner:Spring Boot 起来后自动跑一次 run(),适合初始化数据。
 */
@Component
public class FaqDataLoader implements ApplicationRunner {

    private final VectorStore vectorStore;

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

    @Override
    public void run(ApplicationArguments args) {
        // ① 加载:Tika 是"万能扫描仪",md/pdf/docx 都能抽成纯文本
        FileSystemResource faqFile = new FileSystemResource("docs/faq.md");
        if (!faqFile.exists()) {
            throw new IllegalStateException("找不到 docs/faq.md,请在 lesson-08 根目录下启动");
        }
        TikaDocumentReader reader = new TikaDocumentReader(faqFile);
        List<Document> rawDocs = reader.get();
        System.out.println("========== ① 加载完成:读出 " + rawDocs.size() + " 个原始文档 ==========");

        // ② 切分:每块约 250 token,丢掉小于 60 字符的碎块
        TokenTextSplitter splitter = TokenTextSplitter.builder()
                .withChunkSize(250)          // 每块最大 token 数(RAG 调参第一课)
                .withMinChunkSizeChars(60)   // 太碎的小尾巴丢弃
                .build();
        List<Document> chunks = splitter.apply(rawDocs);
        System.out.println("========== ② 切分完成:切成 " + chunks.size() + " 个知识块 ==========");

        // ③ 嵌入 + ④ 入库:add() 内部自动调本地 ONNX 模型,文本不出本机
        vectorStore.add(chunks);
        System.out.println("========== ③④ 嵌入入库完成,共 " + chunks.size() + " 个向量块 ==========");
    }
}

第四段:FaqAssistantController.java——/ask 接口,回答带引用。

package com.springai.lesson08;

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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * GET http://localhost:8100/ask?q=年假怎么申请
 * 返回 {question, answer, references}——references 就是"引用出处"。
 */
@RestController
public class FaqAssistantController {

    private final ChatClient ragChatClient;   // RagConfig 里挂好顾问的那个 Client
    private final VectorStore vectorStore;    // 手动再查一次,只为把引用展示给用户

    public FaqAssistantController(ChatClient ragChatClient, VectorStore vectorStore) {
        this.ragChatClient = ragChatClient;
        this.vectorStore = vectorStore;
    }

    @GetMapping("/ask")
    public Map<String, Object> ask(@RequestParam("q") String q) {
        // 这行背后已自动"检索+拼资料+调模型",看起来和第 1 课一模一样
        String answer = ragChatClient.prompt().user(q).call().content();

        // 故意再查一次向量库,把命中的原文片段当"引用"返回——引用溯源(citation)
        List<Document> hits = vectorStore.similaritySearch(
                SearchRequest.builder().query(q).topK(2).build());

        List<Map<String, String>> references = new ArrayList<>();
        if (hits != null) {
            for (Document doc : hits) {
                Map<String, String> ref = new LinkedHashMap<>();
                ref.put("content", doc.getText());   // 命中的知识片段原文
                references.add(ref);
            }
        }

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("question", q);
        result.put("answer", answer);
        result.put("references", references);   // 用户看得见依据,不是模型瞎编
        return result;
    }
}

第五段:Lesson08Application.java——标准启动类。

package com.springai.lesson08;

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

/**
 * 启动后自动建知识库,再访问 GET http://localhost:8100/ask?q=...
 */
@SpringBootApplication
public class Lesson08Application {
    public static void main(String[] args) {
        SpringApplication.run(Lesson08Application.class, args);
    }
}

注意一个细节:整个工程没有一句"手动检索向量库再拼字符串"的胶水代码——检索全靠 defaultAdvisors 那道顾问工序,业务代码只管 .user(q).call()。这就是声明式 RAG 的爽点。

四、实测输出:问文档里有的 vs 文档外的,判若两机

以下是 2026-10-05 本机真实运行(DeepSeek 实测,HTTP 200)。先看启动日志,确认 RAG 前四步跑完:

Tomcat started on port 8100 (http)
========== ① 加载完成:Tika 读出 1 个原始文档 ==========
========== ② 切分完成:切成 4 个知识块(chunk) ==========
========== ③④ 嵌入入库完成:知识库已就绪,共 4 个向量块 ==========

整份 faq.md 被读成 1 个文档,切成 4 块,全部本地嵌入后入内存库。

测试一:问文档里有的(年假),回答带依据。

GET http://localhost:8100/ask?q=年假和请假怎么申请?
{
  "question": "年假和请假怎么申请?",
  "answer": "年假方面,入职满 1 年享 5 天,每多 1 年增加 1 天,上限 15 天。请假需通过 OA 系统提交申请,年假需提前 3 个工作日,病假凭医院证明可当天申请。",
  "references": [
    { "content": "入职满 1 年享 5 天年假,每多 1 年增加 1 天,上限 15 天。请假通过 OA 系统提交申请,年假需提前 3 个工作日……" }
  ]
}

看出门道了吗?回答里的"5 天/每年+1/上限 15/提前 3 工作日"和知识库第 2 条逐字对得上;references 第一段正好从"入职满 1 年享 5 天年假"开头——切分生效,检索精确命中,没把 6 条全塞进去。

测试二:问文档外的(年会),看它怎么拒答。

GET http://localhost:8100/ask?q=公司年会什么时候开?
{
  "question": "公司年会什么时候开?",
  "answer": "这个问题知识库中暂无相关信息,建议您联系人工客服(400-800-8888)。"
}

FAQ 里根本没有年会,它没有编一个"12 月开"出来,而是老老实实说不知道、还报了人工客服电话。这就是系统提示词 + 低温双保险压住幻觉的现场——生产级 RAG 最该验收的一条,不是它答得多漂亮,而是它敢说不知道。

排查提示:卡住 ONNX 下载是模型文件没就位(见第八节);回答里全是瞎编就查 yml 的 temperature 和系统提示词挂没挂;PS 控制台中文乱码是显示问题,服务端返回是 UTF-8。

五、挑战题:改个参数,看看会怎样

  1. ⭐ 玩切分大小:把 FaqDataLoader 里的 withChunkSize(250) 改成 800,重启后再问"年假怎么请",对比 references 的命中精度和条数——体会"块太大=把全书塞给模型"。答案在源码 FaqDataLoader.java 里。
  2. ⭐⭐ 亲手放一次幻觉:把 RagConfig 系统提示词里第 2 条兜底规则删掉,再问"年会什么时候开",看它开始编什么——亲眼看一次幻觉长什么样,你就懂这道闸多值钱。
  3. ⭐⭐ 换一份自己的文档:把 docs/faq.md 换成你自己的 md(哪怕 3 条),重启后提问,观察 references 有没有跟着变——验证"知识更新就是改文件重启"。

六、生产环境进阶:三个加分项

1. 本地嵌入 = 数据不出本机。 文档切片的向量化全在本机 ONNX 算,只有最终问题发给 DeepSeek。员工手册这种敏感资料不上传,这是 RAG 选本地嵌入的合规理由。

2. 换生产向量库只改依赖。 本课是内存版 SimpleVectorStore,关机即丢。生产换 Redis / PgVector / Milvus,只换 pom 依赖和配置,VectorStore 接口不变,RagConfig 这段业务代码一行不动——面向接口编程的胜利。

3. 加相似度阈值兜底。 similaritySearch 返回的文档元数据里带距离分,可以做个"相似度低于 0.5 就直接走人工、不打扰模型"的阈值,省 token 又更稳(挑战题作业 5 的方向)。

七、面试回答模板

面试官:RAG 完整流水线分几步?为什么要"先切分再检索",整块文档直接入库不行吗?

一句话:加载→切分→嵌入→入库→检索增强五步,前四步建库时一次做完,第五步每次提问实时发生。展开:切分是因为检索按块找相似,整块 6 条 FAQ 存一起,问"年假"会把 6 条全塞给模型,又贵又稀释注意力;切成小块才能精确命中。(指向本课第一节 / lesson-08 的 FaqDataLoader)

追问:QuestionAnswerAdvisor 挂上之后,Controller 那行 .call() 背后多发生了什么?

它自动拿问题去向量库检索相似片段,拼进提示词再发给模型。所以业务代码和普通 ChatClient 一模一样,却已经是"开卷考试"。展开:这是 Spring AI 声明式 RAG 的核心,检索逻辑全收进 Advisor,业务零胶水代码。(指向本课 RagConfig + 第三节)

追问:大模型为什么会幻觉?RAG 怎么压下去?

幻觉是模型凭记忆一本正经地编。三件套:检索给真资料 + 系统提示词写死"不知道就说不知道" + temperature 调低到 0。敢兜底比乱答值钱,第四节测试二就是现场。(指向本课第四节实测)

八、总结表

坑现象解法
ONNX 模型下载超时启动报 Failed to cache the resourcehf-mirror 下 model.onnx+tokenizer.json 到 models/,yml 指向 file:./models/
嵌入属性前缀写错不报错但仍走远程下载1.0.9 用 spring.ai.embedding.transformer.*
chunk 切太大问什么都把全文塞给模型withChunkSize 调到 250,小块检索才准
PS 5.1 抓 JSON 中文乱码Invoke-RestMethod 拿到 mojibakeWebClient 拿字节流再 UTF8.GetString
JAVA_HOME 指向 JDK8mvn 跑在 Java 8构建前 $env:JAVA_HOME="C:\Program Files\Java\jdk-17"
启动目录不对找不到 docs/faq.md必须在 lesson-08 根目录下启动

九、关于这个系列

本文是「Java 后端实战精通营」系列第 8 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。

👉 Spring AI 实战精通营(10 课):gitee.com/j67mk2/spri…

  • 本文对应源码位置:lesson-08/(内含 RagConfig 挂问答顾问 + FaqDataLoader 启动建库 + FaqAssistantController 带引用的 /ask 接口)

系列文章一览(按发布顺序):

篇主题
1Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用
2Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用
3Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON
4Spring AI 工具调用:@Tool 让大模型自己查订单查库存
5Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒
6Spring AI 多模态:给大模型一双眼睛,图片它也能看懂
7Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步
8Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话
9Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定
10Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官

下一篇预告:《Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定》——本课你只挂了一道"资料安检",下一课把它扩成一条完整通道:记忆、查订单、知识库全部串进一个接口,模型还记得你上句话叫什么。

跑完有任何报错,把终端输出发评论区,一起排查。你问文档外的问题时,它真的老实说不知道了吗?


标签建议:SpringAI、RAG、大模型幻觉 摘要建议(≤256 字):大模型最招人恨的就是"一本正经地编"。本文用 Spring AI 的 QuestionAnswerAdvisor 跑通一个带引用出处的知识库问答助手:Tika 读 FAQ、切分、本地 ONNX 嵌入入库,回答自动带命中片段,文档外问题老实走兜底。逐行拆解四个类与实测输出,附切分调参等 3 道挑战题和面试模板,源码在 gitee lesson-08 可 clone 直接跑。