技术博客

RAG 开发入门(五):把检索工具交给 Agent

Agentic RAG 的关键,是把检索层封装成稳定工具,让 Agent 自己决定什么时候查、用哪些关键词查,以及是否继续追查。

发布时间

阅读信息

约 15 分钟

主题标签

RAG / Java / Agentic RAG

第四篇最后留下了一个方法:

List<SearchHit> searchDocs(List<String> keywords, int topK);

检索层内部可以继续做 BM25、向量检索、多模态召回、元数据过滤、合并去重和 reranker。对上层来说,它最好只是一个稳定工具:输入关键词,返回排好序、带来源的候选证据。

这篇从这里进入 Agentic RAG。

Agentic RAG 流程

1. 先把 Agent 当成黑盒

这篇先不展开 Agent 框架内部怎么实现。对 RAG 来说,可以先把 Agent 当成一个黑盒:它接收用户任务,能看到一组工具,调用工具后能观察结果,然后决定下一步动作。

这个黑盒里可能有 ReAct、function calling、tool calling、planner,也可能只是一次模型调用加上一段循环代码。Java 后端在第一版落地时,没必要一开始就把这些概念全部摊开。先把接口边界设计清楚,系统会更稳。

Agentic RAG 里的关键工具,就是检索。

第四篇已经把检索层讲到了混合召回和重排。现在要做的事,是把这些复杂度收在 searchDocs 里,让 Agent 通过这个工具拿证据。

2. Workflow RAG 容易卡在分支上

第二篇的最小 RAG 是一个固定流程:用户问题进来,系统检索一次,组装 prompt,再调用模型回答。这个流程很适合讲清楚 RAG 的基本链路,也适合一些边界很稳定的问答场景。

问题复杂以后,固定流程会开始长分支。

比如用户问:“出差回来,高铁票和酒店发票怎么报销?”系统可能要先查差旅制度,再查票据要求,再查报销入口,再查住宿标准和超标说明。第一次检索命中的内容,往往只能告诉系统下一步该查什么。

如果用 workflow 写,就要预先设计很多分支:查不到怎么办,查到报销入口怎么办,查到票据要求怎么办,查到住宿标准怎么办,证据冲突怎么办,是否还要换一组词继续查。问题类型越多,分支越难维护。

Agentic RAG 把这部分决策交给 Agent。检索工具仍然稳定,变化的是工具调用策略:先查什么,查到什么以后继续查什么,什么时候停下来回答。

3. 检索工具的输入就是关键词

这里要把工具边界收紧。searchDocs 的输入就是关键词。

import java.util.List;

public interface RagSearchTool {

    List<SearchHit> searchDocs(List<String> keywords, int topK);
}

这里的 keywords 是 Agent 生成的。它根据用户任务和上一轮 SearchHit,决定这一轮要用哪些词继续查。检索层只消费这些关键词,不负责理解用户意图,也不负责改写问题。

不要把问题改写、过滤条件、知识库范围都塞进这个工具输入里。Agent 最容易做好的事,是根据当前任务和已看到的证据,生成下一组关键词。检索层要做好的事,是拿这些关键词稳定搜索。

权限、租户、系统范围、版本范围这类信息,应该从服务端会话上下文里来。Agent 调工具时只给关键词,后端在工具内部按当前用户、当前系统和当前租户做过滤。

这样设计有几个好处。

第一,Agent 的动作更容易审计。日志里只要记录每一轮关键词、返回结果和下一步动作,就能看清楚它为什么继续查。

第二,检索层可以独立演进。今天内部是 BM25,明天加向量,后天加 reranker,工具签名不用跟着变。

第三,权限边界更清楚。Agent 不能自己声明要查哪个租户、哪个知识库、哪个版本,系统从可信上下文里决定它能看到什么。

4. 一轮检索通常只能打开第一扇门

Agentic RAG 的价值,经常体现在多轮检索。

很多问题的第一轮检索,只能找到入口文档。入口文档会告诉你这个问题属于哪个制度、哪个流程、哪个系统,但往往不会把所有细节一次讲完。

用户的问题越接近日常表达,第一轮关键词越可能偏粗。Agent 拿到第一批 SearchHit 后,能从里面看到更具体的词:制度名称、字段名、票据类型、审批动作、异常原因。下一轮检索就可以围绕这些词继续查。

这里没有神秘的东西。Agent 做的就是根据证据继续补关键词。它每次都在缩小问题范围,也在补齐回答需要的证据链。下面用一个日常问答示例完整看一遍。

5. 一个迭代检索问答助手示例

可以把一次 Agentic RAG 运行记录成这样的轨迹。

用户问:

出差回来,高铁票和酒店发票怎么报销?

第一轮,Agent 先查最直接的线索。

Agent 思考:先找到差旅报销入口和票据总要求。
调用工具:searchDocs(["差旅报销", "高铁票", "酒店发票"], 5)

searchDocs 返回两条比较有用的证据:

1. 财务制度 / 差旅报销 / 报销入口
   出差结束后,员工在财务系统发起差旅报销单,并关联出差申请。

