夜雨聆风学习资料网

ARTICLE · 990554

手搓一个 Agent08:从文档问答到生产级智能体

手搓一个 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,描述写得明明白白:"公司内部制度问我"。于是:

  1. Agent调用 searchKnowledge("公司薪酬制度");
  2. 这个词被转成向量,去向量库检索;
  3. 命中了早前上传、切好、存好的"员工手册.pdf"第12页;
  4. 带元数据的片段回到Agent手里;
  5. 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"的样子。

我们下章见。

相关学习资料