2026-03-31-rag-phase2-3-design

RAG Phase 2 & 3 — Agentic RAG Level 1-3 设计文档

创建日期:2026-03-31 关联 Issue:#16(P1) 前置依赖:Action #1(ToolRegistry ✅)、Action #7(BeanOutputConverter ✅)、Phase 1(Chunking + Threshold ✅) 分支策略:Phase 2 和 Phase 3 各一个独立 PR


背景

Phase 1 完成了检索质量基础(Chunking + 相似度阈值)。Phase 2 & 3 解决剩余的两个核心问题:

问题 影响
RAG 在 ChatService 强制预处理(Bug B1) RAG context 被拼入 userMessage 存入 Redis,污染对话历史
口语查询与存储向量语义错位 召回率低,找不到相关文档
单次检索信息不足时无法补充 复杂多角度问题召回不完整

Phase 2 — Agentic RAG Level 1-2

目标

  • 让 Agent 自主决定何时检索(Level 1)
  • 检索前自动改写查询提升召回率(Level 2)
  • 消除 Bug B1(RAG context 历史污染)

新增组件

2.1 QueryRewritercom.dawn.ai.service

职责:将用户口语查询改写为适合向量检索的语义短语。

@Slf4j
@Service
@RequiredArgsConstructor
public class QueryRewriter {

    private final ChatClient chatClient;

    @Setter
    @Value("${app.ai.rag.query-rewrite-enabled:true}")
    private boolean queryRewriteEnabled;

    record RewriteResult(String rewrittenQuery) {}

    public String rewrite(String originalQuery) {
        if (!queryRewriteEnabled) {
            return originalQuery;
        }
        BeanOutputConverter<RewriteResult> converter =
            new BeanOutputConverter<>(RewriteResult.class);

        String response = chatClient.prompt()
            .system("将用户问题改写为适合向量检索的关键词短语,保留核心语义,去除口语助词。"
                    + converter.getFormat())
            .user(originalQuery)
            .options(OpenAiChatOptions.builder().temperature(0.1).build())
            .call()
            .content();

        return converter.convert(response).rewrittenQuery();
    }
}

配置项:app.ai.rag.query-rewrite-enabled: true

改写示例:

输入:  "Dawn AI 贵不贵?月费大概多少啊"
输出:  "Dawn AI 定价 月费 价格 收费标准"

2.2 KnowledgeSearchToolcom.dawn.ai.agent.tools

放在 tools 包,自动被 ToolRegistry 发现,无需改 AgentOrchestratorTaskPlanner

@Slf4j
@Component
@Description("搜索内部知识库,获取与问题相关的背景信息。需要查询产品信息、技术文档或领域知识时调用。")
public class KnowledgeSearchTool implements Function<KnowledgeSearchTool.Request, KnowledgeSearchTool.Response> {

    record Request(@JsonProperty(required = true) String query) {}
    record Response(String context, int docsFound) {}

    // 通过 @RequiredArgsConstructor 注入(与项目其他组件保持一致)
    private final QueryRewriter queryRewriter;
    private final RagService ragService;
    private final MeterRegistry meterRegistry;

    @Value("${app.ai.rag.default-top-k:5}")
    private int defaultTopK;

    private Counter dedupCounter;

    @PostConstruct
    void initMetrics() {
        dedupCounter = Counter.builder("ai.rag.dedup.skipped")
                .description("RAG queries skipped due to deduplication")
                .register(meterRegistry);
    }

    public Response apply(Request req) {
        String rewrittenQuery = queryRewriter.rewrite(req.query());
        List<Document> docs = ragService.retrieve(rewrittenQuery, defaultTopK);
        String context = formatContext(docs);
        log.info("[KnowledgeSearchTool] query='{}' → rewritten='{}', docsFound={}",
                req.query(), rewrittenQuery, docs.size());
        return new Response(context, docs.size());
    }

    private String formatContext(List<Document> docs) {
        if (docs.isEmpty()) return "未找到相关知识库内容。";
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < docs.size(); i++) {
            sb.append(String.format("[%d] %s\n", i + 1, docs.get(i).getText()));
        }
        return sb.toString();
    }
}

2.3 删除 ChatService RAG 预处理(消除 Bug B1)

// 删除 ChatService.chat() 中的以下代码:
if (request.isRagEnabled()) {
    String context = ragService.buildContext(userMessage);
    if (!context.isBlank()) {
        userMessage = context + "\n\nUser question: " + userMessage;
    }
}

// 同步删除:ChatRequest.ragEnabled 字段及其 getter/setter

数据流(改造后)

用户: "Dawn AI 月费多少?"

ChatService  AgentOrchestrator(原始 userMessage,无 RAG 预处理)
  └── TaskPlanner: 生成计划 [knowledgeSearchTool, finish]
  └── ReAct Loop:
        LLM Reason: 需要查询产品定价信息
        LLM Action: knowledgeSearchTool("Dawn AI 月费多少?")
           QueryRewriter: "Dawn AI 定价 月费 价格"
           RagService.retrieve("Dawn AI 定价 月费 价格", topK=5)
           Response(context="[1]月费¥99...", docsFound=1)
        LLM Observe: 已获得定价信息
        LLM Answer: "Dawn AI 月费为 ¥99"

