写在前面

这是”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”是远远不够的。真正需要回答的是:

  1. 什么时候需要检索? ——不是每一步都需要查
  2. 到底要查询什么? ——从目标推导信息缺口
  3. 应该去哪里查询? ——不同信息在不同地方
  4. 一次没查到怎么办? ——迭代式检索
  5. 查出来太多怎么办? ——过滤、排序、截断

这五个问题才是 Agent 检索的核心。


查询什么:让 Agent 知道自己缺什么

第一个坑是:Agent 怎么知道自己应该查询什么?

最简单的做法是直接拿用户输入去检索。比如用户问”Redis 适不适合我们的场景”,直接拿这句话去向量检索。但这样做的效果通常很差——“这个”指代什么?”我们的场景”具体是什么场景?模型不知道,检索也不知道。

我在实践中摸索出一个四步法:

第一步:明确当前要做出的判断。 查询是手段不是目的。比如用户问”Redis 适不适合我们的场景”,首先要解析出判断标准——用户关心的是什么?是高可用、运维成本,还是性能?

第二步:推导所需证据。 如果判断标准是”高可用”,那就需要知道 Redis Sentinel 和 Redis Cluster 的高可用机制、故障恢复时间、数据一致性保证等。

第三步:区分已知和未知。 如果用户之前说过”我们的 QPS 是 5 万”,那这个信息已经知道了,不需要再查。

第四步:判断缺口是否值得查询。 不是所有信息都值得查。有些信息不重要,或者不影响最终判断。在资源紧张的时候,可以跳过不关键的查询——这是一种降级机制。

这四步说起来简单,但实现的时候需要在 Prompt 里明确教会 Agent 这个思考过程。我的做法是在 System Prompt 里加一段指引:

1
2
3
4
5
6
7
8
9
10
11
12
{
"retrieval_planning": {
"instruction": "在发起检索之前,先回答以下问题:",
"questions": [
"我要做出什么判断?",
"做出这个判断需要哪些证据?",
"我已经知道什么?",
"我还缺什么?",
"缺失的信息是否值得查询?"
]
}
}

去哪里检索:查询空间不是固定的

第二个坑是:Agent 怎么知道应该去哪里找信息?

我一开始的设计是给 Agent 一个统一的 search(query) 工具,背后接向量数据库。后来发现这远远不够。

举个例子,用户问”Redis 7.0 的 Function 特性怎么样”,这个问题:

  • 技术文档在知识库里(向量数据库)
  • 但 Redis 7.0 的版本发布时间在官网上(外部 API)
  • 用户之前讨论过 Redis 版本选择在对话历史里(对话记录)

一个 search(query) 工具根本覆盖不了这些场景。

后来我重新设计了检索架构,核心思路是查询空间是动态的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"query_space": {
"knowledge_base": {
"description": "技术文档、产品说明、最佳实践",
"suitable_for": ["技术特性查询", "方案对比", "配置说明"],
"retrieval_method": "vector_search"
},
"conversation_history": {
"description": "用户之前说过的话、确认的方案",
"suitable_for": ["用户约束", "已确认决策", "偏好"],
"retrieval_method": "keyword_search"
},
"tool_cache": {
"description": "之前工具调用的结果",
"suitable_for": ["性能数据", "配置信息", "测试结果"],
"retrieval_method": "id_lookup"
},
"external_api": {
"description": "实时数据",
"suitable_for": ["最新版本", "实时价格", "系统状态"],
"retrieval_method": "api_call"
}
}
}

这里有个关键点:可查询空间会随着任务执行不断变化。比如第一轮检索发现 Redis 7.0 有 Function 特性,第二轮可能就需要去查 Function 的性能测试数据——这个数据源在第一轮的时候根本不需要考虑。

如果用 ES 做检索,还有一个实用技巧:Index Profile(索引说明)。给每个索引建一份元数据说明,告诉 Agent 这个索引存了什么、适合查什么、不适合查什么:

1
2
3
4
5
6
7
8
9
{
"index_name": "refund_records",
"description": "存储用户退款申请及支付渠道处理状态",
"suitable_for": ["查询退款是否成功", "查询退款为什么没有到账"],
"not_suitable_for": ["查询商品是否允许退款"],
"fields": ["order_id", "user_id", "refund_status"],
"freshness": "near_real_time",
"authority": "refund_service"
}

这样 Agent 在决定去哪里查的时候,可以先看 Index Profile,而不是盲目地把所有索引都搜一遍。索引多了之后,还可以做成索引树——先检索”哪个索引最可能有答案”,再检索具体索引。


Chunk 切割:不是一刀切就完事了

第三个坑是 Chunk 切割。

最开始我用的是最简单的方案:每 500 Token 切一段,Overlap 50 Token。效果嘛……能用,但经常出问题:

  • 一个完整的 API 说明被从中间切开了,前半段有参数说明,后半段有返回值,但分别在两个 Chunk 里
  • 表格被切成了好几段,每段都没有表头,模型看到的是一堆没有上下文的数字
  • 一张架构图的说明文字被切到了不同的 Chunk,图和说明对不上

后来我改成了混合切割方案

1
文档结构确定大边界 → 语义判断内部边界 → 长度限制负责兜底

具体来说:

  1. 先按文档的标题、章节结构切割
  2. 如果某个章节太长,再用语义相似度判断在哪里切
  3. 如果语义切割后某个 Chunk 还是太长,就用固定长度兜底

