7. 模块化 RAG

6 min

QuestionAnswerAdvisor 用一行代码就搞定了"检索 + 生成",但它是个黑盒,检索策略、查询处理、结果过滤全部固定。当业务场景变复杂,你会发现它不够用。

1. 模块化 vs 一体化

先看两种方案的差异:

QuestionAnswerAdvisor RetrievalAugmentationAdvisor
查询预处理 无,原样检索 支持重写、压缩、扩展
检索控制 有限的 SearchRequest 配置 独立的 DocumentRetriever,可自定义
空结果处理 固定行为 可配置是否兜底
扩展性 封闭 每个阶段可替换、可组合

模块化 RAG 把管道拆分为四个独立阶段:

RAG模块化阶段

每个阶段有独立的接口,可以像搭积木一样自由组合。不需要的阶段直接跳过,用默认实现即可。

添加依赖:

xml
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-rag</artifactId>
</dependency>

2. 查询表述不佳:查询重写

用户提问往往口语化、结构松散,直接拿去做向量检索效果不好。比如"spring ai的rag咋搞的",关键词都在,但表述不够规范,检索评分可能不高。

RewriteQueryTransformer 用 LLM 将用户查询重写为更适合向量检索的形式:把口语化表述转为书面表达,补全缩写,理清句子结构。

java
RewriteQueryTransformer rewriter = RewriteQueryTransformer.builder()
    .chatClientBuilder(chatClientBuilder)
    .build();

2.1 测试查询重写

src/test/java/com/albertstack/rag/rag/QueryRewriteTest.java

java
package com.albertstack.rag.rag;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
class QueryRewriteTest {

    @Autowired
    private ChatClient.Builder chatClientBuilder;

    @Test
    void shouldRewriteColloquialQuery() {
        var rewriter = RewriteQueryTransformer.builder()
            .chatClientBuilder(chatClientBuilder)
            .build();

        // 口语化查询:关键词都在,但表述不够检索友好
        Query original = new Query("spring ai的rag咋搞的");
        Query rewritten = rewriter.transform(original);

        log.info("原始查询:{}", original.text());
        log.info("重写后:{}", rewritten.text());

        // 重写后应该包含核心关键词,且表述更规范
        assertThat(rewritten.text()).isNotEqualTo(original.text());
        assertThat(rewritten.text()).satisfiesAnyOf(
            t -> assertThat(t).containsIgnoringCase("Spring AI"),
            t -> assertThat(t).containsIgnoringCase("RAG")
        );
    }

    @Test
    void shouldImproveFragmentedQuery() {
        var rewriter = RewriteQueryTransformer.builder()
            .chatClientBuilder(chatClientBuilder)
            .build();

        // 碎片化查询:缩写多、语法不完整
        Query original = new Query("embedding模型选哪个比较好 ollama上的");
        Query rewritten = rewriter.transform(original);

        log.info("原始查询:{}", original.text());
        log.info("重写后:{}", rewritten.text());

        assertThat(rewritten.text()).isNotBlank();
        // 重写后应该比原始查询更结构化
        log.info("原始长度:{},重写后长度:{}", original.text().length(), rewritten.text().length());
    }
}

运行测试,观察控制台输出:

  • "spring ai的rag咋搞的" -> "Spring AI 实现 RAG(检索增强生成)的步骤"
  • "embedding模型选哪个比较好 ollama上的" -> "Ollama embedding 模型推荐"

3. 多轮对话丢上下文:查询压缩

假设用户的对话是这样的:

用户:Spring AI 支持哪些向量数据库?
助手:支持 PGVector、Chroma、Milvus、Pinecone 等...
用户:第一个怎么配置?

"第一个" 指的是 PGVector,但直接拿 "第一个怎么配置" 去检索,什么也找不到。

因此,这里要用到CompressionQueryTransformer 结合对话历史,将查询压缩为自包含的问题:

java
CompressionQueryTransformer compressor = CompressionQueryTransformer.builder()
    .chatClientBuilder(chatClientBuilder)
    .build();

3.1 测试查询压缩

src/test/java/com/albertstack/rag/rag/QueryCompressionTest.java

java
package com.albertstack.rag.rag;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.preretrieval.query.transformation.CompressionQueryTransformer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
class QueryCompressionTest {

    @Autowired
    private ChatClient.Builder chatClientBuilder;

