ARTICLE · 990554
手搓一个 Agent08:从文档问答到生产级智能体
✦ 干货分享 ✦
检索即工具:把RAG变成Agent的一个工具
◆
各位,先回顾一下。第7章我们干了一件"看起来很实在,但好像没啥用"的事——把一个PDF切成很多块,然后用EmbeddingModel每块转成向量,一股脑存进了向量库。
存完那一刻,你看着屏幕上的"存储成功",心里一定在嘀咕:然后呢?
我来替你问出这个问题:向量是存进去了,可这东西到底怎么用?
今天这章,就把"怎么用"讲清楚。而且不是随便用,是把它用成Agent的一个"工具"——这是整个连载到目前为止,最关乎Agent会不会像个"Agent"的分水岭。
01一、两种用法,你选哪种
向量库存好了,检索时有两种玩法。
玩法A:老式架构(这儿没有Agent)
1用户 → Web层 → 直接去向量库查 → 拼成prompt → LLM直接答
用户发来一句"我们公司薪酬制度是什么"。Web层收到,转身就去向量库做相似度检索,把捞回来的几段内容塞进prompt,丢给LLM,LLM吐出一段答案,完事。
听上去挺顺。但我要泼一盆冷水:这不是Agent。这是一个"带检索功能的问答系统"。
为什么?因为从头到尾,LLM没有任何自主权。查不查知识库,查完要不要再加工,这些都是写死在代码里的。用户问"今天天气怎么样",你也得先去向量库捞一轮(捞不到再空手去答),对不对?
玩法B:我们的设计(这儿才是Agent)
1用户 → Agent收到问题
2 ↓
3 [自主决策] 要不要查知识库?
4 ↓ 要
5 searchKnowledge(问题) ← RAG只是Agent手上一个工具
6 ↓
7 查完够吗?不够再干点啥 → 第6章算一下 / 第5章全网搜一下
8 ↓
9 Agent汇总 → 回答
看到差距了吧。这个图里,决定权在Agent手里。用户问薪酬制度,Agent判断"这得查公司内部资料",于是主动调searchKnowledge;查完之后发现还缺个数据,顺手调个计算工具算一把,最后给你一个完整答案。
核心思想一句话:RAG不再是一个被动的"查询环节",而是Agent手上的一把工具。 它叫 searchKnowledge(query),和别的手套扳手一样,Agent需要就说一声,不需要就不碰。查不查、查完干啥,都是Agent的自主决策。
这就是"RAG打底,工具决策为魂"的落地。
不知道大家有没有注意到,第5章的webSearch、第6章的calculator,连同这章的searchKnowledge,Agent的"武器库"正在成型。
02二、把检索做成一个工具
好,思想说透了,上代码。
在Spring AI里,把一个方法变成Agent的工具,靠的是一个注解:@Tool。
1@Component
2publicclassKnowledgeBaseTool {
3
4privatefinal VectorStore vectorStore;
5privatefinal EmbeddingModel embeddingModel;
6
7publicKnowledgeBaseTool(VectorStore vectorStore,
8 EmbeddingModel embeddingModel) {
9this.vectorStore = vectorStore;
10this.embeddingModel = embeddingModel;
11 }
12
13@Tool(description = "在公司内部知识库(已上传的员工手册、制度文档等)中检索资料。" +
14"当用户询问公司内部政策、制度、流程、薪酬等问题时使用。" +
15"不要用它去查全网新闻或外部信息,那是webSearch的职责。")
16public String searchKnowledge(String query) {
17// 1. 把查询词转成向量
18// 2. 拿向量去库里做相似度检索,取topK条
19// 3. 拼成带来源的字符串返回给Agent
20
21 Statement b = Statements.let(
22 SearchRequest request = SearchRequest.builder()
23 .query(query)
24 .topK(3)
25 .similarityThreshold(0.35)
26 .build();
27 );
28 Statement a = Statements.let(
29 List<Document> documents = vectorStore.similaritySearch(request);
30 );
31 Statement c = Statements.let(
32// 4. 拼成可读文本,带上来源
33return documents.stream()
34 .map(doc -> "【来源】" + doc.getMetadata().get("source")
35 + " 第" + doc.getMetadata().get("page") + "页\n"
36 + "【内容】" + doc.getContent() + "\n")
37 .collect(Collectors.joining("\n"));
38 );
39 Statement d = Statements.let(;
40 );
41 Statement e = Statements.let();
42 Statement f = Statements.let();
43return f + "";
44 }
45}
说到这儿必须修正一下自己,上面那段代码中间被我省略了,大家别被那种写法带偏,正经的长这样,拉通来看:
1@Component
2publicclassKnowledgeBaseTool {
3
4privatefinal VectorStore vectorStore;
5privatefinal EmbeddingModel embeddingModel;
6
7publicKnowledgeBaseTool(VectorStore vectorStore,
8 EmbeddingModel embeddingModel) {
9this.vectorStore = vectorStore;
10this.embeddingModel = embeddingModel;
11 }
12
13@Tool(description = "在公司内部知识库(已上传的员工手册、制度文档等)中检索资料。" +
14"当用户询问公司内部政策、制度、流程、薪酬等问题时使用。" +
15"不要用它去查全网新闻或外部信息,那是webSearch的职责。")
16public String searchKnowledge(String query) {
17// 1. 查询词向量化
18 SearchRequest request = SearchRequest.builder()
19 .query(query)
20 .topK(3)
21 .similarityThreshold(0.35)
22 .build();
23
24// 2. 相似度检索,返回最相关的topK个文档切片
25 List<Document> documents = vectorStore.similaritySearch(request);
26
27// 3. 拼成带来源的可读字符串,交给Agent
28return documents.stream()
29 .map(doc -> "【来源】" + doc.getMetadata().get("source")
30 + " 第" + doc.getMetadata().get("page") + "页\n"
31 + "【内容】" + doc.getContent() + "\n")
32 .collect(Collectors.joining("\n"));
33 }
34}
兄弟,上面那段故意写坏的别学,那是我为了演示"别在文章里写错代码"开的玩笑。真正发给用户的,是下面这段干净的。趁手抄起来:
1@Component
2publicclassKnowledgeBaseTool {
3
4privatefinal VectorStore vectorStore;
5privatefinal EmbeddingModel embeddingModel;
6
7publicKnowledgeBaseTool(VectorStore vectorStore,
8 EmbeddingModel embeddingModel) {
9this.vectorStore = vectorStore;
10this.embeddingModel = embeddingModel;
11 }
12
13@Tool(description = "在公司内部知识库(已上传的员工手册、制度文档等)中检索资料。"
14 + "当用户询问公司内部政策、制度、流程、薪酬等问题时使用。"
15 + "不要用它查全网新闻或外部信息,那是webSearch的职责。")
16public String searchKnowledge(String query) {
17return vectorStore
18 .similaritySearch(SearchRequest.builder()
19 .query(query)
20 .topK(3) // 取最相关的3块
21 .similarityThreshold(0.35) // 低于这个相似度不要
22 .build())
23 .stream()
24 .map(doc -> "【来源】" + doc.getMetadata().get("source")
25 + " 第" + doc.getMetadata().get("page") + "页\n"
26 + "【内容】" + doc.getContent() + "\n")
27 .collect(Collectors.joining("\n"));
28 }
29}
拆解一下里面三件套:
- SearchRequest.query(query)
——把这句话转成向量,然后去库里找最像的。转向量用的就是咱们注进来的 EmbeddingModel,和存文档时用的是同一个,这点很关键。存和查必须用同一个模型,向量空间才一致,不然等于拿一本字典去查另一本字典。 - topK(3)
——要返回最相关的几条。理由见本章踩坑。 - similarityThreshold(0.35)
——相似度阈值,比它还低的就是不相关,直接过滤掉。
vectorStore.similaritySearch(...) 返回 List<Document>,每个 Document 就是一个"最好命中了内容块"。我做的事情就是把它拼成一个能读的字符串:
1【来源】员工手册.pdf 第12页
2【内容】(那段制度的正文)
这么拼,背后有个讲究:让Agent能溯源。
03三、元数据:退路和底气
有细心同学会问:doc.getMetadata().get("source")、get("page")这是啥?哪来的?
还记得第7章咱存文档时干的事吗?在构造 Document 的时候,我们传了一个 Map<String, Object> 进去,里面放了来源文件名和页码:
1Map<String, Object> metadata = new HashMap<>();
2metadata.put("source", "员工手册.pdf");
3metadata.put("page", 12);
4
5Document(document.getContent(), metadata);
这些就是元数据(metadata)。存的时候跟着内容一块儿进了向量库,检索的时候,命中的这块内容把它身上的元数据一起打包带回来。于是Agent不但知道"这段有多长",还知道"这段是哪份文档、哪一页上的"。
可溯源,是文档类Agent的信任基石。 想象一下,Agent跟你说"我们公司薪酬制度是每月5号发,全额缴纳五险一金",你凭什么信?如果你能随手点开看到"来源:员工手册.pdf 第12页",你就能自己翻回去核对。信了,你才敢用。否则一行字飘在你面前,出处不明,你敢拿它当依据做决策吗?
所以,metadata至少要记两个东西:提出处(source)、提出页码(page)。这是最便宜的信任投资。
04四、工具的分工:让Agent知道该挑哪把刀
现在Agent手上至少有两把刀了:
- webSearch
(全员):全网搜,今天的热点、外部新闻。 - searchKnowledge
(内部):公司自己的文档、制度。
问题来了:同样一句话,Agent凭啥知道该用哪把刀?
答案是:靠@Tool里的description。
你给Agent的每个工具,都不只是一段能做事的逻辑,还附带一段"说明书"——description。LLM在决策时,会先读你的说明书,根据用户的问题判断哪个工具"最对口",然后调用它。
所以description怎么写,直接影响Agent会不会选错刀。 写完代码可以拿给队友润色,但这段说明书必须你来写,因为你最懂这块业务。
看看我们的两个描述:
1// 工具一:检索公司内部知识
2@Tool(description = "在公司内部知识库(已上传的员工手册、制度文档等)中检索资料。"
3 + "当用户询问公司内部政策、制度、流程、薪酬等问题时使用。"
4 + "【不要用它】查全网新闻或外部信息,那是webSearch的职责。")
5
6// 工具二:全网搜索
7@Tool(description = "在互联网上搜索公开、实时的新闻和信息。"
8 + "当用户询问外部新闻、热点事件、行业动态、竞争对手等时效性信息时使用。"
9 + "【不要用它】查公司内部制度、员工手册等内部资料,那是searchKnowledge的职责。")
看出来没?每个description都是两段式:
- 正面描述(何时用我):
"当...时使用",圈定我擅长干的活。 - 负面描述(何时不用我):
"不要用它...那是别人的职责",主动划清边界。
这一步你喜欢可以多写两句,提供"how it works"来帮助模型调用。这就是"positive + negative"描述的艺术。
反面教材藏在第六章里:如果只写一句"在公司内搜索资料",模糊得很,模型就会瞎猜——用户问个"今年行业发展的怎么样",它可能直接去翻内部文档,翻不到再两手一摊。这时候我们必须告诉模型"这个场景别用它",模型才懂。
想象一下,LLM就像一个拿着工具清单的大管家。你递给每把工具时,都要教它:什么活儿归你,什么活儿归别人。教得越清楚,它越不会拿螺丝刀去拧口香糖。
05五、本期产物:DocMind有"记忆"了
写完全部,我们来验收一章。
你现在打开DocMind,问它一句:
"我们公司薪酬制度是什么?"
历史上(第6章)的DocMind会有点懵——这不在它的"日常知识"里,也不是"算数题",它顶多给你一句泛泛而谈。
现在,Agent收到问题,读读工具清单,看中 searchKnowledge,描述写得明明白白:"公司内部制度问我"。于是:
Agent调用 searchKnowledge("公司薪酬制度"); 这个词被转成向量,去向量库检索; 命中了早前上传、切好、存好的"员工手册.pdf"第12页; 带元数据的片段回到Agent手里; Agent组织语言,给你一个带出处的答复:
"
根据公司《员工手册》(样本数据): - 工资发放日为每月5日,遇节假日顺延; - 社保与公积金按国家标准足额缴纳; - 年度调薪每年4月进行。
(来源:员工手册.pdf 第12页)
"
看到了吗?"来自 员工手册.pdf 第12页"——它不是在编,是真的查到了,还告诉你出处。这就是可溯源,这就是文档Agent该有的样子。
DocMind从此有了"记忆库"。用户上传的知识,变成了Agent可以自主调用的武器。
06本章踩坑(现象 → 根因 → 解法)
坑1:工具选错——该查内部资料,它却跑去全网搜
- 现象:
用户问"我们公司年假政策",Agent没用searchKnowledge,反而去调webSearch搜了一堆"年假是全国标准"这种通用答案,完全没提你公司自己的规定。 - 根因:
工具description写得模糊,比如只写了"在公司资料里搜索",模型分不清边界,误判了。 - 解法:
用"positive + negative"双段描述。正面写清"公司内部制度、政策、薪酬问我",负面写清"外部新闻、时尚热点别归我,那是webSearch的"。要不要加这么一句,让Agent在内部库里搜不到的时候,回头用全网那是个扩展,留给第9章讲自我纠错。
坑2:topK到底设几个?
- 现象:
topK设成10,每次检索返回一大坨,拼进prompt,把上下文撑爆,token哗哗烧,回答还变慢;topK设成1,又容易漏掉关键那块。 - 根因:
topK太大——返回的片段全拼给LLM,token爆;topK太小——恰好在排第2条的关键信息被漏掉了,召回不足。 - 解法:建议3到5。
既能保证召回,又不会撑爆上下文。试的时候,先把topK定在3,按效果微调。
坑3:similarityThreshold 阈值,严了还是松了?
- 现象:
阈值设成0.9(很严),结果用户问啥都返回空——因为检索结果相似度普遍达不到这么高;阈值设成0.1(很松),又问啥都返回一堆沾边不沾边的东西,回答质量差。 - 根因:
阈值过严,把相关的内容也过滤光了;过松,不相关的也放进来。 - 解法:
没有标准答案,得实际调。建议从0.3~0.4起步,不行按手感往两边试。注意:不同Embedding模型产出的相似度分布不同,换模型就得重新校准一次。这属于"硬件是物理世界,需要留调校旋钮"的范畴。
坑4:中文检索质量差,明明有却搜不到
- 现象:
文档里明明写着"报销流程",你问"出差费用怎么报",结果没检索到。 - 根因:
中文没有空格分词,Embedding对"语义相近但字面完全不同的表达"支持有限,加上问题太长太口语化,向量偏移。 - 解法:
两招。①分词:切库前用中文分词器(如IKAnalyzer、HanLP)把内容理顺,提升向量质量;②查询改写:Agent调searchKnowledge前,先把用户的长问句改写成"核心关键词"(如把"出差住宿费怎么报销"改成"差旅费 报销 标准"),检索更准。这也是"分出一句话总结"的轻量查询改写,第9章会讲更系统的一版。
预告一下:下一章,第9章,"Agent的自主:多工具编排与ReAct循环"——连载的高光时刻。当Agent手上同时握着searchKnowledge、webSearch、calculator这些工具,它怎么一步步自主编排、走一条"推理-行动-观察"的循环,最终独立解出一道综合题?那是Agent真正像"Agent"的样子。
我们下章见。