Skip to content

RAG 检索增强生成

RAG(Retrieval-Augmented Generation)是解决 LLM 知识局限性的核心范式,Spring AI 提供了完整的模块化 RAG 流水线。

为什么需要 RAG?

LLM 存在三大固有局限:

问题描述RAG 解法
知识截止训练数据有时效性注入实时/私有知识
幻觉问题模型可能编造事实基于检索到的真实文档回答
私有数据无法访问企业内部数据将私有数据向量化后检索
传统方式:用户问题 → LLM → 回答(可能不准确)

RAG 方式:
用户问题 → 检索相关文档 → 问题 + 文档上下文 → LLM → 基于事实的回答

RAG 完整流水线

Spring AI 将 RAG 分为两个阶段:

【离线阶段:知识入库】
原始文档(PDF/Word/HTML)

    ▼ DocumentReader
文档对象 List<Document>

    ▼ TextSplitter
文档分块 List<Document>(每块 512 tokens)

    ▼ EmbeddingModel
向量化 float[]

    ▼ VectorStore.add()
存入向量数据库 ✅

【在线阶段:检索增强】
用户问题

    ▼ EmbeddingModel
问题向量化

    ▼ VectorStore.similaritySearch()
检索 Top-K 相关文档

    ▼ PromptTemplate
构建增强 Prompt(问题 + 文档上下文)

    ▼ ChatModel
生成最终回答

Advisor API:模块化 RAG

Spring AI 通过 Advisor 机制将 RAG 无缝集成到 ChatClient 调用链中:

QuestionAnswerAdvisor

最常用的 RAG Advisor,自动完成检索和 Prompt 增强:

java
@Configuration
public class RagConfig {

    @Bean
    public ChatClient ragChatClient(
        ChatClient.Builder builder,
        VectorStore vectorStore
    ) {
        return builder
            .defaultAdvisors(
                new QuestionAnswerAdvisor(
                    vectorStore,
                    SearchRequest.builder()
                        .topK(5)
                        .similarityThreshold(0.75)
                        .build()
                )
            )
            .build();
    }
}

// 使用:和普通 ChatClient 完全一样
@GetMapping("/ask")
public String ask(@RequestParam String question) {
    return ragChatClient.prompt()
        .user(question)
        .call()
        .content();
    // 框架自动:检索 → 增强 Prompt → 调用 LLM
}

运行时动态 RAG

java
// 不同问题使用不同知识库
@GetMapping("/ask")
public String ask(
    @RequestParam String question,
    @RequestParam String domain  // "tech" / "legal" / "finance"
) {
    VectorStore targetStore = vectorStoreRegistry.get(domain);

    return chatClient.prompt()
        .user(question)
        .advisors(new QuestionAnswerAdvisor(targetStore))  // 运行时注入
        .call()
        .content();
}

文档读取(DocumentReader)

Spring AI 支持多种文档格式:

java
// PDF 文档
DocumentReader pdfReader = new PagePdfDocumentReader(
    new ClassPathResource("docs/manual.pdf"),
    PdfDocumentReaderConfig.builder()
        .withPageTopMargin(0)
        .withPageExtractedTextFormatter(
            ExtractedTextFormatter.builder()
                .withNumberOfTopTextLinesToDelete(0)
                .build()
        )
        .withPagesPerDocument(1)
        .build()
);

// Word 文档 / HTML / 纯文本(需要 Tika)
DocumentReader tikaReader = new TikaDocumentReader(resource);

// JSON 文档
DocumentReader jsonReader = new JsonReader(
    new ClassPathResource("data/products.json"),
    "name", "description", "category"  // 提取的字段
);

// 读取文档
List<Document> docs = pdfReader.get();

文档分块(TextSplitter)

分块策略直接影响检索质量:

java
// Token 分块(推荐)
TextSplitter tokenSplitter = new TokenTextSplitter(
    512,    // 每块最大 token 数
    128,    // 重叠 token 数(保持上下文连贯)
    5,      // 最小块大小
    10000,  // 最大块大小
    true    // 保留分隔符
);

// 字符分块
TextSplitter charSplitter = new CharacterTextSplitter(
    "\n\n",  // 分隔符(段落)
    1000,    // 块大小(字符)
    200      // 重叠大小
);

