#6、Spring AI RAG 深度技术解析 — 从原理到生产实践

Spring AI RAG 深度技术解析 --- 从原理到生产实践

摘要: 检索增强生成(Retrieval-Augmented Generation, RAG)是当前大语言模型应用中最核心的架构范式之一。本文从 Spring AI 框架的视角出发,系统性地拆解 RAG 的技术原理、架构设计、核心组件、进阶策略及生产化落地要点,帮助读者建立起从理论到实践的完整知识体系。


1. RAG 的本质:为什么需要检索增强生成

1.1 大模型的核心痛点

大语言模型(LLM)虽然能力强大,但在实际应用中面临三个根本性局限:

  • 知识截止日期(Knowledge Cutoff):模型训练完成后,无法自动获取最新信息。一个在 2025 年训练的模型不知道 2026 年发生了什么事。
  • 幻觉(Hallucination):模型在不确定时会"编造"答案,尤其在处理长尾知识或专业领域问题时。这不是 bug,而是语言模型基于概率生成的天性。
  • 缺乏内部知识访问能力:模型无法"查阅"企业内部的数据库、文档库、知识库。它的知识完全来自于训练语料。

RAG 的出现正是为了解决这三个问题------它不试图让模型记住更多,而是让模型学会"查资料"

1.2 RAG 的核心思想

RAG 的核心逻辑极其简洁,可以用一句话概括:

在回答之前,先检索与问题相关的信息,然后将这些信息作为上下文提供给 LLM,让 LLM 基于检索到的内容生成答案。

这个简单的思路带来了三个关键转变:

复制代码
传统 LLM 调用:  用户问题 → LLM → 答案(依赖模型内部知识)
RAG 模式:       用户问题 → 检索器 → 相关知识 + 问题 → LLM → 基于事实的答案

1.3 RAG Pipeline 的抽象视角

从最抽象的层面看,任何 RAG 系统都可以分解为两个阶段、四个步骤:

css 复制代码
graph LR
    A[用户查询] --> B[检索阶段]
    B --> C[检索结果]
    C --> D[生成阶段]
    D --> E[最终答案]
    
    subgraph 索引管线(离线)
        F[原始文档] --> G[文档分割]
        G --> H[Embedding]
        H --> I[(向量数据库)]
    end
    
    I -.-> B

索引管线(Indexing Pipeline) --- 离线执行,将原始文档转化为可检索的向量索引。 查询时管线(Query-time Pipeline) --- 在线执行,处理用户查询并生成答案。


2. RAG 与 Fine-tuning 的博弈与协同

在讨论 RAG 的架构之前,必须澄清一个常被混淆的问题:RAG 和 Fine-tuning 到底是什么关系?

2.1 本质差异

维度 RAG Fine-tuning
知识来源 外部检索 + 模型能力 模型内部参数
更新成本 更新索引库即可,无需重新训练 需要重新训练或微调
幻觉控制 强(基于检索事实) 弱(依赖模型记忆)
推理成本 增加检索延迟和 Token 消耗 基本不变
适合场景 知识密集型、频繁更新、高准确性要求 风格适配、指令遵循、稳定性要求

2.2 何时用 RAG

  • 需要引用具体数据源(法律条文、财报数据、产品文档)
  • 知识频繁变化(实时新闻、内部政策更新)
  • 需要精确追溯信息来源
  • 长尾知识(罕见问题、小众领域)

2.3 何时用 Fine-tuning

  • 改变模型的输出风格或语调(让模型更像客服或专家)
  • 让模型遵循特定指令格式(始终输出 JSON)
  • 少量高频任务(分类、实体抽取)
  • 减少推理时的 Prompt 长度(将行为编码进参数)

2.4 RAG + Fine-tuning 的最佳协同

在实践中,RAG 和 Fine-tuning 不是互斥的,而是互补的。理想的架构是:

Fine-tuning 负责"如何回答",RAG 负责"回答什么"。

举个例子:一个法律咨询 AI

  • Fine-tuning 让模型学会法律文书的严谨语气和引用格式
  • RAG 从法律数据库中检索最新的法条和判例

在 Spring AI 中,这意味着你可以同时使用 ChatClientVectorStore ------ 微调后的模型回答风格更专业,RAG 提供的事实更准确。


3. Spring AI RAG 整体架构

3.1 Spring AI 的定位

Spring AI 是 Spring 生态中面向 AI 应用的抽象层。它的设计哲学沿袭了 Spring 一贯的风格:

提供抽象接口,让开发者用最少的代码切换不同实现。

在 RAG 领域,Spring AI 提供了完整的构建块:

scss 复制代码
Spring AI RAG 组件层次

┌─────────────────────────────────────────────┐
│                 应用层                         │
│   ChatClient / StreamingChatClient           │
├─────────────────────────────────────────────┤
│                 增强层                         │
│   QuestionAnswerAdvisor                      │
│   RetrievalAugmentationAdvisor               │
├─────────────────────────────────────────────┤
│                 检索层                         │
│   VectorStore (PGVector / Pinecone / Redis)  │
│   DocumentRetriever                          │
├─────────────────────────────────────────────┤
│                 处理层                         │
│   DocumentReader / DocumentTransformer       │
│   TokenTextSplitter / ContentFormatter       │
├─────────────────────────────────────────────┤
│                 基础设施层                      │
│   EmbeddingModel (OpenAI / Ollama / BAAI)    │
│   ChatModel / StreamingChatModel             │
└─────────────────────────────────────────────┘