    @Test
    void shouldResolvePronouns() {
        var compressor = CompressionQueryTransformer.builder()
            .chatClientBuilder(chatClientBuilder)
            .build();

        // 模拟多轮对话历史
        List<Message> history = List.of(
            new UserMessage("Spring AI 支持哪些向量数据库?"),
            new AssistantMessage("Spring AI 支持 PGVector、Chroma、Milvus、Pinecone 等多种向量数据库。")
        );

        // 用户的跟进问题,包含代词"第一个"
        Query followUp = Query.builder()
            .text("第一个怎么配置?")
            .history(history)
            .build();

        Query compressed = compressor.transform(followUp);

        log.info("原始查询:{}", followUp.text());
        log.info("对话历史:{} 条消息", history.size());
        log.info("压缩后:{}", compressed.text());

        // 压缩后应该包含 PGVector,因为"第一个"指的是它
        assertThat(compressed.text()).containsIgnoringCase("PGVector");
    }
}

运行测试,观察压缩结果:

原始查询:第一个怎么配置?
对话历史:2 条消息
压缩后:Spring AI 中如何配置 PGVector 向量数据库?

使用 CompressionQueryTransformer压缩后,对话历史中的上下文被正确解析,代词 "第一个 "被替换为具体实体。

4. 一次查询漏文档:多查询扩展

知识库里同一概念可能有多种表述。用户问 "Spring AI 如何实现 RAG",但文档里写的是 "检索增强生成的配置方法",单次查询可能命中不了。

为防止漏查,可以用 MultiQueryExpander 将一个查询扩展为多个不同角度的变体,分别检索后合并结果:

java
MultiQueryExpander expander = MultiQueryExpander.builder()
    .chatClientBuilder(chatClientBuilder)
    .numberOfQueries(3) // LLM 生成 3 个变体查询
    .includeOriginal(true) // 保留原始查询(默认就是 true)
    .build();
  • numberOfQueries(3):让 LLM 生成 3 个不同角度的变体
  • includeOriginal(true):默认行为,原始查询也会保留在结果列表中。最终返回的查询数 = 原始 1 个 + 变体 N 个

4.1 测试多查询扩展

src/test/java/com/albertstack/rag/rag/MultiQueryExpandTest.java

java
package com.albertstack.rag.rag;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.preretrieval.query.expansion.MultiQueryExpander;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
class MultiQueryExpandTest {

    @Autowired
    private ChatClient.Builder chatClientBuilder;

    @Test
    void shouldExpandToMultipleQueries() {
        var expander = MultiQueryExpander.builder()
            .chatClientBuilder(chatClientBuilder)
            .numberOfQueries(3)
            .build();

        Query original = new Query("Spring AI 如何实现 RAG");
        List<Query> expanded = expander.expand(original);

        log.info("原始查询:{}", original.text());
        log.info("扩展结果(共 {} 个):", expanded.size());
        for (int i = 0; i < expanded.size(); i++) {
            log.info("  查询 {}:{}", i + 1, expanded.get(i).text());
        }

        // includeOriginal 默认为 true,所以结果数 = 原始 1 个 + 变体 3 个 = 4 个
        assertThat(expanded).hasSize(4);
        // 第一个应该是原始查询
        assertThat(expanded.get(0).text()).isEqualTo(original.text());
    }

    @Test
    void shouldExcludeOriginalWhenConfigured() {
        var expander = MultiQueryExpander.builder()
            .chatClientBuilder(chatClientBuilder)
            .numberOfQueries(3)
            .includeOriginal(false) // 不保留原始查询
            .build();

        Query original = new Query("Spring AI 如何实现 RAG");
        List<Query> expanded = expander.expand(original);

        log.info("excludeOriginal 模式,扩展结果(共 {} 个):", expanded.size());
        for (int i = 0; i < expanded.size(); i++) {
            log.info("  查询 {}:{}", i + 1, expanded.get(i).text());
        }

        // 只有 LLM 生成的变体,没有原始查询
        assertThat(expanded).hasSize(3);
    }
}

测试运行结果如下:

text
原始查询:Spring AI 如何实现 RAG
扩展结果(共 4 个):
  查询 1:Spring AI 如何实现 RAG
  查询 2:Spring AI 搭建 RAG 系统的详细教程与核心代码实现
  查询 3:Spring AI 框架下的 RAG 架构设计与 Retriever 配置
  查询 4:如何在 Spring AI 中集成向量存储实现检索增强生成应用


