RAG 作为 Tool:让 Agent 自主决定何时检索
作者:程序员马丁
Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。
上一篇给 TinyAgent 装上了 Reflection 机制,让 Agent 不只会执行,还能自我评估、自我纠错。结尾留了一个伏笔:Agent 系列从第 5 篇开始就有一个 searchKnowledge 工具,但它一直是硬编码的——写死了几个关键词匹配分支,返回固定的文本。RAG 系列花了大量篇幅讲的向量检索、重排序、混合检索,在 Agent 这边一个都没用上。
这一篇,咱们把 RAG 真正接入 Agent——让检索变成 Agent 的一个工具,由智能体自主决定什么时候查知识库、查什么、查到的结果怎么用。
本项目中具体代码已上传 GitHub TinyAgent,大家 Clone 项目后,将代码分支切换到 1.14.x,默认主分支是最新代码。运行前复制
.env.example为.env,把自己的 API Key 填进去,默认阿里云百炼平台;.env已加入.gitignore,切分支时不会丢。
Mock 版知识搜索
从第 5 篇开始,TinyAgent 的工具箱里就有一个 SearchKnowledgeTool。回顾一下它的核心实现:
public String invoke(String input) {
String query = ToolUtils.extractField(input, "query");
String lowerQuery = query.toLowerCase();
if (lowerQuery.contains("扫地机") && (lowerQuery.contains("推荐")
|| lowerQuery.contains("老人") || lowerQuery.contains("产品"))) {
return "{\"matched\":\"扫地机选购指南\", \"content\":\"比特 S10 Lite ...\"}";
}
if (lowerQuery.contains("耳机")) {
return "{\"matched\":\"耳机产品列表\", \"content\":\"比特 AirX ...\"}";
}
// ...... 更多 if-else 分支
return "{\"matched\":\"七天无理由退货政策\", \"content\":\"签收次日起 7 天内...\"}";
}
本质上就是一个 if-else 关键词匹配器。之前这么写是因为 Agent 系列的重点在循环控制、记忆、规划、反思这些机制,知识检索用 Mock 不影响主线叙事。但随着 TinyAgent 的能力越来越完整,是时候换成真正的 RAG 检索了——覆盖全量知识、理解语义、支持动态更新,而不是靠 if-else 穷举几个关键词分支。
整体设计:RAG 作为 Tool 的架构
在动手写代码之前,先把架构理清楚。RAG 作为 Agent 的一个 Tool,和之前的 queryOrder、compareProducts 这些工具本质上没区别——都是实现了 Tool 接口,由 Agent 自主决定什么时候调用。区别在于,RAG 工具内部跑的是一条完整的检索链路。

