RAG 还是 Agent 自主检索?Claude Code 删掉向量库之后,我把选型讲透了

前段时间刷到「小林面试笔记」的一篇推文,标题很扎人:面试官冷笑,"RAG 和 Agent 自己搜文件怎么选?"
候选人的回答我看了三遍,每一遍都觉得耳熟,因为我带过的项目里,几乎每个团队都在同一个地方卡住过:
"RAG 能做语义匹配,文件搜索只能查关键词,所以应该优先用 RAG。"
面试官接着问了一句很轻的话:"你比较的是搜索算法,还是取资料的流程?"
这一句直接把问题从"技术选型"拉回到了"你到底在解决什么"。后面候选人又答"那让 Agent 全部自己找,就不用维护索引了",面试官再补一刀:"它没想到的目录和关键词怎么办?每次重新探索要花多少轮?"
整场对话其实只做了一件事:把两个被混在一起的概念拆开。
这篇就把这件事从头讲清楚。不是复述那篇文章,而是把它背后那条真实的技术演进线、三套能直接跑的代码、以及企业项目里必须补的六个坑,一次讲完。看完你至少能做到两件事:面试时不再答"语义比关键词强",做项目时知道第一版该不该上向量库。
文章目录
- [RAG 还是 Agent 自主检索?Claude Code 删掉向量库之后,我把选型讲透了](#RAG 还是 Agent 自主检索?Claude Code 删掉向量库之后,我把选型讲透了)
-
- 一、痛点场景:三次事故,都不是"检索坏了"
- 二、是什么:先分清两个坐标轴
-
- [2.1 RAG 原本根本不是"向量检索"](#2.1 RAG 原本根本不是“向量检索”)
- [2.2 预建索引:在模型醒来之前,就决定了它看到什么](#2.2 预建索引:在模型醒来之前,就决定了它看到什么)
- [2.3 自主检索:下一次搜什么,由上一次看到什么决定](#2.3 自主检索:下一次搜什么,由上一次看到什么决定)
- [2.4 把它们放进同一个坐标系](#2.4 把它们放进同一个坐标系)
- [2.5 大白话版本](#2.5 大白话版本)
- [2.6 术语对照,避免下次再吵](#2.6 术语对照,避免下次再吵)
- 三、为什么用:收益和代价,都要摊开算
-
- [3.1 预建索引:把成本前置,换复用](#3.1 预建索引:把成本前置,换复用)
- [3.2 自主检索:把成本后置,换适应](#3.2 自主检索:把成本后置,换适应)
- [3.3 硬数据:这不是观点之争](#3.3 硬数据:这不是观点之争)
- [3.4 一句话概括两者的分工](#3.4 一句话概括两者的分工)
- 四、它是怎么演进过来的
- 五、怎么用:三套可以直接跑的代码
-
- [5.1 预建索引:一百行,看清每个零件](#5.1 预建索引:一百行,看清每个零件)
- [5.2 自主检索:工具不重要,闸门才重要](#5.2 自主检索:工具不重要,闸门才重要)
- [5.3 换成真模型](#5.3 换成真模型)
- [5.4 混合:企业项目里真正该先做的形态](#5.4 混合:企业项目里真正该先做的形态)
- [5.5 版本对齐,这是最容易被跳过的一步](#5.5 版本对齐,这是最容易被跳过的一步)
- [5.6 上线前必须补的六件事](#5.6 上线前必须补的六件事)
- 六、竞品对比:五条路线,各自的死穴
- 七、常用场景:什么活派给谁
- [八、面试官高频 13 问](#八、面试官高频 13 问)
- 九、收尾:把问题问对
- 九、收尾:把问题问对
一、痛点场景:三次事故,都不是"检索坏了"
先说三个我自己遇到或者被同事拉着排查过的现场。它们看起来毫不相关,但根因是同一个。

场景一:凌晨两点的工单机器人
客服系统接了知识库,用户问"退款几天到账"。机器人回复得特别流畅,把退款流程、审核节点、需要准备的材料讲得明明白白,唯独没有回答"几天"。用户追问一次,它换了个说法又讲了一遍流程。
值班同事第一反应是"向量模型不行,换个大点的"。可把命中的三篇文档翻出来一看,里面确实没有"时效"这两个字------退款时效写在另一份《超期订单处理规范》里。
这就是向量检索最典型的失效方式:它找的是"读起来像问题的段落",而不是"能回答问题的段落"。问句讲的是"到账时间",文档讲的是"时效为 T+1 到 T+3 个工作日",语义上是近的,但排序没把它顶上来。它答得越流畅,你越不会怀疑它。
场景二:改完编译不过
团队在内部 Agent 上接了一个代码索引服务,每天凌晨三点重建。有天下午同事把一个函数从 checkoutV2 改名成 checkout_v2,Agent 还在按旧名字改调用方。生成的代码语法完全正确,只是引用的东西已经不存在了。
大家的第一反应是"索引服务挂了,重建一下"。没有挂。它忠实地描述了三个小时前的代码库。
这是索引类方案的结构性缺陷,不是 bug:只要资料会变,索引就一定有一段时间在说谎,而且它说谎的时候不会报错。你查到的每一条结果看起来都合理,只是它们属于过去。
场景三:账单翻了六倍
有个团队把 Agent 的自主检索开得很足,允许它自己列目录、自己 grep、自己读文件。上线第一周账单涨了六倍。抽样看 trace 才发现,Agent 在一个 3000 多文件的仓库里猜错了关键词,然后连续读了 41 个文件,累计 31 万 token,最后还是没找到那个配置项,给了一个"可能在这个文件里"的答案。
三个场景,三种失败。但它们有一个共同点:
大家讨论的都是"用什么方式搜索",而真正决定成败的是"什么时候搜、读到什么之后还要不要继续"。
第一种是流程太短(只搜一次,搜错就完了),第二种是流程太旧(决策在问题来之前就做完了),第三种是流程太长(没有停手条件)。三个都不是算法问题。
二、是什么:先分清两个坐标轴
2.1 RAG 原本根本不是"向量检索"
RAG 这三个字母是 Retrieval-Augmented Generation,检索增强生成。它描述的是一个很朴素的思路:模型自己不知道的事,先去外面取证据,再基于证据回答。
它从头到尾没有规定过"必须用向量",也没有规定过"只能检索一次"。2020 年那篇原始论文里,检索器用的是稠密向量索引,那是因为当时的任务(开放域问答)恰好适合,不是因为 RAG 的定义里写着向量。
后来工程界把一整套做法打包成了"RAG 方案",大家就默认了这套打包:分块、嵌入、向量库、top-k、拼上下文。这套东西是 RAG 的一种实现,不是 RAG 本身。
这个区别非常关键。因为一旦你把 RAG 等同于"向量检索",接下来的所有讨论都会滑向"向量和关键词谁强",而这根本不是我一开始要解决的问题。
2.2 预建索引:在模型醒来之前,就决定了它看到什么
我们平时说的 RAG,准确说法是预建索引式检索。它有两个阶段:
- 离线阶段:解析文档、切块、建倒排或嵌入、写入索引。这一步和用户问题无关,是提前做好的。
- 在线阶段:用户提问、检索 top-k、重排、拼上下文、生成。这一步是固定的,每次跑同一条链路。
它的核心特征只有一句话:相关性的判断,发生在模型开始思考之前。
2.3 自主检索:下一次搜什么,由上一次看到什么决定
Agent 自主检索(agentic search)把检索从"一步"变成了"一圈":
- 先看目录,或者先猜一个关键词
- 调用工具(grep、read、SQL、HTTP)拿回结果
- 根据结果判断还缺什么,再决定下一步
- 直到证据够了,或者确认范围内没有
它的核心特征也只有一句话:相关性的判断,发生在循环里,由模型每一步决定。
Anthropic 在 2025 年 9 月那篇《Effective context engineering for AI agents》里给这个模式起了个名字,叫 just-in-time 上下文加载:模型手里只留轻量标识(文件路径、查询语句、URL),真正的内容等到需要时再用工具取。文章里有句话很到位------这其实是在模仿人:我们不背下整个图书馆,我们建目录、建收件箱、建书签,需要时再去取。
2.4 把它们放进同一个坐标系

这张图是全文最重要的一张。横轴是取资料的流程 (固定 还是 自主循环),纵轴是搜索方式(精确匹配 还是 语义匹配)。四个格子分别是:
| 位置 | 谁在这里 | 典型形态 |
|---|---|---|
| 左上:语义 + 固定 | 经典向量 RAG | chunk、embed、top-k,一次到位 |
| 右上:语义 + 循环 | Agentic RAG | 把向量检索做成一个工具,Agent 决定调几次 |
| 左下:精确 + 固定 | 站内搜索、BM25 | 一次召回,按分数排,人自己点进去 |
| 右下:精确 + 循环 | Claude Code、Cursor | grep、读文件、再 grep |
一旦画成这张图,那句"RAG 和文件搜索怎么选"就自动失效了。因为它们根本不在同一根轴上:RAG 说的是流程(预取),文件搜索说的是工具(字面匹配)。你完全可以做一个"用 grep 的 RAG",也完全可以做一个"固定只查一次向量库的 Agent"。
面试官问"你比较的是搜索算法,还是取资料的流程",指的就是这个。
2.5 大白话版本
图书馆的比喻。
你要找"某本书里关于税率的某一页"。
预建索引式 RAG,像是图书馆员提前给你做好了卡片目录:按主题、按关键词、按作者都排好了。你报一个主题,他三十秒给你抽出五本书、翻到大概那几页。快,稳定,可复制。但如果这本书昨天刚换了新版,卡片还是旧的;如果这本书的内容被裁成了一段一段,那页表格可能正好被切成两半。
Agent 自主检索,像是你自己拿着借书证进书库:先看分区牌,走到经济类,抽出一本翻目录,看到某一章提到"附录 B",于是转头去找附录 B。慢,费腿,但你能顺着线索一直追下去,而且你摸到的是今天这一版。
现实里最高效的做法,是先让馆员告诉你"大概在 C 区第三排",然后你自己走过去翻。这就是第五节要讲的混合方案。
2.6 术语对照,避免下次再吵
| 说法 | 严格含义 | 常见误用 |
|---|---|---|
| RAG | 先取外部证据再生成,是流程概念 | 被等同于"向量库方案" |
| 向量检索 | 一种搜索方式,靠嵌入相似度 | 被等同于 RAG |
| BM25 / 全文检索 | 一种搜索方式,靠词频与逆文档频率 | 被当成"落后方案" |
| Agentic Search | 检索发生在工具循环里 | 被当成"不用 RAG 了" |
| Agentic RAG | Agent 把检索(可以是向量)当工具用 | 被当成"Agent 替代 RAG" |
| 长上下文直塞 | 整篇塞进窗口,不检索 | 被当成"检索的替代品" |
三、为什么用:收益和代价,都要摊开算
3.1 预建索引:把成本前置,换复用
它的收益是三件事:
复用。一份资料解析一次,可以服务一万次提问。这是它最被低估的价值------不是"快",是"不用重复做同一件脏活"。
可控。链路是固定的,所以每一步都能单独观测、单独评估、单独回归。召回不准就调查询和索引,候选不准就调排序和范围。出问题能定位到环节。
可预算。一次查询花多少 token,基本是常数。对 To B 产品来说,这一点比准确率还重要,因为报价要能算出来。
它的代价也是三件事:
会过期。资料一变,索引就开始落后。这不是工程没做好,是结构决定的。你可以把重建频率提到每分钟,但永远追不上"刚保存的那个文件"。
会漏。切块这个动作本身就是有损的。一段话被切成两半,前半段没有主语,后半段没有条件,两边单独看都不完整。表格被切开更是灾难。而漏掉的那些,不会以"我漏了"的形式出现,只会以"我不知道"或者更糟的"我编一个"的形式出现。
多一份副本。索引里存着资料的内容摘要,它存在另一套基础设施上,往往还配着另一套权限。对一个把代码当命根子的公司来说,这是一块实打实的新攻击面。
3.2 自主检索:把成本后置,换适应
收益:
永远读的是当前状态。文件改完 100 毫秒后再问,读到的是新的字节。没有"索引延迟"这个概念,因为根本没有索引。
能顺着线索追。读到一份说明,里面引用了另一份配置,配置又指向实际实现------下一步该读什么,是上一份材料告诉你的,用户最初的问题里根本没提。这种多跳追查,靠一次 top-k 是做不出来的。
不需要预设范围。有些任务开始时你只知道症状,不知道答案在哪个目录。硬要先建索引,等于要求你在看懂问题之前就决定去哪找。
少一套基础设施。没有嵌入模型、没有向量库、没有重建任务、没有切块策略要调。对中小团队来说,"少一个会半夜挂掉的东西"本身就是巨大的收益。
代价:
轮数就是钱。一次向量查询是一个来回,一次自主检索可能是三到六次工具调用,每次都要过一次模型。Anthropic 自己写得很直白:运行时探索比读取预先算好的数据慢。
依赖"起手上下文"。这是最容易被忽略的一点。Agent 得先知道"大概去哪找",才搜得动。如果它不知道这个仓库里有个 docs/ 目录,它可能永远想不起来去看。所以真正落地的项目,几乎都会配一份目录地图、一份 CLAUDE.md、或者一组 LSP 工具,让它的第一跳不至于瞎猜。
结果不稳定。同一个问题问两次,走的路径可能完全不同。这对"必须可复现"的场景(审计、合规)是硬伤。
会提前停。找到一个看起来相关的文件就收工,是自主检索最常见的失败方式。它不会告诉你"我只看了三个文件就下结论了"。
3.3 硬数据:这不是观点之争
几个公开可查的节点,值得记住:
SWE-bench 的第一次转身。 2023 年 10 月的原始 baseline 用的是标准 RAG:把代码库切块、嵌入、取 top-k、生成补丁。解决率 1.96%。同期的 SWE-agent 把"检索"换成"工具"(打开文件、滚动、改行),一下跳到 12.47%。同一个时代、同一批题,差六倍。
Claude Code 的删除动作。 2025 年 5 月,Anthropic 把 Claude Code 里整套向量检索链路拿掉了------嵌入管道、本地向量库、切块启发式,全部删掉,只留一个 grep。作者 Boris Cherny 后来在播客里说,团队原本以为这是用质量换简单,结果质量反而更好,"这点很意外"。Cat Wu 补了一句关键的:如果真想要语义搜索,你可以通过 MCP 把它接回来。
AAAI 2026 那篇论文。 亚马逊的《Keyword Search Is All You Need》,控制变量做得非常干净:同一个模型(Claude 3 Sonnet,200K 上下文,温度 0.001),同一批六个数据集,同一个评测框架,唯一的差别是检索器------一边是 Bedrock Knowledge Base 加 Titan 嵌入,另一边是一个 ReAct Agent 调 pdfmetadata、rga、pdfgrep 三个命令行工具。
结果:忠实度 0.81 对 0.86,达到 94.52%;上下文召回 0.68 对 0.77,达到 88.05%;答案正确率 0.59 对 0.65,达到 91.48%。
更有意思的是两个"反转点":在 FinanceBench 这种长表格、密排版的财报上,关键词 Agent 的答案正确率 30.40%,反超了 RAG 的 24.24%;在 BlockchainSolana 这种术语要求极精确的技术文档上,达成率冲到 99.97%。
论文的结论写得很克制,但分量很重:向量数据库对于高质量检索并非必需,基于简单关键词工具的 Agent 方案,对许多应用来说是可行的替代。
这三件事合起来说明什么?
说明"必须上向量库"从来不是技术规律,而是 2023 年那批工具链留下的路径依赖。当模型足够会搜、语料有结构、更新又频繁时,把检索交给循环里的模型,是一个更简单也更准的选择。
但它不说明向量没用了。 论文里那 5.5% 的忠实度差距是真的,召回率差 12% 也是真的。在语料又大又无结构、用户用词和文档用词完全对不上的场景里,向量仍然是唯一能用的把手。
3.4 一句话概括两者的分工

左边是预取 :功夫花在问题来之前,把资料组织成一个可复用的查询入口。
右边是现取:功夫花在问题来之后,把查找变成一串由观察驱动的动作。
一个是批发,一个是零售。批发的单价低,但你要先压货;零售不用压货,但每单都贵一点。选哪个,取决于你的货会不会过期。
四、它是怎么演进过来的

这条线值得完整走一遍,因为每一步都是被上一代的失败逼出来的。
2020:RAG 论文。 动机很单纯------模型的参数里没有你的私有知识,那就去外面取。取完拼进上下文再生成。这时候"检索"是个黑盒,用什么实现都行。
2023 上半年:chunk + embed 成为标配。 LangChain、LlamaIndex 把这套流程封装成几个函数,任何人三行代码就能跑起一个"知识库问答"。整个行业第一次有了统一的 RAG 心智模型,也第一次把"RAG"和"向量库"绑死在了一起。
这一年埋下的问题,两年后才还:大家开始把工具链当成方法论。
2023 年 10 月:SWE-bench 打脸。 1.96% 这个数字当时很刺眼。它暴露的是"相似不等于相关"在代码上格外致命:你要找 processPayment 的定义,向量给你一堆 handlePayment、paymentProcessor,最相似的那个往往不是你要的那个。标识符是精确匹配问题,硬要用相似度解,方向就错了。
2023 年 10 月:SWE-agent 换成工具。 12.47%。它没换模型,没换数据,只是把"检索"从一次向量查询,换成了"打开文件、往下滚、改这一行"。这是第一次有人用 benchmark 证明:给模型工具,比给模型候选更有效。
2024:混合检索和 rerank 补锅。 既然关键词准、语义全,那就两个都上,再加一个重排模型把结果压一遍。同期 GraphRAG 试图把"实体关系"这种结构信息补回去------方向是对的,但构图成本高,而且图也会过期。这一年行业的主旋律是"在旧框架里打补丁"。
2025 年 5 月:Claude Code 删掉向量库。 这是标志性事件。不是小创业公司妥协,是 Anthropic 在自己最核心的编程产品上,把一整套基础设施拿掉了,而且公开说效果更好。
2025 年 9 月:just-in-time 写进官方博客。 Anthropic 把那件事上升成方法论:不要把可能用到的资料都预处理进上下文,只保留轻量标识,运行时按需取。同时给了配套的手段------上下文压缩、结构化笔记、子 Agent。这时候"检索"已经不再是 RAG 的专属动词,而是 Agent 的一个动作。
2026:AAAI 论文把它推广到非代码领域。 之前可以说"代码有精确标识符,所以 grep 好用,别的领域不一样"。这篇论文用财报、区块链文档、论文综述六个数据集说明:不行,这个规律是通用的。
2026 之后:收敛成混合。 现在再看一线团队的做法,几乎都是同一个形状:索引负责定位候选,Agent 负责读原文、核对版本、按需补查。 索引没有消失,它从"前门"退到了"目录"。
一个容易被误读的点,我单独说一句:
Claude Code 删掉向量库,不是因为"向量检索不行",是因为"代码这个语料不满足向量的前提"。代码有精确名称、结构关系明确、每天都在变、还不能随便复制到第三方基础设施上------四条全踩在向量的短板上。换个语料,结论可能完全反过来。
把这句话想明白,你就不会在面试里说"向量已经过时了",也不会在项目里说"我们必须上向量库"。
五、怎么用:三套可以直接跑的代码
下面三段代码我都跑过,Python 3.11+,第一个和第二个只用标准库,第三个需要 openai(可选)。
目录结构:
text
rag-vs-agentic-search/
code/
bm25_index.py 预建索引(纯标准库)
agentic_search.py 自主检索(工具 + 预算 + trace)
hybrid_search.py 混合检索(索引定位 + 原文核对 + 补查)
docs/ 示例语料
示例语料我故意做成一个小陷阱:问"退款多久到账",答案不在《退款说明》里,而在《超期订单处理规范》的"时效说明"一节。这正是场景一里那个事故。
5.1 预建索引:一百行,看清每个零件
python
# bm25_index.py
from __future__ import annotations
import math
import os
import re
from dataclasses import dataclass, field
# 英文/数字按词切,中文按单字切。生产环境换成 jieba 效果更好。
TOKEN_RE = re.compile(r"[a-zA-Z0-9_]+|[\u4e00-\u9fff]")
SUFFIXES = (".md", ".txt", ".py", ".yaml", ".yml", ".json")
def tokenize(text: str) -> list[str]:
return TOKEN_RE.findall(text.lower())
@dataclass
class Chunk:
doc_id: str # 相对路径,必须保留,否则回答没法给出处
offset: int # 原文中的字符偏移,方便回到原文补读
text: str
@dataclass
class BM25Index:
k1: float = 1.5
b: float = 0.75
chunk_size: int = 600
overlap: int = 80
chunks: list[Chunk] = field(default_factory=list)
tf: list[dict[str, int]] = field(default_factory=list)
lens: list[int] = field(default_factory=list)
df: dict[str, int] = field(default_factory=dict)
@classmethod
def build(cls, root: str, **kw) -> "BM25Index":
idx = cls(**kw)
for dirpath, _, names in os.walk(root):
for name in sorted(names):
if not name.endswith(SUFFIXES):
continue
path = os.path.join(dirpath, name)
rel = os.path.relpath(path, root).replace("\\", "/")
with open(path, encoding="utf-8", errors="ignore") as fh:
text = fh.read()
step = max(idx.chunk_size - idx.overlap, 1)
for start in range(0, max(len(text), 1), step):
piece = text[start:start + idx.chunk_size]
if piece.strip():
idx.chunks.append(Chunk(rel, start, piece))
idx._fit()
return idx
def _fit(self) -> None:
for ch in self.chunks:
counts: dict[str, int] = {}
toks = tokenize(ch.text)
for t in toks:
counts[t] = counts.get(t, 0) + 1
self.tf.append(counts)
self.lens.append(len(toks))
for t in counts:
self.df[t] = self.df.get(t, 0) + 1
def search(self, query: str, top_k: int = 5) -> list[tuple[Chunk, float]]:
n = len(self.chunks)
if not n:
return []
avg = sum(self.lens) / n
q = tokenize(query)
scored: list[tuple[float, int]] = []
for i, counts in enumerate(self.tf):
s = 0.0
for t in q:
f = counts.get(t, 0)
if not f:
continue
idf = math.log(1 + (n - self.df[t] + 0.5) / (self.df[t] + 0.5))
denom = f + self.k1 * (1 - self.b + self.b * self.lens[i] / (avg or 1))
s += idf * f * (self.k1 + 1) / denom
if s > 0:
scored.append((s, i))
scored.sort(reverse=True)
return [(self.chunks[i], s) for s, i in scored[:top_k]]
这段是核心逻辑,__main__ 里补三行就是命令行入口。跑一下:
bash
python bm25_index.py docs "退款多久到账"
text
chunks=4 vocab=159
[ 2.78] 超期订单处理规范.md@0 # 超期订单处理规范 超过 90 天的订单不允许自动退款...
[ 2.03] config.yaml@0 refund: gateway: alipay timeout_ms: 8000 ...
[ 1.23] 退款说明.md@0 # 退款流程说明 用户发起退款后,系统会先校验订单状态...
这段代码里有三个设计决定,比算法本身重要:
第一,Chunk 必须带 doc_id 和 offset。 只存文本块是最常见的偷懒,代价是你永远回不到原文。没有 offset,回答就没法给出处;没有出处,用户就无法核对,这个系统在 To B 场景里等于不可用。
第二,build 和 search 是两个方法。 这个物理分界提醒你:建索引是离线的、批量的、可以慢慢来的;检索是在线的、单次的、必须快的。很多项目把这两件事混在一个请求里,于是每个问题都在重新解析整个目录。
第三,我故意用 BM25 而不是向量。 一半是为了让代码零依赖能跑;另一半是因为在"有明确标识符"的语料上,BM25 常常比向量更准 。timeout_ms、refund、错误码这类东西,就是要精确匹配。
5.2 自主检索:工具不重要,闸门才重要
python
# agentic_search.py(节选,完整版见同目录)
import json
import os
import re
from dataclasses import dataclass, field
@dataclass
class Budget:
max_steps: int = 6 # 最多几轮"想 -> 查"
max_files: int = 12 # 最多打开几个文件
max_chars: int = 40000 # 最多读回多少字符(约 2 万 token)
steps: int = 0
files: set[str] = field(default_factory=set)
chars: int = 0
def spend_step(self) -> bool:
if self.steps >= self.max_steps:
return False
self.steps += 1
return True
def spend_read(self, path: str, n_chars: int) -> bool:
if n_chars > self.max_chars - self.chars:
return False
if path not in self.files and len(self.files) >= self.max_files:
return False
self.files.add(path)
self.chars += n_chars
return True
工具层,三个就够:看目录、按字面找位置、读某一段。
python
class Tools:
def __init__(self, root: str, budget: Budget | None = None):
self.root = os.path.abspath(root)
self.budget = budget or Budget()
self.trace: list[dict] = []
def _safe(self, rel: str) -> str:
"""权限边界:只允许读 root 之内的路径。"""
p = os.path.abspath(os.path.join(self.root, rel))
if p != self.root and not p.startswith(self.root + os.sep):
raise PermissionError(f"越界访问: {rel}")
return p
def grep(self, pattern: str, path: str = ".", max_hits: int = 20) -> dict:
base = self._safe(path)
try:
rx = re.compile(pattern, re.IGNORECASE)
except re.error:
rx = re.compile(re.escape(pattern), re.IGNORECASE)
hits = []
for dirpath, _, names in os.walk(base):
for name in sorted(names):
if not name.endswith((".md", ".txt", ".py", ".yaml", ".yml", ".json")):
continue
full = os.path.join(dirpath, name)
rel = os.path.relpath(full, self.root).replace("\\", "/")
with open(full, encoding="utf-8", errors="ignore") as fh:
for no, line in enumerate(fh, 1):
if rx.search(line):
hits.append({"path": rel, "line": no,
"text": line.strip()[:120]})
if len(hits) >= max_hits:
break
if len(hits) >= max_hits:
break
if len(hits) >= max_hits:
break
self.trace.append({"tool": "grep", "args": {"pattern": pattern}, "ret": hits})
return {"hits": hits, "count": len(hits)}
def read_file(self, path: str, start: int = 0, end: int = 1500,
line: int | None = None) -> dict:
p = self._safe(path)
with open(p, encoding="utf-8", errors="ignore") as fh:
text = fh.read()
if line: # grep 命中的是行号,换算成字符偏移
lines = text.splitlines(keepends=True)
start = sum(len(x) for x in lines[:max(line - 1, 0)])
end = start + 1500
piece = text[start:end]
if not self.budget.spend_read(path, len(piece)):
return {"error": "预算不足,拒绝这次读取",
"hint": "请基于已有证据回答,或缩小范围"}
return {"path": path, "start": start, "end": start + len(piece),
"text": piece, "truncated": start + len(piece) < len(text)}
主循环,不到三十行,但每一行都在防一个坑:
python
def run(question: str, tools: Tools, policy, verbose: bool = True) -> dict:
history: list[dict] = []
while True:
if not tools.budget.spend_step():
return {"answer": "预算耗尽,只给出候选,不下结论",
"history": history, "stopped": "budget"}
action = policy(question, history)
tool, args = action.get("tool"), action.get("args", {})
if verbose:
print(f"[step {tools.budget.steps}] {tool}({args}) <- {action.get('why', '')}")
if tool == "finish":
return {"answer": args.get("answer", ""), "history": history,
"stopped": "finish"}
fn = getattr(tools, tool, None)
if fn is None:
obs = {"error": f"未知工具 {tool}"}
else:
try:
obs = fn(**args)
except Exception as exc: # 工具报错要回灌给模型,别让循环崩掉
obs = {"error": f"{type(exc).__name__}: {exc}"}
history.append({"action": action, "obs": obs})
没有 API Key 也能跑,我给了一个离线降级策略:把问句里的疑问词剥掉再 grep,命中就读那一段,没命中就换更短的词再来一轮。
bash
python agentic_search.py docs "退款多久到账"
text
[step 1] grep({'pattern': '退款多久到账'}) <- 先按原句找
-> {"hits": [], "count": 0}
[step 2] grep({'pattern': '退款到账'}) <- 没命中,剥掉疑问词再来一轮
-> {"hits": [{"path": "超期订单处理规范.md", "line": 6,
"text": "退款到账时效为 T+1 到 T+3 个工作日。"}], "count": 1}
[step 3] read_file({'path': '超期订单处理规范.md', 'line': 6}) <- 读原文,核对条件
-> {"text": "退款到账时效为 T+1 到 T+3 个工作日。\n银行卡渠道可能延长至 T+7,节假日顺延。"}
[step 4] finish({'answer': '退款到账时效为 T+1 到 T+3 个工作日。'}) <- 证据够了
=== budget ===
steps=4 files=1 chars=46
四步,读了一个文件,花了 46 个字符。对比场景三那 31 万 token,差别不在模型,在闸门。
这段代码里我想强调四个点:
第一,grep 返回的是 path + line + 一行摘要,不是全文。 这是自主检索里最容易做错的地方。一次返回五千行,模型会被淹掉,后面的判断全废。给位置,让它自己决定读哪段。
第二,read_file 里嵌着预算判断。 预算如果写在 Prompt 里("你最多读十个文件"),模型是会忘的,也是会绕的。写在工具里,它连试都试不了。
第三,Budget 有三条上限,缺一不可。 只有轮数上限,它可能一轮里读一百个文件;只有字符上限,它可能空转二十轮猜词。三条一起卡,成本才是真的可控。
第四,允许失败。 第一次 grep 没命中是常态,不是异常。真正的异常是"没命中还硬答"。所以策略里要显式留一条"改词重试"的分支,和一条"找不到就说找不到"的出口。
5.3 换成真模型
ScriptedPolicy 只是演示。真跑的时候换成这个:
python
class LLMPolicy:
SYSTEM = (
"你是一个检索 Agent。可用工具:\n"
'list_dir(path) 看目录\n'
'grep(pattern, path) 按正则找位置,返回 path/line/text\n'
'read_file(path, line) 读命中行附近原文\n'
'finish(answer) 给出结论\n'
"规则:\n"
"1. 候选不等于证据,必须 read_file 看过原文再下结论;\n"
"2. 一次只输出一个 JSON 动作,不要解释;\n"
"3. 找不到就说找不到,不要猜。"
)
def __init__(self, model: str = "gpt-4o-mini", base_url: str | None = None):
from openai import OpenAI
self.client = OpenAI(base_url=base_url) if base_url else OpenAI()
self.model = model
def __call__(self, question: str, history: list[dict]) -> dict:
msgs = [{"role": "system", "content": self.SYSTEM},
{"role": "user", "content": question}]
for item in history:
msgs.append({"role": "assistant",
"content": json.dumps(item["action"], ensure_ascii=False)})
msgs.append({"role": "user",
"content": json.dumps(item["obs"], ensure_ascii=False)[:4000]})
resp = self.client.chat.completions.create(
model=self.model, messages=msgs, temperature=0,
response_format={"type": "json_object"})
return json.loads(resp.choices[0].message.content)
base_url 这个参数是给国内模型留的口子,任何 OpenAI 兼容接口都能直接换。历史里把 observation 截到 4000 字符,是为了防止某一轮灌太多把上下文撑爆------这个细节不写,长任务跑到第六轮就会开始乱。
5.4 混合:企业项目里真正该先做的形态

python
# hybrid_search.py(核心逻辑)
CONSTRAINT_WORDS = ("时效", "到账", "期限", "超时", "重试", "条件", "例外",
"前提", "版本", "限制", "不适用", "除外")
class HybridRetriever:
def __init__(self, root, index, tools=None):
self.root = os.path.abspath(root)
self.index = index
self.tools = tools or Tools(root, Budget(max_steps=8, max_files=8,
max_chars=30000))
self.trace: list[str] = []
def retrieve(self, question: str, top_k: int = 3) -> list[Evidence]:
# 1) 索引给候选:只给位置,不给结论
hits = self.index.search(question, top_k=top_k)
self.trace.append(f"索引召回 {len(hits)} 个候选")
# 2) 逐个回原文
evidence: list[Evidence] = []
for chunk, _score in hits:
got = self.tools.read_file(chunk.doc_id, chunk.offset,
chunk.offset + 1200)
if "error" in got:
self.trace.append(f"读取被拒: {chunk.doc_id}")
continue
evidence.append(Evidence(chunk.doc_id, chunk.offset,
got["text"], "index"))
# 3) 检查约束词覆盖:候选读完了但没有一条提到"时效",说明漏了
joined = "".join(e.text for e in evidence)
missing = [w for w in CONSTRAINT_WORDS if w in question and w not in joined]
if missing:
self.trace.append(f"候选未覆盖约束词 {missing},触发补查")
for w in missing:
for h in self.tools.grep(w, max_hits=5).get("hits", []):
if any(e.path == h["path"] for e in evidence):
continue
rd = self.tools.read_file(h["path"], line=h["line"])
if "error" not in rd:
evidence.append(Evidence(h["path"], rd["start"],
rd["text"], "grep"))
return evidence
跑一下:
bash
python hybrid_search.py docs "退款到账有什么例外情况"
text
=== trace ===
- 索引召回 2 个候选: 超期订单处理规范.md@0, 退款说明.md@0
- 候选未覆盖约束词 ['例外'],触发补查
=== check ===
{'sources': ['超期订单处理规范.md', '退款说明.md'], 'chars': 323,
'from_index': 2, 'from_grep': 0, 'enough': True}
这段代码里有一个我认为最能体现"工程判断"的设计:约束词覆盖检查。
索引不知道"时效""例外""前提"这些词对你的业务有多重要。它只知道哪个块和问句更像。所以候选读完了,很可能恰好缺了那句话------场景一就是这么翻的。
补的办法很土,但有效:把这类词写成一个常量表,读完候选后检查一遍,缺了就用 grep 补。它不需要模型,不增加延迟,却能挡住最常见的一类漏答。
真实项目里这张表要按业务慢慢长出来。金融场景会长出"费率""起购""赎回",法务场景会长出"除外""不可抗力",运维场景会长出"超时""重试""限流"。
5.5 版本对齐,这是最容易被跳过的一步
索引里的 offset 是建索引那一刻的偏移。文件被人编辑过之后,同一个 offset 指向的可能已经是另一段话。这不会报错,只会让你悄悄读错地方。
最小成本的防法,是建索引时把 mtime 或 git revision 一起存下来,读之前比一次:
python
import os
import subprocess
def file_rev(path: str) -> str:
"""取文件的版本标记:优先 git commit,退化成 mtime。"""
try:
return subprocess.check_output(
["git", "log", "-1", "--format=%H", "--", path],
text=True, stderr=subprocess.DEVNULL).strip() or "untracked"
except Exception:
return f"mtime:{int(os.path.getmtime(path))}"
def assert_fresh(root: str, rel: str, indexed_rev: str) -> None:
current = file_rev(os.path.join(root, rel))
if current != indexed_rev:
raise StaleIndexError(
f"{rel} 已变更(索引 {indexed_rev[:8]},当前 {current[:8]}),"
f"请重新读取原文而不是复用候选")
抛异常比打日志好。打日志没人看,抛异常至少会让这次回答降级或者重试,而不是给出一个看起来对的旧答案。
5.6 上线前必须补的六件事

前五件决定它能不能用,最后一件决定你敢不敢改它。
一、停手条件。 轮数、文件数、token 三个上限,加一条"连续两轮没有新证据就强制收敛"。最后这条最有用,因为最常见的失控不是走太远,是在原地打转。
二、版本对齐。 如上。候选必须带 path、offset、版本,读之前比一次。
三、权限在工具层做。 不要在 Prompt 里写"不许读 /etc",那是建议,不是边界。要在 read_file 里按当前用户身份查 ACL。还有一条更隐蔽的:索引本身也不能放宽可见范围。如果索引把 A 部门没权限的文档摘要也召回了,模型就会在回答里带出它的内容。搜得到不等于读得到,这条要落到两个地方。
四、返回位置,不返回全文。 见 5.2。这一条决定了后续所有轮次的质量上限。
五、全链路 trace。 每次工具调用的入参、返回摘要、耗时、轮次,全部留。没有它,"答错了"就是一个无法复现的玄学问题。
六、固定评测集。 拿二十条真实问题,跑两条链路,比四个数:任务完成率、证据覆盖率、平均轮数、平均 token。只看"单次搜索快不快"一定会选错,因为自主检索的收益在第二轮、第三轮才出现。
六、竞品对比:五条路线,各自的死穴

| 维度 | 预建索引 RAG | Agent 自主检索 | 混合(索引 + Agent) | 长上下文直塞 | GraphRAG |
|---|---|---|---|---|---|
| 数据新鲜度 | 差,取决于重建频率 | 最好,永远读当前 | 好,看候选是否带版本校验 | 最好 | 差,图也要重建 |
| 首次上线成本 | 中,要建管道 | 低,给工具就行 | 中,两套都要 | 极低 | 高,要抽实体关系 |
| 单次延迟 | 低且稳定 | 高且波动 | 中 | 低(但首字延迟高) | 中 |
| 单次成本 | 低且可预测 | 高,随轮数波动 | 中 | 极高(长文档) | 中高 |
| 语义模糊查询 | 强 | 弱,除非接语义工具 | 强 | 强 | 中 |
| 精确标识符 | 中(BM25 强、向量弱) | 强 | 强 | 中,容易看漏 | 中 |
| 多跳追查 | 弱,一次到位 | 强 | 强 | 弱 | 中,靠预设关系 |
| 可解释性 | 好,链路固定 | 中,有 trace 才行 | 好 | 差,说不清依据在哪 | 好,有图 |
| 运维负担 | 高,索引要同步 | 低 | 中 | 极低 | 很高 |
| 安全面 | 多一份内容副本 | 只需控好工具权限 | 中 | 无新增 | 多一份图 |
| 最适合 | 大而稳定的语料 | 有结构、常变的语料 | 两者都有 | 单篇长文、强推理 | 关系密集型问答 |
逐条点评几句,这部分面试很爱问:
预建索引 RAG。 死了吗?没有。它在"十万份制度文档、每天几千次提问、答案都在文档里"这个场景里依然是最优解,因为那 5.5% 的忠实度差距实实在在,而语料又不怎么变。它的死穴是更新和切块,不是准确性。
Agent 自主检索。 万能的吗?不是。它在"四年的客服对话记录、用户用词和工单用词完全对不上"这种语料上会非常痛苦,因为根本没有可用的关键词。它的死穴是"起手不知道去哪"和"提前停"。
混合。 现实中 80% 的项目最后都落到这里。它的代价是要维护两套东西,收益是能把两边的死穴互相补上。判断要不要上,看一句话:你的候选回来之后,需不需要回原文核对条件? 需要,就上混合。
长上下文直塞。 被严重低估的一条。200 页的 PDF 现在很多模型能一口吃下,那就别检索了。检索本身是有损的,能整篇读的时候,整篇读最不容易漏。它的死穴是成本和延迟,以及"内容太多时模型反而抓不住重点"这个已知现象。
GraphRAG。 思路最漂亮,落地最重。它解决的是"多跳关系"这个真问题,但建图成本高、图会过期、实体抽取质量不稳定。除非你的业务天然是关系密集的(供应链、股权、案件关联),否则不建议第一版就上。
七、常用场景:什么活派给谁

| 场景 | 语料特征 | 推荐起点 | 理由 |
|---|---|---|---|
| 客服 / 工单 FAQ | 大、稳定、问题重复 | 预建索引 + 混合检索 | 复用率高,成本可预测 |
| 代码库问答 / 改代码 | 天天变、标识符精确 | 自主检索 | 索引追不上提交速度 |
| 财报 / 合同审阅 | 长表格、条款号精确 | 精确检索 + 逐条核对 | 表格被切块就废了 |
| OnCall 排障 | 错误码、日志、配置链 | 自主检索 | 需要沿调用链追 |
| 制度 / 合规问答 | 稳定、必须给引用 | 混合 + 强引用校验 | 答错代价高,必须可核对 |
| 单篇长文摘要 | 一篇,能塞进窗口 | 直接全文读 | 检索反而丢信息 |
| 多源调研报告 | 多网站、实时 | 自主检索 + 网页抓取 | 数据不在你手里 |
再补三个具体的判断口诀,比表格好用:
语料会不会变? 会变、而且变得勤,就别急着建索引。
问题里有没有"精确的东西"? 错误码、函数名、订单号、条款号------有,就优先字面检索。这类问题用向量是拿大炮打蚊子,还打不准。
答错的代价有多大? 代价高的(合规、金额、医疗),必须强制"读原文 + 给引用",不管用哪条链路。代价低的(闲聊、推荐),一次向量召回就够了,别为了 2% 的准确率多花三倍的钱。
八、面试官高频 13 问
Q1:RAG 和 Agent 自己搜文件,是两套互斥方案吗?
不是。RAG 说的是"先取证据再生成"这个流程,文件搜索说的是"怎么匹配内容"这个工具。两个维度正交。你可以用 grep 做 RAG,也可以让 Agent 去调向量库。
Q2:为什么 Claude Code 敢把向量库删了?
因为代码这个语料同时满足四个条件:有精确标识符、结构关系明确、变化极快、不能随便复制到第三方基础设施。这四条恰好是向量检索的四个短板。换个语料,比如四年的客服对话,结论就反过来。
Q3:那什么情况下必须用向量检索?
三个条件同时成立时:语料大且无结构(没法靠目录导航)、用户用词和文档用词对不上(关键词搜不到)、问题不需要多跳(一次召回就够)。典型的例子是海量客服记录、论文库、法规全文。
Q4:Agent 自主检索的成本怎么控?
三道闸门:轮数、文件数、token。加一个软条件:连续两轮没有新证据就收敛。闸门写在工具里,不写在 Prompt 里。
Q5:索引过期怎么处理?
两条路。一是让候选带版本(git rev 或 mtime),读之前比一次,不一致就强制回原文;二是把重建做成增量,按文件改动触发而不是定时全量。第一条更便宜,建议先做。
Q6:怎么证明"证据够了",而不是"搜到几条就算完"?
把"够"定义成可检查的条件,而不是模型的感觉。工程上常见的三种:约束词覆盖(问题里的关键限定词在证据里都出现过)、来源多样性(不能只有一个文件)、可引用性(每条结论都能指到具体段落)。三者都是代码能判的。
Q7:混合方案里,索引该返回什么给 Agent?
返回位置和版本,不返回结论。具体是 path、offset 或行号、一小段摘要、版本标记。摘要的作用是让 Agent 判断"要不要读",而不是"答案是什么"。
Q8:chunk 怎么切?
先看文档结构,按标题切,别按固定字数硬切。硬切会把表格、代码块、列表切断,这是最伤的。必须硬切的时候,重叠给到 10% 到 15%,并且把标题层级写进每个块的元数据,否则切出来的块会失去上下文。
Q9:这套东西怎么评测?
固定二十到五十条真实问题,每条标注"答案在哪几个文件的哪几段"。然后比四个数:任务完成率、证据覆盖率(该读的段落读到了几成)、平均轮数、平均 token。别只看最终答案对不对,因为答案对可能是蒙对的。
Q10:权限怎么管?
两个地方都要管。工具层按当前身份校验 ACL,索引层也不能放宽可见范围。后者更容易被忽略:索引里如果存了没权限的文档摘要,模型会在回答里把它带出来,而且你查日志都看不出来源。
Q11:什么时候干脆别检索,直接全文塞进去?
文档能塞进上下文、而且问题需要对全文做整体理解时(总结、找矛盾、跨章节推理),直接塞。检索是有损的,能整篇读就别切。判断标准很简单:塞进去的 token 成本,比检索加核对的总成本更低,就直塞。
Q12:模型能力不够时,自主检索会怎样?
会退化成"随机游走"。它猜不出该搜什么词,读了两段就下结论,或者在一个目录里反复打转。所以自主检索对模型能力有硬门槛,2023 年的模型做不了这件事。能力不够的时候,老老实实用索引,别硬上。
Q13:如果让你从零做一个企业知识库问答,第一版怎么做?
第一版不建向量库。先做三件事:把文档结构理清楚、做一份目录地图、上 BM25 加约束词覆盖检查。跑两周,看真实问题里有多少条命不中。命不中的那批,再针对性地加语义检索。这样你既知道向量该用在哪,也不会一上来就背一套要还两年的基础设施。
九、收尾:把问题问对
回头看那场面试,候选人一共错了三次,但三次错在同一个地方:他一直在回答"哪个搜索算法更好",而问题问的是"哪种取资料的流程适合这个任务"。
三句话总结这篇:
第一,RAG 不等于向量库。它是"先取证据再生成"的流程,向量只是其中一种搜索方式。把这两个概念分开,一半的争论会自动消失。
第二,预建索引和自主检索,是在拿不同的东西换不同的东西。前者用"资料组织的复用"换"过期和漏召回",后者用"多轮成本"换"新鲜和可追查"。没有更优,只有更匹配。
第三,真实项目里最常见的是混合:索引负责找到门口,Agent 负责进去看,并且必须回到原文核对版本和条件。候选永远不是证据,这句话可以贴在工位上。
最后留一个我自己的判断标准,比任何表格都好用:
拿你手上最常见的二十个问题,逐条问自己:一个刚入职、有目录权限、会用搜索框的新人,能不能找到答案?
如果大部分能,那你的向量库可能是在为少数流量维护一整套基础设施,该把它从"前门"降级成"兜底工具"。
如果大部分不能,那说明问题不在检索方式,在资料的命名和结构------先去整理目录,比换模型有用得多。
代码都在同目录的 code/ 下,docs/ 是那份带陷阱的示例语料,可以直接跑。想验证文中那个"RAG 会漏掉时效"的结论,把 docs/超期订单处理规范.md 改个名字再跑一次就行。
本文为原创文章,如需转载,请联系作者获得授权,并注明出处。
?**
第一版不建向量库。先做三件事:把文档结构理清楚、做一份目录地图、上 BM25 加约束词覆盖检查。跑两周,看真实问题里有多少条命不中。命不中的那批,再针对性地加语义检索。这样你既知道向量该用在哪,也不会一上来就背一套要还两年的基础设施。
九、收尾:把问题问对
回头看那场面试,候选人一共错了三次,但三次错在同一个地方:他一直在回答"哪个搜索算法更好",而问题问的是"哪种取资料的流程适合这个任务"。
三句话总结这篇:
第一,RAG 不等于向量库。它是"先取证据再生成"的流程,向量只是其中一种搜索方式。把这两个概念分开,一半的争论会自动消失。
第二,预建索引和自主检索,是在拿不同的东西换不同的东西。前者用"资料组织的复用"换"过期和漏召回",后者用"多轮成本"换"新鲜和可追查"。没有更优,只有更匹配。
第三,真实项目里最常见的是混合:索引负责找到门口,Agent 负责进去看,并且必须回到原文核对版本和条件。候选永远不是证据,这句话可以贴在工位上。
最后留一个我自己的判断标准,比任何表格都好用:
拿你手上最常见的二十个问题,逐条问自己:一个刚入职、有目录权限、会用搜索框的新人,能不能找到答案?
如果大部分能,那你的向量库可能是在为少数流量维护一整套基础设施,该把它从"前门"降级成"兜底工具"。
如果大部分不能,那说明问题不在检索方式,在资料的命名和结构------先去整理目录,比换模型有用得多。
代码都在同目录的 code/ 下,docs/ 是那份带陷阱的示例语料,可以直接跑。想验证文中那个"RAG 会漏掉时效"的结论,把 docs/超期订单处理规范.md 改个名字再跑一次就行。
本文为原创文章,如需转载,请联系作者获得授权,并注明出处。