3.2 核心接口与抽象

Spring AI 为 RAG 定义了几个关键接口,理解这些接口是掌握整个框架的基础:

java 复制代码
// 文档表示
public class Document {
    private String id;
    private String content;          // 文档文本内容
    private Metadata metadata;       // 元数据(来源、时间、类型等)
    private List<Double> embedding;  // 向量表示
}

// 向量存储抽象
public interface VectorStore {
    void add(List<Document> documents);
    void delete(List<String> idList);
    List<Document> similaritySearch(SearchRequest request);
}

// 检索增强建议器(核心 RAG 组件)
public interface RetrievalAugmentationAdvisor {
    ChatResponse advise(ChatRequest request);
}

// 文档检索器
public interface DocumentRetriever {
    List<Document> retrieve(RetrievalRequest request);
}

3.3 最小化 RAG 实现

在 Spring AI 中,实现一个完整的 RAG 流程只需要几行代码:

java 复制代码
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
    return new PgVectorStore(jdbcTemplate, embeddingModel);
}

@Service
public class RagService {
    private final ChatClient chatClient;
    
    public RagService(ChatClient.Builder builder, VectorStore vectorStore) {
        this.chatClient = builder
            .defaultSystem("""
                你是一个专业的AI助手。请基于提供的上下文信息回答问题。
                如果上下文信息不足以回答问题,请明确说明,不要编造答案。
                回答时请引用信息来源。
                """)
            .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore))
            .build();
    }
    
    public String ask(String question) {
        return chatClient.prompt()
            .user(question)
            .call()
            .content();
    }
}

这段代码背后发生的事情:

  1. QuestionAnswerAdvisor 拦截用户请求
  2. 调用 VectorStore.similaritySearch() 执行向量检索
  3. 将检索到的文档格式化为上下文,注入 Prompt
  4. 调用 LLM 生成基于上下文的回答

4. 文档加载与解析:RAG 的入口

RAG 的质量上限在很大程度取决于输入数据的质量。垃圾进,垃圾出 在 RAG 中体现得尤为明显。

4.1 Spring AI 的 DocumentReader 体系

Spring AI 提供了丰富的文档读取器,覆盖了大部分常见格式:

java 复制代码
// 读取 PDF
DocumentReader pdfReader = new PdfDocumentReader(
    new FileSystemResource("document.pdf"));

// 读取 JSON
DocumentReader jsonReader = new JsonDocumentReader(
    new ClassPathResource("data.json"));

// 读取 Markdown
DocumentReader mdReader = new MarkdownDocumentReader(
    new FileSystemResource("README.md"));

// 读取 CSV
DocumentReader csvReader = new CsvDocumentReader(
    new FileSystemResource("data.csv"));

// 读取 HTML
DocumentReader htmlReader = new HtmlDocumentReader(
    new UrlResource("https://example.com"));

4.2 文档解析的深度策略

PDF 解析的陷阱

PDF 是 RAG 中最常见的输入格式,也是最容易出问题的格式。PDF 本质上是一个排版格式,不是内容格式,解析质量参差不齐。

Spring AI 的 PdfDocumentReader 基于 Apache PDFBox。对于扫描版 PDF(图片格式),需要使用 OCR 进行预处理:

java 复制代码
// 原生 PDF(文本可选中)
DocumentReader reader = new PdfDocumentReader(resource);

// 扫描版 PDF(需先 OCR 处理)
// Spring AI 本身不内置 OCR,可以结合 Tesseract 预处理
// 或者使用 AWS Textract、Azure Document Intelligence 等云服务

实际经验:对于 PDF 中的表格,原生解析几乎一定会丢失结构信息。建议使用专门的文档解析服务或自定义表格提取逻辑。

元数据的价值

文档加载时保留的元数据(Metadata)在后续的检索和过滤中至关重要:

java 复制代码
Document doc = new Document("content");
doc.getMetadata().put("source", "financial-report-2026-q1.pdf");
doc.getMetadata().put("page", 42);
doc.getMetadata().put("category", "quarterly");
doc.getMetadata().put("created_at", "2026-04-15");
doc.getMetadata().put("author", "finance-team");

为什么元数据重要? 元数据允许你在检索时进行预过滤------只检索特定分类、特定时间范围、特定来源的文档,大大提升检索精准度。


5. 文档分割:决定检索质量的隐形之手

5.1 为什么需要分割?

  • LLM 上下文窗口有限:即使 GPT-4 有 128K 上下文,检索几百个文档也不现实
  • 检索精度要求:一个 100 页的文档,不可能作为一个向量整体检索------相关性会稀释
  • 成本控制:Token 就是成本,塞入过多无关上下文既浪费又降低质量

5.2 分割粒度的权衡

