Spring AI 检索增强生成(RAG)实战:让模型掌握你的私有知识

Spring AI 检索增强生成(RAG)实战:让模型掌握你的私有知识

大模型经过万亿级语料训练,确实知识渊博,但它天生有两个缺陷:训练数据有截止日期 ,以及对私有业务数据一无所知。你可以问"Spring Boot 3 新特性是什么?"模型能回答得头头是道;但你问"我们公司上个月的订单异常率是多少?"模型只能一脸茫然,然后给你编一个数字。

解决这个问题的标准方案就是 RAG(Retrieval-Augmented Generation,检索增强生成) 。它的核心思想非常朴素:在模型回答前,先从你的知识库中找到最相关的文档片段,把它们作为参考上下文注入提示词,让模型"带着答案阅读"并生成回复。

本文将从 RAG 的完整流程出发,结合 Spring AI 提供的向量化、存储、检索组件,详细讲解如何构建一个可用的私有知识问答系统。


一、为什么需要 RAG?

先列举直接微调(Fine-tuning)不如 RAG 的理由:

维度 微调(Fine-tuning) RAG
知识更新 每次重新训练,成本高周期长 增删文档即可,秒级生效
私有知识 需要大量标注数据 直接把文档切碎存起来就能用
可解释性 黑盒 可返回引用来源,便于溯源
成本 GPU 训练费用昂贵 只需向量化与检索,成本低
幻觉问题 仍会胡编乱造 有检索结果作为约束,大幅降低幻觉

当然 RAG 也不是银弹,它依赖于检索质量。如果检索不到相关资料,模型仍然无法正确回答。因此,构建高质量的 RAG 管道是工程重点。

RAG 的整体流程:

markdown 复制代码
原始文档 → 文档加载(DocumentReader)→ 文档切分(DocumentSplitter)
→ 向量化(EmbeddingModel)→ 向量入库(VectorStore)
                                          ↑
用户提问 → 问题向量化 → 语义检索 TopK → 拼接上下文 + 问题 → 大模型生成回答

下面分步详解。


二、文档加载与切片

2.1 文档加载器

Spring AI 提供了丰富的文档读取器:

  • PagePdfDocumentReader:读取 PDF;
  • TikaDocumentReader:基于 Apache Tika,支持 PDF、Word、PPT、HTML 等;
  • JsonReader:读取 JSON 文件;
  • TextReader:读取纯文本文件;
  • UrlDocumentReader:从 URL 加载内容。

一个典型的加载方式:

ini 复制代码
@Value("classpath:/docs/company-handbook.md")
private Resource handbookResource;

TikaDocumentReader reader = new TikaDocumentReader(handbookResource);
List<Document> documents = reader.read();

Document对象包含 idtext(正文)、metadata(元数据,如文件路径、页码、标题等)。元数据在检索过滤中非常有用,例如按部门、文章类别过滤。

2.2 文档切分

加载进来的文档可能是一整本几十页的手册,直接向量化会导致:

  • embedding 向量太长,语义被稀释;
  • 超出模型上下文限制;
  • 检索返回整篇文档,token 消耗巨大。

因此必须切片(Splitter) 。Spring AI 提供多种切分器:

切分器 策略 适用场景
TokenTextSplitter 按 token 数切分,带重叠 最通用,适合保持语义边界
ParagraphTextSplitter 按段落切分 结构性较强的文档
SentenceTextSplitter 按句子切分 短文本知识库
TextSplitter抽象 自定义实现 特殊格式

示例:使用 TokenTextSplitter,每个切片 500 token,重叠 100 token:

ini 复制代码
TextSplitter splitter = TokenTextSplitter.builder()
        .withChunkSize(500)
        .withChunkOverlap(100)
        .build();

List<Document> chunks = splitter.apply(documents);

为什么需要重叠?因为关键句子可能跨越两个切片的边界,适当重叠可避免信息被切断。建议 chunk size 300~800,overlap 10%~20%。


三、向量化与向量存储

3.1 EmbeddingModel

切片后的文本无法直接进行语义比较,需要转换为向量。Spring AI 的 EmbeddingModel抽象了对各厂商 embedding API 的调用:

typescript 复制代码
@Configuration
public class EmbeddingConfig {

    @Bean
    public EmbeddingModel embeddingModel() {
        // 这里以 OpenAI 为例,Spring AI 会自动读取 spring.ai.openai.api-key
        return new OpenAiEmbeddingModel(OpenAiApi.builder()
                .apiKey(System.getenv("OPENAI_API_KEY"))
                .build());
    }
}

如果是本地 Ollama:

