概述:

RAG(Retrieval-Augmented Generation)是 Spring AI 中最核心的工程化模式——将私有知识库与 LLM 结合,让大模型能够基于企业内部文档回答问题。Spring AI 把 RAG 抽象成了四个标准阶段:

  1. 文档加载(Document Loading):从文件系统、数据库等来源读取原始文档
  2. 文档转换(Document Transformation):切分、清洗、元数据提取
  3. 向量入库(Vector Ingestion):Embedding 向量化 + 写入 VectorStore
  4. 检索增强(Retrieval Augmentation):相似度搜索 + 上下文注入 Prompt → LLM 生成

DocumentReader

DocumentSplitter

EmbeddingModel

VectorStore.add

QuestionAnswerAdvisor

注入 System Prompt

原始文档

Document 列表

Document Chunks

向量

向量数据库

用户提问

相似度搜索

ChatModel

增强回答

RAG 的本质是把"检索"变成 LLM 可理解的"上下文",让模型从"凭记忆回答"变成"照着材料回答"。

一、 文档加载:DocumentReader 体系结构

整个 RAG 管道的入口就是 DocumentReader 接口,设计极简:

public interface DocumentReader {
    List<Document> get();
}

一个 get() 方法,返回 Document 列表。Document 包含两部分核心数据:

  • content(String):文档的文本内容
  • metadata(Map):文档的元数据(文件名、页码、作者等)

Spring AI 内置了六种 Reader 实现:

实现类 适用格式 底层引擎 典型场景
TextReader 纯文本 .txt Java NIO 日志、配置文件
JsonReader JSON 文件 Jackson API 文档、结构化数据
PagePdfDocumentReader PDF 文本型 Apache PDFBox 产品手册、技术白皮书
TikaDocumentReader 几乎所有格式 Apache Tika 多格式混合的文档库
TokenizingDocumentReader 任意文本 内置 Tokenizer 需要按 token 粒度读取
MarkdownDocumentReader Markdown 文件 内置解析器 技术文档、README

TikaDocumentReader:万能格式解析器

TikaDocumentReader 是最常用的 Reader,它利用 Apache Tika 的自动格式检测能力,一个类搞定 PDF、Word、HTML、Excel 等数十种格式:

public class TikaDocumentReader implements DocumentReader {
    private final Resource resource;

    @Override
    public List<Document> get() {
        try (InputStream inputStream = resource.getInputStream()) {
            BodyContentHandler handler = new BodyContentHandler(-1);  // -1 禁用大小限制
            AutoDetectParser parser = new AutoDetectParser();
            Metadata metadata = new Metadata();
            parser.parse(inputStream, handler, metadata);

            Map<String, Object> docMetadata = extractMetadata(metadata);
            return List.of(new Document(handler.toString(), docMetadata));
        } catch (Exception e) {
            throw new DocumentReadException("Failed to read document: " 
                + resource.getFilename(), e);
        }
    }
}

关键细节BodyContentHandler(-1) 中的 -1 参数禁用了 Tika 默认的内容大小限制(默认 100KB)。如果不设置这个,大文档会被静默截断——这个坑排查了很久才定位到。

实践踩坑:PDF 扫描件(图片型 PDF)Tika 解析出来是空字符串。这是因为 Tika 只能提取文本层,无法 OCR 识别图片中的文字。解决方案有两个:一是预处理时用 OCR 工具(如 Tesseract)将扫描件转为可搜索 PDF;二是在 Reader 层做二次处理,检测到空内容时自动调用 OCR。

自定义 Reader 实践

实际项目中,通常封装一层,根据文件扩展名选择最合适的 Reader:

public DocumentReader createReader(Resource resource) {
    String filename = resource.getFilename().toLowerCase();
    if (filename.endsWith(".pdf")) {
        return new PagePdfDocumentReader(resource);
    }
    if (filename.endsWith(".json")) {
        return new JsonReader(resource);
    }
    return new TikaDocumentReader(resource);  // 兜底方案
}

文档切分:DocumentSplitter 与智能断点算法

为什么需要切分

Embedding 模型的输入有严格限制——主流的 text-embedding-3-small 最大 8192 token,bge-large-zh 仅 512 token。一篇 50 页的 PDF 可能包含数万 token,直接 Embedding 会被截断。因此必须在 Embedding 之前将文档切分为大小合适的 “chunk”。