List<Document> chunks = tokenSplitter.apply(rawDocs);

分块策略选择

场景推荐策略chunk size
技术文档TokenTextSplitter512 tokens
法律合同按段落分块1000 chars
新闻文章按句子分块3-5 句
代码文件按函数/类分块自定义

元数据过滤

通过元数据精确控制检索范围:

java
// 存储时添加元数据
List<Document> docs = rawDocs.stream()
    .map(doc -> {
        doc.getMetadata().put("department", "技术部");
        doc.getMetadata().put("version", "2024-Q4");
        doc.getMetadata().put("confidential", false);
        return doc;
    })
    .toList();
vectorStore.add(docs);

// 检索时按元数据过滤
SearchRequest request = SearchRequest.builder()
    .query(userQuestion)
    .topK(5)
    .similarityThreshold(0.7)
    // 只检索技术部的非机密文档
    .filterExpression("department == '技术部' && confidential == false")
    .build();

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

过滤表达式语法

// 等值
"source == 'official-docs'"

// 比较
"version >= '2024'"

// 逻辑组合
"department == 'tech' && level <= 3"

// IN 操作
"category in ['Java', 'Spring', 'AI']"

// NOT
"NOT confidential"

高级 RAG 模式

结合向量检索和关键词检索,提升召回率:

java
@Service
public class HybridSearchService {

    @Autowired
    private VectorStore vectorStore;

    @Autowired
    private ElasticsearchClient esClient;

    public List<Document> hybridSearch(String query, int topK) {
        // 向量检索(语义相关)
        List<Document> semanticResults = vectorStore.similaritySearch(
            SearchRequest.builder().query(query).topK(topK).build()
        );

        // 关键词检索(精确匹配)
        List<Document> keywordResults = esClient.search(query, topK);

        // RRF 融合排序(Reciprocal Rank Fusion)
        return rrfMerge(semanticResults, keywordResults, topK);
    }

    private List<Document> rrfMerge(
        List<Document> list1, List<Document> list2, int topK
    ) {
        Map<String, Double> scores = new HashMap<>();
        int k = 60; // RRF 常数

        for (int i = 0; i < list1.size(); i++) {
            String id = list1.get(i).getId();
            scores.merge(id, 1.0 / (k + i + 1), Double::sum);
        }
        for (int i = 0; i < list2.size(); i++) {
            String id = list2.get(i).getId();
            scores.merge(id, 1.0 / (k + i + 1), Double::sum);
        }

        // 按 RRF 分数排序,返回 topK
        return scores.entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(topK)
            .map(e -> findDocById(e.getKey(), list1, list2))
            .toList();
    }
}

2. 查询改写(Query Rewriting)

用 LLM 改写用户问题,提升检索质量:

java
@Service
public class QueryRewriteRagService {

    @Autowired
    private ChatClient chatClient;

    @Autowired
    private VectorStore vectorStore;

    public String ragWithQueryRewrite(String originalQuery) {
        // Step 1: 用 LLM 改写查询(生成多个变体)
        String rewritePrompt = """
            将以下问题改写为 3 个不同角度的搜索查询,每行一个:
            原始问题:%s
            """.formatted(originalQuery);

        String rewrittenQueries = chatClient.prompt()
            .user(rewritePrompt)
            .call()
            .content();

        // Step 2: 用多个查询检索,合并去重
        Set<Document> allDocs = new LinkedHashSet<>();
        for (String query : rewrittenQueries.split("\n")) {
            allDocs.addAll(vectorStore.similaritySearch(
                SearchRequest.builder().query(query.trim()).topK(3).build()
            ));
        }

        // Step 3: 用检索结果回答原始问题
        String context = allDocs.stream()
            .map(Document::getContent)
            .collect(Collectors.joining("\n\n---\n\n"));

        return chatClient.prompt()
            .system("基于以下上下文回答问题,如果上下文中没有相关信息,请说明。")
            .user("上下文:\n" + context + "\n\n问题:" + originalQuery)
            .call()
            .content();
    }
}

3. 重排序(Reranking)

检索后用更精准的模型重新排序:

java
@Service
public class RerankRagService {

    @Autowired
    private VectorStore vectorStore;

