开场:知识库找不到的那个函数
前年公司搭了一个"代码知识库问答":把几个核心仓库的代码全部切块,用 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 篇:注意力是有限的预算),只能按需去找。
"按需去找"有两种主流的做法:
程序先检索一次,
把结果塞进上下文"] 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 是一条分成两段的流水线:
它有三个关键部件,常被叫做"RAG 三件套"。
2.2 第一件:embedding
第 09 篇讲过,embedding 是把一个 token 变成一个向量,意思相近的 token,向量也相近。RAG 用的是同样的思路,只是粒度更大:把一整段文本变成一个向量,意思相近的段落,向量也相近。
这一步用的是专门的 embedding 模型。它和聊天用的大模型不是同一个东西,它的输出不是文字,而是一个几百到几千维的向量。
2.3 第二件:向量库和相似度
所有文本块的向量算好之后,存进一个向量数据库。提问时,把问题也变成一个向量,然后在库里找"离它最近"的 K 个向量,对应的文本块就是检索结果。
"离得近"一般用余弦相似度来衡量:两个向量的夹角越小,余弦值越接近 1,越相似。直观地说,它比较的是两个向量"指向的方向"是否一致,而不在乎它们的长短。
「回调超时怎么重试」"] -.->|"夹角小:相似"| 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 篇那个循环的一个具体应用):
加个条件缩小 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 的机器上都能用。
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 精确查找。
有循环"| 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 的长处:措辞不同,但意思相近。
动手作业
- 把
load(..., 30)里的 30 改成 10 或者 100,看看块的大小对向量检索的结果有什么影响; - 用
-dir换一个检索范围,比如-dir examples,再问几个问题; - 如果你接了 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 有循环。
检索一次
意思相近"] --- X(("检索")) A["Agentic Search
循环里搜
名字精确"] --- X style R fill:#e3f2fd,stroke:#1565c0 style A fill:#fff3e0,stroke:#e65100
评论
还没有评论,来说点什么吧。