2. 财务制度 / 差旅报销 / 票据要求
   交通费和住宿费需要上传合规票据,票据信息要和报销单一致。

这轮结果让 Agent 知道要走差旅报销单,也知道交通费和住宿费都要票据。但“高铁票具体上传什么”还没有说清楚,所以继续查交通票据。

第二轮关键词变得更具体:

Agent 思考:需要确认高铁票报销需要哪种凭证。
调用工具:searchDocs(["高铁票", "电子客票", "报销凭证"], 5)

这次返回的证据变成:

1. 财务制度 / 交通费报销 / 高铁票
   高铁票报销需上传铁路电子客票报销凭证,截图和购票记录只能作为辅助说明。

2. 财务制度 / 交通费报销 / 票据抬头
   电子票据抬头应与公司开票信息一致,金额要和报销明细一致。

这轮已经能回答高铁票部分。用户还问了酒店发票,Agent 继续查住宿费。

第三轮:

Agent 思考:需要确认酒店发票、住宿标准和超标处理。
调用工具:searchDocs(["酒店发票", "住宿标准", "超标说明"], 5)

返回结果:

1. 财务制度 / 住宿费报销 / 酒店发票
   酒店发票需包含公司抬头、纳税人识别号、住宿日期和金额。

2. 财务制度 / 住宿费报销 / 标准与超标
   住宿金额超过城市标准时,需要补充超标说明或上传审批附件。

这时证据已经够了。Agent 可以生成回答:

可以按这几步处理:

1. 在财务系统发起差旅报销单,并关联对应的出差申请。

2. 高铁票上传铁路电子客票报销凭证。截图和购票记录可以作为辅助说明,
   但报销凭证仍然要上传。

3. 酒店发票要核对公司抬头、纳税人识别号、住宿日期和金额。

4. 如果酒店金额超过当地住宿标准,补充超标说明或上传审批附件。

这个示例里,Agent 没有一次性把问题扔给模型回答。它先用粗关键词打开入口,再根据 SearchHit 里的制度名称、票据类型和流程名称继续补关键词。每一轮检索都留下了可回看的轨迹,最后回答也能对应到具体证据。

6. SearchHit 要让 Agent 看得懂

检索工具返回给 Agent 的结果,不能只是一段正文。

Agent 需要判断这条证据是否有用,至少要看到来源、标题路径、摘要、分数和引用信息。可以先把 SearchHit 设计成这样:

import java.util.Map;

public record SearchHit(
        String chunkId,
        String titlePath,
        String snippet,
        String sourceUri,
        double score,
        Map<String, String> metadata
) {
}

snippet 给 Agent 快速判断内容是否相关,titlePath 帮它理解这段内容属于哪个主题,sourceUri 用来保留引用,metadata 可以带上系统、版本、文档类型和更新时间。

返回结果要控制长度。每轮给 Agent 太多内容,它会被噪音淹没,调用成本也会上升。第一版可以从每轮 5 到 10 条 SearchHit 开始,再根据日志调。

更重要的是,每条结果要能回看。线上回答错了,要能看到 Agent 每一轮用了什么关键词,工具返回了哪些 SearchHit,Agent 为什么继续查,最后用了哪些证据回答。

7. Agent 需要清晰的停止条件

Agent 可以多轮检索,但系统要给它边界。

第一版可以设置几个简单规则:最多检索几轮,每轮最多返回多少条,证据不足时怎么回答,哪些工具调用必须记录日志,哪些来源可以进入最终回答。

比如:

public record AgentRagOptions(
        int maxSearchRounds,
        int topKPerRound,
        boolean requireSource
) {
}

maxSearchRounds 控制成本和延迟。topKPerRound 控制每轮给 Agent 看的证据量。requireSource 表示最终回答必须基于可引用来源。

RAG 本来就是为了补模型参数里没有的知识,减少凭空猜测。Agentic RAG 也是一样。Agent 可以更主动地查资料,但回答仍然要落在证据上。查不到证据时,系统应该让它说清楚没查到,避免让模型继续猜。

8. System 提示词要约束检索行为

第一版可以先把 Agent 的检索行为写进 system 提示词里。比如:

你是一个企业知识库问答助手。你的任务是基于知识库资料回答用户问题。

你可以使用一个检索工具:

searchDocs(keywords, topK)

工具说明:
- keywords 由你生成,只能是一组关键词。
- keywords 不要写成完整句子。
- keywords 不要包含权限、租户、系统范围、知识库范围等过滤条件。
- 服务端会根据当前用户、租户、系统和权限范围自动过滤资料。
- 工具返回 SearchHit 列表,每条 SearchHit 包含 titlePath、snippet、sourceUri、score 和 metadata。

工作方式:
1. 如果用户问题需要制度、流程、说明文档或历史资料支撑,先调用 searchDocs。
2. 第一轮关键词可以宽一点,用来找到入口文档。
3. 观察 SearchHit 后,由你生成下一轮更具体的关键词。
4. 不要重复使用已经证明无效的关键词组合。
5. 最多检索 3 轮。证据足够时停止检索并回答。
6. 回答只能基于 SearchHit 中出现的资料,不要编造制度、流程或数字。
7. 如果证据不足,明确说明没查到足够依据,并列出已经检索过的关键词。
8. 最终回答要尽量给出可执行步骤,并保留来源信息。

