这个系列写到第5篇,前面已经把手里的Agent骨架搭到了能跑起来的程度:模型调用、工具调用、简单记忆都有了雏形。但只要你拿真实业务去试,很快就会撞上一堵墙——模型对私有知识和最新信息一无所知。RAG(Retrieval-Augmented Generation,检索增强生成)就是拆这堵墙的主流方案,而RAG落地的基础,是一套从原始文档到可检索向量的知识库。这篇内容就围绕“RAG基础与知识库构建”展开,不讲花哨理论,只讲链路里每个环节该怎么选、怎么做、避哪些坑。
读这篇不需要太多前置知识:跟上系列的人已经有了一个Agent骨架,没有也没关系,单独把这篇当作RAG入门也能跟得上。我会按一条实际跑通的路径来写:先说明RAG要解决什么问题,再讲知识库怎么从清洗、切分、向量化一路搭起来,然后是检索侧的选型与调优,最后给一份排查清单。你可以当它是笔记,也可以按它一步步实现。
1. 为什么 RAG 成了 Agent 工具箱里的必备件
1.1 大模型的两个硬伤:幻觉与知识截止
先用大白话把问题说透。大模型本质上是一个“参数化记忆体”,训练过程中从海量语料里学到的知识,被压缩成了一堆权重。训练一结束,这些参数就冻结了,模型没有增量学习机制。你问它一份内部SOP里的操作步骤,它根本没见过,自然答不出来。更麻烦的是,它不会直接承认不知道,而是根据上下文里相似的表达“猜”一个答案出来。猜出来的东西往往语法通顺、结构完整,看起来像那么回事,但内容完全是编的。这就是大家常说的幻觉。
第二个硬伤是知识时效性。一次大规模训练的算力成本非常高,没有哪个团队能每个月重新训练一版模型,所以模型的知识永远停在训练数据的截止日期。做Agent时你会很快发现,用户真正想查的往往是那些“最新”“私有”“只在某个系统里存在”的信息:某个版本的产品定价、刚发布的活动规则、某条运营通知。这些东西在模型脑子里完全没有位置。面对这两个硬伤,常规思路有两条:一条是微调,直接更新模型权重;另一条就是RAG,从外部给它接一个可查询的资料库。
1.2 RAG 本质上是在给模型“开卷考试”
打个更生活化的比方。微调相当于让一个学生把新知识背进脑子里,成本高、周期长,而且背完还会忘、还会到期;RAG则相当于开卷考试——答题之前允许他翻资料。RAG 的执行链路并不神秘:用户输入一个query,系统先把query变成一个向量,去知识库里检索出最相关的几个文本片段,再把这些片段拼进prompt里,最后让模型基于这些片段生成回答。整个过程中,模型权重一个字节都没改,改的是“喂给它看的上下文”。
这也是RAG特别适合Agent系统的原因。第一是可更新,知识库的文档增删改,几分钟之内就能反映到回答里;第二是可审计,每一条回答都能追溯到底引用了哪一段原文,出了问题找得着源头;第三是可撤回,某条内容不想让模型答了,直接从索引里删掉就行,不需要重新训练。对一个要上线的系统来说,这三点是实打实的工程价值,比“模型更聪明”这种感性的好处重要得多。
1.3 一套 RAG 链路长什么样
整体链路拆开看是两套管线。离线索引侧负责构建知识库:文档加载、数据清洗、分块切分、向量化、写入向量存储;在线查询侧负责服务用户:query处理、向量化、相似度检索、重排序、组装上下文、交给模型生成。两边最容易被忽略却最关键的约束是:必须使用同一个Embedding模型。哪怕只是索引侧和查询侧换了一个模型版本,向量空间就不一致,检索结果会莫名其妙地崩掉。
这套链路看起来环节多,其实真正的难点并不在工程实现,而是集中在三块:一是切分粒度,切大了检索噪声多,切小了语义被切断;二是向量质量,Embedding模型选得对不对直接影响召回的“含金量”;三是检索之后的排序策略,向量召回只是粗排,离“精准命中”还差一步。下面三章就按这个顺序,把这几个坑一个一个填上。
2. 知识库构建:从原始文档到可检索的向量空间
2.1 数据清洗要先于一切:垃圾进,垃圾出
很多人在搭建知识库时第一反应就是选Embedding模型、调切分参数,但其实最先该花时间的是一道看起来没有技术含量的工序——数据清洗。真实业务环境里的文档形态五花八门:PDF、Word、Markdown、HTML、Excel,混在一起。如果不做清洗,切出来的chunk里会夹带页眉、页码、导航链接、重复内容,这些噪声会一并被向量化,检索时可能排到很前面,把真正有用的信息挤下去。
我踩过的一个典型例子是这样的:某份PDF文档每个分页底部都有“第X页 共Y页”的重复文字,切分之后这些带页码的片段噪音很大,用户问一个具体业务点时,答案里总是先冒出来一段页码相关的内容。原因就在于这些“高频重复文本”的向量与很多query都有一点相关性,容易被相似度排序抬到前面。
清洗工作不需要做太多自动化深加工,几条规则脚本就能解决大部分问题:去页眉页脚和页码、PDF里的表格按行列顺序转成文本、扫描件先跑OCR、HTML去掉标签和导航区块、短行和碎片段落按语义合并、重复版本去重、统一换行编码。这里必须提醒一点:批量清洗之前,先随机挑20个样本人工核对一遍清洗结果。PDF尤其要注意导出型文档,有些文本层文字顺序是乱的,清洗脚本看不到,只有肉眼抽查才能发现。如果后续检索效果上不去,别一头扎进优化向量检索,先回头看看清洗这一步是不是有遗漏。
2.2 Chunk 切分:切得好不好,直接决定检索质量
清洗完之后,要做的第一件事是决定“以什么粒度把长文档切成小块”,也就是 chunk 切分。这里有两个核心参数:chunk size 和 overlap。chunk size 是每一段的长度,commonly按 token 数或字符数计算;overlap 是相邻两个 chunk 之间保留的重叠区域,目的是防止一句话或关键信息被硬生生切断,半边落在前一块、半边落在后一块导致检索漏掉。
chunk 切分没有银弹,但要考虑三个维度。第一个是向量模型的输入上限,比如某Embedding模型最大支持512个token,那chunk就不能切到500以上,否则向量化时会截断,尾部信息白白丢失。第二个是业务查询的粒度,如果用户经常问“某个产品的完整退款流程”,那chunk应该偏大一些,让答案完整落在同一段里;如果用户频繁查“故障码含义”“这一条规则最后一句”,chunk偏小反而更准。第三个是文档本身的结构,有标题、段落、列表的文档,天然适合按结构切。
中文场景下,我常用的起步参数是:一个支持512 token的Embedding模型,chunk size 取350到500 token,overlap 取80到120 token。先跑通再调,不要一上来就追求理论最优。切分策略大体分三类,直接列个表对比:
| 策略 | 做法 | 适合场景 | 缺点 |
|---|---|---|---|
| 定长截断 | 按固定token/字符数切 | 快速原型、结构较差文档 | 容易切断语义 |
| 按结构切分 | 按标题、段落、列表边界切 | 文档结构良好 | 结构零散时难以处理 |
| 语义切分 | 根据语句和主题边界切 | 内容冗长、主题跳转频繁 | 计算成本高、参数多 |
给一个最朴素的递归切分参考实现,方便理解原理。真实项目里可以直接用成熟的分割器,但底层思路是一样的:
def recursive_split(text, chunk_size=500, overlap=80): if len(text) <= chunk_size: return [text] # 在 chunk_size 附近找最近的换行符作为断点 cut = text.rfind("\n", 0, chunk_size + 1) if cut == -1: cut = chunk_size first = text[:cut] rest = text[cut - overlap:] # 上一个块尾部保留重叠内容 return [first] + recursive_split(rest, chunk_size, overlap)我实测下来最稳妥的是一个“两段式”方案:先按文档结构粗切一次,对仍然超长的块再做定长细切。原因很简单,纯定长会把章节的整体脉络切断,纯按结构又会遇到天生结构很差的文档,两者结合能兼顾检索精度和实现成本。等到你发现某个特定类别的文档检索总是不理想,再针对它单独定制切分规则,比如表格类文档按行切、问答类文档按问句切。
2.3 Embedding:把文本变成向量的关键一步
Embedding模型的作用是把一块文本编码成高维向量,让语义相近的文本在向量空间里距离更近。选模型的时候,很多人只看评测榜单,但我的建议是结合自己的业务场景临时验证一下。关注几个维度就够了:向量维度、最大输入长度、语言支持、领域适配度、是否开源。中文场景一定要试中文表现好的模型,英文模型在中文上的语义区分度往往肉眼可见地差一大截。
经验里有一个特别坑的地方,就是索引侧和查询侧必须用同一个Embedding模型。这不是一句废话,实际发生过有人索引时用模型A,后来觉得模型B更好,只改了查询侧,结果检索质量全面崩塌,折腾半天才定位到“两边向量空间不一致”这个根因。另一个要注意的是向量维度和存储索引的匹配,维度太高会显著增加内存占用,如果是小规模实验,几百维的模型跑起来最省心。
批量向量化阶段,控制batch size是非常实用的优化点。一次塞太多文本进去,轻则请求超时,重则显存溢出;把文本按长度排序再分组,同等资源下吞吐会稳定不少。另外在Embedding之前,统一把文本转成UTF-8、去掉控制字符和HTML标签、压缩多余空白,这一步能避免很多莫名奇妙的脏数据混进向量里。至于Embedding效果怎么检验,我的做法是准备30到50个典型的业务query,跑一次检索看前排命中的结果是否“像样”。这个验证在早期能省下大量后期调优时间。
3. 向量存储与检索:让“查得到”变成“查得准”
3.1 向量数据库选型:先按体量选,别一上来就上重武器
知识库构建完成后需要有一个地方存向量并提供检索。我把主流方案拉出来对比过,这里直接给结论:
| 方案 | 形态 | 适合场景 | 注意点 |
|---|---|---|---|
| Chroma | 本地嵌入式 | 原型验证、小数据量(十万级) | 最简单,生产功能弱 |
| FAISS | 内存/磁盘索引库 | 开发测试、单机检索 | 只提供索引,不解决存取和权限 |
| Milvus | 分布式向量服务 | 百万级以上、复杂过滤 | 部署和维护成本高 |
| pgvector | Postgres扩展 | 已有PG、要事务一致性 | 需要安装扩展、调索引参数 |
| Elasticsearch | 搜索引擎 | 已有ES体系、混合检索 | 索引策略复杂、运维成本不低 |
个人建议是分阶段选型。从一个Agent原型出发,数据量在十万级以下,Chroma 或 FAISS 完全够用。不少人一上来就奔着分布式向量库去,结果部署和维护成本远超收益。反过来,如果团队本来就重度依赖 PostgreSQL,pgvector 是最平滑的选择,备份、权限、SQL过滤都可以复用,不需要额外引入一套新系统。等到数据量真正上去、过滤条件复杂到撑不住,再考虑迁移到专业向量服务,这个顺序最省钱。
3.2 相似度计算与 Top-K:理解排序依据
向量检索的核心是算相似度。常用的指标有几种:余弦相似度、内积、欧氏距离。工程上最常见的做法是余弦相似度,因为它看的是方向而非长度,对文本长度差异不那么敏感。有一个简化技巧值得记住:向量归一化之后,内积和余弦相似度在数值上是等价的。所以很多向量数据库默认用内积,你要清楚它背后其实还是在算余弦。另外,如果你的模型输出不归一化,就用显式余弦,不要盲目相信数据库默认的L2距离。
Top-K参数也值得讲两句。K取太大,一堆不相关的片段会被塞进prompt,生成结果时模型容易“捡了芝麻丢西瓜”,反而被干扰;K取太小,关键信息可能根本冒不出来。我一般的起点是5到8,然后根据效果调。判断依据不是“答得是否顺畅”,而是查看召回的片段得分分布:如果前几名和后面的分数差距很微弱,说明检索结果的区分度不够,这时候该回去检查清洗和切分,而不是一味加大K值。
3.3 检索之后别急着生成:重排与混合检索
向量召回本质上是一次粗排。它擅长把握“整体语义相似”,但不擅长处理“关键信息藏在细节里”的情况。举个实际例子,用户查“产品A的保修期限是多久”,向量召回可能先捞出几段讲产品A通用介绍的内容,真正写了保修政策的那句话反而排在后面。这时候就需要在检索之后、生成之前,加一道重排。
最低成本的重排方案是用一个相关性打分模型对召回回来的8到10个候选重新打分,再取前3到5个。如果不引入额外模型,也可以用 RRF(Reciprocal Rank Fusion)做无监督融合:
score = Σ 1 / (k + rank_i)
k 通常取60。思路很简单:同一个片段在多路检索结果里的排名越靠前,最终得分越高,既不依赖模型,也不需要训练。
我更推荐的是把“向量检索”和“关键词检索”两路结果做混合。向量模型对同义改写很友好,比如“怎么退钱”和“退款流程说明”能匹配上;但对产品型号、报错码、法规编号这类精确token,向量检索反而不如传统BM25关键词检索稳。混合检索写完,效果往往立竿见影。做法也不复杂:query并行发给向量索引和BM25索引,各自返回Top-N,用RRF合并排序,再交给生成模型。工程实现成本不高,收益却很明显。
4. RAG 链路调优的实战经验
4.1 一个真实案例:从答非所问到精准引用
拿我经历过的一个模拟项目X来说。它的知识库里有企业版产品的介绍、定价、服务条款等文档,用户问“企业版的续费周期怎么计算”,系统最初给出的回答居然引用了退换货条款,完全答非所问。我当时没有急着改prompt,而是先做了排查。
第一步,把这次回答实际检索出来的Top-5片段打印出来。结果显示第一条确实是“续费周期计算”,但它是被错误切进了一个很长的chunk里,和价格调整的说明混在一起,语义被稀释了。真正写到续费算法的那段内容排到了第4位,被前面的噪声片段挤掉了。第二步,定位到根源就是切分粒度问题。我把相关文档的纯文本chunk从700token降到300token,overlap保留50,重新跑了一遍。这下Top-3全部命中续费相关内容,回答能给出准确算法,并且可以标出引用来源。
这件事给我最大的启发是:RAG调优的第一动作永远是“看召回”,而不是“改prompt”。很多问题表面上是生成不对,根子全在检索。只要把检索引擎喂进去的内容改对了,生成侧几乎不用动。
4.2 常见坑位清单
结合多个项目踩过的情况,我把高频问题整理成一张表,方便排查时对着看:
| 症状 | 可能原因 | 排查/解决方向 |
|---|---|---|
| 答非所问 | chunk过大、召回噪声高 | 打印Top-5,重新切分 |
| 回答泛泛而谈 | 召回片段太短、缺关键细节 | 增大chunk或overlap |
| 模型仍在复述错误内容 | 幻觉、未限制来源 | prompt要求只基于检索片段回答,强制标注引用 |
| 最新文档总是检索不到 | 索引没有增量更新 | 建立文档级版本号,更新时先删旧后写新 |
| 同一问题答案不稳定 | 数据重复、Top-K随机 | 文档去重,固定检索种子 |
| 不该泄漏的权限内容出现 | 索引未做权限隔离 | 给chunk加metadata权限标签,检索后过滤 |
增量更新这件事特别值得多说一句。知识库不是构建完就结束的,文档会改版、下架、新增。一个简单可靠的做法是给每个chunk都带上doc_id和updated_at两个字段,更新文档时先按doc_id删除旧chunk,再写入新的,避免新旧版本同时在索引里打架。
4.3 给 RAG 加一道简单评估
很多项目上线前都会被问一句“这个RAG效果到底行不行”,但回答往往靠感觉。我的建议是先搭一套极简的人工测试集,20到30条就够。每一条包含query、参考答案、期望命中的文档。跑完后逐条检查三个指标:召回命中率,也就是期望文档是否出现在Top-5里;答案正确率,人工给0、0.5、1分;引用一致率,即答案内容是否与检索片段内容一致。
这套轻量评估的价值不是统计数据本身,而是帮你把问题定位到具体环节。遇到失败样本,先问一句:是根本没召回,还是召回了但生成不对。如果是前者,去调清洗、切分、Embedding;如果是后者,去调prompt、重排或模型温度参数。我在实际项目中反复用这个判断顺序,效率比漫无目的地调参高很多。
这里有一个小建议,从设计初期就预留调试输出:每次回答时把Top-3片段的id和首句一起打印出来。这个看起来不起眼的细节,能让你在定位问题上节省几倍时间,强烈推荐。
最后再分享一点个人体会:知识库构建初期不要追求“一步到位”的完美切分,先用最简单的清洗加定长切分把全链路跑通,然后拿几十条真实业务query去检验,哪一类问题最集中就先调哪一块。这个迭代思路比一开始就上复杂方案,既省钱又高效。RAG 与知识库这部分相对独立,但放到整个 Agent 体系里,它其实是在替 Agent 解决“什么时候该动用外部知识”的一个子问题:当来了一条 query,是直接让模型回答,还是先去翻资料?下一阶段再讲到工具调度和记忆分层时,你会发现自己对这套外挂知识的边界会理解得更清楚。