复制代码
太细(句子级)        ←→        太粗(章节级)
  检索精度高                         上下文完整
  上下文碎片化                       检索噪声大
  丢失跨句关系                       可能含无关内容

经验法则 :检索的"原子单位"应该是一个自包含的思想单元------能够独立被理解且包含足够上下文的最小段落。

5.3 Spring AI 的分割器策略

Spring AI 提供了 DocumentTransformer 接口以及开箱即用的 TokenTextSplitter

java 复制代码
// 基于 Token 的分割(推荐)
DocumentTransformer splitter = TokenTextSplitter.builder()
    .defaultMaxTokenSize(500)        // 每个分割块最多 500 token
    .minChunkSizeChars(350)          // 最小字符数
    .minOverlap(50)                  // 块间重叠(保持上下文连续性)
    .build();

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

分割参数调优指南:

场景 推荐 chunk size overlap 理由
问答型 FAQ 200-300 tokens 20-50 答案本身较短,精确匹配更重要
文档分析 500-1000 tokens 50-100 需要更多上下文来理解
代码检索 300-500 tokens 30-50 函数/类通常在此长度
法律条文 400-600 tokens 50-80 条款间有交叉引用
长文档摘要 800-1500 tokens 100-200 需要完整段落理解

5.4 高级分割策略

语义分割------不基于固定的 Token 数,而是基于内容的语义边界(段落、标题、列表):

java 复制代码
// 自定义语义分割器示例(概念)
List<Document> semanticChunks = documents.stream()
    .flatMap(doc -> splitByMarkdownHeaders(doc).stream())
    .flatMap(doc -> splitByParagraphs(doc).stream())
    .collect(Collectors.toList());

递归字符分割------从粗粒度到细粒度递归分割,直到块大小满足要求:

java 复制代码
// 递归分割:先按 ## 分割,再按段落分割,最后按句子分割
// Spring AI 的 TokenTextSplitter 内置了类似递归逻辑

实际建议:在实践中,最好根据你的文档特性定制分割策略。比如 Markdown 文档可以按标题层级分割,代码可以按函数或类分割,PDF 可以按页面或段落分割。没有万能的分割策略。


6. Embedding:将语义转化为向量

6.1 Embedding 的核心作用

Embedding 模型将文本映射到高维向量空间,使得语义相近的文本在向量空间中距离较近。

arduino 复制代码
"苹果发布了新款手机"      [0.23, -0.45, 0.78, ...]   ←→
"iPhone 16 正式发布"      [0.21, -0.42, 0.80, ...]   ← 语义相近,向量距离小
"天气预报说明天下雨"      [0.67, 0.12, -0.34, ...]   ← 语义无关,向量距离大

6.2 Spring AI 的 Embedding 抽象

java 复制代码
public interface EmbeddingModel {
    // 单条文本 embedding
    EmbeddingResponse embed(EmbeddingRequest request);
    
    // 批量 embedding(通常有优化)
    List<EmbeddingResponse> embed(List<EmbeddingRequest> requests);
    
    // 获取向量维度
    int dimensions();
}

6.3 Embedding 模型选型

Spring AI 支持多种 Embedding 实现:

Embedding 模型 维度 适用场景 Spring AI 支持
OpenAI text-embedding-3-small 1536 通用、英文为主
OpenAI text-embedding-3-large 3072 高精度需求
BAAI/bge-large-zh-v1.5 1024 中文场景 ✅ (ONNX)
Ollama (nomic-embed-text) 768 本地部署
Alibaba Qwen Embeddings 1024 中文/多语言

中文场景的现实考量:

java 复制代码
// 中文推荐:使用 BAAI/bge 系列或 Qwen Embeddings
@Bean
public EmbeddingModel embeddingModel() {
    // 方案1:本地部署(Ollama)
    return new OllamaEmbeddingModel(ollamaApi)
        .withModel("bge-m3");  // 支持中英文
    
    // 方案2:阿里通义千问
    return new TongyiEmbeddingModel(tongyiApi);
    
    // 方案3:OpenAI(英文为主场景)
    return new OpenAiEmbeddingModel(openAiApi)
        .withModel("text-embedding-3-small");
}

选择 Embedding 模型的关键维度:

  1. 语义理解能力:模型是否真正理解你所在领域的语义
  2. 维度大小:高维度更精确但计算和存储成本更高
  3. 语言支持:中文场景需要专门的中文 embedding 模型
  4. 延迟和吞吐:在线服务 vs 本地推理

6.4 Embedding 的最佳实践

java 复制代码
// 批量处理优于逐条处理(大多数模型支持 batch)
List<Document> batch = chunkedDocuments;
List<List<Double>> embeddings = embeddingModel.embed(batch); // 一次调用

// 缓存 embedding 结果(避免重复计算)
// 向量数据库通常会自动处理缓存

7. 向量数据库:RAG 的记忆存储

7.1 向量数据库 vs 传统数据库

向量数据库专门为向量相似性搜索优化,但不存在"完美"的向量数据库------只有适合你场景的。

7.2 Spring AI 的 VectorStore 实现

