Spring AI Alibaba RAG实战:基于向量存储的检索增强生成

Spring AI Alibaba RAG实战:基于向量存储的检索增强生成

导读:RAG(Retrieval-Augmented Generation,检索增强生成)是目前企业 AI 落地最成熟的技术路线。它解决了大模型最核心的两个痛点:知识截止日期和企业私有数据无法访问。本文从零构建完整 RAG 流水线,深入讲解检索策略、Prompt 注入机制与效果评估方法。


一、为什么 RAG 是企业 AI 的标配

直接和大模型对话有两个根本性的局限:

局限一:模型不知道你的业务知识

你的公司有几千份产品文档、几万条历史工单、一套独特的业务规范------这些内容不在模型的训练集里,模型无从回答。就算强行"喂"进去,也会遭遇上下文窗口限制和高昂的 Token 费用。

局限二:模型会产生幻觉

当模型不知道答案时,它倾向于"编造"一个听起来合理的回答,这在企业场景下是不可接受的风险。

RAG 的解法直白有效:先搜索,再回答。每次用户提问时,先从知识库里检索相关内容,把检索结果和问题一起送给模型,让模型"基于材料作答",大幅降低幻觉概率。


二、RAG 流水线全貌

复制代码
【离线阶段:知识入库】

原始文档(PDF/Word/HTML)
        |
   Document Loader  ← PdfPageReader / MarkdownPageReader
        |
   Text Splitter    ← 分块(固定长度 / 语义分割)
        |
   EmbeddingModel   ← text-embedding-v3(DashScope)
        |
   VectorStore      ← Milvus / Redis / Elasticsearch
        |
   向量数据库(存储 Document + Embedding)


【在线阶段:检索回答】

用户提问
    |
 EmbeddingModel(将问题向量化)
    |
 VectorStore.similaritySearch(Top-K 相似文档)
    |
 QuestionAnswerAdvisor(拼装 Prompt)
    |
 ChatModel(基于检索内容生成回答)
    |
 最终回答返回给用户

三、环境准备与依赖

3.1 Maven 依赖

xml 复制代码
<dependencies>
    <!-- Spring AI Alibaba DashScope(含 Embedding 支持) -->
    <dependency>
        <groupId>com.alibaba.cloud.ai</groupId>
        <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
    </dependency>

    <!-- 向量存储:Redis Vector(本文示例使用 Redis) -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-redis-store-spring-boot-autoconfigure</artifactId>
    </dependency>

    <!-- PDF 文档加载 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-pdf-document-reader</artifactId>
    </dependency>

    <!-- Spring Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
</dependencies>

3.2 YAML 配置

yaml 复制代码
spring:
  ai:
    dashscope:
      api-key: ${AI_DASHSCOPE_API_KEY}
      chat:
        options:
          model: qwen-plus
          temperature: 0.3    # RAG 场景建议低温度,减少幻觉
          max-tokens: 2048
      # ① DashScope Embedding 模型配置
      embedding:
        options:
          model: text-embedding-v3  # 1.1版新增的高性能 Embedding 模型
          dimensions: 1024

    # Redis Vector Store 配置
    vectorstore:
      redis:
        uri: redis://localhost:6379
        index: rag-knowledge-index
        prefix: "doc:"
        initialize-schema: true  # 首次启动自动创建索引

  redis:
    host: localhost
    port: 6379

四、知识入库:Document 处理流水线

4.1 文档加载器

