检索,RAG 还是 Agentic Search

outlier 15 分钟 5836 字 0 0
系列教程Agent从入门到放弃 第 19 / 19 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02①大模型、Chatbot、Agent,一次把名字理清楚
  3. 03②Agent 的最小定义,一个 while 循环
  4. 04③Agent 简史,从 ReAct 到 Claude Code
  5. 05④Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
查看教程目录

开场:知识库找不到的那个函数

前年公司搭了一个"代码知识库问答":把几个核心仓库的代码全部切块,用 embedding 模型向量化,存进向量数据库,前面接一个大模型。宣传的口号是:"有问题,问它,比翻代码快。"

上线第一周,我问了它一个具体的问题:"支付回调超时之后,是怎么重试的?"

它返回了五段代码,都和"支付"有关:一段是支付请求的构造,一段是支付状态的枚举定义,一段是支付成功后发消息的逻辑……大模型根据这五段代码,写了一段听起来很合理的回答。可惜里面没有真正处理回调超时的那段代码,所以它的回答是推测出来的,而且推测错了。

坐我旁边的同事听我吐槽完,在终端里敲了一行:

grep -rn "callback" --include="*.go" | grep -i "timeout\|retry"

两秒钟,一个叫 handleCallbackTimeout 的函数出现在屏幕上,下面就是重试的逻辑。

那一刻我很困惑:一套花了几周搭起来、用了"先进"技术的系统,输给了一行几十年前就有的命令?

后来我发现,不只是我们。主流的编码 Agent,几乎都没有把"向量检索"作为找代码的主要方式,而是让模型自己去 grep、去 glob、去读文件。这一篇就来讲这两种检索方式:RAG 和 Agentic Search。读完本篇,你应该能回答这几个问题:

  • RAG 的三件套(embedding、向量库、切块)分别是什么?
  • Agentic Search 是什么?它和 RAG 的根本区别在哪里?
  • 为什么主流的编码 Agent 更偏向 Agentic Search?
  • 什么场景下,RAG 仍然是更好的选择?
  • 怎么帮 Agent 搜得更准?

一、问题:知识太多,上下文装不下

上一篇讲了项目记忆:每次都需要的那一小部分知识,写进一个文件,每次都加载。

但一个项目里绝大部分的知识,是只有某些时候才需要的:某个函数在哪里定义、某个配置项是什么意思、某个报错之前是怎么处理的。这些不可能都塞进上下文(第 14 篇:注意力是有限的预算),只能按需去找。

"按需去找"有两种主流的做法:

flowchart TB Q["一个问题"] --> R["RAG
程序先检索一次,
把结果塞进上下文"] Q --> A["Agentic Search
模型自己决定搜什么、
搜几次、读哪些"] R --> M1(("模型回答")) A --> M2(("模型回答")) style R fill:#e3f2fd,stroke:#1565c0 style A fill:#fff3e0,stroke:#e65100

二、RAG:检索,然后生成

2.1 一条流水线

RAG 是 Retrieval-Augmented Generation 的缩写,"检索增强生成"。它的思路是:在模型回答之前,先用程序检索出相关的资料,塞进 prompt 里,让模型基于这些资料来回答。

完整的 RAG 是一条分成两段的流水线:

flowchart LR subgraph OFF["离线:建索引(提前做好)"] direction LR D["文档 / 代码"] --> C["切块"] C --> E["每块算一个 embedding"] E --> V[("向量库")] end subgraph ON["在线:每次提问"] direction LR Q["问题"] --> QE["问题算一个 embedding"] QE --> S["找最相似的 K 块"] S --> P["拼进 prompt"] P --> M(("模型回答")) end V --> S style OFF fill:#e3f2fd,stroke:#1565c0 style ON fill:#e8f5e9,stroke:#2e7d32

它有三个关键部件,常被叫做"RAG 三件套"。

2.2 第一件:embedding

第 09 篇讲过,embedding 是把一个 token 变成一个向量,意思相近的 token,向量也相近。RAG 用的是同样的思路,只是粒度更大:把一整段文本变成一个向量,意思相近的段落,向量也相近。

这一步用的是专门的 embedding 模型。它和聊天用的大模型不是同一个东西,它的输出不是文字,而是一个几百到几千维的向量。

2.3 第二件:向量库和相似度

所有文本块的向量算好之后,存进一个向量数据库。提问时,把问题也变成一个向量,然后在库里找"离它最近"的 K 个向量,对应的文本块就是检索结果。

"离得近"一般用余弦相似度来衡量:两个向量的夹角越小,余弦值越接近 1,越相似。直观地说,它比较的是两个向量"指向的方向"是否一致,而不在乎它们的长短。

flowchart LR Q["问题向量
「回调超时怎么重试」"] -.->|"夹角小:相似"| A["文本块 A
支付请求的构造"] Q -.->|"夹角小:相似"| B["文本块 B
支付状态的枚举"] Q -.->|"夹角大:不相似"| C["文本块 C
handleCallbackTimeout"] style C fill:#ffcdd2,stroke:#b71c1c

这张图也解释了开场的那次翻车:在 embedding 的眼里,"支付""回调""超时"这几个词的整体语义,和"支付请求的构造""支付状态的枚举"挺像的;而 handleCallbackTimeout 那段代码里,大部分是 if、for、time.Sleep 这些和"支付"无关的东西,整体语义反而没那么像。

2.4 第三件:切块

文档和代码不能整个丢进去算向量(太长了,而且一个向量没法表示一整个文件的所有内容),要先切成小块。切块有很多种策略:

  • 按固定长度切:每 30 行、每 500 个 token 切一块。简单,但可能把一个函数从中间切成两半;
  • 按结构切:代码按函数、按类切;文档按标题、按段落切。块更完整,但实现更复杂;
  • 带重叠地切:相邻的块之间重叠一部分,减少"关键信息正好被切在边界上"的情况。

切块是 RAG 里最考验经验的部分:块太大,一块里混着很多不相关的内容,向量变得"模糊";块太小,上下文被切碎,检索出来的东西没头没尾。

2.5 RAG 的几种常见改进

基础版的 RAG 问题不少,所以实际系统里往往还会叠加几层改进:

  • 改写问题:先让模型把用户的问题改写成更适合检索的形式,比如把"我的钱怎么还没到账"扩展成"退款 到账 时效 处理时间",或者干脆生成几个不同说法的查询,分别检索再合并;
  • 重排序(rerank):向量检索先粗筛出几十个候选,再用一个更精细、也更贵的模型,把"问题"和"每个候选"放在一起逐个打分,重新排序,只留最相关的几个。粗筛快但不准,精排准但慢,两者配合;
  • 混合检索:向量检索和传统的关键词检索(比如 BM25)各检索一遍,结果合并。下面第五节还会说到它;
  • 元数据过滤:检索之前先按文件类型、目录、时间筛一遍,缩小范围。

你会发现,这些改进有一个共同的方向:让检索这一步变得更"聪明"。改写问题需要模型,重排序也常常用到模型。RAG 一路改进下去,越来越像一个"只转一两圈的 Agent"。这也从侧面说明了 Agentic Search 的思路:既然检索需要聪明,那不如直接让最聪明的那个模型来决定怎么搜。

三、Agentic Search:让模型自己去搜

3.1 一个循环

Agentic Search 的思路完全不同:不提前建任何索引,而是给模型几个搜索工具,让它像程序员一样,自己决定搜什么、搜几次、读哪些文件。

编码 Agent 常见的搜索工具:

  • grep(或者更快的 rg):按关键词或正则,在文件内容里搜索;
  • glob:按文件名的模式找文件,比如 **/*_test.go;
  • list_files:列目录,了解项目结构;
  • read_file:读一个文件的全部或部分。

它的过程是一个循环(第 02 篇那个循环的一个具体应用):

sequenceDiagram participant M as 模型 participant T as 搜索工具 M->>T: grep -rn "callback" T-->>M: 87 处匹配,太多了 Note over M: 范围太大,
加个条件缩小 M->>T: grep -rn "callback" | grep -i timeout T-->>M: payment/callback.go:142: func handleCallbackTimeout M->>T: read_file payment/callback.go 第 130-200 行 T-->>M: 函数的完整代码 Note over M: 看到了重试逻辑,
里面调用了 backoff.Retry M->>T: grep -rn "func Retry" pkg/backoff T-->>M: 重试的具体实现 Note over M: 够了,可以回答了

注意它做的几件事:搜出来太多,就加条件缩小;找到一个函数,就读它的代码;代码里调用了别的函数,就顺着线索继续找。每一步搜什么,都取决于上一步看到了什么。

3.2 配套仓库里的 Agentic Search

mini-agent 没有专门的 grep 工具,它直接用 run_bash 执行 grep 或 rg。agent/prompt.go 的基础 prompt 里有这么两条:

- 先弄清楚再动手:用 list_files 了解项目结构,用 read_file 读相关文件。
- 搜索代码优先用 run_bash 执行 grep -rn 或 rg。

就这么两句话,加上一个能跑命令的工具,它就具备了 Agentic Search 的能力。没有索引,没有向量库,没有切块。

四、为什么编码 Agent 偏向 Agentic Search

4.1 五个原因

原因一:代码里全是精确的名字。 函数名、变量名、错误信息、配置项,这些是精确匹配的东西。你要找 handleCallbackTimeout,grep 一下就是它,不会有任何歧义。向量检索擅长的是"意思相近",在一个处处是精确名字的世界里,这个长处反而用不上。

原因二:代码一直在变。 RAG 的索引是提前建好的,代码一改,索引就旧了。你刚重命名了一个函数,向量库里还是旧名字;你刚加了一个文件,向量库里还没有它。要么不停地重建索引,要么忍受过时的结果。grep 每次搜的都是磁盘上此刻的文件,永远是最新的。

原因三:Agent 有循环。 这是最根本的一条。RAG 是"程序检索一次,模型基于结果回答",检索不准就没有第二次机会。而 Agent 有循环:搜一次不准没关系,看看结果,换个词再搜。单次检索的不足,被循环弥补了。第 05 篇说过,编码 Agent 成熟的原因之一是结果可验证;这里是另一个角度:检索的结果对 Agent 来说也是"可验证"的,搜到的代码是不是它要的,读一下就知道了。

原因四:可解释。 grep 没搜到,你知道是因为关键词不对;向量检索没搜到,你很难知道为什么。是切块切坏了?embedding 模型不理解这个领域的术语?相似度阈值设得不对?调试 RAG,往往比调试代码难得多。

原因五:零成本。 不用部署向量库,不用跑 embedding 模型,不用维护索引的同步。任何一台装了 grep 的机器上都能用。

flowchart TB A["Agentic Search"] --> A1["精确:名字就是名字"] A --> A2["永远最新:搜的是此刻的磁盘"] A --> A3["有循环:搜错了再搜"] A --> A4["可解释:没搜到,知道为什么"] A --> A5["零成本:不用索引"] style A fill:#fff3e0,stroke:#e65100

4.2 代价

Agentic Search 也有代价:

  • 慢一些、贵一些:每搜一次都是一圈循环,都要调用一次模型。RAG 只检索一次;
  • 需要模型会搜:把自然语言的问题"翻译"成代码里可能出现的关键词,这本身需要能力。模型越强,搜得越准;
  • 搜的结果可能很多:一个宽泛的 grep 可能返回几百行,一下子把上下文撑大(第 28 篇的截断就是为这种情况准备的)。

4.3 怎么让 Agent 搜得更好

Agentic Search 的效果,很大程度上取决于模型能不能把问题"翻译"成正确的关键词,以及能不能在一大堆结果里找到重点。这两件事,都可以通过上下文工程帮它一把:

  • 在项目记忆里画一张目录地图:几行字说清楚"支付相关的代码在 payment/,回调处理在 payment/callback.go,公共的重试逻辑在 pkg/backoff/"。它就不用每次都从 list_files 开始摸索(第 17 篇);
  • 告诉它项目的命名习惯:比如"处理函数统一以 handle 开头""错误码定义在 errors.go"。知道了命名规律,它挑关键词就准得多;
  • 在 system prompt 里提醒搜索策略:比如"中文问题要先想想代码里对应的英文标识符""先限定目录和文件类型,再搜关键词";
  • 把工具输出控制住:结果太多需要截断时,明确告诉它"一共有多少、只给你看了多少、怎么缩小范围",而不是悄悄截掉。mini-agent 的截断提示会写明输出一共多少字符、中间省略了多少,并提醒模型"用 grep 过滤、用 head/tail、或者 read_file 指定 offset 和 limit"(第 28 篇)。

最后这一条很容易被忽略:截断本身不可怕,可怕的是模型不知道自己只看到了一部分。 实验 2 里那个"retry.go 没挤进前 15 行"的情况,配套代码只打印了一句"最多显示 15 行",没说一共匹配了多少行。如果它写的是"共 40 行匹配,只显示前 15 行",一个 Agent 一眼就会知道该缩小范围再搜一次。这也是一个可以动手改的小练习:给 examples/ch18-retrieval/main.go 的 grep 部分加上匹配总数。

4.4 网页搜索也是一样的道理

这一篇一直在说代码,但 Agentic Search 的思路并不局限于代码。第 05 篇讲过的"深度研究"类 Agent,做的就是网页上的 Agentic Search:搜一次,读几个网页,发现新的线索,换个关键词再搜,直到觉得信息够了。

它和编码 Agent 的差别,在于第 05 篇说的"可验证":代码搜到了,读一下就知道是不是要找的那个函数;网页搜到了,里面说的是不是真的,就难判断得多。另外,网页是不可信的内容,里面可能藏着专门写给 Agent 看的指令,这是第 38 篇的主题。

五、RAG 仍然是对的选择的时候

这不是说 RAG 没用。在下面这些场景里,它依然是更好的选择:

  • 内容量巨大,而且以自然语言为主:几万篇客服文档、几十年的论文库、公司的所有会议纪要。grep 在这里不好用,因为同一个意思有无数种说法;
  • 问题偏语义,和原文的措辞差别很大:用户问"我的钱怎么还没到账",文档里写的是"退款处理时效说明"。关键词完全对不上,但意思是一样的;
  • 没有循环可用:一个简单的问答机器人,只有一次调用的机会,检索必须一次就尽量准;
  • 延迟要求高:用户等不了 Agent 搜五轮。

而且两者并不是非此即彼:

  • 混合检索:关键词检索和向量检索一起用,结果合并。关键词保证精确的名字不漏,向量保证换了说法也能找到;
  • 把向量检索做成 Agent 的一个工具:给 Agent 一个 semantic_search 工具,和 grep 并列。它自己判断这一步该用哪个:找精确的函数名用 grep,找"处理退款的地方"这种模糊的概念,先用语义搜索定位大概的范围,再用 grep 精确查找。
flowchart LR Q{"什么样的检索?"} -->|"代码、精确的名字
有循环"| G["Agentic Search
grep / glob / read"] Q -->|"海量自然语言文档
措辞差别大
只有一次机会"| R["RAG
向量检索"] Q -->|"都有"| H["混合:
两种都给 Agent 当工具"] style G fill:#fff3e0,stroke:#e65100 style R fill:#e3f2fd,stroke:#1565c0 style H fill:#e8f5e9,stroke:#2e7d32

六、动手:同一个问题,两种检索

实验 1:跑一遍对比

配套代码 examples/ch18-retrieval/main.go 对仓库里的 Go 代码(internal/、agent/、cmd/ 三个目录)做了两种检索:

  • 向量检索:每 30 行切一块,用 TF-IDF 当成"穷人版 embedding",按余弦相似度取前 3;
  • grep:从问题里抽关键词,逐行精确匹配。

这个实验不调用任何模型,在 Playground 的第 18 篇里也能直接跑,检索的是仓库源码的快照。

go run ./examples/ch18-retrieval -q "处理 API 返回的错误和重试"

输出分成两段。向量检索那一段,返回了三个代码块:client.go 里 APIError 的定义、retry.go 的开头、retry.go 里指数退避的那几行。意思上确实相关,而且是整块代码,读起来有上下文。

grep 那一段就有点尴尬了:

$ grep -rniE '处理|API|返回的错误和重试'
internal/llm/client.go:28: // 设置了 DEEPSEEK_API_KEY 时,背后是真实的 DeepSeek;否则是离线的演示模型。
internal/llm/client.go:37: //    DEEPSEEK_API_KEY   设置了就用真实的 DeepSeek(https://platform.deepseek.com 申请)
……

程序从中文问题里机械地抽出了"处理""API""返回的错误和重试"三个词,结果匹配到的大多是 DEEPSEEK_API_KEY 这种无关的内容。问题是中文的,代码是英文的,直接拿问题里的词去 grep,当然搜不准。

实验 2:像 Agent 一样挑关键词

真实的 Agent 不会这么傻,它会先把问题"翻译"成代码里可能出现的词。用 -grep 手动扮演一次 Agent:

go run ./examples/ch18-retrieval -q "处理 API 返回的错误和重试" -grep "retry|APIError|429"

这次 grep 的结果就精确多了:APIError 的定义、RetryAfter 字段、DeepSeek 客户端里解析 retry-after 响应头的那几行……每一行都和"错误与重试"有关,而且精确到行号。

但你仔细看会发现一个新问题:程序最多显示 15 行,这 15 行被 client.go、deepseek.go 和测试文件占满了,真正实现重试逻辑的 retry.go 反而没挤进来。关键词太宽,结果太多,重要的东西被淹没了。这时候一个 Agent 会怎么做?缩小范围再搜一次:

go run ./examples/ch18-retrieval -q "处理 API 返回的错误和重试" -grep "func .*Retry|Retryable"

这次是十来行:除了几行测试,剩下的就是 retry.go 里 RetryModel 的几个方法和 Retryable 函数,直接指向重试的核心实现。搜得太宽就收窄,这正是 3.1 节那张时序图里的第二步。

再试一个问题:

go run ./examples/ch18-retrieval -q "怎么让模型停下来等工具结果" -grep "stop_reason|StopReason"

"翻译关键词"这一步,正是 Agent 在循环里会自己做的事。 一次没翻译准,看看结果,换个词再来。

实验 3:看看"穷人版 embedding"的上限

配套代码用的不是真正的 embedding 模型,而是 TF-IDF:英文按单词切(驼峰命名会拆开,RetryModel 会变成 retry、model 和它本身),中文按相邻两个字切("重试"是一个词,"试和"也是一个词)。它本质上还是在比较"用了哪些词",对同义词几乎没有理解能力。

把问题换一个说法,比如:

go run ./examples/ch18-retrieval -q "服务端说太忙了怎么办"

意思和"处理错误和重试"差不多,但用词完全不同。看看向量检索还能不能找到 retry.go。真正的 embedding 模型在这种情况下会好很多,这正是 RAG 的长处:措辞不同,但意思相近。

动手作业

  1. 把 load(..., 30) 里的 30 改成 10 或者 100,看看块的大小对向量检索的结果有什么影响;
  2. 用 -dir 换一个检索范围,比如 -dir examples,再问几个问题;
  3. 如果你接了 DeepSeek,让 mini-agent 回答同一个问题(go run ./cmd/mini-agent -p "这个仓库是怎么处理 API 错误和重试的?",加上 -v 可以看到它的工具调用过程),数一数它 grep 了几次、读了哪些文件。

七、踩坑记录:检索的七个坑

坑 1:切块把函数切成了两半。
按固定行数切,一个函数的签名在上一块,函数体在下一块。检索到哪一块都是残缺的。代码的切块,尽量按函数、按类型的边界来。

坑 2:索引过时了。
代码改了,索引没更新,检索出来的是已经被删掉的函数。RAG 用在代码上,最大的维护成本就是这个。

坑 3:盲目相信 Top-K。
向量检索永远会返回 K 个结果,哪怕没有一个是真正相关的。开场那次就是:五段代码都"挺像",但没有一段是答案。模型拿着这五段不相关的代码,还是会努力编出一个回答。检索结果要让模型(或人)判断一下是否真的相关,而不是无条件地塞进去。

坑 4:用问题原文直接 grep。
实验 1 里的翻车。尤其是中文问题、英文代码,直接拿问题里的词去搜,基本搜不到。要么让 Agent 自己挑关键词,要么在 system prompt 里提醒它"搜索时用代码里可能出现的英文标识符"。

坑 5:grep 的范围太宽,结果把上下文撑爆。
grep -rn "error" 在一个大项目里可能返回几千行。配套代码只显示前 15 行,mini-agent 会对所有工具输出做截断(第 28 篇),就是为了防这个。好的做法是先用更具体的关键词,或者先限定目录和文件类型。

坑 6:以为上了 RAG 就不需要 Agent 了,或者反过来。
两种方式各有所长。代码问题优先 Agentic Search,海量自然语言文档优先 RAG,复杂的场景两个都给 Agent 当工具,让它自己选。

坑 7:搜到了注释,就当成了事实。
grep 搜到一行注释:"这里会自动重试三次"。Agent 拿着这一行就回答了,可是代码早就改成了不重试,注释没人更新。搜索只负责"找到",不负责"判断真假"。对代码来说,代码本身比注释和文档更可信:搜到线索之后,读一下真正执行的那几行,再下结论。这和第 14 篇说的"过时的信息比没有信息更糟"是一回事。

一句话总结

RAG 是程序用向量相似度检索一次、把结果塞给模型,擅长海量文档和语义相近的问题;Agentic Search 是模型在循环里自己 grep、自己读、自己换关键词,精确、永远最新、零成本;编码 Agent 偏向后者,是因为代码里全是精确的名字,而且 Agent 有循环。

flowchart LR R["RAG
检索一次
意思相近"] --- X(("检索")) A["Agentic Search
循环里搜
名字精确"] --- X style R fill:#e3f2fd,stroke:#1565c0 style A fill:#fff3e0,stroke:#e65100

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