    @Autowired
    private RerankModel rerankModel; // 如 DashScope Rerank

    public List<Document> rerankSearch(String query, int topK) {
        // 先粗检索更多候选
        List<Document> candidates = vectorStore.similaritySearch(
            SearchRequest.builder().query(query).topK(topK * 3).build()
        );

        // 用 Rerank 模型精排
        return rerankModel.rerank(query, candidates, topK);
    }
}

完整 RAG 应用示例

java
@Service
public class EnterpriseKnowledgeService {

    @Autowired
    private VectorStore vectorStore;

    @Autowired
    private ChatClient chatClient;

    @Autowired
    private ChatMemory chatMemory;

    /**
     * 企业知识库问答(RAG + 多轮对话)
     */
    public String ask(String question, String sessionId) {
        return chatClient.prompt()
            .system("""
                你是企业内部知识库助手。
                请基于提供的文档内容回答问题。
                如果文档中没有相关信息,请明确说明"文档中未找到相关信息",不要编造答案。
                回答要简洁准确,必要时引用文档来源。
                """)
            .user(question)
            .advisors(
                // 多轮对话记忆
                new MessageChatMemoryAdvisor(chatMemory, sessionId, 10),
                // RAG 检索增强
                new QuestionAnswerAdvisor(
                    vectorStore,
                    SearchRequest.builder()
                        .topK(5)
                        .similarityThreshold(0.72)
                        .build()
                )
            )
            .call()
            .content();
    }

    /**
     * 批量导入文档
     */
    public void ingestDocuments(List<Resource> resources) {
        resources.parallelStream().forEach(resource -> {
            try {
                List<Document> docs = new TikaDocumentReader(resource).get();
                List<Document> chunks = new TokenTextSplitter(512, 128).apply(docs);

                // 添加来源元数据
                chunks.forEach(doc ->
                    doc.getMetadata().put("source", resource.getFilename())
                );

                vectorStore.add(chunks);
                log.info("导入完成:{} ({} 块)", resource.getFilename(), chunks.size());
            } catch (Exception e) {
                log.error("导入失败:{}", resource.getFilename(), e);
            }
        });
    }
}

RAG 质量评估

java
@Component
public class RagEvaluator {

    @Autowired
    private ChatClient evaluatorClient;

    /**
     * 评估 RAG 回答质量(忠实度 + 相关性)
     */
    public EvaluationResult evaluate(
        String question,
        String answer,
        List<Document> retrievedDocs
    ) {
        String context = retrievedDocs.stream()
            .map(Document::getContent)
            .collect(Collectors.joining("\n"));

        String evalPrompt = """
            评估以下 RAG 系统的回答质量,以 JSON 格式输出:
            
            问题:%s
            检索到的上下文:%s
            系统回答:%s
            
            评估维度:
            1. faithfulness(忠实度):回答是否完全基于上下文,0-1 分
            2. relevance(相关性):回答是否回答了问题,0-1 分
            3. completeness(完整性):是否充分利用了上下文信息,0-1 分
            
            输出格式:{"faithfulness": 0.9, "relevance": 0.85, "completeness": 0.8, "comment": "..."}
            """.formatted(question, context, answer);

        return evaluatorClient.prompt()
            .user(evalPrompt)
            .call()
            .entity(EvaluationResult.class);
    }
}

record EvaluationResult(
    double faithfulness,
    double relevance,
    double completeness,
    String comment
) {}

最佳实践

RAG 调优清单

  1. 分块大小:512 tokens 是通用起点,根据文档类型调整
  2. 重叠比例:10-20% 重叠,保持语义连贯
  3. 相似度阈值:0.7-0.8,过低引入噪声,过高召回不足
  4. Top-K:3-5 个文档,过多会稀释关键信息
  5. 元数据过滤:多租户场景必须用元数据隔离数据
  6. 定期重建索引:文档更新后及时重新向量化

常见陷阱

  • 分块过小:丢失上下文,检索结果碎片化
  • 分块过大:超出 LLM 上下文窗口,关键信息被稀释
  • 不设相似度阈值:低质量文档污染回答
  • 忽略文档预处理:PDF 乱码、HTML 标签等噪声影响向量质量

相关组件

本站内容由 褚成志 整理编写,仅供学习参考