java 复制代码
package com.example.rag.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 知识入库 Service
 * 负责文档加载、分块、Embedding、存储全流程
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class KnowledgeIngestionService {

    private final VectorStore vectorStore;

    /**
     * PDF 文档入库
     *
     * @param pdfResource PDF 文件资源
     * @param metadata    元数据(如来源、分类、版本等)
     */
    public void ingestPdf(Resource pdfResource, Map<String, Object> metadata) {
        log.info("开始处理 PDF:{}", pdfResource.getFilename());

        // 1. 加载 PDF(按页读取)
        PdfDocumentReaderConfig config = PdfDocumentReaderConfig.builder()
                .withPageExtractedTextFormatter(
                        // 配置文本提取格式:保留文档结构
                        PagePdfDocumentReader.PagesTextFormatter.of()
                )
                .withPagesPerDocument(1) // 每页作为一个 Document
                .build();

        PagePdfDocumentReader reader = new PagePdfDocumentReader(pdfResource, config);
        List<Document> rawDocuments = reader.get();
        log.info("PDF 共 {} 页,开始分块处理", rawDocuments.size());

        // 2. 文本分块(Token 级别,含重叠窗口)
        TokenTextSplitter splitter = new TokenTextSplitter(
                512,    // 每块最大 Token 数
                128,    // 重叠 Token 数(滑动窗口,保证上下文连续性)
                5,      // 最小块大小
                10000,  // 最大块大小
                true    // 保留分隔符
        );
        List<Document> chunks = splitter.apply(rawDocuments);

        // 3. 注入元数据(文件名、时间等)
        String fileName = pdfResource.getFilename();
        chunks.forEach(doc -> {
            Map<String, Object> docMetadata = new HashMap<>(metadata);
            docMetadata.put("source", fileName);
            docMetadata.put("ingestTime", System.currentTimeMillis());
            doc.getMetadata().putAll(docMetadata);
        });

        // 4. 向量化并存储(VectorStore 内部调用 EmbeddingModel)
        vectorStore.add(chunks);
        log.info("成功入库 {} 个文本块,文件:{}", chunks.size(), fileName);
    }

    /**
     * 纯文本入库(适合 FAQ、规则等结构化内容)
     */
    public void ingestText(String content, String title, Map<String, Object> metadata) {
        Document document = new Document(content, metadata);
        document.getMetadata().put("title", title);
        document.getMetadata().put("type", "text");
        document.getMetadata().put("ingestTime", System.currentTimeMillis());

        // 如果内容较长,同样需要分块
        if (content.length() > 1000) {
            TokenTextSplitter splitter = new TokenTextSplitter(512, 64, 5, 10000, true);
            List<Document> chunks = splitter.apply(List.of(document));
            vectorStore.add(chunks);
            log.info("长文本已分为 {} 块入库,标题:{}", chunks.size(), title);
        } else {
            vectorStore.add(List.of(document));
            log.info("短文本入库成功,标题:{}", title);
        }
    }

    /**
     * 批量删除(根据元数据条件)
     */
    public void deleteBySource(String source) {
        // 使用过滤表达式删除指定来源的所有文档
        vectorStore.delete(List.of(source));
        log.info("已删除来源为 '{}' 的所有文档", source);
    }
}

4.2 入库 REST 接口

java 复制代码
@RestController
@RequestMapping("/api/knowledge")
@RequiredArgsConstructor
public class KnowledgeController {

    private final KnowledgeIngestionService ingestionService;

    @PostMapping("/ingest/pdf")
    public ResponseEntity<Map<String, Object>> ingestPdf(
            @RequestParam("file") MultipartFile file,
            @RequestParam(defaultValue = "general") String category,
            @RequestParam(defaultValue = "") String tags) {

        try {
            Resource resource = file.getResource();
            Map<String, Object> metadata = Map.of(
                    "category", category,
                    "tags", tags,
                    "uploadUser", "system"
            );
            ingestionService.ingestPdf(resource, metadata);

            return ResponseEntity.ok(Map.of(
                    "status", "success",
                    "fileName", file.getOriginalFilename(),
                    "size", file.getSize()
            ));
        } catch (Exception e) {
            return ResponseEntity.internalServerError()
                    .body(Map.of("error", e.getMessage()));
        }
    }
}

五、检索问答:RAG 核心实现

5.1 使用 QuestionAnswerAdvisor 自动注入上下文

java 复制代码
package com.example.rag.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;

/**
 * RAG 检索问答 Service
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class RagQueryService {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;

    /**
     * 基础 RAG 问答
     * QuestionAnswerAdvisor 会自动:
     * 1. 将问题向量化
     * 2. 从 VectorStore 检索 TopK 相关文档
     * 3. 将检索结果拼入 Prompt
     */
    public String query(String question) {
        log.info("RAG 查询:{}", question);

        // 构建 QuestionAnswerAdvisor(核心 RAG 组件)
        QuestionAnswerAdvisor ragAdvisor = new QuestionAnswerAdvisor(
                vectorStore,
                SearchRequest.defaults()
                        .withTopK(5)             // 检索 Top-5 相关文档
                        .withSimilarityThreshold(0.7) // 相似度阈值,低于此值的不使用
        );

        return chatClient.prompt()
                .system("""
                        你是一个企业知识库问答助手。请严格基于提供的参考资料回答问题。
                        如果参考资料中没有相关信息,请明确告知用户"知识库中暂无相关信息",
                        不要凭空编造答案。
                        """)
                .user(question)
                .advisors(ragAdvisor)
                .call()
                .content();
    }

    /**
     * 带元数据过滤的 RAG 问答
     * 只在特定分类的文档中检索
     */
    public String queryWithFilter(String question, String category) {
        SearchRequest searchRequest = SearchRequest.defaults()
                .withTopK(5)
                .withSimilarityThreshold(0.65)
                // 元数据过滤:只检索指定分类的文档
                .withFilterExpression("category == '" + category + "'");

        QuestionAnswerAdvisor ragAdvisor = new QuestionAnswerAdvisor(
                vectorStore, searchRequest);

        return chatClient.prompt()
                .user(question)
                .advisors(ragAdvisor)
                .call()
                .content();
    }

    /**
     * 流式 RAG 问答
     */
    public Flux<String> streamQuery(String question) {
        QuestionAnswerAdvisor ragAdvisor = new QuestionAnswerAdvisor(
                vectorStore,
                SearchRequest.defaults().withTopK(3));

        return chatClient.prompt()
                .user(question)
                .advisors(ragAdvisor)
                .stream()
                .content();
    }
}