对于特殊内容,我做了单独处理:

内容类型 处理方式
表格 整表为一个 Chunk,附带表名和表头;大表格按行分组,每组重复表头
代码 按函数/类切割,保留函数签名和注释
图片 提取周围文本作为描述,同时保存原图
长列表 保留共同标题,按语义分组切割

父子文档:小粒度召回,大粒度阅读

单一切割粒度有一个经典矛盾:切小了,召回精准但上下文不完整;切大了,上下文完整但噪声多。

我最终采用的方案是父子文档

1
2
3
原始文档 → 切成大 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
2
3
4
5
6
用户查询
├─ 向量检索 → 语义相似的技术文档
├─ 关键字检索 → 精确匹配的错误码、版本号
└─ 对话历史检索 → 用户之前说的约束

统一结构 → 硬条件过滤 → 去重 → RRF 排序 → 业务规则调整

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
2
3
第一轮检索 → 判断证据是否充分 → 不充分 → 生成下一轮 Query → 第二轮检索 → ...

证据充分 / 达到预算 → 停止

判断证据是否充分是关键。我的做法是让模型输出结构化评估:

1
2
3
4
5
6
7
8
9
{
"sufficient": false,
"known_facts": ["昨天发布了新的优惠计算模块"],
"missing_evidence": [
"优惠计算模块的平均执行耗时",
"数据库慢查询情况",
"缓存命中率变化"
]
}

missing_evidence 就是下一轮检索的输入。

停止条件要同时设效果和成本两类:

  • 效果条件:证据充分、连续两轮无新信息
  • 成本条件:达到最大轮数、超时、Token 预算用完

这里和第二篇聊的 TAO 状态循环有一个呼应:迭代式检索的”检索—评估—补充检索”循环,本质上就是一个针对检索任务的 Observe-Think-Act 循环。每一轮检索都是一次 Act,评估证据充分性就是 Observe,决定下一步查什么就是 Think。


性能优化:能不查就不查

检索链路越复杂,延迟和成本越高。我的优化策略是三层:

第一层:基础优化

  • 优化中间件索引和 Mapping
  • 避免生成宽泛昂贵的 Query
  • 消除重复 Embedding 和重复查询
  • 能预计算的不要放到在线链路

第二层:最小充分检索

不要试图一次性解决所有问题,找出当前最小的、可以独立完成的目标。

比如用户问”帮我看看 Redis Cluster 适不适合我们,如果适合再看看怎么部署”。第一阶段只需要判断”适不适合”——如果结论是不适合,部署方案根本不需要查。

判断最小目标的原则:完成之后能否给用户一个有意义的结果。如果能,它就是一个合适的最小目标。

第三层:预算控制

给每次检索设置 Budget:

1
2
3
4
5
6
7
8
9
{
"retrieval_budget": {
"max_time_ms": 3000,
"max_rounds": 3,
"max_queries": 10,
"max_documents": 50,
"token_budget": 8000
}
}

简单问题走轻量路径,证据不足再逐步升级;预算不足则主动降级,用当前最好的结果回答。

这个思路和第三篇聊的动态编排是一回事——不是预先规定一条固定策略,而是根据当前状态动态决定下一步怎么做。上下文管理里的动态编排控制的是”往窗口里塞什么”,检索里的预算控制的是”花多少资源去找”。

缓存:能不查就不查

还有最后一层优化:缓存。同样的查询反复出现是很常见的——同一个用户在不同轮次问类似的问题,或者不同用户问同一个热门话题。

缓存类型 适用场景 注意事项
检索结果缓存 同一 Query 短时间内重复出现 原始查询要先改写再匹配缓存,不然”Redis 怎么样”和”Redis 好不好”匹配不上
最终答案缓存 FAQ、制度查询等答案稳定的问题 依赖实时数据时容易返回过期答案,要谨慎

我的建议是优先缓存检索结果,谨慎缓存最终答案。检索结果缓存的”过期”影响相对可控(最多就是返回稍旧的文档),但最终答案缓存过期可能直接给出错误结论。


写在最后

做了一段时间 Agent 检索之后,我最大的感受是:RAG 只是检索的起点,不是终点。

Chunk、Embedding、Top-K 这些是基础,但真正让检索好用的是围绕它们的一系列机制:

  • 查询规划:让 Agent 知道自己缺什么、去哪里找
  • 查询改写:把用户口语转换成有效的检索 Query
  • 多路召回:不同检索方式互补
  • 迭代式检索:一次查不够就多查几次
  • 性能优化:能不查就不查,能少查就少查

这些东西看起来零散,但其实是一条线:怎么让 Agent 在需要的时候,用合适的代价,找到真正需要的信息。

第三篇里我写过一个公式:Context Window = f(Context)。现在可以把 f 里的 Retrieve 这一步展开看了——它不是一个简单的 search(query),而是一个包含查询规划、查询改写、多路召回、迭代补充、性能控制的完整子系统。

到这里,意图识别、规划与执行、上下文管理、智能检索——Agent 的四个核心模块都聊完了。回头看这四篇,其实它们不是独立的:意图识别决定检索什么,检索结果进入上下文,上下文影响规划决策,规划又触发新的检索。这是一个循环,而这个循环,可能就是 Agent 系统最本质的东西。

站内搜索

没有找到内容!