public interface DocumentSplitter {
    List<Document> split(List<Document> documents);
}

TokenTextSplitter 核心实现

public class TokenTextSplitter implements DocumentSplitter {
    public static final int DEFAULT_CHUNK_SIZE = 800;
    public static final int DEFAULT_CHUNK_OVERLAP = 200;

    private final int chunkSize;
    private final int chunkOverlap;

    @Override
    public List<Document> split(List<Document> documents) {
        List<Document> result = new ArrayList<>();
        for (Document doc : documents) {
            List<String> chunks = splitText(doc.getContent());
            for (int i = 0; i < chunks.size(); i++) {
                Map<String, Object> chunkMetadata = new HashMap<>(doc.getMetadata());
                chunkMetadata.put("chunk_index", i);
                chunkMetadata.put("chunk_total", chunks.size());
                chunkMetadata.put("source_doc", doc.getId());
                result.add(new Document(chunks.get(i), chunkMetadata));
            }
        }
        return result;
    }
}

关键元数据:每个 chunk 都保留了 chunk_indexchunk_totalsource_doc。这在调试检索效果时至关重要——如果某段内容检索不出来,可以快速定位到它在原始文档中的位置。

智能断点算法

简单按字符数切割会在句子中间截断,导致语义碎片化。TokenTextSplitter 实现了自然断点查找:

private List<String> splitText(String text) {
    List<String> chunks = new ArrayList<>();
    int start = 0;
    while (start < text.length()) {
        int end = Math.min(start + chunkSize, text.length());
        if (end < text.length()) {
            // 在 chunk 边界 ±50 字符范围内寻找自然断点
            int breakPoint = findNaturalBreak(text, Math.max(start, end - 50), 
                Math.min(text.length(), end + 50));
            if (breakPoint > 0) {
                end = breakPoint;
            }
        }
        chunks.add(text.substring(start, end).trim());
        start = end - chunkOverlap;  // 重叠区域保证跨 chunk 语义连贯
    }
    return chunks;
}

private int findNaturalBreak(String text, int from, int to) {
    // 优先级:段落分隔(双换行)> 句号/问号/感叹号 > 换行 > 逗号
    for (int i = to; i >= from; i--) {
        char c = text.charAt(i);
        if (c == '\n' && i > 0 && text.charAt(i - 1) == '\n') return i;
    }
    for (int i = to; i >= from; i--) {
        char c = text.charAt(i);
        if (c == '。' || c == '?' || c == '!' || c == '.') return i + 1;
    }
    return -1;  // 找不到自然断点,就用硬截断
}

断点优先级:段落分隔 > 句子结尾标点 > 换行 > 逗号。这个优先级设计保证了每个 chunk 的语义完整性。

Chunk 参数调优经验

chunkSize chunkOverlap 适用场景 效果评价
500 100 短文本、FAQ chunk 太小,语义不完整,检索精度偏低
800 200 技术文档(默认) 大多数场景的最优解
1000 250 法律文书、学术论文 单句较长时保证完整性
1500 300 综述类长文 可能超出 bge-small 等模型的输入限制

Embedding 转换:从文本到向量

切分后的 chunk 需要经过 EmbeddingModel 转为向量才能存入向量数据库:

public interface EmbeddingModel {
    EmbeddingResponse embed(EmbeddingRequest request);
    List<float[]> embed(List<Document> documents);
    
    default float[] embed(String text) {
        return embed(List.of(new Document(text))).get(0);
    }
}

Embedding 的本质是将文本映射到一个高维空间中的点——语义相似的文本在空间中距离相近。Spring AI 支持 OpenAI、Azure、HuggingFace、Ollama 等十余种 Embedding 模型接入。

// 实际使用:批量 Embedding 提升吞吐
List<Document> chunks = splitter.split(reader.get());
List<float[]> vectors = embeddingModel.embed(chunks);

// 向量维度取决于模型:OpenAI text-embedding-3-small → 1536维
//                      bge-large-zh → 1024维

性能注意embed(List<Document>) 批量方法远比循环调用 embed(String) 高效,因为 Embedding API 通常支持批量输入,网络往返次数从 N 次降为 1 次。