5.2 检索策略对比

三种主要检索策略的适用场景:

复制代码
+------------------+--------------------------------------------+------------------+
| 检索策略          | 原理                                        | 适用场景         |
+------------------+--------------------------------------------+------------------+
| Top-K 相似度     | 返回向量距离最近的 K 个文档                   | 通用问答         |
| MMR 多样性算法   | 在相似度基础上,增加结果多样性(避免重复内容)  | 多角度综合分析   |
| 混合检索         | 向量检索 + 关键词(BM25)检索结果融合          | 专业术语精准查询 |
+------------------+--------------------------------------------+------------------+

Top-K 检索(默认,最简单):

java 复制代码
SearchRequest.defaults()
    .withTopK(5)
    .withSimilarityThreshold(0.7)

MMR 多样性检索(结果更丰富,避免检索到重复内容):

java 复制代码
// Spring AI 的 VectorStore 部分实现支持 MMR
SearchRequest.builder()
    .withTopK(10)           // 先取 10 个候选
    .withSimilarityThreshold(0.6)
    // 通过 filterExpression 配合实现 MMR(具体实现依赖 VectorStore)
    .build()

六、Prompt 组装与 Token 超限处理

6.1 QuestionAnswerAdvisor 的 Prompt 模板

QuestionAnswerAdvisor 使用的默认 Prompt 模板大致如下(可以自定义):

复制代码
系统提示:{你设置的 defaultSystem}

参考资料(从知识库检索到的相关内容):
------
{document_1_content}
------
{document_2_content}
------
...

用户问题:{user_question}

请基于以上参考资料回答问题。

自定义 Prompt 模板:

java 复制代码
// 自定义 QuestionAnswerAdvisor 的用户文本模板
String customUserTextAdvice = """
        以下是从知识库中检索到的相关参考资料:
        
        {question_answer_context}
        
        ---------------------
        基于以上资料,请回答:{user_input}
        
        注意事项:
        1. 如果资料中有明确答案,直接引用
        2. 如果资料不足以回答,请告知用户
        3. 不要捏造资料中没有的信息
        """;

QuestionAnswerAdvisor ragAdvisor = new QuestionAnswerAdvisor(
        vectorStore,
        SearchRequest.defaults().withTopK(5),
        customUserTextAdvice  // 传入自定义模板
);

6.2 Token 超限截断策略

当检索到的文档过多,拼接后的 Prompt 可能超过模型的上下文限制。处理策略:

java 复制代码
@Component
public class TokenLimitHandler {

    // qwen-plus 的上下文窗口为 131072 tokens,留出 2048 用于回答
    private static final int MAX_CONTEXT_TOKENS = 120000;

    /**
     * 截断过长的检索文档列表
     */
    public List<Document> truncateDocuments(List<Document> documents) {
        List<Document> result = new ArrayList<>();
        int totalTokens = 0;

        for (Document doc : documents) {
            // 粗估 Token 数(中文约 1.5字/token,英文约 4字/token)
            int docTokens = estimateTokens(doc.getContent());

            if (totalTokens + docTokens <= MAX_CONTEXT_TOKENS) {
                result.add(doc);
                totalTokens += docTokens;
            } else {
                log.warn("文档列表 Token 超限,已截断,保留 {} 个文档,约 {} tokens",
                        result.size(), totalTokens);
                break;
            }
        }
        return result;
    }

    private int estimateTokens(String text) {
        if (text == null) return 0;
        // 中英文混合粗估
        long chineseCount = text.chars()
                .filter(c -> c >= 0x4E00 && c <= 0x9FFF).count();
        long otherCount = text.length() - chineseCount;
        return (int) (chineseCount / 1.5 + otherCount / 4);
    }
}

七、效果评估指标

7.1 三个核心指标

复制代码
1. 检索准确率(Retrieval Precision)
   = 检索到的相关文档数 / 检索到的总文档数
   含义:检索结果中有多少是真正有用的

2. 答案相关性(Answer Relevancy)
   = 答案与问题的语义相似度
   评估方式:用 EmbeddingModel 计算相似度,或用 LLM 作为评判者

3. 幻觉检测(Faithfulness)
   = 答案中有多少内容有文档支撑
   评估方式:让 LLM 判断答案的每句话是否能在参考资料中找到依据