配置项(新增)

app:
  ai:
    rag:
      query-rewrite-enabled: true   # QueryRewriter 开关,false 时原样传入

Phase 3 — Agentic RAG Level 3(Iterative Retrieval)

目标

  • 支持多轮检索:LLM 自主判断信息是否充分,不足则换角度再次调用 knowledgeSearchTool
  • 防止重复检索浪费 Token
  • 通过 Prompt 引导 LLM 合理使用多轮检索能力

3.1 StepCollector 扩展

新增 RETRIEVED_QUERIES ThreadLocal,生命周期与现有 STEPS/COUNTER/MAX_STEPS 完全一致。

// 新增字段
private static final ThreadLocal<Set<String>> RETRIEVED_QUERIES =
        ThreadLocal.withInitial(HashSet::new);

// init() 追加
RETRIEVED_QUERIES.get().clear();

// 新增静态方法
public static boolean isQueryRetrieved(String query) {
    return RETRIEVED_QUERIES.get().contains(query);
}
public static void markQueryRetrieved(String query) {
    RETRIEVED_QUERIES.get().add(query);
}

// clear() 追加
RETRIEVED_QUERIES.remove();

3.2 KnowledgeSearchTool 防重复逻辑

在 Phase 2 实现基础上,apply() 开头加去重判断:

public Response apply(Request req) {
    String rewrittenQuery = queryRewriter.rewrite(req.query());

    if (StepCollector.isQueryRetrieved(rewrittenQuery)) {
        dedupCounter.increment();
        log.info("[KnowledgeSearchTool] Skipping duplicate query: {}", rewrittenQuery);
        return new Response("(已检索过相同内容,请换个角度或直接生成回答)", 0);
    }
    StepCollector.markQueryRetrieved(rewrittenQuery);

    List<Document> docs = ragService.retrieve(rewrittenQuery, defaultTopK);
    // ... 其余逻辑不变
}

3.3 TaskPlanner Prompt 注入

buildPlanPrompt() 的业务约束部分追加:

- 若单次检索信息不足,可多次调用 knowledgeSearchTool 从不同角度补充,
  直到信息充分再生成最终答案。每次请求最多检索 {maxRagCalls} 次。

maxRagCallsapplication.yml 注入,与 maxSteps 独立:

app:
  ai:
    rag:
      max-calls-per-session: 3   # 每次请求最多 RAG 调用次数,独立于 maxSteps

3.4 指标

指标 类型 注册位置 语义
ai.rag.calls_per_session Histogram AgentOrchestrator 每次会话中 knowledgeSearchTool 调用次数
ai.rag.dedup.skipped Counter KnowledgeSearchTool 因去重跳过的检索次数

ai.rag.calls_per_session 计算方式:StepCollector.collect() 后,过滤 action == "knowledgeSearchTool" 的步骤数量,在 AgentOrchestrator.doChat() 末尾 record()


完整架构(Phase 1-3 完成后)

用户问题
    ChatService(纯路由,无 RAG 预处理)
    AgentOrchestrator
    ├── TaskPlanner(含 maxRagCalls 引导语)
    └── Spring AI ReAct Loop
           LLM 推理
          ├── knowledgeSearchTool(query)         按需,可多次(最多 maxRagCalls               ├── QueryRewriterBeanOutputConvertertemperature=0.1               ├── StepCollector.isQueryRetrieved() 防重复
               └── RagService.retrieveChunked + Threshold          ├── calculatorTool / weatherTool / ...
          └── 最终答案

文件变更汇总

Phase 2 PR

操作 文件
新增 src/main/java/com/dawn/ai/service/QueryRewriter.java
新增 src/main/java/com/dawn/ai/agent/tools/KnowledgeSearchTool.java
修改 src/main/java/com/dawn/ai/service/ChatService.java(删除 RAG 预处理)
修改 src/main/java/com/dawn/ai/dto/ChatRequest.java(删除 ragEnabled)
修改 src/main/resources/application.yml(新增 query-rewrite-enabled)
新增 src/test/java/com/dawn/ai/service/QueryRewriterTest.java
新增 src/test/java/com/dawn/ai/agent/tools/KnowledgeSearchToolTest.java

Phase 3 PR

操作 文件
修改 src/main/java/com/dawn/ai/agent/StepCollector.java(RETRIEVED_QUERIES)
修改 src/main/java/com/dawn/ai/agent/tools/KnowledgeSearchTool.java(防重复)
修改 src/main/java/com/dawn/ai/agent/plan/TaskPlanner.java(maxRagCalls prompt)
修改 src/main/java/com/dawn/ai/agent/AgentOrchestrator.java(RAG 指标)
修改 src/main/resources/application.yml(max-calls-per-session)
修改 src/test/java/com/dawn/ai/agent/StepCollectorTest.java
修改 src/test/java/com/dawn/ai/agent/tools/KnowledgeSearchToolTest.java(防重复测试)

不在范围内

  • RAG Evaluation 框架(有真实数据后再做)
  • Cross-encoder Reranking
  • Hybrid Search(向量 + BM25)
  • Self-RAG(Agentic RAG Level 4)