typescript 复制代码
@Bean
public EmbeddingModel embeddingModel() {
    return new OllamaEmbeddingModel(OllamaApi.builder()
            .baseUrl("http://localhost:11434")
            .build());
}

Embedding 的选择对检索质量影响巨大,建议使用与模型同生态的向量模型。测试阶段可以直接用 OpenAI text-embedding-3-small,成本低效果不错。

3.2 VectorStore 接口

Spring AI 提供了统一的 VectorStore接口,核心方法是:

arduino 复制代码
public interface VectorStore {

    void add(List<Document> documents);

    Optional<Boolean> delete(List<String> idList);

    List<Document> similaritySearch(SearchRequest request);
}

SearchRequest允许设置查询文本、topK、相似度阈值、元数据过滤等。Spring AI 支持的向量库存储类型很多:

  • SimpleVectorStore:内存实现,适合开发测试;
  • PgVectorStore:基于 PostgreSQL + pgvector 插件;
  • RedisVectorStore:基于 Redis Search 模块;
  • MilvusVectorStoreQdrantVectorStoreChromaVectorStore等。

以 PgVector 为例,先创建数据库表:

sql 复制代码
CREATE EXTENSION IF NOT EXISTS vector;

然后配置:

scss 复制代码
@Bean
public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) {
    return PgVectorStore.builder(jdbcTemplate, embeddingModel)
            .dimensions(1536)          // 与 embedding 模型维度一致
            .distanceType(VectorStore.DistanceType.COSINE_DISTANCE)
            .initializeSchema(true)    // 自动建表
            .build();
}

SimpleVectorStore最适合入门:

typescript 复制代码
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
    return SimpleVectorStore.builder(embeddingModel).build();
}

3.3 文档入库

将加载、切分后的文档保存到向量库:

ini 复制代码
@Service
public class KnowledgeBaseService {

    private final VectorStore vectorStore;
    private final TextSplitter textSplitter = TokenTextSplitter.builder()
            .withChunkSize(500)
            .withChunkOverlap(100)
            .build();

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

    public void ingest(String resourcePath) {
        TikaDocumentReader reader = new TikaDocumentReader(
                new ClassPathResource(resourcePath)
        );
        List<Document> docs = reader.read();
        List<Document> chunks = textSplitter.apply(docs);
        vectorStore.add(chunks);
        System.out.println("已入库切片数: " + chunks.size());
    }
}

vectorStore.add内部会对每个切片调用 EmbeddingModel 生成向量,与原始文本一起保存。


四、相似度检索

用户提问后,我们需要在向量库中找到最相关的几个切片。检索 API:

less 复制代码
@RestController
public class SearchController {

    private final VectorStore vectorStore;

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

    @GetMapping("/search")
    public List<String> search(@RequestParam String query) {
        List<Document> results = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(query)
                        .topK(5)
                        .similarityThreshold(0.5)
                        .build()
        );
        return results.stream().map(Document::getText).toList();
    }
}

参数解释:

  • topK:返回最相似的 K 个文档,通常 3~5;
  • similarityThreshold:相似度阈值,过滤低质量命中;
  • 可以加 filterExpression做元数据过滤,比如只查询某部门的文档:
ini 复制代码
.filterExpression("department == '技术部'")

检索结果按相似度降序排列。如何选择 TopK?K 太小可能漏掉关键信息,K 太大则上下文膨胀、干扰模型判断,还可能超 token 限制。可以先取 4 个切片,每个 500 token,正好约 2000 token 的上下文,平衡效果与成本。


五、注入 Prompt:让模型基于检索结果回答

有了检索到的上下文,接下来把它拼入 System/User 消息,要求模型仅依据上下文回答。不要先给模型看问题,再让它自由发挥。正确姿势:

ini 复制代码
@Service
public class RagChatService {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;

    public RagChatService(ChatClient chatClient, VectorStore vectorStore) {
        this.chatClient = chatClient;
        this.vectorStore = vectorStore;
    }

    public String ask(String question) {
        // 1. 检索
        List<Document> docs = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(question)
                        .topK(4)
                        .build()
        );

        // 2. 拼接上下文
        String context = docs.stream()
                .map(Document::getText)
                .reduce((a, b) -> a + "\n---\n" + b)
                .orElse("未找到相关资料");

        // 3. 注入 Prompt
        String systemPrompt = """
                你是企业内部知识助手。请仅根据以下资料回答问题。
                如果资料中找不到答案,请明确回答"资料库中没有相关信息",不要编造。

                参考资料:
                %s
                """.formatted(context);

