写在前面
这是”Agent 系统开发手记”系列的第四篇。第一篇聊了意图识别——把用户想干嘛搞清楚,第二篇聊了规划、纠偏与执行循环——让 Agent 一步步受控地走向目标,第三篇聊了上下文管理——在有限窗口里让模型看到它真正需要的信息。这三篇做完,我以为 Agent 的基本骨架差不多了,直到我遇到了检索这个问题。
说实话,刚开始做 Agent 的时候,我对 RAG 的理解就是”文档切 Chunk、生成向量、存到 Milvus、查的时候 Top-K 召回”。后来真正做技术选型 Agent 才发现,这个理解跟”后端就是写 CRUD”一样肤浅。用户问”我们的场景适合用什么消息队列”,Agent 需要去知识库里找技术文档;用户说”刚才那个方案的性能数据呢”,Agent 需要检索之前的对话历史;用户问”Redis 7.0 和 6.x 的区别”,Agent 需要精确定位版本相关的技术细节——这些场景表面上都是”检索”,但背后的机制完全不同。
第三篇里我聊过,Context Window 的构建是一个五步流水线:Retrieve → Select → Compress → Order → Inject。其中 Retrieve 这一步,就是检索。当时只是提了一嘴,真正做的时候才发现,”怎么把需要的信息捞出来”这件事,远比我想象的复杂。这篇文章就把自己在检索上踩过的坑整理下来。
从一个让我头疼的场景说起
我那个技术选型 Agent 跑了一段时间之后,收到了一个用户投诉。
用户问:”我们团队要选一个消息队列,日均消息量大概 500 万,要求延迟低、运维简单,有什么推荐?”
Agent 回答了一大堆关于 Kafka 的内容——高吞吐、分区机制、生态成熟。看起来挺专业的,但用户实际想要的是一个运维简单的方案,而 Kafka 的运维复杂度在消息队列里算是高的。Agent 推荐 Kafka,本质上是因为知识库里关于 Kafka 的文档最多、最长,向量检索召回的内容自然也最多。
更坑的是,用户追问”Kafka 运维复杂度怎么样”,Agent 又从知识库里捞出一篇”Kafka 运维最佳实践”,洋洋洒洒讲了一堆监控、调优、故障处理的内容。用户看完的结论是:”这运维也太复杂了吧,你刚才还推荐这个?”
我去排查了一下原因,发现问题出在好几个地方:
- 检索的时候没有理解”运维简单”是一个硬约束,直接拿用户的完整问题去向量检索了
- 知识库里 Kafka 的文档最多,向量相似度高的内容自然偏向 Kafka
- 没有把”运维简单”这个约束和”技术特性”分开检索
- 召回的内容没有经过约束过滤,直接全塞给了模型
这个问题和第三篇里聊的上下文管理直接相关——检索出来的内容如果没经过筛选就塞进 Context Window,模型看到的就是一堆噪声。第一篇里聊的意图识别也在这里起了作用——“运维简单”到底是硬约束还是软偏好,直接影响检索策略。但当时我对这些都没什么感觉,直到真正踩了坑才知道。
先搞清楚一个认知:RAG 不等于 Agent 检索
刚开始做 Agent 的时候,我把”检索”和”RAG”画等号。后来才发现,RAG 只是 Agent 检索中的一种场景。
Agent 需要”去找东西”的场景远不止知识库问答:
| 场景 | 检索目标 | 数据来源 |
|---|---|---|
| 知识库问答 | 技术文档、产品说明 | 向量数据库 |
| 对话历史召回 | 用户之前说的约束、确认的方案 | 对话记录 |
| 工具结果查询 | 之前查过的性能数据、配置信息 | 工具输出缓存 |
| 实时数据获取 | 当前系统状态、最新价格 | 业务数据库、外部 API |
| 中间产物检索 | 之前分析过的方案对比、计算结果 | Agent 自身产生的数据 |
所以如果面试官或者同事问”你们的检索怎么做”,只回答”Chunk、Embedding、Top-K”是远远不够的。真正需要回答的是:
- 什么时候需要检索? ——不是每一步都需要查
- 到底要查询什么? ——从目标推导信息缺口
- 应该去哪里查询? ——不同信息在不同地方
- 一次没查到怎么办? ——迭代式检索
- 查出来太多怎么办? ——过滤、排序、截断
这五个问题才是 Agent 检索的核心。
查询什么:让 Agent 知道自己缺什么
第一个坑是:Agent 怎么知道自己应该查询什么?
最简单的做法是直接拿用户输入去检索。比如用户问”Redis 适不适合我们的场景”,直接拿这句话去向量检索。但这样做的效果通常很差——“这个”指代什么?”我们的场景”具体是什么场景?模型不知道,检索也不知道。
我在实践中摸索出一个四步法:
第一步:明确当前要做出的判断。 查询是手段不是目的。比如用户问”Redis 适不适合我们的场景”,首先要解析出判断标准——用户关心的是什么?是高可用、运维成本,还是性能?
第二步:推导所需证据。 如果判断标准是”高可用”,那就需要知道 Redis Sentinel 和 Redis Cluster 的高可用机制、故障恢复时间、数据一致性保证等。
第三步:区分已知和未知。 如果用户之前说过”我们的 QPS 是 5 万”,那这个信息已经知道了,不需要再查。
第四步:判断缺口是否值得查询。 不是所有信息都值得查。有些信息不重要,或者不影响最终判断。在资源紧张的时候,可以跳过不关键的查询——这是一种降级机制。
这四步说起来简单,但实现的时候需要在 Prompt 里明确教会 Agent 这个思考过程。我的做法是在 System Prompt 里加一段指引:
1 | { |
去哪里检索:查询空间不是固定的
第二个坑是:Agent 怎么知道应该去哪里找信息?
我一开始的设计是给 Agent 一个统一的 search(query) 工具,背后接向量数据库。后来发现这远远不够。
举个例子,用户问”Redis 7.0 的 Function 特性怎么样”,这个问题:
- 技术文档在知识库里(向量数据库)
- 但 Redis 7.0 的版本发布时间在官网上(外部 API)
- 用户之前讨论过 Redis 版本选择在对话历史里(对话记录)
一个 search(query) 工具根本覆盖不了这些场景。
后来我重新设计了检索架构,核心思路是查询空间是动态的:
1 | { |
这里有个关键点:可查询空间会随着任务执行不断变化。比如第一轮检索发现 Redis 7.0 有 Function 特性,第二轮可能就需要去查 Function 的性能测试数据——这个数据源在第一轮的时候根本不需要考虑。
如果用 ES 做检索,还有一个实用技巧:Index Profile(索引说明)。给每个索引建一份元数据说明,告诉 Agent 这个索引存了什么、适合查什么、不适合查什么:
1 | { |
这样 Agent 在决定去哪里查的时候,可以先看 Index Profile,而不是盲目地把所有索引都搜一遍。索引多了之后,还可以做成索引树——先检索”哪个索引最可能有答案”,再检索具体索引。
Chunk 切割:不是一刀切就完事了
第三个坑是 Chunk 切割。
最开始我用的是最简单的方案:每 500 Token 切一段,Overlap 50 Token。效果嘛……能用,但经常出问题:
- 一个完整的 API 说明被从中间切开了,前半段有参数说明,后半段有返回值,但分别在两个 Chunk 里
- 表格被切成了好几段,每段都没有表头,模型看到的是一堆没有上下文的数字
- 一张架构图的说明文字被切到了不同的 Chunk,图和说明对不上
后来我改成了混合切割方案:
1 | 文档结构确定大边界 → 语义判断内部边界 → 长度限制负责兜底 |
具体来说:
- 先按文档的标题、章节结构切割
- 如果某个章节太长,再用语义相似度判断在哪里切
- 如果语义切割后某个 Chunk 还是太长,就用固定长度兜底
对于特殊内容,我做了单独处理:
| 内容类型 | 处理方式 |
|---|---|
| 表格 | 整表为一个 Chunk,附带表名和表头;大表格按行分组,每组重复表头 |
| 代码 | 按函数/类切割,保留函数签名和注释 |
| 图片 | 提取周围文本作为描述,同时保存原图 |
| 长列表 | 保留共同标题,按语义分组切割 |
父子文档:小粒度召回,大粒度阅读
单一切割粒度有一个经典矛盾:切小了,召回精准但上下文不完整;切大了,上下文完整但噪声多。
我最终采用的方案是父子文档:
1 | 原始文档 → 切成大 Chunk(父文档) → 每个父文档切成小 Chunk(子文档) |
比如知识库里有一段 Redis 高可用说明:
Redis Sentinel 提供自动故障转移,适合中小规模部署。Redis Cluster 提供分片和高可用,适合大规模部署。Cluster 的运维复杂度显著高于 Sentinel。
如果用户问”Redis Cluster 运维复杂度怎么样”,子文档”Cluster 的运维复杂度显著高于 Sentinel”会被精准召回。但如果只把这一句话给模型,它缺少上下文——为什么复杂?和 Sentinel 比具体复杂在哪?
父子文档的做法是:用这句话(子文档)做召回,命中后找到包含完整高可用对比的整个段落(父文档),把父文档给模型。这样既找得准,又看得全。
父文档排序用 RRF 算法:
1 | Score(P) = Σ 1/(k + rank(c_i)) |
其中 rank(c_i) 是子文档在召回结果中的排序位置。一个父文档下被召回的子文档越多、排名越靠前,父文档的最终得分就越高。
层次文档:从父子到多层
父子文档还可以扩展成层次文档——从整份文档到段落到句子,建多层结构:
- 第一层:整份文档
- 第二层:章节
- 第三层:小节
- 第四层:段落
- 第五层:句子或独立事实
每一层都可以额外保存摘要。检索时根据问题类型选择不同的召回层次:
| 问题类型 | 优先召回层次 |
|---|---|
| 事实型、参数型 | 段落级或事实级 |
| 解释型、原因型 | 小节级 |
| 总结型、概览型 | 章节级或文档级摘要 |
| 对比型、综合型 | 多个层次同时召回 |
比如用户问”Redis 7.0 和 6.x 的区别”(对比型),需要同时召回版本特性的小节级内容;用户问”Redis 是什么”(总结型),召回文档级摘要就够了。
查询改写:别直接拿用户原话去检索
第四个坑是查询改写。
用户输入往往是口语化的、模糊的,直接拿去检索效果很差。我在实践中总结了几条改写规则:
| 规则 | 说明 | 示例 |
|---|---|---|
| 保留硬约束 | 不遗漏用户明确限制 | “预算有限” → 保留为过滤条件 |
| 消除歧义 | 结合上下文确定含义 | “Redis” → 根据上下文确定是 Sentinel 还是 Cluster |
| 统一术语 | 转换为标准术语 | “消息中间件” → “消息队列” |
| 从问题转向缺口 | 改写成需要补齐的证据 | “Redis 怎么样” → “Redis 高可用机制、运维复杂度、性能指标” |
| 禁止补充事实 | 不能加入没有证据的内容 | 不要把”可能”变成”确定” |
HyDE:先猜答案再检索
一个特别好用的技巧是 HyDE(Hypothetical Document Embeddings)。
用户问”Redis Cluster 运维复杂度怎么样”,直接把这句话向量化,能表达的信息很有限。HyDE 的做法是先让模型生成一份”假想的答案”:
Redis Cluster 运维复杂度较高,主要体现在节点管理、数据迁移、故障排查等方面。需要关注分片策略、slot 分配、集群扩缩容等操作……
然后拿这段假想文档去向量检索,找到真实的技术文档。因为真实文档通常以”陈述答案”的方式写作,而用户 Query 以”提出问题”的方式表达,HyDE 生成的假想答案能缩小这个语义差距。
注意:HyDE 生成的不是真实答案,可能包含错误。它的作用只是帮助定位语义相近的文档,不能直接作为最终证据。
5W1H:把模糊问题拆成可查询的子问题
还有一种改写思路是用 5W1H 框架把用户的模糊问题拆解成具体的、可查询的子问题:
| 维度 | 说明 | 示例 |
|---|---|---|
| What | 发生了什么 | 退款当前是什么状态 |
| Who | 涉及谁 | 哪一个用户、订单、支付渠道 |
| When | 什么时候发生 | 退款何时发起、最后一次状态更新 |
| Where | 发生在哪里 | 卡在退款服务还是支付渠道 |
| Why | 为什么会发生 | 是否存在失败码或回调异常 |
| How | 怎样执行的 | 退款链路已执行了哪些步骤 |
这个方法特别适合用户输入非常笼统的场景。比如用户问”Redis 怎么样”,拆解后变成:What(Redis 有哪些特性)、Who(我们的团队能不能 hold 住)、How(运维复杂度怎么样)、When(版本选择)——每一个子问题都可以独立检索。
召回优化:多路召回 + 混合排序
第五个坑是召回效果。
单一路由的检索效果有天花板。向量检索擅长语义匹配,但对精确匹配(错误码、版本号)效果不稳定;关键字检索擅长精确匹配,但对同义词无能为力。
我的方案是多路召回 + 混合排序:
1 | 用户查询 |
RRF 排序的核心公式:
1 | RRF Score = Σ 1/(k + rank) |
它不关心原始分数,只关心排名。某个 Chunk 如果同时被多路召回且排名都靠前,最终分数就更高。这个算法简单、稳定,而且不需要解决不同检索系统分数不可比的问题。
业务规则调整的例子:
- 官方文档优先于社区博客
- 当前版本优先于历史版本
- 标题精确命中可以加权
- 高度重复的结果降权
怎么评估检索效果?
做了多路召回、混合排序,怎么知道效果好不好?我主要看这几类指标:
| 指标 | 说明 | 适用场景 |
|---|---|---|
| Recall@K | 标准相关内容中有多少出现在前 K 个结果 | 衡量召回覆盖率 |
| Precision@K | 前 K 个结果中有多少真正相关 | 衡量结果精准度 |
| Hit Rate@K | 前 K 个中至少出现一个正确结果 | 快速判断”有没有召回” |
| MRR | 第一个正确结果排在什么位置 | 衡量排序质量 |
| NDCG | 同时考虑相关性等级和排序位置 | 有分级相关性时最准确 |
| 证据覆盖率 | 必需证据中有多少被成功召回 | Agent 场景最核心的指标 |
在 Agent 场景里,我最关注的是证据覆盖率——不是”召回来的东西相不相关”,而是”做出判断需要的证据有没有被召回来”。Recall@K 再高,如果关键证据没召回,Agent 的结论照样可能是错的。
迭代式检索:一次查不够就多查几次
第六个坑是一次检索经常查不全。
用户问”为什么昨天上线之后订单接口响应时间突然升高”,第一轮检索可能只召回发布记录——“上线了新的优惠计算模块”。但仅凭这个信息不能确定就是原因,还需要查数据库慢查询、缓存命中率、下游接口耗时等。
这就是迭代式检索:
1 | 第一轮检索 → 判断证据是否充分 → 不充分 → 生成下一轮 Query → 第二轮检索 → ... |
判断证据是否充分是关键。我的做法是让模型输出结构化评估:
1 | { |
missing_evidence 就是下一轮检索的输入。
停止条件要同时设效果和成本两类:
- 效果条件:证据充分、连续两轮无新信息
- 成本条件:达到最大轮数、超时、Token 预算用完
这里和第二篇聊的 TAO 状态循环有一个呼应:迭代式检索的”检索—评估—补充检索”循环,本质上就是一个针对检索任务的 Observe-Think-Act 循环。每一轮检索都是一次 Act,评估证据充分性就是 Observe,决定下一步查什么就是 Think。
性能优化:能不查就不查
检索链路越复杂,延迟和成本越高。我的优化策略是三层:
第一层:基础优化
- 优化中间件索引和 Mapping
- 避免生成宽泛昂贵的 Query
- 消除重复 Embedding 和重复查询
- 能预计算的不要放到在线链路
第二层:最小充分检索
不要试图一次性解决所有问题,找出当前最小的、可以独立完成的目标。
比如用户问”帮我看看 Redis Cluster 适不适合我们,如果适合再看看怎么部署”。第一阶段只需要判断”适不适合”——如果结论是不适合,部署方案根本不需要查。
判断最小目标的原则:完成之后能否给用户一个有意义的结果。如果能,它就是一个合适的最小目标。
第三层:预算控制
给每次检索设置 Budget:
1 | { |
简单问题走轻量路径,证据不足再逐步升级;预算不足则主动降级,用当前最好的结果回答。
这个思路和第三篇聊的动态编排是一回事——不是预先规定一条固定策略,而是根据当前状态动态决定下一步怎么做。上下文管理里的动态编排控制的是”往窗口里塞什么”,检索里的预算控制的是”花多少资源去找”。
缓存:能不查就不查
还有最后一层优化:缓存。同样的查询反复出现是很常见的——同一个用户在不同轮次问类似的问题,或者不同用户问同一个热门话题。
| 缓存类型 | 适用场景 | 注意事项 |
|---|---|---|
| 检索结果缓存 | 同一 Query 短时间内重复出现 | 原始查询要先改写再匹配缓存,不然”Redis 怎么样”和”Redis 好不好”匹配不上 |
| 最终答案缓存 | FAQ、制度查询等答案稳定的问题 | 依赖实时数据时容易返回过期答案,要谨慎 |
我的建议是优先缓存检索结果,谨慎缓存最终答案。检索结果缓存的”过期”影响相对可控(最多就是返回稍旧的文档),但最终答案缓存过期可能直接给出错误结论。
写在最后
做了一段时间 Agent 检索之后,我最大的感受是:RAG 只是检索的起点,不是终点。
Chunk、Embedding、Top-K 这些是基础,但真正让检索好用的是围绕它们的一系列机制:
- 查询规划:让 Agent 知道自己缺什么、去哪里找
- 查询改写:把用户口语转换成有效的检索 Query
- 多路召回:不同检索方式互补
- 迭代式检索:一次查不够就多查几次
- 性能优化:能不查就不查,能少查就少查
这些东西看起来零散,但其实是一条线:怎么让 Agent 在需要的时候,用合适的代价,找到真正需要的信息。
第三篇里我写过一个公式:Context Window = f(Context)。现在可以把 f 里的 Retrieve 这一步展开看了——它不是一个简单的 search(query),而是一个包含查询规划、查询改写、多路召回、迭代补充、性能控制的完整子系统。
到这里,意图识别、规划与执行、上下文管理、智能检索——Agent 的四个核心模块都聊完了。回头看这四篇,其实它们不是独立的:意图识别决定检索什么,检索结果进入上下文,上下文影响规划决策,规划又触发新的检索。这是一个循环,而这个循环,可能就是 Agent 系统最本质的东西。