excludeOriginal 模式,扩展结果(共 3 个):
  查询 1:Spring AI 实现 RAG 的完整代码示例与配置步骤
  查询 2:Spring AI 架构设计:如何集成向量存储实现检索增强生成
  查询 3:Spring AI 入门教程:构建检索增强生成应用的实践指南

经过扩展后,查询从单个表述扩展成多个不同表述的变体,覆盖了同一主题的不同说法,分别检索后合并去重,能召回更多相关文档。

5. 检索结果不精准:文档检索器

VectorStoreDocumentRetriever 封装了 VectorStore,提供比 SearchRequest 更精细的检索控制:

java
VectorStoreDocumentRetriever retriever = VectorStoreDocumentRetriever.builder()
    .vectorStore(vectorStore)
    .similarityThreshold(0.5) // 低于此值的文档被过滤
    .topK(5) // 最多返回 5 个文档
    .build();

5.1 测试不同阈值的检索效果

src/test/java/com/albertstack/rag/rag/DocumentRetrieverTest.java

java
package com.albertstack.rag.rag;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

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

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
class DocumentRetrieverTest {

    @Autowired
    private VectorStore vectorStore;

    @BeforeEach
    void setUp() {
        // 准备主题差异较大的文档,方便观察阈值过滤效果
        vectorStore.add(List.of(
            new Document("Spring AI 支持 PGVector、Chroma、Milvus 等向量数据库",
                Map.of("source", "vector-db.txt")),
            new Document("Spring Boot Actuator 提供健康检查和监控端点",
                Map.of("source", "actuator.txt")),
            new Document("Java 21 引入了虚拟线程,大幅提升并发性能",
                Map.of("source", "java21.txt"))
        ));
    }

    @Test
    void shouldFilterByThreshold() {
        Query query = new Query("Spring AI 的向量数据库支持");

        // 低阈值:宽松匹配,可能返回"沾边"的文档
        var looseRetriever = VectorStoreDocumentRetriever.builder()
            .vectorStore(vectorStore)
            .similarityThreshold(0.1)
            .topK(5)
            .build();
        List<Document> looseResults = looseRetriever.retrieve(query);

        log.info("低阈值(0.1) 检索结果数:{}", looseResults.size());
        looseResults.forEach(doc -> log.info("  来源:{},内容:{}",
            doc.getMetadata().get("source"),
            doc.getText().substring(0, Math.min(50, doc.getText().length()))));

        // 高阈值:严格匹配,只返回高度相关的文档
        var strictRetriever = VectorStoreDocumentRetriever.builder()
            .vectorStore(vectorStore)
            .similarityThreshold(0.8)
            .topK(5)
            .build();
        List<Document> strictResults = strictRetriever.retrieve(query);

        log.info("高阈值(0.8) 检索结果数:{}", strictResults.size());
        strictResults.forEach(doc -> log.info("  来源:{},内容:{}",
            doc.getMetadata().get("source"),
            doc.getText().substring(0, Math.min(50, doc.getText().length()))));

        // 低阈值返回的文档数 >= 高阈值
        assertThat(looseResults.size()).isGreaterThanOrEqualTo(strictResults.size());
    }
}

运行前,请先注释掉 AiConfig 中的 CommandLineRunner ingestDocuments,改为在测试中直接添加文档,避免文档干扰。

text
Calling EmbeddingModel for document id = 147c0873-cfdf-4289-b55a-97ac1b673b11
Calling EmbeddingModel for document id = 79f0b6df-2771-4634-a681-0ebe255832ac
Calling EmbeddingModel for document id = 8ddf363c-eb3b-41ee-8994-7a4d7b91dcc9
原始查询:Spring AI 的向量数据库支持
低阈值(0.1) 检索结果数:3
  来源:vector-db.txt,内容:Spring AI 支持 PGVector、Chroma、Milvus 等向量数据库
  来源:actuator.txt,内容:Spring Boot Actuator 提供健康检查和监控端点
  来源:java21.txt,内容:Java 21 引入了虚拟线程,大幅提升并发性能
高阈值(0.8) 检索结果数:1
  来源:vector-db.txt,内容:Spring AI 支持 PGVector、Chroma、Milvus 等向量数据库

运行测试,观察两组结果的差异。低阈值可能把其他内容也捞回来(因为都和 Spring 沾边),高阈值只留下真正相关的 "向量数据库" 文档。

similarityThreshold 的调优建议:

阈值 效果 风险
0.3-0.5 宽松匹配,覆盖面广 可能引入不相关内容
0.5-0.7 平衡精准和覆盖,推荐起点 -
0.7-0.9 严格匹配,结果精准 可能过滤掉有用文档

6. 知识库没答案怎么办:上下文增强

用户的问题不一定总能在知识库中找到匹配。检索结果为空时,是直接告诉用户"没找到",还是让 LLM 用自身知识兜底?

通过 ContextualQueryAugmenterallowEmptyContext 可以控制这个行为:

java
ContextualQueryAugmenter augmenter = ContextualQueryAugmenter.builder()
    .allowEmptyContext(true)
    .build();
  • allowEmptyContext(true):没检索到文档时,仍将查询发给 LLM,模型基于自身知识回答
  • allowEmptyContext(false)(默认):直接返回"未找到相关信息",不调用 LLM

7. 整合组件

7.1 更新 AiConfig

把各组件组装到 AiConfig 中。相比前面的 ragChatClient,这里改为注册一个 RetrievalAugmentationAdvisor Bean:

src/main/java/com/albertstack/rag/config/AiConfig.java

java
package com.albertstack.rag.config;

import com.albertstack.rag.service.IngestionService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;

import java.util.List;

@Slf4j
@Configuration
public class AiConfig {

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

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

    @Bean
    public RetrievalAugmentationAdvisor ragAdvisor(
            ChatClient.Builder chatClientBuilder, VectorStore vectorStore) {
        return RetrievalAugmentationAdvisor.builder()
            .queryTransformers(RewriteQueryTransformer.builder()
                .chatClientBuilder(chatClientBuilder).build())
            .documentRetriever(VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .similarityThreshold(0.5)
                .topK(5)
                .build())
            .queryAugmenter(ContextualQueryAugmenter.builder()
                .allowEmptyContext(true)
                .build())
            .build();
    }

    @Bean
    public CommandLineRunner ingestDocuments(IngestionService ingestionService) {
        return args -> {
            List<String> files = List.of(
                "docs/spring-ai-intro.txt",
                "docs/rag-concepts.txt",
                "docs/ollama-guide.txt"
            );
            for (String file : files) {
                var resource = new ClassPathResource(file);
                int chunks = ingestionService.ingest(resource);
                log.info("已摄入: {} -> {} 个分块", file, chunks);
            }
        };
    }
}

AiConfig 的调整总结:

  • 删除了 ragChatClient:不再用 QuestionAnswerAdvisor 构建专用 ChatClient
  • 新增 ragAdvisorRetrievalAugmentationAdvisor 作为独立 Bean,按需挂载到任意 ChatClient 上
  • chatClient 保持不变:基础 ChatClient 不绑定任何 Advisor,更灵活

7.2 更新 RagService

src/main/java/com/albertstack/rag/service/RagService.java

java
package com.albertstack.rag.service;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

@Service
public class RagService {

    private final ChatClient chatClient;
    private final RetrievalAugmentationAdvisor ragAdvisor;

    public RagService(ChatClient chatClient, RetrievalAugmentationAdvisor ragAdvisor) {
        this.ragAdvisor = ragAdvisor;
        this.chatClient = chatClient;
    }

    public String chat(String question) {
        return chatClient.prompt()
            .user(question)
            .advisors(ragAdvisor)
            .call()
            .content();
    }

    public Flux<String> chatStream(String question) {
        return chatClient.prompt()
            .user(question)
            .advisors(ragAdvisor)
            .stream()
            .content();
    }

    public Flux<ChatResponse> chatStreamDetail(String question) {
        return chatClient.prompt()
            .user(question)
            .advisors(ragAdvisor)
            .stream()
            .chatResponse();
    }
}

关键变化: 不再通过 @Qualifier 注入 ragChatClient,而是注入基础 chatClient + ragAdvisor,调用时通过 .advisors(ragAdvisor) 挂载。

同一个 ChatClient 可以按需挂载不同的 Advisor,不用维护多个 ChatClient Bean。

RagController 不需要改动,它只依赖 RagService 的接口,内部实现的切换对它完全透明。

7.3 整合测试

前面分别测试了各个组件的独立行为,现在验证完整管道的端到端效果。

src/test/java/com/albertstack/rag/rag/ModularRagTest.java

java
package com.albertstack.rag.rag;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.albertstack.rag.service.RagService;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
class ModularRagTest {

    @Autowired
    private RagService ragService;