这段提示词要把行为说清楚:关键词由 Agent 生成,检索工具只负责查,回答必须落在证据上。后端还要把每一轮关键词和 SearchHit 记日志,方便之后排查。

9. Java 里可以先做一个很薄的 Agent 循环

第一版 Agentic RAG 可以先不上复杂框架。Java 后端先写一个很薄的循环,把模型调用和工具调用串起来。

伪代码大概是这样。模型每一轮返回一条消息,后端根据消息类型决定下一步:如果是工具调用,就执行 searchDocs;如果是最终回答,就结束。

import java.util.List;

public class AgenticRagService {

    private final ChatClient chatClient;
    private final RagSearchTool searchTool;

    public AgenticRagService(ChatClient chatClient, RagSearchTool searchTool) {
        this.chatClient = chatClient;
        this.searchTool = searchTool;
    }

    public RagAnswer answer(String userTask, AgentRagOptions options) {
        AgentMemory memory = AgentMemory.start(userTask);
        int searchRounds = 0;

        while (searchRounds < options.maxSearchRounds()) {
            // 让模型基于当前任务和已观察到的 SearchHit,生成下一条消息。
            AgentMessage message = chatClient.nextMessage(memory);

            if (message.type() == AgentMessageType.FINAL_ANSWER) {
                // 模型认为证据已经够了,直接返回最终回答。
                FinalAnswerMessage answer = (FinalAnswerMessage) message;
                return RagAnswer.from(answer.content(), answer.sources());
            }

            if (message.type() != AgentMessageType.TOOL_CALL) {
                throw new IllegalStateException("Unsupported agent message: " + message.type());
            }

            ToolCallMessage toolCall = (ToolCallMessage) message;

            if (!"searchDocs".equals(toolCall.toolName())) {
                throw new IllegalArgumentException("Unsupported tool: " + toolCall.toolName());
            }

            // 关键词由 Agent 生成,检索工具只负责拿关键词搜索。
            List<SearchHit> hits = searchTool.searchDocs(
                    toolCall.keywords(),
                    options.topKPerRound()
            );

            // 把工具结果放回对话记忆,下一轮模型会基于这些证据继续判断。
            memory = memory.withToolResult(toolCall, hits);
            searchRounds++;
        }

        return RagAnswer.insufficientEvidence(memory.searchedKeywords());
    }
}

public enum AgentMessageType {
    TOOL_CALL,
    FINAL_ANSWER
}

public interface AgentMessage {

    AgentMessageType type();
}

public record ToolCallMessage(
        String toolName,
        List<String> keywords
) implements AgentMessage {

    @Override
    public AgentMessageType type() {
        return AgentMessageType.TOOL_CALL;
    }
}

public record FinalAnswerMessage(
        String content,
        List<String> sources
) implements AgentMessage {

    @Override
    public AgentMessageType type() {
        return AgentMessageType.FINAL_ANSWER;
    }
}

这段代码只是表达结构。真实的 function calling 或 tool calling 返回值里,通常也会有类似的消息类型:要么是工具调用消息,要么是最终回答消息。后端不要猜模型想干什么,按消息类型分发就行。

如果消息是工具调用,后端读取 Agent 生成的关键词,调用 searchDocs,再把 SearchHit 写回对话记忆。while 继续下一轮,模型看到这些证据后,再决定继续查还是回答。

真实项目里还要补日志、超时、异常处理、权限上下文、token 控制和引用格式。这里点到结构就够了,代码不继续展开。

10. 先把工具边界做稳

Agentic RAG 第一版不用急着选框架。先把检索层包成一个稳定工具,让 Agent 每一轮只提交关键词。

List<SearchHit> searchDocs(List<String> keywords, int topK);

这个接口背后可以继续接 BM25、向量检索、多模态召回和 reranker。Agent 不需要知道这些细节,它只需要根据用户任务和上一轮 SearchHit,决定下一轮该查哪些关键词。

真正要盯紧的是日志。

每一轮都应该能看到:Agent 生成了哪些关键词,searchDocs 返回了哪些 SearchHit,Agent 为什么继续查,或者为什么开始回答。线上回答错了,排查也从这里开始:是资料没处理好,还是召回没命中,还是重排把结果排偏了,还是 Agent 太早停了。

这一组 RAG 入门文章到这里结束。后面真的落代码,问题会回到几个具体接口:文档怎么入库,关键词怎么进入检索层,SearchHit 怎么记录,最终回答怎么保留来源。能看清每一轮发生了什么,比多接一个框架更重要。

RAGJavaAgentic RAGAgent知识库
上一篇 RAG 开发入门(一):从关键词检索到 Agentic RAG 下一篇 RAG 开发入门(四):从关键词到向量,再回到混合检索