Spring AI 通过 VectorStore 接口抽象了所有向量数据库的操作:

java 复制代码
public interface VectorStore {
    void add(List<Document> documents);
    void delete(List<String> idList);
    List<Document> similaritySearch(SearchRequest request);
}

7.3 主流实现对比

解决方案 部署方式 适合规模 Spring AI 支持度 特点
PGVector 自托管 百万级 原生支持 PostgreSQL 插件,善用已有数据库
Redis Stack 自托管 百万级 原生支持 缓存+向量,低延迟
Pinecone SaaS 亿级 原生支持 全托管,无需运维
Chroma 嵌入式 十万级 原生支持 开发环境首选
Milvus 自托管 十亿级 原生支持 大规模分布场景
Qdrant 自托管 / SaaS 亿级 原生支持 Rust 实现,性能优秀
Elasticsearch 自托管 亿级 原生支持 全文检索+向量混合
Weaviate 自托管 / SaaS 亿级 原生支持 自带 schema 管理

7.4 生产选型建议

java 复制代码
// 开发环境:Chroma(零配置嵌入式)
@Bean
public VectorStore chromaVectorStore(EmbeddingModel embeddingModel) {
    return new ChromaVectorStore(chromaApi, embeddingModel, "collection_name");
}

// 中小规模生产:PGVector(复用 PostgreSQL 基础设施)
@Bean
public VectorStore pgVectorStore(EmbeddingModel embeddingModel, 
                                  JdbcTemplate jdbcTemplate) {
    return new PgVectorStore(jdbcTemplate, embeddingModel, 
        PgVectorStore.PgDistanceType.COSINE_DISTANCE, 
        PgIndexType.HNSW,  // HNSW 索引,支持 ANN 搜索
        false);             // 不移除现有文档
}

// 大规模生产:Pinecone(全托管)
@Bean
public VectorStore pineconeVectorStore(PineconeApi api, 
                                       EmbeddingModel embeddingModel) {
    return new PineconeVectorStore(api, embeddingModel, 
        "my-index", PineconeVectorStore.MetadataFields.NONE);
}

7.5 元数据过滤:提升检索精度的关键

纯向量搜索找到的是"语义相似"的文档。结合元数据过滤,可以找到"语义相似且符合条件"的文档。

java 复制代码
// Spring AI 中实现带过滤的检索
SearchRequest request = SearchRequest.builder()
    .query("2026年第一季度财报数据")     // 查询文本
    .topK(10)                                // 返回前10条
    .similarityThreshold(0.75)               // 相似度阈值
    .filterExpression("category == 'quarterly' && created_at > '2026-01-01'")
    .build();

List<Document> results = vectorStore.similaritySearch(request);

元数据过滤的核心价值

  • 权限控制:只检索用户有权访问的文档
  • 时效性控制:只检索特定时间范围内的信息
  • 分类过滤:只检索特定类别的文档
  • 混合搜索前置条件:缩小搜索范围后再执行向量搜索

8. 检索策略:如何找到最相关的信息

检索是 RAG 的核心环节。检索质量直接决定了生成质量。

8.1 基础检索:向量相似性搜索

最简单的检索方式是余弦相似度搜索:

java 复制代码
// 余弦距离:1 - cos(A, B)
// 值域 [0, 2],越小越相似
PgVectorStore.PgDistanceType.COSINE_DISTANCE

// 内积距离:常用于归一化向量
// 值域 [-1, 1],越大越相似
PgVectorStore.PgDistanceType.NEGATIVE_INNER_PRODUCT

// 欧几里得距离:适合低维向量
PgVectorStore.PgDistanceType.EUCLIDEAN_DISTANCE

经验结论 :对于文本 Embedding,余弦相似度通常是默认选择。如果使用 OpenAI Embedding(已经 L2 归一化),内积和余弦等价。

8.2 Top-K 策略

java 复制代码
// K 值的影响
K = 1-3:   高精度,低召回(严谨问答场景)
K = 5-10:  适中(通用知识问答)
K = 10-20: 高召回,低精度(摘要、综合分析场景)

动态 Top-K:根据问题的复杂度动态调整 K 值。

java 复制代码
// 概念:问题越复杂,需要的上下文越多
int dynamicTopK(String question) {
    int words = question.split(" ").length;
    int tokens = estimateTokens(question);
    if (tokens < 10) return 3;    // 简单问题
    if (tokens < 50) return 5;    // 一般问题
    return 10;                     // 复杂问题
}

8.3 相似度阈值

只保留超过相似度阈值的文档,避免引入噪音:

java 复制代码
SearchRequest request = SearchRequest.builder()
    .query(question)
    .topK(20)
    .similarityThreshold(0.7)  // 低于 0.7 的结果将被丢弃
    .build();

合理的阈值通常通过实验确定。先用 0.7 作为起点,根据实际效果上浮或下调。

8.4 Spring AI 的 Advisor 机制

Spring AI 通过 Advisor 模式实现灵活的检索增强控制:

java 复制代码
// 1. QuestionAnswerAdvisor:最简单的 RAG
// 自动执行检索并将结果注入 Prompt
.defaultAdvisors(new QuestionAnswerAdvisor(vectorStore))