    @Test
    void shouldAnswerPreciseQuery() {
        String question = "Spring AI 支持哪些向量数据库?";
        String answer = ragService.chat(question);
        log.info("精确查询 - 问题:{}", question);
        log.info("精确查询 - 回答:{}", answer);

        assertThat(answer).isNotBlank();
    }

    @Test
    void shouldHandleColloquialQuery() {
        // 口语化查询,关键词在但表述不规范,经过重写后应能正确检索
        String question = "spring ai的rag咋搞的";
        String answer = ragService.chat(question);
        log.info("口语化查询 - 问题:{}", question);
        log.info("口语化查询 - 回答:{}", answer);

        assertThat(answer).isNotBlank();
    }

    @Test
    void shouldStreamResponse() {
        String question = "什么是 RAG?";
        log.info("流式查询 - 问题:{}", question);

        var thinkingText = new StringBuilder();
        var contentText = new StringBuilder();

        ragService.chatStreamDetail(question)
                .doOnNext(chunk -> {
                    var result = chunk.getResult();
                    if (result == null || result.getOutput() == null) return;

                    // 思考过程在 Generation metadata 的 "thinking" 字段中
                    Object thinking = result.getMetadata().get("thinking");
                    if (thinking != null && !thinking.toString().isEmpty()) {
                        thinkingText.append(thinking);
                        System.out.print(thinking);
                        return;
                    }

                    // 回答内容
                    String text = result.getOutput().getText();
                    if (text != null && !text.isEmpty()) {
                        contentText.append(text);
                        System.out.print(text);
                    }
                })
                .doOnComplete(System.out::println)
                .blockLast();

        log.info("思考过程:{} 字符", thinkingText.length());
        log.info("回答内容:{} 字符", contentText.length());

        assertThat(contentText.toString()).isNotBlank();
    }
}

8. 加入多查询扩展

如果想进一步提升召回率,在 ragAdvisor 中加入 MultiQueryExpander

java
@Bean
public RetrievalAugmentationAdvisor ragAdvisor(
        ChatClient.Builder chatClientBuilder, VectorStore vectorStore) {
    return RetrievalAugmentationAdvisor.builder()
        .queryTransformers(RewriteQueryTransformer.builder()
            .chatClientBuilder(chatClientBuilder).build())
        .queryExpander(MultiQueryExpander.builder()
            .chatClientBuilder(chatClientBuilder)
            .numberOfQueries(3)
            .build())
        .documentRetriever(VectorStoreDocumentRetriever.builder()
            .vectorStore(vectorStore)
            .similarityThreshold(0.5)
            .topK(5)
            .build())
        .queryAugmenter(ContextualQueryAugmenter.builder()
            .allowEmptyContext(true)
            .build())
        .build();
}

完整处理流程:

  1. 用户查询 -> RewriteQueryTransformer 重写 -> 语义更精确
  2. 重写后 -> MultiQueryExpander 扩展 -> 3 个变体查询
  3. 3 个查询分别经过 VectorStoreDocumentRetriever 检索 -> 合并去重
  4. 检索结果 -> ContextualQueryAugmenter 注入 Prompt -> LLM 生成回答

9. 选型指南

不是所有场景都需要全套组件。根据业务需求选择:

方案 组件组合 适用场景
最简 DocumentRetriever + QueryAugmenter 用户查询规范、企业内部工具
推荐 + RewriteQueryTransformer 大多数场景,覆盖模糊查询
高召回 + MultiQueryExpander 知识库内容丰富但表述多样
多轮对话 + CompressionQueryTransformer 聊天式 RAG

10. 本章小结

QuestionAnswerAdvisor 升级到了 RetrievalAugmentationAdvisor,通过业务场景逐步引入了各个模块化组件。

知识点 解决的问题 说明
RewriteQueryTransformer 用户查询模糊 用 LLM 重写查询,提升检索命中率
CompressionQueryTransformer 多轮对话丢上下文 结合对话历史,将查询压缩为自包含问题
MultiQueryExpander 单次查询召回不足 扩展为多个变体查询,分别检索后合并
VectorStoreDocumentRetriever 检索参数不够灵活 精细控制 topK、相似度阈值
ContextualQueryAugmenter 空结果处理不可控 配置检索无结果时是否兜底调用 LLM

接下来把存储层从内存中的 SimpleVectorStore 升级到生产级的 Qdrant,解决数据持久化和性能扩展的问题。