        // 4. 调用模型
        return chatClient.prompt()
                .system(systemPrompt)
                .user(question)
                .call()
                .content();
    }
}

这里的关键是把上下文放入 System 消息,而不是 User 消息。这样能明确告诉模型"这些是权威参考",降低它被用户自由文本干扰的可能性。

5.1 带来源引用的回答

为了让回答可追溯,可以在检索文档时保留元数据,并在 Prompt 中要求模型标注来源:

ini 复制代码
String context = docs.stream()
        .map(doc -> "来源:%s\n内容:%s".formatted(
                doc.getMetadata().get("source"),
                doc.getText()))
        .reduce((a, b) -> a + "\n" + b)
        .orElse("无");

String systemPrompt = """
        根据以下资料回答问题,并在回答末尾注明"资料来源:xxx"。
        %s
        """.formatted(context);

模型可能输出:"根据公司手册第3章的规定,请假需提前一天申请。(资料来源:company-handbook.pdf)"。

5.2 检索后重排序(Rerank)

基础向量检索偶尔会返回语义相近但与问题不完全相关的结果。可以在向量检索后增加重排序模型,例如 Cohere Rerank 或 BGE-Reranker。Spring AI 目前不内置,但可以自行调用外部重排序 API,将候选文档重排后选择 TopK。这也是一种进阶优化手段。


六、Spring AI 中的 Advisors:更优雅的 RAG 集成

Spring AI 在 ChatClient 中提供了 Advisor 概念,用于拦截请求和响应。其中 QuestionAnswerAdvisor内置了 RAG 核心逻辑:自动检索向量库,并注入 Prompt。

使用方式:

typescript 复制代码
@Configuration
public class RagConfig {

    @Bean
    public ChatClient ragChatClient(ChatModel chatModel, VectorStore vectorStore) {
        return ChatClient.builder(chatModel)
                .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore))
                .build();
    }
}

之后,直接调用:

arduino 复制代码
@Service
public class RagService {

    private final ChatClient ragChatClient;

    public RagService(ChatClient ragChatClient) {
        this.ragChatClient = ragChatClient;
    }

    public String ask(String question) {
        return ragChatClient.prompt()
                .user(question)
                .call()
                .content();
    }
}

QuestionAnswerAdvisor做了哪些事?

  1. 将用户问题转化为搜索请求;
  2. 从 VectorStore 检索 TopK 文档;
  3. 将文档内容拼接到 System Prompt 的默认模板中;
  4. 调用模型;
  5. 在响应中附加检索信息的元数据,便于追踪。

默认模板大致为:

markdown 复制代码
上下文信息如下,请基于此回答问题:
---------------------
{context}
---------------------

它保证了最小的侵入性,你不需要手动管理上下文拼接。但如果你需要自定义 Prompt 格式,或者对上下文加入严格规则(如"不要编造"),建议手动实现,像前一节那样。


七、完整示例:私有知识问答系统

下面整合一个从文档导入到问答的完整示例。

7.1 配置

typescript 复制代码
@Configuration
public class RagApplicationConfig {

    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }

    @Bean
    public ChatClient chatClient(ChatModel chatModel) {
        return ChatClient.builder(chatModel).build();
    }
}

7.2 文档导入接口

kotlin 复制代码
@RestController
@RequestMapping("/api/kb")
public class KnowledgeBaseController {

    private final VectorStore vectorStore;
    private final TextSplitter textSplitter;

    public KnowledgeBaseController(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
        this.textSplitter = TokenTextSplitter.builder()
                .withChunkSize(400)
                .withChunkOverlap(80)
                .build();
    }

    @PostMapping("/ingest")
    public String ingest(@RequestParam String filePath) {
        TikaDocumentReader reader = new TikaDocumentReader(
                new FileSystemResource(filePath)
        );
        List<Document> docs = reader.read();
        List<Document> chunks = textSplitter.apply(docs);
        vectorStore.add(chunks);
        return "成功导入 " + chunks.size() + " 个切片";
    }
}

7.3 问答接口

less 复制代码
@RestController
@RequestMapping("/api/rag")
public class RagController {

    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    public RagController(VectorStore vectorStore, ChatClient chatClient) {
        this.vectorStore = vectorStore;
        this.chatClient = chatClient;
    }

    @GetMapping("/ask")
    public String ask(@RequestParam String question) {
        List<Document> docs = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(question)
                        .topK(4)
                        .build()
        );

        String context = docs.stream()
                .map(doc -> doc.getMetadata().getOrDefault("source", "未知") + ": " + doc.getText())
                .reduce((a, b) -> a + "\n\n" + b)
                .orElse("无资料");