// 2. RetrievalAugmentationAdvisor:更灵活的配置
.defaultAdvisors(RetrievalAugmentationAdvisor.builder()
    .documentRetriever(DefaultDocumentRetriever.builder()
        .vectorStore(vectorStore)
        .similarityThreshold(0.75)
        .topK(5)
        .build())
    .contentFormatter(DefaultContentFormatter.builder()
        .withTemplate("""
            上下文信息:
            ------------
            {documents}
            ------------
            请基于上述上下文回答以下问题。
            """)
        .build())
    .build())

8.5 高级检索策略

查询重写(Query Rewriting)------用户的问题往往不适合直接用于检索。比如"能告诉我这个怎么用?"中的"这个"是代词,需要解析。通过 LLM 将用户问题改写成更适合检索的形式。

arduino 复制代码
用户问题: "去年的营收怎么样?"
重写后: "2025年公司年度营收数据"

查询分解(Query Decomposition)------将复杂问题分解为多个子问题分别检索:

arduino 复制代码
用户问题: "苹果和微软2025年的营收对比"
子问题1: "苹果公司2025年营收是多少"
子问题2: "微软公司2025年营收是多少"

HyDE(Hypothetical Document Embedding)------先生成一个假设的理想文档,再基于这个假设文档检索:

makefile 复制代码
用户问题: "什么是量子计算?"
假设文档: "量子计算是一种利用量子力学原理进行信息处理的计算方式..."
检索: 用假设文档的 embedding 去匹配真实文档

这些高级策略在 Spring AI 中可以通过自定义 Advisor 或 Processor 实现。框架提供了扩展点,策略本身需要开发者根据场景实现。


9. 增强生成:让 LLM 基于上下文作答

9.1 Prompt 模板设计

检索到的文档如何呈现给 LLM 是影响生成质量的关键因素。

java 复制代码
// 自定义上下文模板
ContentFormatter formatter = DefaultContentFormatter.builder()
    .withTemplate("""
        你是一个专业的AI助手。
        
        ## 上下文信息
        
        以下是与你需要回答的问题相关的参考信息。请仔细阅读这些信息,
        它们来自我们公司的知识库:
        
        {% for document in documents %}
        [来源:{{ document.metadata.source }}]
        {{ document.content }}
        
        {% endfor %}
        
        ## 回答要求
        
        1. 如果上下文信息充分,请基于上下文给出准确、详细的回答
        2. 如果上下文信息不足,请明确说明"根据现有信息无法回答"
        3. 回答时请在句末标注信息来源:[[来源:xxx]]
        4. 不要编造上下文之外的信息
        5. 使用专业、清晰的语言
        
        ## 用户问题
        
        {{ userInput }}
        """)
    .build();

9.2 Prompt 注入的工作流程

Spring AI 的 QuestionAnswerAdvisor 内部执行以下步骤:

markdown 复制代码
1. 接收用户请求
2. 调用 VectorStore.similaritySearch() 检索相关文档
3. 将检索结果注入 Prompt(通过 ContentFormatter)
4. 构造增强后的 Prompt:
   System: [原始 System Message + 检索到的上下文]
   User: [用户问题]
5. 调用 ChatModel
6. 返回基于上下文的回答

9.3 流式输出与 RAG

java 复制代码
// 流式 RAG 响应
@Bean
public ChatClient streamingChatClient(ChatClient.Builder builder) {
    return builder
        .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore))
        .build();
}

// Controller
@GetMapping("/chat/stream")
public Flux<String> streamChat(@RequestParam String question) {
    return streamingChatClient.prompt()
        .user(question)
        .stream()
        .content();
}

9.4 引用溯源

在生产系统中,告诉用户答案的来源是建立信任的关键:

java 复制代码
// 在 Prompt 中要求标注来源
// 同时在后处理中解析引用

@Service
public class CitedRagService {
    
    public AnswerWithSources answer(String question) {
        String response = chatClient.prompt()
            .user(question)
            .call()
            .content();
        
        // 解析引用来源(从元数据中提取)
        List<String> sources = extractSources(response);
        
        return new AnswerWithSources(response, sources);
    }
    
    private List<String> extractSources(String response) {
        // 从 LLM 输出中解析 [[来源: xxx]] 格式的引用
        Pattern pattern = Pattern.compile("\\[\\[来源:(.+?)\\]\\]");
        Matcher matcher = pattern.matcher(response);
        return matcher.results()
            .map(r -> r.group(1))
            .distinct()
            .collect(Collectors.toList());
    }
}

public record AnswerWithSources(
    String answer,
    List<String> sources
) {}

10. 高级 RAG 模式

10.1 多轮对话中的 RAG

单轮 RAG 相对简单,多轮对话中的 RAG 则面临更多挑战:

挑战 1:上下文消歧

arduino 复制代码
用户: "什么是量子计算?"
助手: "量子计算是..."
用户: "它和传统计算有什么区别?"
      ↑ 这里的"它"指的是量子计算,需要从对话历史推断

解决方案:上下文压缩(Contextual Compression)