二、向量入库:VectorStore 写入与检索

public interface VectorStore {
    void add(List<Document> documents);
    List<Document> similaritySearch(SearchRequest request);
    List<Document> similaritySearch(String query);
}

Spring AI 内置了 20+ 种 VectorStore 实现,覆盖主流向量数据库:

实现类 向量数据库 适用场景
PgVectorStore PostgreSQL + pgvector 已有 PG 基础设施的团队
RedisVectorStore Redis Stack 低延迟、高并发检索
ChromaVectorStore Chroma 轻量级、快速原型开发
MilvusVectorStore Milvus 十亿级向量规模
SimpleVectorStore 内存 Map 开发测试、小数据集 Demo

相似度检索的内部流程

// SearchRequest 精细化控制
SearchRequest request = SearchRequest.builder()
    .query("Spring AI 支持哪些模型?")
    .topK(5)                       // 返回最相似的 5 个文档
    .similarityThreshold(0.75)     // 相似度阈值,低于此值的结果丢弃
    .filterExpression("type == 'manual'")  // 元数据过滤
    .build();

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

similarityThreshold 的重要性:不设阈值时,即使知识库中没有相关内容,向量数据库也会返回"最相似"的结果——相似的噪声。设 0.75 阈值后,相关性不足的结果直接被过滤,避免 LLM 被不相关内容误导。

检索增强:QuestionAnswerAdvisor 核心机制

这是整个 RAG 管道中最关键的一环——将检索到的文档注入 LLM 的 System Prompt,实现"带证据的回答"。

public class QuestionAnswerAdvisor implements CallAroundAdvisor {
    private final VectorStore vectorStore;
    private final SearchRequest searchRequest;
    private static final int DEFAULT_ORDER = 0;

    private static final String DEFAULT_CONTEXT_TEMPLATE = """
        以下是可能对回答问题有帮助的上下文信息:
        ---------------------
        {context}
        ---------------------
        请基于以上上下文信息回答用户的问题。
        如果上下文信息不足以回答问题,请如实告知。
        """;

    @Override
    public ChatResponse aroundCall(
            AdvisorSupport advisorSupport,
            CallAroundAdvisorChain chain) {

        // 1. 提取用户原始问题
        String userText = extractUserText(advisorSupport.getMessages());

        // 2. 构建搜索请求并执行相似度检索
        SearchRequest request = searchRequest.query(userText);
        List<Document> documents = documentRetriever.retrieve(request);

        // 3. 拼接检索到的文档内容作为上下文
        String context = documents.stream()
            .map(Document::getContent)
            .collect(Collectors.joining("\n---\n"));

        // 4. 将上下文注入 System Prompt
        String systemPrompt = DEFAULT_CONTEXT_TEMPLATE
            .replace("{context}", context);

        // 5. 构建增强后的消息列表
        List<Message> enhancedMessages = new ArrayList<>();
        enhancedMessages.add(new SystemMessage(systemPrompt));
        advisorSupport.getMessages().stream()
            .filter(m -> m.getMessageType() != MessageType.SYSTEM)
            .forEach(enhancedMessages::add);

        advisorSupport.setMessages(enhancedMessages);
        return chain.next(advisorSupport);
    }
}

三个关键设计决策

1. 上下文注入位置:为什么是 System 消息?

RAG Advisor 将检索结果放在 System 消息而非 User 消息中。这是有意的设计——LLM 对 System 消息的遵从度远高于 User 消息。放在 System 消息里,模型会把上下文当作"必须遵守的事实依据";放在 User 消息里,模型则可能将其视为"仅供参考的信息"。

2. "如实告知"为何不是摆设?

"如果上下文信息不足以回答问题,请如实告知。"

这行 Prompt 是防幻觉的关键。实测去掉这行后,LLM 在检索结果不充分时会"强行编造"——用自己的训练数据补充答案,造成看似正确实则虚假的回复。加上后,模型更倾向于说"抱歉,当前文档中没有相关信息"。

3. 已有 System 消息的覆盖策略

filter(m -> m.getMessageType() != MessageType.SYSTEM) 将原始 System 消息过滤掉,用 RAG 上下文消息替代。如果你的应用需要同时保留原始 System 指令(如角色设定)和 RAG 上下文,需要自定义 Advisor,将两者合并而不是直接覆盖。