        return chatClient.prompt()
                .system("""
                        你是一个只能依据给定资料回答的智能助手。
                        如果资料不足以回答问题,请直接说"资料库中未找到相关信息",不要自行编造。
                        
                        资料:
                        %s
                        """.formatted(context))
                .user(question)
                .call()
                .content();
    }

    // 流式回答
    @GetMapping(value = "/ask/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> askStream(@RequestParam String question) {
        List<Document> docs = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(question)
                        .topK(4)
                        .build()
        );
        String context = docs.stream()
                .map(Document::getText)
                .reduce((a, b) -> a + "\n\n" + b)
                .orElse("无资料");

        return chatClient.prompt()
                .system("基于资料回答,资料中未提到就说不知道。资料如下:\n" + context)
                .user(question)
                .stream()
                .content();
    }
}

7.4 测试

先导入一篇文档:

arduino 复制代码
curl -X POST "http://localhost:8080/api/kb/ingest?filePath=/data/员工手册.pdf"

再提问:

arduino 复制代码
curl "http://localhost:8080/api/rag/ask?question=年假是怎么规定的?"

输出应该引用手册内容,而不是凭空回答。


八、优化 RAG 效果的核心技巧

问题 优化手段
切片边界切断关键信息 增大 overlap;按标题/章节结构切分
检索结果不相关 调低 topK 阈值、尝试动态 topK;使用混合检索
检索结果太冗余 使用 Rerank 模型重排序;为元数据加过滤条件
模型仍幻觉 在 System Prompt 中强制"无信息禁止回答";提高模板的约束力
token 超限 减小 chunk size、限制 topK、压缩上下文
英文/中文嵌入效果不一 选择支持中英文的 embedding 模型,如 bge-large-zh
知识更新不及时 ingest 时对旧文档执行 delete 再 add;或按版本管理

8.1 混合检索

语义检索擅长理解意图,但关键词精确匹配有时更可靠(例如产品型号"iPhone 15 Pro Max")。可以把向量检索与 BM25 文本检索结合,融合分数。Spring AI 未内置混合检索,但可以自行实现:

  • 用 Elasticsearch 或 PostgreSQL 全文检索获得关键词命中;
  • 用 VectorStore 获得向量命中;
  • 按权重合并,取 TopK。

8.2 问答链路中加入引用

为了让用户信任结果,回答中应提供来源。将元数据中的文件名、页码拼入响应。为此,可以采用自定义 Advisor 拦截响应,将检索到的来源附加在回答之后。


九、总结

RAG 是当前大模型落地最快、最实用的私域知识解决方案。在 Spring AI 中实现 RAG 只需要六步:

  1. 加载文档 :使用 TikaDocumentReader等读取多种格式;
  2. 切分文档TokenTextSplitter生成合适大小的切片;
  3. 向量化EmbeddingModel将文本转为向量;
  4. 存储VectorStore存入向量库(内存、PostgreSQL、Redis 等);
  5. 检索 :用户提问后 similaritySearch取回 TopK 相关文档;
  6. 注入:将上下文拼入 System Prompt,调用 ChatClient 生成答案。

这六步构成了 RAG 的最小闭环。在此基础上,你还可以结合前面章节的知识:

  • 用 ChatMemory 保留多轮对话上下文;
  • 用 ChatClient 输出 JSON 让 RAG 返回结构化结果;
  • 用 @Tool 将 RAG 封装为模型可调用的工具,实现"知识查询"技能。
相关推荐
Nturmoils1 小时前
只备份一个 schema,别把整库都搬走
后端
妙码生花1 小时前
利用AI从零学Go并完成实战项目,完工总结:目录结构
前端·后端·go
妙码生花1 小时前
利用AI从零学Go并完成实战项目,完工总结:商业级开源产品定位和核心特性介绍
前端·后端·go
程序员cxuan1 小时前
GPT-6 Astra 的提示词泄露了,里面居然藏着个保安?
人工智能·后端·程序员
苍何2 小时前
WorkBuddy + 飞书的 8 种神仙用法(建议收藏)
后端
Captaincc2 小时前
掘金AI用量统计v0.1.0大更新-支持桌面宠物自定义和订阅额度卡片
前端·后端
大哥43092 小时前
AI 接口高并发 ≠ 秒杀高并发:为什么我把并发闸门挂在 LLM 调用汇聚点
后端
大哥43092 小时前
Redis 主从下的库存一致性:我推翻了"付款前查库存"这个方案
后端
ikoala2 小时前
DeepSeek 官方仓库惊现 DeepSeek Harness 桌面端!
前端·javascript·后端