java 复制代码
// 在检索前,将对话历史压缩为最新的上下文
// 或者让 LLM 将用户问题补全为独立问题

@Component
public class ContextualQueryEnricher {
    
    public String enrichQuery(String currentQuestion, 
                               List<Message> history) {
        if (history.isEmpty()) {
            return currentQuestion;
        }
        
        // 使用 LLM 将问题补全
        String enriched = chatClient.prompt()
            .system("""
                根据对话历史,将用户的最后一句话改写为可以独立检索的查询。
                只需要返回改写后的查询,不要有其他内容。
                """)
            .user("""
                历史对话:
                %s
                
                用户最新问题:%s
                """.formatted(formatHistory(history), currentQuestion))
            .call()
            .content();
        
        return enriched != null ? enriched : currentQuestion;
    }
}

挑战 2:历史信息的累积

随着对话进行,历史消息越来越多。如果不加控制:会超出上下文窗口、检索噪音增加、成本线性增长。

解决方案:滑动窗口和摘要化

10.2 多路检索(Multi-Route Retrieval)

结合多种检索策略,综合利用各自优势:

markdown 复制代码
用户问题
    │
    ├──→ 向量检索(语义匹配)
    ├──→ 关键词检索(精确匹配,如 Elasticsearch)
    ├──→ 图检索(实体关系,如 Neo4j)
    └──→ SQL 查询(结构化数据)
            │
            └──→ 融合排序 → 最终上下文

Spring AI 实现思路

java 复制代码
@Component
public class HybridRetriever {
    
    private final VectorStore vectorStore;
    private final ElasticsearchRestClient esClient;
    
    public List<Document> retrieve(String query, int topK) {
        // 1. 向量检索
        List<Document> vectorResults = vectorStore.similaritySearch(
            SearchRequest.builder().query(query).topK(topK).build());
        
        // 2. 关键词检索
        List<Document> keywordResults = keywordSearch(query, topK);
        
        // 3. 融合排序(Reciprocal Rank Fusion)
        return reciprocalRankFusion(vectorResults, keywordResults, topK);
    }
    
    private List<Document> reciprocalRankFusion(
            List<Document>... rankings) {
        // RRF: score = Σ 1/(k + rank)
        // 融合多路检索结果,被多路都命中的文档排名更高
        // ...
    }
}

10.3 RAPTOR:层次化摘要检索

RAPTOR(Recursive Abstractive Processing for Tree-Organized Retrieval)是一种将文档构建成层次化摘要树的方法。

css 复制代码
            ┌─────────────┐
            │ 全局摘要      │  ← Level 2(最抽象)
            └──────┬──────┘
                   │
         ┌─────────┼─────────┐
         │         │         │
   ┌────┴───┐ ┌───┴────┐ ┌──┴────┐
   │摘要集群A │ │摘要集群B │ │摘要集群C │  ← Level 1
   └────┬───┘ └───┬────┘ └───┬───┘
        │         │          │
   ┌────┴───┐    ...         ...
   │原始块A1 │
   │原始块A2 │
   └────────┘                   ← Level 0(原始文档块)

检索时,从顶层开始逐层匹配,找到最合适的粒度。这种模式在处理超长文档(如一本书或一份研究报告)时特别有效。

10.4 Self-RAG:让 LLM 自我评估

Self-RAG 是一种让 LLM 在生成过程中自我反思的范式:

markdown 复制代码
1. 检索:根据问题检索相关文档
2. 评估:判断检索到的文档是否与问题相关
   - 相关 → 基于文档生成答案
   - 不相关 → 拒绝回答或请求补充信息
3. 事实核查:检查生成的答案是否基于检索到的文档
4. 最终输出:如果有冲突,修正或标注不确定性

Spring AI 中可以通过自定义 Advisor 实现 Self-RAG 的核心逻辑。


11. Spring AI 的 RAG 生产化实践

11.1 完整的 RAG 管线

一个生产级的 RAG 系统通常包含以下步骤:

java 复制代码
// 索引管线(IndexingPipeline)
@Configuration
public class IndexingPipelineConfig {
    
    private final EmbeddingModel embeddingModel;
    private final VectorStore vectorStore;
    
    @Bean
    public CommandLineRunner indexDocuments(
            @Value("${documents.path}") String docPath) {
        return args -> {
            // 1. 扫描文档
            List<File> files = scanDocuments(docPath);
            
            for (File file : files) {
                // 2. 文档加载
                DocumentReader reader = createReader(file);
                List<Document> documents = reader.read();
                
                // 3. 文档分割
                DocumentTransformer splitter = TokenTextSplitter.builder()
                    .defaultMaxTokenSize(500)
                    .minOverlap(50)
                    .build();
                List<Document> chunks = splitter.apply(documents);
                
                // 4. 向量化并存储
                vectorStore.add(chunks);
                
                log.info("Indexed: {} ({} chunks)", file.getName(), chunks.size());
            }
        };
    }
    