7.2 简单评估实现

java 复制代码
@Service
@RequiredArgsConstructor
public class RagEvaluationService {

    private final ChatClient evaluatorClient;
    private final EmbeddingModel embeddingModel;

    /**
     * 幻觉检测:让 LLM 判断答案是否忠于参考资料
     */
    public double evaluateFaithfulness(String answer,
                                        String context,
                                        String question) {
        String prompt = String.format("""
                请判断以下 AI 回答是否完全基于提供的参考资料,没有捏造信息。
                
                参考资料:
                %s
                
                问题:%s
                
                AI 回答:
                %s
                
                请以 0-1 的数值评分(1=完全忠于资料,0=严重捏造):
                只输出数字,不要解释。
                """, context, question, answer);

        String scoreStr = evaluatorClient.prompt(prompt).call().content().trim();
        try {
            return Double.parseDouble(scoreStr);
        } catch (NumberFormatException e) {
            log.warn("评分解析失败:{}", scoreStr);
            return 0.5;
        }
    }

    /**
     * 答案相关性评估:基于向量相似度
     */
    public double evaluateRelevancy(String question, String answer) {
        float[] questionEmbedding = embeddingModel.embed(question);
        float[] answerEmbedding = embeddingModel.embed(answer);
        return cosineSimilarity(questionEmbedding, answerEmbedding);
    }

    private double cosineSimilarity(float[] a, float[] b) {
        double dotProduct = 0, normA = 0, normB = 0;
        for (int i = 0; i < a.length; i++) {
            dotProduct += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
    }
}

八、DashScope text-embedding-v3 特性说明

Spring AI Alibaba 1.1 版新增了 text-embedding-v3 的专项支持,这个模型相比旧版有几个显著提升:

复制代码
+------------------------+------------------+------------------+
| 特性                    | text-embedding-v1 | text-embedding-v3 |
+------------------------+------------------+------------------+
| 向量维度                 | 1536             | 1024(可调)      |
| 支持语言                 | 中英文            | 多语言(50+语种)  |
| 最大 Token               | 2048             | 8192              |
| 中文效果                 | 良好              | 优秀              |
| 批量请求                 | 支持             | 支持               |
+------------------------+------------------+------------------+

配置 text-embedding-v3:

yaml 复制代码
spring:
  ai:
    dashscope:
      embedding:
        options:
          model: text-embedding-v3
          # 可选:指定输出维度(512/768/1024,维度越小,存储和计算越快)
          dimensions: 1024

九、生产注意事项

1. 入库幂等性:同一文档多次入库会产生重复向量,需要基于文档 hash 做去重检查。

2. 增量更新:文档变更时,先删除旧向量(按元数据过滤),再重新入库。

3. 相似度阈值调优0.7 是经验值,实际需要根据业务语料测试。阈值过高则召回太少,过低则引入不相关内容。

4. 分块策略选择

  • 固定 512 Token 分块:简单通用,适合大多数场景;
  • 按段落分块:适合结构化文档(技术文档、规范等);
  • 语义分块:效果最好但实现复杂,适合对质量要求极高的场景。

十、总结

RAG 体系的核心四步:加载 → 分块 → 向量化 → 检索问答。本文的关键收获:

  1. QuestionAnswerAdvisor 是 Spring AI 中 RAG 的"一键开关",极大简化了实现复杂度;
  2. text-embedding-v3 是 DashScope 目前综合效果最好的 Embedding 模型,1.1 版已完整支持;
  3. 相似度阈值、Top-K 数量需要结合实际语料反复调优;
  4. 幻觉检测是 RAG 质量保障的关键一环,不能省略。

下一篇深入向量数据库的生产级配置:Milvus 集群部署、ES 向量字段优化,以及混合查询的实现细节。


参考资料

相关推荐
Physicist in Geophy.1 小时前
claude code workflow
人工智能
大傻^1 小时前
Spring AI Alibaba 快速入门:基于通义千问的AI应用开发环境搭建
java·人工智能·后端·spring·springai·springaialibaba
伯恩bourne1 小时前
Google Guava:Java 核心工具库的卓越之选
java·开发语言·guava
跨境卫士-小汪2 小时前
高风险订单识别不足如何设置拦截与二次核验
大数据·人工智能·产品运营·跨境电商·营销策略
小王不爱笑1322 小时前
Spring 基础核心
java
心勤则明2 小时前
用 Spring AI Alibaba 打造智能查询增强引擎
java·人工智能·spring
Arva .2 小时前
Spring 的三级缓存,两级够吗
java·spring·缓存
爱喝一杯白开水2 小时前
Java 定时任务完全指南
java
njsgcs2 小时前
图卷积是如何处理不同输入长度的 消息传递
人工智能