DocumentRetriever:检索策略抽象

Spring AI 还抽象出了 DocumentRetriever 接口,将"从哪检索"与"如何检索"解耦:

public interface DocumentRetriever {
    List<Document> retrieve(SearchRequest request);
}

VectorStore 本身实现了这个接口,但你也可以实现自定义的 Retriever——比如先查缓存、没命中再查向量数据库,或者同时从多个 VectorStore 聚合结果。

三、Advisor 责任链:多 Advisor 协作模式

Spring AI 的 Advisor 机制基于经典的责任链模式(Chain of Responsibility),多个 Advisor 可以按顺序叠加:

public interface CallAroundAdvisor extends Ordered {
    ChatResponse aroundCall(
        AdvisorSupport advisorSupport,
        CallAroundAdvisorChain chain);
}

public class CallAroundAdvisorChain {
    private final List<CallAroundAdvisor> advisors;
    private final ChatModel chatModel;
    private int currentIndex = 0;

    public ChatResponse next(AdvisorSupport advisorSupport) {
        if (currentIndex < advisors.size()) {
            return advisors.get(currentIndex++).aroundCall(advisorSupport, this);
        }
        // 所有 Advisor 执行完毕,最终调用 ChatModel
        return chatModel.call(advisorSupport.getPrompt());
    }
}

多 Advisor 调用链示意

RAGAdvisor → SafetyAdvisor → HistoryAdvisor → LoggingAdvisor → ChatModel.call()
  (注入上下文)   (安全检查)     (对话历史)      (日志记录)        (最终调用)

自定义 Advisor 实践:可以项目中实现了一个 PermissionAdvisor,在 System 消息中注入当前用户的权限范围:

public class PermissionAdvisor implements CallAroundAdvisor {
    @Override
    public ChatResponse aroundCall(
            AdvisorSupport support, CallAroundAdvisorChain chain) {
        
        String userPermissions = SecurityContextHolder.getContext()
            .getAuthentication().getAuthorities().toString();
        
        String permissionHint = "当前用户只能访问以下权限范围内的信息:" + userPermissions;
        support.getMessages().add(0, new SystemMessage(permissionHint));
        return chain.next(support);
    }
}

这样 LLM 就能基于用户权限来过滤回答内容,避免越权访问。

四、实践建议与调优经验

基于项目实战经验,总结 RAG 调优关键点:

  • Chunk 大小是检索质量的基石:800 token 是技术文档的甜点值。chunk 太大检索不准,太小语义不完整。建议根据实际文档类型做 A/B 测试,而不是盲从默认值。
  • 相似度阈值不可省略similarityThreshold(0.75) 能有效过滤不相关内容。没有阈值时,即使知识库中不存在答案,向量数据库也会返回"相似度最高"的噪声,LLM 会基于这些噪声强行编造。
  • Embedding 模型与 Chunk 大小要匹配bge-large-zh 最大支持 512 token,给它 1000 token 的 chunk 会被静默截断。先用模型文档确认输入限制,再设定 chunkSize。
  • 重叠区域(overlap)防止边界信息丢失:文档在 chunk 边界处的内容容易"断裂"。200 token 的 overlap 保证了边界信息的跨 chunk 覆盖。
  • 扫描件 PDF 需要预处理:Tika 无法提取图片型 PDF 的文字。建议在入库前用 OCR 工具预处理,或在管道中增加扫描件检测 + OCR 环节。
  • 批量 Embedding 而非逐条调用embeddingModel.embed(List<Document>) 比循环调用效率高数倍,减少网络往返。
  • 元数据是调试利器:每个 chunk 保留 chunk_indexsource_doc 等元数据。当检索效果不理想时,能快速定位问题出在哪个文档的哪一段。

总结

RAG 管道的核心是将"检索"与"生成"解耦,通过标准化的四阶段流水线(加载 → 切分 → 入库 → 检索)实现可扩展的知识库问答系统。

RAG 看起来流程清晰,但实际调优中大部分问题不在代码层,而在数据层——文档切得太碎语义不连贯,切得太大搜索命中率低,扫描件未做 OCR 导致内容丢失。框架提供工具,真正的效果取决于对业务文档的理解和参数调优。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