    private DocumentReader createReader(File file) {
        String name = file.getName().toLowerCase();
        Resource resource = new FileSystemResource(file);
        
        if (name.endsWith(".pdf")) return new PdfDocumentReader(resource);
        if (name.endsWith(".md"))  return new MarkdownDocumentReader(resource);
        if (name.endsWith(".csv")) return new CsvDocumentReader(resource);
        // 扩展更多格式
        throw new UnsupportedOperationException("Unsupported format: " + name);
    }
}

11.2 增量索引

生产环境中,文档会持续更新。全量重建索引成本太高,需要增量索引策略:

java 复制代码
@Service
public class IncrementalIndexingService {
    
    private final VectorStore vectorStore;
    private final DocumentIdStrategy idStrategy;
    
    // 文档更新时调用
    public void updateDocument(String docId, String newContent) {
        // 1. 删除旧索引
        vectorStore.delete(List.of(docId));
        
        // 2. 重新分割和索引
        Document doc = Document.builder()
            .id(docId)
            .content(newContent)
            .metadata(Map.of("updated_at", Instant.now().toString()))
            .build();
        
        List<Document> chunks = splitter.apply(List.of(doc));
        vectorStore.add(chunks);
    }
    
    // 监听文件变化(适用于本地文件)
    @EventListener
    public void onFileChange(FileChangeEvent event) {
        updateDocument(event.getFileId(), event.getContent());
    }
}

11.3 缓存策略

RAG 系统中,相同或相似的问题往往会被反复问到。缓存可以有效降低延迟和成本:

java 复制代码
@Configuration
public class RagCacheConfig {
    
    @Bean
    public CacheManager ragCacheManager() {
        // 使用 Caffeine 作为本地缓存
        CaffeineCacheManager cacheManager = new CaffeineCacheManager("rag-cache");
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(1, TimeUnit.HOURS)
            .recordStats());
        return cacheManager;
    }
    
    @Bean
    public ChatClient cachedChatClient(ChatClient.Builder builder) {
        return builder
            .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore))
            .build();
    }
}

@Service
public class CachedRagService {
    
    @Cacheable(value = "rag-cache", key = "#question")
    public String ask(String question) {
        return chatClient.prompt()
            .user(question)
            .call()
            .content();
    }
}

11.4 异步索引和批量处理

java 复制代码
@Service
public class AsyncIndexingService {
    
    private final Executor executor = Executors.newVirtualThreadPerTaskExecutor();
    
    @Async
    public CompletableFuture<Void> indexDocumentAsync(MultipartFile file) {
        return CompletableFuture.runAsync(() -> {
            // 异步处理文档索引
            indexDocument(file);
        }, executor);
    }
    
    // 批量索引
    public void batchIndex(List<File> files) {
        List<CompletableFuture<Void>> futures = files.stream()
            .map(file -> CompletableFuture.runAsync(() -> indexDocument(file), executor))
            .toList();
        
        // 等待所有完成
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
            .join();
    }
}

11.5 API 设计

java 复制代码
@RestController
@RequestMapping("/api/rag")
public class RagController {
    
    private final RagService ragService;
    
    // 标准问答
    @PostMapping("/ask")
    public ResponseEntity<RagResponse> ask(@RequestBody @Valid RagRequest request) {
        RagResponse response = ragService.ask(request);
        return ResponseEntity.ok(response);
    }
    
    // 流式问答
    @PostMapping(value = "/ask/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> askStream(@RequestBody RagRequest request) {
        return ragService.askStream(request)
            .map(content -> ServerSentEvent.<String>builder()
                .data(content)
                .build());
    }
    
    // 带引用来源的问答
    @PostMapping("/ask/cited")
    public ResponseEntity<CitedResponse> askWithCitations(
            @RequestBody @Valid RagRequest request) {
        CitedResponse response = ragService.askWithCitations(request);
        return ResponseEntity.ok(response);
    }
    
    // 文档上传和索引
    @PostMapping("/documents/upload")
    public ResponseEntity<Void> uploadDocument(
            @RequestParam("file") MultipartFile file) {
        ragService.indexDocument(file);
        return ResponseEntity.accepted().build();
    }
}

public record RagRequest(
    @NotBlank String question,
    @Min(1) @Max(20) Integer topK,
    Double similarityThreshold,
    String filterExpression
) {}

public record RagResponse(
    String answer,
    long processingTimeMs
) {}

public record CitedResponse(
    String answer,
    List<Source> sources,
    long processingTimeMs
) {
    public record Source(String title, String content, double score) {}
}

12. 评估与持续优化

12.1 RAG 评估框架

评估 RAG 系统需要从两个维度进行:

检索质量

  • 命中率(Hit Rate): 检索结果中是否包含正确答案
  • 平均倒数排名(MRR): 正确答案在结果中的位置
  • NDCG(归一化折损累计增益): 排序质量的综合指标

生成质量

  • 忠实度(Faithfulness): 答案是否基于检索到的上下文,而非幻觉
  • 答案相关性(Answer Relevance): 答案是否回答了问题
  • 上下文精度(Context Precision): 检索结果中的噪音比例

Spring AI 本身不内置评估工具,但可以集成 Spring Boot Actuator 和应用监控:

java 复制代码
@Component
public class RagMetricsCollector {
    
