给搜索 Agent 装上 RAG 和记忆系统
上一篇文章的结尾我立了个 flag:搜索 Agent 的”搜索 → 抓取 → 基于内容回答”和 RAG 的”检索 → 取文档 → 基于文档回答”在逻辑上几乎同构,Agent Loop、trace、规则引擎应该都能直接复用。这两周(roadmap 的第四、五周)把 RAG 和记忆系统做完了,flag 基本成立。这篇文章分两部分:第一部分讲 agentic RAG 怎么落地,第二部分讲记忆系统的分层设计——后者是这两周真正的重头戏,会展开讲。照例,完整代码和实验数据在文末的仓库里。
先交代节奏:第四周 RAG 原计划单独一周,实际和第五周压进了同一周做。不是赶进度,而是动手前发现这两块不是并列关系,而是地基与上层:
1 | 第四周 RAG → 造出「向量检索器」retriever |
记忆系统里”按语义找回相关事实”这个能力,缺的部件恰好就是 RAG 的检索器。分开两周做反而要走回头路。
动手前还修正了两个认知偏差,简单记一下:
偏差一:”RAG 已经过时了”。 调研后发现退场的只是 2023 年那种”切块塞库、top-k 拼接”的朴素 RAG,RAG 本身演化成了 agentic RAG 并下沉为基础设施。对”X 已经过时了”这类判断要警惕——往往不是消失,而是换了形态。
偏差二:”我还没学过 RAG”。 厘清定义后发现,我第二、三周做的搜索 Agent——迭代循环 + 规则判断是否检索 + 纠正降级 + trace——本身就是一个完整的 agentic RAG,只是数据源是网页、检索方式是关键词。Agent 的能力是”循环 + 工具”的组合,换工具不换骨架。
RAG 的基本原理(分片、向量化、召回、重排、生成)我之前写过两篇:RAG(上)和 RAG(下),本文不再重复,直接进入工程部分。
第一部分:Agentic RAG
和朴素 RAG 的根本区别:谁拥有推理路径
朴素 RAG 是一条人写死的管线:来一个问题,无脑 retrieve-then-generate,检索结果再差也带着往下跑。agentic RAG 把检索降级成 Agent 循环里的一个工具——查不查、查几次、查完不满意要不要换关键词再查,由模型(加上规则引擎兜底)在循环里动态决定。推理路径的所有权从开发者转移给了系统本身,这和上一篇里 Workflow 与 Agent 的分界线是同一条。
说明:本周语料只有 6 篇本地技术笔记、切出来 100 个 chunk,这个体量直接塞长上下文完全装得下,根本”不需要” RAG。只有当语料大到塞不下、或需要权限控制、成本控制、可追溯引用时,检索才是必选项。所以这周做 RAG 的目的是学机制,不是因为非用不可——但小语料有个好处:每条查询我都能一眼判断召回对不对,评测不用猜。
chunking:被一组 A/B 数据钉死的第一道天花板
写正式代码前照例先做实验(第三周”json_schema 吃掉 tools”的教训之后,这已经是固定流程)。本周最值钱的一组数据来自 chunking 策略的 A/B:用 15 条标注查询(答案确实落在那 6 篇笔记里)评测两种切法:
| 切法 | hit@1 | top-1 平均相似度 | chunk 数 / 平均尺寸 |
|---|---|---|---|
| 按 Markdown 标题切 | 87% (13/15) | 0.764 | 100 个 / 395 字 |
| 按字数切(400 字滑窗 / 80 重叠) | 53% (8/15) | 0.683 | 94 个 / 389 字 |
注意第三列:两种切法的 chunk 数量和平均尺寸几乎一样,检索器、embedding、top_k 全没动,唯一变量是”切的时候有没有保住语义结构”。34 个百分点的召回差距纯粹来自这一件事。”检索质量是 RAG 的天花板”这句话我在 RAG(上)里就写过,但这次是第一次亲手把它跑成数字——chunking 不是预处理小事,而是 RAG 的第一道天花板,且关键变量是结构完整性,不是 chunk 大小。
按标题切听起来简单,实现里有几个不做就会翻车的细节:
1 | m = HEADING_RE.match(line) # ^#{1,6} 标题行 |
- 用一个标题栈维护当前位置的完整层级路径,每个 chunk 的 section 字段形如
RAG-1 文档摄入 > Chunking 策略——这个路径既是检索时的上下文线索,也是后面引用约束的来源字段。 - 代码块内的
#是注释不是标题,解析时要跳过 ``` 包裹的区域,否则技术笔记里一段 shell 代码就能把切分搅乱。 - 切完还有两道修正:太短的 section(孤立标题、一两行的段)向相邻段合并,避免产出一堆信息量为零的碎 chunk;超长的 section 再按段落滑窗切,相邻块带约 100 字重叠。
- 每个 chunk 的正文带上
# 标题路径前缀再去做 embedding——让向量里编码进”这段话是讲什么的”的结构信息。
向量库:100 个 chunk 用不上 FAISS
embedding 用千问的 text-embedding-v3,动手前先验证链路:返回 1024 维向量,实测已经 L2 归一化(范数 = 1.000),这意味着余弦相似度直接算点积;单批 3 条延迟 592ms;一次传 23 条(超过单批上限 10)会自动切成 3 批正确返回。链路稳定,放心建库。
向量库没有上 FAISS 或 Chroma,就用 numpy:
1 | scores = self.vectors @ query_vector # 余弦相似度 = 点积 |
- 语料只有 100 个 chunk,numpy 全内存检索完全够用,零外部依赖,持久化就是
.npy+.json两个文件。学习阶段不要为了”像生产系统”而引入重型组件。 - 向量已归一化,余弦相似度退化为一次矩阵点积。
argpartition先以 O(N) 拿到无序的 top-k,再只对这 k 个排序,不对全量打分结果做完整排序。
这个存储类有一个为下文埋的设计:构造时接受一个 namespace 参数,不同 namespace 在同一目录下用不同文件前缀(docs_vectors.npy / memory_facts_vectors.npy)。当时是给记忆系统预留的——第二部分会看到它怎么被用上。会不会串库?实验里往文档库塞了一条”毒事实”探针,确认它不会出现在记忆侧的召回里,反之亦然,隔离成立。
接回 Agent Loop:检索是一个工具,路由是一条新支路
预期中最顺利的部分:retrieve_documents 只是工具注册表里多的一项,agent.py 主循环一行没动。但有个地方躲不掉——纠正机制要重新设计优先级。v2 只有一条规则(该联网没联网就纠正),v3 变成两级:
1 | any_tool_called = has_searched or has_retrieved |
- 问题涉及本项目内容(”第三周的降级阈值是多少”)→ 优先纠正去查本地库;通用事实性问题 → 沿用 v2 的联网纠正。两个纠正各只注入一次。
- 前置条件从 v2 的
already_searched升级成any_tool_called——只要模型碰过任何工具,就尊重它 stop 的判断。否则会出现:模型已经查完本地库准备回答,规则又强行让它联网,多跑冤枉轮次。这是第三周假阳性修复在双工具下的推广。
多一个工具,就多一条”该不该用这个工具”的判定支路——这是工具数量的隐藏成本。 第二周读到”工具集臃肿是最常见失败模式”时只觉得是工具选择的问题,现在看,每加一个工具,路由规则、纠正逻辑、测试用例都要跟着配套,成本是结构性的。
引用约束:三个失败模式的对策,也是记忆系统的输入
roadmap 点名了 RAG 的三个经典失败模式,对策各有归属:
| 失败模式 | 表现 | 对策 |
|---|---|---|
| 召回不准 | 答案在库里但没召回 | 根因大多在 chunking(见上文 A/B),加 min_score 过滤低分噪音 |
| 引用错位 | 把 A chunk 的内容标成 B 的来源 | 评测时逐条核对引用的 chunk 是否真含该事实 |
| 过度总结 | 把多个 chunk 揉成流畅但失真的一段话 | 强制分点回答、每点对应来源,不允许自由叙述 |
引用约束的具体做法:system prompt 强制要求事实性陈述标注 [doc#section],来源字段由 chunk 元数据提供。实测 4 个 RAG 用例 3 个引用正确,翻车的那个见后面踩坑表。
但这周对引用约束的理解多了一层:它不只是给用户看的溯源标记。一句话带不带引用,是”模型的自由发挥”和”有依据的结论”之间最廉价的分类器——这个性质在记忆系统里直接变成了长期记忆的写入闸门。这是第二部分的事了。
第二部分:记忆系统
RAG 不是记忆,聊天历史也不是长期记忆
第五周开工前先把三个容易混淆的东西掰开:
- RAG 召回的是离线预处理的文档库——它不随对话生长,昨天聊了什么它不知道;
- 聊天历史是会话内的原始流水——会话一结束就没了,而且越长越塞不下上下文窗口;
- 记忆系统要做的是:跨轮、跨会话地积累信息,并且在每轮调用前决定把什么喂给模型。
读 Anthropic 的 context engineering 文章时有句话点醒了我:记忆系统的难点从来不在”怎么存”——存就是写 json、写向量库,没有任何技术含量——而在”每轮怎么挑”。把这个命题想清楚之后,整个系统的设计就顺了:所有模块都是在为”挑选”服务。
分层总览:一个门面、两层存储、一个挑选器
整个记忆系统收敛在一个 memory/ 包里,对 agent.py 只暴露两个方法——轮前读、轮后写:
1 | agent.py |
分层的依据不是”短期/长期”这个时间标签本身,而是三个问题的不同答案:生命周期多长、怎么写入、每轮以什么方式进 prompt。 下面逐层拆。
短期记忆:双闸门,evict 不等于删除
短期记忆持有最近的完整对话轮次,两道闸门控制规模:轮次闸门(K=3 轮)和字符闸门(预算超限),先触发者先裁。但有两个设计点比”裁剪”本身更重要。
第一,工具的中间结果根本不进短期记忆。 每轮存的 ConversationTurn 只有 user 输入、assistant 最终回答和一行工具调用摘要(retrieve_documents(✓) 这种)——检索回来的 chunk 正文、抓取的网页正文统统不存。依据是 compaction 原则:优先丢”可重新获取的原材料”。检索 chunk 是最典型的可重获取数据,丢了随时能 re-retrieve;而刚刚发生的对话是不可重建的。裁剪优先级排下来是:
1 | 最该丢 ──────────────────────────────────► 最不该丢 |
第二,evict 不是删除,是一次分层压缩。 超出 K 轮的旧轮次被裁掉前要过两道工序:
1 | 超出 K 轮的旧轮次 |
值得注意的是成本控制:低保真摘要只在 evict 时调一次模型,不是每轮都调;累积摘要本身也设了 1200 字符上限,防止”摘要”自己膨胀成新的上下文负担。实测注入 5 轮对话后自动 evict 回 3 轮,产出一条 45 字摘要换掉了被裁的整轮原文——评测语料小、节省还不明显,但这个机制的价值要在几十轮的长对话里才会显现。
长期记忆:三类分治,写入是闸门设计
长期记忆没有做成一个大杂烩列表,而是按”性质”切成三类,分开存、分开写、分开进 prompt:
| 类型 | 性质 | 数据结构 | 写入时机 | 每轮进 prompt 方式 |
|---|---|---|---|---|
| 用户偏好 | 低频、强约束 | key-value | 显式信号触发(”请记住……”) | 全量(很小) |
| 已确认事实 | 中频、会话内高相关 | 列表 + 向量库 | 带引用的结论才晋升 | 语义召回 top-k |
| 主题兴趣 | 累积、弱约束 | 计数器 | 每轮规则累加 | 暂不进(加权召回留了接口) |
三类的写入策略合起来是一个”双通道”设计:规则抽取(零成本)+ 显式信号,唯独没有”每轮让模型抽取记忆”这个看起来最直觉的通道——成本、延迟之外,更重要的是第三周的教训:让模型做高频的判断类任务,假阳性会淹死你。
三类里最有意思的是已确认事实的晋升机制。什么样的内容配进长期记忆?我的答案是复用第一部分的引用约束:
1 | sources = CITATION_RE.findall(unit) # 找 [doc#section] 引用 |
- 逐句(或逐列表项)扫描模型的回答,只有带
[doc#section]引用的句子才有资格晋升——引用即”已确认”,没引用的句子可能是幻觉,宁可漏掉也不污染记忆库。 - 晋升的事实存两份:json 里存元数据(fact / source / turn / 时间戳),向量库的
memory_factsnamespace 里存对应向量——就是第一部分埋的那个伏笔,文档库和记忆库共用同一套 embedding 和存储实现,按 namespace 隔离。 - 一轮里抽出的多条事实批量 embed 一次,不逐条调 API。
召回侧就是再调一次 RAG 的检索器:用户当前问题做 query,在 memory_facts namespace 里查 top-k、过 min_score 卡掉低分噪音。四轮真实对话的实测:累计晋升 10 条事实,每轮稳定召回 3 条相关的;主题计数器同期记下 agent_loop×4、tool_calling×3、fallback×3、rag×2、chunking×2——这份兴趣画像本周只记不用上,加权召回推到下周。
上下文装配:所有”挑选”的最终出口
前面攒下的所有东西——偏好、摘要、事实、最近几轮——最终都要过同一道门:每轮调用前的上下文装配。装配器按固定顺序、分段预算组装 messages:
| 段 | 内容 | 预算(字符) | 进出规则 |
|---|---|---|---|
| 1 | System prompt | 固定 | 永远在 |
| 2 | 用户偏好 | 很小 | 永远在 |
| 3 | 历史摘要 | 小 | 有则在 |
| 4 | 召回的长期事实 | 800 | 语义 top-k |
| 5 | 最近 K 轮对话 | 4000 | 双闸门裁剪 |
| 6 | 当前问题 + 本轮检索 | 最大 | 永远在 |
两条排序逻辑都有实验支撑。纵向:越稳定、约束越强的越靠前;横向:越靠后的内容对模型的影响越强——后者我专门验证过:构造”段 3 旧摘要说上限 500、段 6 本轮检索说 1500”的冲突场景,不加任何”以最新为准”的显式指令,3/3 跟随了本轮新检索。我本来担心第三周”json_schema 吃掉 tools”的优先级坑换个形态复现,结果没有——装配顺序本身就是一种优先级声明,把当前问题和新检索放最末段,模型自然偏向新信息。
超预算时的裁剪也分先后,而且各段手法不同:
1 | 超预算? |
装配的动态性在四轮真实对话里肉眼可见:
| 轮 | 装配出现的段 | 上下文字符 | 事实召回 |
|---|---|---|---|
| 1 | system, current | 1390 | 0 |
| 2 | system, facts, recent, current | 2908 | 3 |
| 3 | system, preferences, facts, recent, current | 3750 | 3 |
| 4 | 同上(满配) | 4026 | 3 |
同一个 Agent,每轮喂进去的上下文构成都不一样——冷启动只有 2 段,第二轮长出事实召回和近期对话,第三轮偏好段加入后满配。平均装配 3018 字符(约 1887 token),全程在 8000 字符总预算内。这张表就是”记忆 = 每轮的挑选问题”的直接证据:装配器本质是一个”挑选 + 降维”器,而这件事换个名字,就叫上下文工程。
一轮对话里记忆的完整时序
把读写两侧合起来,一轮对话里记忆系统的完整动作是:
1 | 用户输入 |
这张时序图直接解释了实测踩到的一个坑:用户说”请记住,回答时先列结论”,当轮不生效,下一轮才生效——因为偏好在轮后才抽取写入,而装配发生在轮前,时序上天然滞后一轮。多数场景可接受(”轮后更新”语义本身是自洽的),但要当轮生效就得在装配前对当前输入做预扫描,属于”知道怎么修、暂时不值得修”的问题。
图里最后那行 save() 也值得一提:每轮结束全量落盘 6 个 json 加 2 个向量库,跨会话还要手动加载快照。这是整个实现里最琐碎、最容易出错的部分——不是设计难,是纯粹的脏活。这份体感对下周很重要,后面说。
踩坑记录
| # | 现象 | 原因 | 解法 |
|---|---|---|---|
| 1 | “请记住:回答涉及本地文档时先列结论”这句纯偏好设置,触发了检索纠正,白白多跑一轮 | 规则引擎的本地指向词含”本地”,对只是提到”本地文档”的偏好语句也判了 True | 偏好语句先过偏好抽取,命中就短路,不再进检索纠正 |
| 2 | 偏好设置当轮不生效,下一轮才出现在 prompt 里 | 轮后写入、轮前装配,时序滞后一轮(见上文时序图) | 符合”轮后更新”语义,可接受;要当轮生效需预扫描 |
| 3 | 引用偶尔用文件全路径而不是文档名,破坏 [doc#section] 契约——还会连带污染事实晋升的 source 字段 |
工具返回里同时给了 doc 和 path 两个字段,prompt 没强制只用 doc |
工具结果隐藏 path,或 prompt 强制只用 doc 字段 |
| 4 | 问”MCP 协议是什么”(纯外部概念),模型先查了本地库再联网,多花一轮 26.5s | “本地优先 + 不认识的名词宁可多查”在外部概念上的副作用 | “本地优先”策略的过度检索成本,暂时接受 |
第 1 条值得多说一句:这是第三周纠正机制假阳性的轮回——规则引擎天生会假阳性,上次修在搜索纠正,这次换到检索纠正又来一遍。只要用无状态规则做有状态判断,假阳性就不是修一次就完的 bug,而是要在每条新规则上重新付一次的税。
v2.0 → v3.0 量化对比
| 指标 | v2.0(第三周) | v3.0(第五周) |
|---|---|---|
| 数据源 | 网页(实时) | 网页 + 本地文档库 |
| 检索方式 | 关键词搜索 | 关键词 + 向量相似度 |
| 记忆能力 | 无 | 短期裁剪 + 三类长期记忆 + 动态装配 |
| 引用粒度 | snippet + URL | chunk 级 [doc#section] |
| 检索准确率 | — | hit@1 87% / hit@3 93% / hit@5 100% |
| 平均轮次(路由用例) | ~2.5 | 1.88 |
| 平均装配上下文 | 未控制 | 3018 字符(8000 预算内) |
| 实现 .py 文件数 / 总行数 | 6 / 1367 | 18 / 3270 |
几个核心认知
- 检索质量是可量化的天花板,且结构 > 大小。 召回不准时,prompt 写得再好、引用约束再严,也救不回压根没被召回的内容。检索和生成是两个独立的优化对象,chunking 的关键变量是语义结构的完整性(87% vs 53% 这组数字会让我记很久)。
- 记忆 = 每轮的挑选问题,本质是上下文工程。 存储没有技术含量,难的是短期裁剪、长期召回、摘要压缩、装配排序共同回答的那个问题:这一轮,什么该进 prompt、什么留在 store 待召回、什么直接丢。
- 写入闸门比召回算法更决定记忆库的质量。 “带引用才晋升””显式信号才记偏好””规则抽取不上模型”——这些看起来保守的闸门,挡住的是幻觉和噪音对记忆库的慢性污染。记忆库一旦脏了,召回越准,错得越自信。
- 给模型的输入,质量比数量更决定上限。 第二周的”工具集臃肿”、第三周的”上下文优先级”、本周的”切法定召回”和”装配即挑选”,其实是同一句话的四个侧面。
- 从”我觉得这样设计对”到”我有数据证明这样设计对”。 设计阶段的每个假设都列出来、实验逐条验证或修正:”业界不用 RAG 了”被修正、”需要显式指令压制旧摘要”被证伪、”分 namespace 不串库”被证实。口号和结论的区别,就是有没有一组可复现的数字。
下一步是 LangGraph。这次手写记忆系统给我攒了一份很具体的痛点清单:每轮手动 save 六个 json 加两个向量库、跨会话手动加载快照、”偏好轮后才写、当轮读不到”的时序坑——这些”状态在哪存、什么时候读写”的脏活,正是 LangGraph 的 checkpointer 和 store 声称要消除的。而装配那部分”挑选”逻辑,我反而想自己留着。框架到底接得住接不住,下篇见分晓。
本文的完整代码(含 experiments.py 九组实验和原始报告 experiment_report.json)在 ai-agent-lab 仓库的 week_4&5 目录。
(全文完)
附:引用
- Anthropic - Effective Context Engineering:compaction 原则与上下文管理
- Microsoft - AI Agents for Beginners:agentic RAG 与记忆分层
- LangGraph - Memory 概念:short-term / long-term 记忆的框架抽象