整条链路分三层:
- 工具层:
RagSearchTool实现Tool接口,Agent 通过 Function Calling 调用它,和调queryOrder没有任何区别。 - 检索层:接收用户 query → 调 Embedding API 转向量 → 在 pgvector 里做向量检索 → 返回 Top-K 个最相关的知识片段。
- 存储层:
knowledge_chunk表,存的是预处理好的知识文档片段和对应的向量。
为什么用 pgvector 而不是 Milvus?TinyAgent 在第 13 篇长期记忆里已经用 pgvector 存用户画像和交互记录了——schema.sql 里有 CREATE EXTENSION IF NOT EXISTS vector,memory_entry 表已经跑在 pgvector 上。读者的 Docker 环境里已经有一个跑着 pgvector 的 PostgreSQL 容器,不需要额外装任何东西。比特严选 300-500 个 SKU 的体量,pgvector 的性能绰绰有余。
知识库表设计
在 schema.sql 里新增一张 knowledge_chunk 表:
CREATE TABLE IF NOT EXISTS knowledge_chunk (
id BIGSERIAL PRIMARY KEY,
source VARCHAR(256) NOT NULL,
content TEXT NOT NULL,
embedding vector(1024),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
四个字段,各管各的事:
| 字段 | 类型 | 说明 |
|---|---|---|
id | BIGSERIAL | 自增主键 |
source | VARCHAR(256) | 来源文件名,如 退货政策.txt,方便追溯 |
content | TEXT | 知识片段的原始文本 |
embedding | vector(1024) | 文本对应的向量,1024 维(和 memory_entry 保持一致) |
created_at | TIMESTAMP | 写入时间 |
和 memory_entry 表的区别在于:memory_entry 是用户级的(有 user_id、key、type),存的是某个用户的画像和交互记录;knowledge_chunk 是全局的,存的是平台的知识文档,所有用户共享。
知识文档准备与导入
1. 准备知识文档
在 src/main/resources/ 下新建一个 knowledge 目录,放入比特严选的知识文档。每个 .txt 文件是一个独立的知识主题:
src/main/resources/knowledge/
├── 退货政策.txt
├── 保修政策.txt
├── 扫地机选购指南.txt
├── 耳机产品信息.txt
├── 智能穿戴产品信息.txt
├── 手机产品信息.txt
├── IoT生态搭配指南.txt
└── 常见故障排查.txt
以 退货政策.txt 为例:
比特严选退货政策
签收次日起 7 天 内,商品外观完好、主要配件齐全,可申请七天无理由退货。
质量问题退货不受 7 天限制,但需要先通过售后检测确认为非人为损坏。检测周期一般为 1-3 个工作日。
以下商品不支持七天无理由退货:已激活的手机和平板电脑、已拆封的耳机(因卫生原因)、定制刻字商品。
退货运费规则:质量问题由比特严选承担运费;非质量问题(如不喜欢、买错了)由买家承担运费,运费约 10-15 元。
退款到账时效:审核通过后 1-3 个工作日原路退回。
每个文件控制在几百字以内——比特严选体量不大,知识文档也不需要特别长。如果某个主题内容较多(比如常见故障排查涵盖多个品类),在文件内部用空行分段,每段聚焦一个子主题,导入时按段切分。
2. 文档导入器
KnowledgeImporter 负责读取 knowledge 目录下的所有 .txt 文件,按段切分,调 Embedding API 生成向量,写入 knowledge_chunk 表。每次运行先清空旧数据再写入,支持反复执行:
public class KnowledgeImporter {
private final DataSource dataSource;
private final EmbeddingClient embeddingClient;
public KnowledgeImporter(DataSource dataSource, EmbeddingClient embeddingClient) {
this.dataSource = dataSource;
this.embeddingClient = embeddingClient;
}
public void importFromResources(String resourceDir) {
// 省略查找文件代码...
System.out.println("[知识导入] 找到 " + txtFiles.size() + " 个文档");
int totalChunks = 0;
for (Path file : txtFiles) {
// 省略导入代码...
}
System.out.println("[知识导入] 完成,共导入 " + totalChunks + " 个片段");
}
// 省略 toVectorString、insertChunk、splitByParagraph、clearAll...
}
几个设计要点:
- 先删后插:
clearAll()在每次导入前清空knowledge_chunk表。用户可能修改了知识文档后重新跑导入,先删后插保证表里的数据和文件内容一致,不会残留 旧版本的片段。 Files.list用 try-with-resources 包裹:Files.list()返回的Stream底层持有目录句柄,用完必须关闭,否则句柄泄漏。这是一个容易踩的坑。- 按空行切段:
splitByParagraph()用双换行符切分,每段作为一个独立的 chunk。这是最简单的切分策略,适合比特严选这种知识文档结构清晰、每段聚焦一个主题的场景。RAG 系列讲过更复杂的切分策略(按语义切、滑动窗口切),但这篇的重点不在切分,够用就好。 - 过滤太短的段:长度不超过 10 个字符的段(比如只有一个标题行)直接跳过,这些段信息密度太低,向量化后也检索不到有价值的内容。
整个导入流程串起来是这样的:

3. 导入放进 Demo 启动流程
导入这件事,与其让读者单独记一个 main,不如直接放进 RagAsToolDemo 的启动流程里:Demo 一启动先跑一遍导入,再进入问答。因为 KnowledgeImporter 是先删后插的幂等实现,重复跑不会累积脏数据,图的就是读者 Clone 下来跑一个 main 就能用,不用记两个入口。
// RagAsToolDemo 启动时先导入知识库(幂等:先清空再写入,重复跑不会重复累积)
KnowledgeImporter importer = new KnowledgeImporter(dataSource, embeddingClient);
importer.importFromResources("knowledge");
读者 Clone 项目后,只需要两步准备:
- 确保 pgvector 的 Docker 容器在跑(第 13 篇长期记忆已经配过了)
- 在
.env里配好 API Key
然后直接运行 RagAsToolDemo.main()。首次进入问答前,控制台会先打印导入进度:
[知识导入] 找到 8 个文档
[知识导入] IoT生态搭配指南.txt → 5 个片段
[知识导入] 保修政策.txt → 4 个片段
[知识导入] 常见故障排查.txt → 4 个片段
[知识导入] 手机产品信息.txt → 2 个片段
[知识导入] 扫地机选购指南.txt → 5 个片段
[知识导入] 智能穿戴产品信息.txt → 4 个片段
[知识导入] 耳机产品信息.txt → 2 个片段
[知识导入] 退货政策.txt → 5 个片段
[知识导入] 完成,共导入 31 个片段
导入完成后,knowledge_chunk 表里就有了 31 条带向量的知识片段,等着 RagSearchTool 来检索。
实现 RagSearchTool
核心来了。RagSearchTool 要替换掉 Mock 版的 SearchKnowledgeTool,实现真正的向量检索。
public class RagSearchTool implements Tool {
private static final double SIMILARITY_THRESHOLD = 0.5;
private final DataSource dataSource;
private final EmbeddingClient embeddingClient;
private final int topK;
// 省略构造函数...
@Override
public String name() {
return "searchKnowledge";
}
@Override
public String description() {
return "检索比特严选知识库,查找售后政策、产品信息、选购指南、故障排查等内容。"
+ "当用户的问题涉及产品知识、平台规则或常见问题时使用。";
}
@Override
public String parameters() {
return """
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索内容,用自然语言描述要查找的信息"
}
},
"required": ["query"]
}""";
}
@Override
public String invoke(String input) {
String query = ToolUtils.extractField(input, "query");
if (query.isBlank()) {
return "{\"error\":\"缺少搜索内容\"}";
}
double[] queryVector = embeddingClient.embed(query);
String vectorStr = toVectorString(queryVector);
String sql = "SELECT source, content, "
+ "1 - (embedding <=> ?::vector) AS similarity "
+ "FROM knowledge_chunk "
+ "WHERE embedding IS NOT NULL "
+ "ORDER BY embedding <=> ?::vector "
+ "LIMIT ?";
// 省略检索拼接...
}
// 省略 toVectorString...
private record ChunkResult(String source, String content, double similarity) {}
}
几个关键设计点:
工具名保持不变。 name() 返回的还是 searchKnowledge,和 Mock 版完全一致。这意味着之前所有 Demo 里的 system prompt、Skill 指令、Plan-and-Execute 的步骤描述,都不需要改动。Agent 之前已经形成需要查知识库时调用 searchKnowledge 的习惯,换成真实 RAG 后这个习惯依然有效。
description 只讲自己的适用场景。 Mock 版的 description 只说明检索比特严选知识库,返回匹配的售后政策、常见问题或产品信息;新版进一步说明当用户的问题涉及产品知识、平台规则或常见问题时使用,让 Agent 一眼看出这是处理知识性问题的工具。
这里要强调一条反面原则:不要在一个工具的描述里点名其它工具(比如补充查订单时改用
queryOrder)。工具名是会变的,一旦改名这句话就成了过时噪音,还让本该自包含的工具描述和兄弟工具的命名强耦合。跨工具到底先选哪个,是编排层的全局策略,后面第二道防线放到 system prompt 里统一处理。
相似度阈值过滤。 检索出 Top-K 结果后,低于 SIMILARITY_THRESHOLD(0.5)的片段直接过滤掉。当知识库里确实没有相关内容时,返回 matched: 0 和明确提示,而不是硬凑一个低相关性的结果。
返回相似度分数。 结果里带了 similarity 字段(余弦相似度,0 到 1 之间)。这个分数 Agent 不一定直接用,但它让调试变得容易——你能直观看到检索结果和 query 有多匹配,方便排查检索命中但结果不相关的问题。
异常不上抛。 数据库查询出错时,返回一个包含 error 字段的 JSON,而不是直接抛异常。这样 Agent 的 ReAct 循环不会因为数据库连接超时而整个中断——它看到 error 信息后可以决定下一步怎么做(告知用户、换个方式查、或者跳过这个步骤)。