    private final MeterRegistry meterRegistry;
    
    public void recordQuery(String question, RagResponse response) {
        // 记录查询延迟
        meterRegistry.timer("rag.query.latency")
            .record(response.processingTimeMs(), TimeUnit.MILLISECONDS);
        
        // 记录检索结果数量
        meterRegistry.gauge("rag.retrieval.topk", response.sources().size());
        
        // 记录平均相似度分数
        double avgScore = response.sources().stream()
            .mapToDouble(Source::score)
            .average()
            .orElse(0.0);
        meterRegistry.gauge("rag.retrieval.avg_score", avgScore);
    }
}

12.2 RAGAS 评估框架集成

RAGAS(Retrieval Augmented Generation Assessment)是专门评估 RAG 系统的框架。虽然它不是 Spring AI 原生组件,但可以作为独立服务集成。

12.3 常见的 RAG 失败模式

css 复制代码
失败模式              表现形式                    解决方案
───────              ────────                    ────────
检索失败              返回了完全不相关的文档        调整分割策略、embedding模型、检索参数
上下文窗口溢出          Token 使用超过限制          减小 Top-K、压缩上下文
幻觉                  答案包含检索结果中没有的信息   优化 Prompt、增加 faithfulness 检查
引用错误               标注了错误的来源              加强引用解析逻辑
更新延迟               文档已修改但索引未更新         实现增量索引机制
冷启动问题              新知识库没有高质量检索结果     预置种子数据,逐步优化嵌入

13. 总结与展望

13.1 RAG 的核心原则

回顾全文,RAG 成功的关键可以归结为几点:

  1. 数据质量决定了 RAG 的天花板 --- 花最多的精力在数据清洗、分割和元数据标注上
  2. 检索策略比模型选择更重要 --- 一个中等水平的 LLM + 优秀的检索机制,远好于一个顶级 LLM + 糟糕的检索
  3. 评估驱动优化 --- 不测量就无法改进。建立 RAG 评估指标(RAGAS)是上线前最重要的工作
  4. RAG + Fine-tuning 是最佳组合 --- 两者解决不同层次的问题,合力才能达到最佳效果
  5. 监控和反馈闭环 --- 生产环境中的 RAG 需要持续的监控、日志分析和用户反馈循环来不断提升

13.2 Spring AI 的优势

Spring AI 在 RAG 领域的独特价值在于:

  • 完整的抽象层 --- 切换向量数据库、Embedding 模型、LLM 提供商时只需修改配置
  • 与 Spring 生态深度融合 --- 事务管理、缓存、异步处理、监控、安全等开箱即用
  • 模块化设计 --- Advisor 机制允许灵活组合 RAG 策略
  • 企业级特性 --- 生产化部署所需的一切都已经在 Spring 生态中

13.3 未来趋势

RAG 技术仍在快速演进,值得关注的方向包括:

  • Agentic RAG --- RAG 系统从被动检索转变为主动推理,如自动判断是否需要补充检索
  • Graph RAG --- 利用知识图谱的结构化关系增强检索的相关性和可解释性
  • 多模态 RAG --- 不仅检索文本,还能检索图像、表格、音频等多种模态
  • RAG + CAG --- 当上下文窗口足够大时,Cache-Augmented Generation 将部分高频的 RAG 查询转化为预加载的缓存
  • 长期记忆 --- RAG 从"一次性检索"转向具有记忆能力的持续学习系统

13.4 写在最后

RAG 不是银弹,它有自己的局限和适用边界。但它确实解决了 LLM 落地中最关键的问题------让模型能够引用外部知识来回答问题。在 Spring AI 的加持下,构建一个生产级的 RAG 系统已经从一件复杂的工作变成了一个标准化的工程实践。

关键在于理解每一层抽象背后的原理,知道何时使用默认配置、何时需要自定义扩展。希望本文能帮助你构建出真正可用的 RAG 系统。


本文基于 Spring AI 1.0+ 版本编写。框架版本迭代可能带来 API 变化,请以官方文档为准。

相关推荐
颜进强1 小时前
Embedding 模型介绍:热门模型对比与应用场景
前端·后端·ai编程
颜进强1 小时前
LanceDB 基础使用:用 TypeScript 完成第一次向量检索
前端·后端·ai编程
颜进强1 小时前
Ollama 从入门到实践:本地模型运行、API 调用
前端·后端·ai编程
颜进强1 小时前
Embedding 基础使用:用 Ollama 和 LangChain.js 生成文本向量
前端·后端·ai编程
颜进强1 小时前
Vector Store 入门:什么是向量数据库,主流产品如何选择
前端·后端·ai编程
AI编程实验室1 小时前
Agent Skills 实战第三课:从规格到工单,别再按前后端拆任务
ai编程
AI大模型-小雄2 小时前
ChatGPT Plus 够不够用?从5种开发场景判断是否需要 Pro
chatgpt·ai编程·开发工具·codex·chatgpt plus·chatgpt pro
canonical_entropy2 小时前
Mission Driver:Loop Engineering 的一种通用参考实现
后端·aigc·ai编程