这一篇接着聊“走进 AI Agent”系列的第四部分:知识获取管道,也就是 RAG(Retrieval-Augmented Generation,检索增强生成)的基础。前面几篇我们把 Agent 的规划、记忆、工具调用都盘了一遍,但有个问题始终绕不开:模型自己的知识截止日期和私有业务知识之间的巨大缺口。RAG 就是补上这个缺口最实用的一条管道。这一篇我尽量不写教科书定义,而是把“RAG 为什么在 Agent 架构里那么重要”“最小可用实现长什么样”“从基础 RAG 往 Agentic RAG 升级要过哪几道坎”一次讲透。
先给结论:RAG 不是“给模型接一个数据库”那么简单,它本质上是一条负责知识获取、清洗、组织、检索与合成的完整管道。你在日志里看到的效果差异,绝大多数不是模型能力造成的,而是管道设计造成的。接下来的内容按“为什么—是什么—怎么做—怎么优化—踩坑记录”来展开,适合刚学完 Agent 基础、准备给 Agent 接入私域知识的同学,也适合已经在跑 RAG、但被“召回不准”“上下文污染”折磨过的人。
1. 为什么 AI Agent 需要一条“知识获取管道”
1.1 Agent 的“知识饥饿”不是小问题
大语言模型的参数化记忆有一个天花板:训练数据截止日期、对私域数据天然不可见、对细粒度的版本差异不敏感。你在 Agent 里直接问“我们公司最新的报销制度是什么”,模型只会把 2022 年甚至更早的通用常识翻出来,更麻烦的是它还会一本正经地编一套规则。这就是知识饥饿,模型不是不想告诉你,是它的“记忆”里根本没有这份资料。
有人会想:那我直接把几千页文档塞进上下文不就行了?对不起,上下文窗口再大也扛不住两个问题。一是真实业务资料动辄几十上百份 PDF、表格、钉钉文档、旧版本文档混在一起,token 开销和响应延迟直接失控;二是“塞进去”不等于“抓得准”,模型在超长上下文里的注意力会很散,经常漏掉关键段落。RAG 的思路则是换个方式:不把整库知识塞给模型,而是每来一次提问,只把最相关的几段资料捞出来,拼成一个精简的上下文再交给模型。这个“按需捞取”的动作,就是知识获取管道。
把 RAG 放进 Agent 的场景里,它的价值会更明显。Agent 的规划器负责“做什么”,工具调度负责“调用什么”,但这两者都得建立在“知道什么”的基础上。如果你把一个没有知识管道的 Agent 放去处理企业内部的运营问答,它表现出来的不是笨,而是“自信地胡说”。有了 RAG,它至少知道哪些话是资料里有依据的、哪些话资料库里没有、需要坦白说不知道。这一步,直接决定了 Agent 能不能从玩具变成可用工具。
1.2 RAG 在 Agent 架构里的准确位置
很多人画 Agent 架构图时,习惯把 RAG 画成一个孤立的“知识库组件”,其实这是不准确的。RAG 在 Agent 体系里至少有两种存在形态。第一种是“隐式管道”,也就是查询一进来,系统自动做检索、拼上下文、再交给大模型生成,用户完全感知不到检索过程。第二种是“显式工具”,Agent 的规划器把“知识检索”本身当成一个可调用工具,模型根据问题判断要不要检索、检索哪个知识库、检索结果怎么用。第二种就是我们常说的 Agentic RAG,它更像是一个会自己决定要不要查资料的助手,而不是被动等待查资料的接口。
两种形态没有绝对的优劣,取决于你的使用场景。如果只是做一个固定的问答机器人,隐式管道足够,稳定且好调试;如果你的 Agent 需要处理多种类型的跨领域问题,比如用户问一句“这个客户之前的工单和报价分别是什么”,让它显式地决定先查工单库再查报价库,效果会明显更好。本篇先讲基础 RAG 这个“根”,把根扎稳了,Agentic RAG 才能搭得起来。
我个人的理解是:RAG 之于 Agent,不是外挂,而是一套“接到问题先想清楚该读什么资料”的机制。它让 Agent 的知识来源从“模型脑内”扩展到“组织沉淀”,这也是企业愿意让 Agent 落地到业务里的前提。后面讲的所有参数、步骤、坑,都是在围绕这一点服务。
2. RAG 基础流程拆解:从文档到答案的四段式管道
2.1 离线索引:加载、切分、嵌入、入库
RAG 的完整流程可以拆成离线索引和在线检索两段。离线索引是“造管道”的阶段,核心四步是文档加载、文本切分、向量化、写入向量库。很多新手把精力全花在最后一步选什么向量数据库上,其实前两步才是决定效果的上限来源。
文档加载这一步,注意不要只盯着 PDF。真实业务资料里,Markdown、Word、HTML、表格、扫描件混在一起非常常见。加载器需要按文档类型分别处理:纯文本用目录加载器,PDF 至少要试一下能否提取出干净的文本层,扫描件就需要 OCR 前置;表格类内容建议保留结构信息,比如转成 Markdown 表格后再切分,纯文本拍平容易把单元格关系丢掉。加载完还没完,要顺带洗数据:去掉页眉页脚、统一换行符、过滤空段落。这一步不做,后面切出来的 chunk 会带着大量噪声。
切分是离线阶段最该花心思的环节。常用的方式是递归字符切分器(RecursiveCharacterTextSplitter),它先从段落级分隔符往下切,切不干净再逐级细化到句子,这样能尽量避免把语义完整的段落拦腰截断。切分要考虑两个核心参数:chunk_size 和 chunk_overlap。chunk_size 决定每一块有多大,chunk_overlap 让相邻块之间保留重合区域,防止一句话正好被切到边界上导致语义断裂。具体取值没有银弹,英文资料和中文资料、对话文本和技术文档的最优值都不一样,后面第三部分我会给一套我常用的起步参数和调整思路。
向量化就是把文本块变成稠密向量。选 Embedding 模型时不要只看排行榜分数,要看你语料的语言分布和领域词是否在它的训练范围里。通用场景可以直接用商业 API,数据敏感的场景建议用可私有化部署的本地模型。向量入库后就完成了,所谓“向量数据库”真正要做的事情是相似度检索,而不是存储本身,选型时可以按数据量、并发量、是否要混合检索来判断,不要盲目上重组件。
2.2 在线检索:召回、重排、合成
在线检索是“走管道”的阶段。用户问题进来,先把问题用同一个 Embedding 模型编码成向量,然后在向量库里做相似度搜索,取回 top-k 个文本块。这一步的关键在于:检索用的 Embedding 模型必须和索引阶段完全一致,否则查询向量和文档向量不在同一个语义空间里,召回质量会直接崩掉。这个低级错误我见过不止一次,值得先提。
向量召回只是初筛。真实业务里,向量相似度和“真的有用”之间并不完全等价:一段文本可能关键词重合度很高,但不是用户真正关心的答案;也可能一句话有歧义,向量层面把它带偏了。所以召回之后通常还需要重排(Rerank)。重排是拿一个更精细的模型,把查询和候选文档两两打分,重新排序后再取 top-n 进入上下文。重排的成本比向量检索高,所以实践上都是“向量粗召回一批,重排精筛一小批”,两层漏斗配合使用。
最后一步是合成。把选出来的文本块拼成 context,和用户问题一起组装进 Prompt,交给生成模型作答。这里有个细节容易忽视:拼 context 的顺序、每个块前标注来源、以及“找不到就直说”的指令,都会显著影响生成质量。不要把所有块一股脑倒进去,适当截断最不相关的尾部,反而能降低模型被噪声带偏的概率。
2.3 关键参数到底怎么定
参数调优是 RAG 最磨人的地方,也是效果差异最大的来源。我把最常用的参数列成一个速查表,再逐个解释背后的取舍逻辑:
| 参数 | 作用 | 我常用的起步值 | 调整思路 |
|---|---|---|---|
| chunk_size | 单个文本块大小 | 500-800 字符 | 语料本身句子长、段落完整,可以偏大;口语化碎片文本用偏小值 |
| chunk_overlap | 相邻块重叠大小 | 50-200 字符 | 取决于是否常出现跨块语义断裂,断裂多就加大 |
| top_k | 向量召回数量 | 20-30 | 取决于库里文档噪声比例,噪声高先加大再配合重排 |
| top_n | 重排后进入上下文的块数 | 3-5 | 上下文窗口小就减小,但不能小于能覆盖答案的块数 |
| similarity_threshold | 相似度过滤阈值 | 不设或设很低 | 设太高容易漏召回,设太低等于没设,先看分布再定 |
chunk_size 是最容易让人纠结的参数。设大了,一个块包含的信息完整,但噪声多、向量被稀释,相似度区分度下降;设小了,语义片段干净、检索精准,但跨句信息被切碎,模型可能看不到完整的上下文。我的经验是:先用 800 字符跑一版,然后拿一批真实问题去测 hit rate,如果发现很多答案分布在两个相邻 chunk 里,就调大 size 或者调大 overlap;如果出现“一个块里混了好几个主题导致检索不准”,就调小。
top_k 和 top_n 的区别很多人搞混,前者是初召回的数量,后者是最终进 Prompt 的数量。两者之间隔着重排器。如果没加重排器,建议直接把 top_k 和 top_n 设成同一个值,别给自己制造一种“好像筛过了”的错觉。加了重排器,初召回可以放宽到 30 左右,重排后再砍到 3 到 5,效果会比单纯调向量相似度阈值稳定得多。
3. 动手搭建一个最小可用的 RAG 管道
3.1 工具选型:原型阶段我为什么选 LangChain + FAISS
工具选型这事,我建议按“原型速度优先,生产环境再换重组件”的思路来。原型阶段,我用的是 LangChain 做调度编排、FAISS 做向量库、OpenAI 或本地 Embedding 模型做编码。选 LangChain 不是因为它在生产环境一定最优秀,而是它把文档加载、切分、向量存储、QA 链这些样板代码都封装好了,能用最短时间跑通整条管道,方便你理解流程。FAISS 则胜在轻量,单机文件级存储,不需要额外起服务,非常适合调试和验证。
如果你在 Java 技术栈里做 Agent,LangChain4j 和 Spring AI 都是值得关注的方案,它们对 RAG 的抽象思路和 LangChain 类似,但不建议同时学好几套,先把一条链路跑通再说。向量库以后要换 Qdrant、Milvus 还是 Elasticsearch,在 LangChain 里基本只需要替换两层接口,这属于低成本切换,不用在原型阶段纠结。
还有一点建议:数据量小于几十万条文本块时,别急着上分布式向量库。单机的 FAISS、Chroma 完全够用,先把检索质量调好。检索质量不好,换来再高性能的存储也是白搭。等到你的命中率测试稳定了、数据量上来了,再迁移不迟。
3.2 一份可以直接跑通的基础代码
这里给一份我常用的最小实现,采用离线索引加在线检索分开的结构。先看离线索引部分:
from langchain_community.document_loaders import DirectoryLoader, PyPDFLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import FAISS # 1. 加载:把 docs 目录下所有 PDF 读进来 loader = DirectoryLoader( "./docs", glob="**/*.pdf", loader_cls=PyPDFLoader, ) docs = loader.load() # 2. 切分:按段落优先,中文场景把句号分号也纳入分隔符 splitter = RecursiveCharacterTextSplitter( chunk_size=800, chunk_overlap=150, separators=["\n\n", "\n", "。", ";", " ", ""], ) chunks = splitter.split_documents(docs) print(f"加载 {len(docs)} 个文档,切分为 {len(chunks)} 个块") # 3. 嵌入 + 入库:用同一个 embedding 模型做向量化,写入 FAISS embeddings = OpenAIEmbeddings(model="text-embedding-3-small") vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local("./faiss_index")在线检索和问答部分:
from langchain_openai import ChatOpenAI from langchain_community.vectorstores import FAISS from langchain.chains import RetrievalQA from langchain.prompts import PromptTemplate # 4. 加载索引,构建检索器 embeddings = OpenAIEmbeddings(model="text-embedding-3-small") vectorstore = FAISS.load_local( "./faiss_index", embeddings, allow_dangerous_deserialization=True, ) retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4}, ) # 5. 组装 Prompt:强调“有依据、不编造、无资料要明说” template = """你是企业内部知识助手。请严格根据以下资料回答问题。 资料: {context} 问题:{question} 如果资料中没有答案,请直接回答“资料库中没有相关信息”,不要编造。""" prompt = PromptTemplate(template=template, input_variables=["context", "question"]) # 6. QA 链:stuff 模式适合块数少的场景,块多了换 map_reduce 或 refine llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) qa = RetrievalQA.from_chain_type( llm=llm, retriever=retriever, chain_type="stuff", return_source_documents=True, ) result = qa.invoke("我们的报销流程是什么?") print(result["result"]) for doc in result["source_documents"]: print("来源:", doc.metadata.get("source"))这份代码的核心思路是“先建索引、后查索引”,两者拆开跑。第一次执行先把所有文档灌进 FAISS,之后每次问答都不需要重新加载原始文档,直接走第 4 到第 6 步。注意第 5 步的 Prompt 设计,我在生成阶段专门加了“资料中没有就直说”的约束,这个约束比很多复杂调参都管用,能明显减少一本正经的编造。
需要提醒一个坑:FAISS 本地序列化加载时,LangChain 出于安全考虑要求显式声明allow_dangerous_deserialization=True。如果加载的是你自己生成的索引文件,这个开关可以用;但如果索引文件来源不可信,一定要改用FAISS.load_local之外的安全校验方案,不要盲目信任外部文件。
3.3 检索效果怎么评:hit rate 与 MRR 计算
很多项目跑通了 RAG 之后,凭感觉说“效果还行”,这是最危险的状态。检索质量必须用指标量化。我最常用的两个指标是 hit rate(命中率)和 MRR(平均倒数排名)。hit rate 衡量的是:对于一组问题,答案所在的文档块是否出现在召回结果的前 k 名里。MRR 则更严格一些,它看正确答案排在第几位,排名越靠前得分越高。
一份可用的评测代码是这样:
def hit_rate(questions, retriever, k=3): hits = 0 for question, gold_content in questions: docs = retriever.invoke(question)[:k] if any(gold_content in doc.page_content for doc in docs): hits += 1 return hits / len(questions) def mrr(questions, retriever, k=3): total = 0.0 for question, gold_content in questions: docs = retriever.invoke(question)[:k] rank = next( (i + 1 for i, doc in enumerate(docs) if gold_content in doc.page_content), None, ) total += 1.0 / rank if rank else 0.0 return total / len(questions)这里的questions是一组手工标注的测试集,每个元素包含一个问题和一个“黄金答案片段”。注意黄金片段不能是一整篇文档,而是你期望被召回的具体句子或段落,这样才能真正反映检索器有没有把最相关的那段捞出来。测试集至少准备二三十个问题,覆盖不同提问方式和知识点的分散程度,否则指标好看没有意义。
我第一次搭完管道时,hit rate 只有四成左右,原因就是测试问题里包含很多口语化问法,比如“报销要怎么做”和文档里的“报销操作流程”字面差异很大,纯向量召回很难对齐。后来在检索前加了查询改写,hit rate 立刻涨到七成以上。这正好引出下一部分:基础 RAG 的天花板在哪,以及如何往上走。
4. 从“基础 RAG”到“Agentic RAG”的升级路径
4.1 基础 RAG 的三个典型瓶颈
基础 RAG 能解决“单轮问题、资料结构规整、知识相对静态”的场景,但一旦遇到真实业务,三个瓶颈就会冒出来。
第一个瓶颈是查询表达与知识表达不一致。用户问“这个月我还能请几天假”,资料里写的是“年度带薪年假按自然年计算,每月累计不超过 2 天”。两者词汇完全不同,向量相似度不高,召回自然失败。基础 RAG 不会去理解用户意图,只会傻傻地把问题原样转成向量去匹配。第二个瓶颈是信息碎片化。答案往往分散在多个文档甚至多个知识库里,比如“某客户的历史采购记录”在 ERP 系统,“他的合同条款”在另一个系统,单跳检索只能拿到其中一半。第三个瓶颈是知识时效与多轮上下文。基础 RAG 每次检索都是独立的,用户第一句问“上个月销售额”,第二句说“和它去年同期比呢”,如果第二句不结合第一句做改写,检索器根本不知道“它”指什么。
这也就是为什么社区里开始出现“Agentic RAG”的概念,它不是某个新算法,而是把 Agent 的规划能力应用到检索流程上,让模型参与“决定怎么查、查什么、查完怎么用”的决策。个人理解,这里是 RAG 和 AI Agent 结合最有价值的交界点。
4.2 查询改写、HyDE 与多路召回
针对第一个瓶颈,最直接的升级是查询改写。做法很简单:在检索之前,先用一个轻量模型把用户原始问题转成更适合检索的形式。比如用户问“我还能休几天”,改写器输出“查询员工剩余年假天数及扣除规则”;用户在多轮对话里说“它呢”,改写器结合上文补全为完整问题。别小看这一步,它相当于把“用户表达空间”映射到“文档表达空间”,通常能把 hit rate 提升十个百分点以上。
更进一层的做法是 HyDE。思路是先让模型针对用户问题生成一个假想的答案文档,然后拿这个假想文档去做向量检索,而不是拿原始问题去检索。原理是:问题往往很短、信息量少,而假想答案的语义更接近目标文档,检索匹配更准。HyDE 不适合所有场景,但它对“用户问得简洁、资料写得详细”的场景非常有效,值得放进工具箱。
多路召回解决的是“一种检索方式不够”的问题。最常见的是“向量召回 + 关键词召回”混合:向量负责语义相似,BM25 负责字面/专业名词匹配,两边结果合并去重后再重排。也可以做多知识库分路召回,比如针对工单库和报价库分别检索,再把结果汇总。多路召回的核心不是路数多,而是每路之间确实有互补性,否则只是增加延迟和噪声。
4.3 Agent 化检索:让模型决定“何时查、怎么查、怎么信”
到了这一步,RAG 就不再是一条被动的“查了之后生成”的管道了,而是变成 Agent 手里的一个工具。Agent 会根据问题类型决定:是先查知识库再回答,还是先调用别的工具拿到上下文再查知识库,甚至是多次检索、每轮检索结果逐步缩小范围。这就是我在前面提到的显式工具形态。
具体实现里,你可以把knowledge_search封装成 Agent 的一个 function calling 工具。模型收到用户问题后,如果判断“这个问题需要私域资料支持”,就会调用这个工具并传入检索关键词;如果判断“这是通用常识或数学计算”,就直接回答不调用检索。这个“知道什么时候不检索”的能力,往往比检索本身更值钱,因为它大幅减少了不必要的延迟和上下文污染。
再往上,就是多跳检索(multi-hop RAG)。比如用户问“对比一下 A 客户和 B 客户的项目周期”,Agent 先检索引出 A 客户的合同信息,再根据结果构建第二跳查询去找 B 客户,最后把两者结果汇总对比。这个过程中,GraphRAG 和知识图谱类方案也会登场:通过实体关系图把分散的信息片段连接起来,让多跳路径更清晰。但要提醒的是,GraphRAG 的构建成本远高于普通 RAG,起步阶段先把查询改写、重排、混合检索做好,性价比要高出很多,不要一上来就奔着最复杂的方案去。
5. 常见问题与排查技巧实录
5.1 三种高频故障的定位思路
跑 RAG 最常遇到的故障是“召回不到答案”。定位时先别急着调模型,按层排查:先确认测试问题对应的“黄金片段”是否真的在原始文档里;然后单独打印检索器返回的前几块,手工判断这些块和问题是否语义相关;再确认 Embedding 模型是否前后一致。如果检索结果里确实有相关内容但答案还是不对,问题多半出在生成阶段,比如 Prompt 里没约束“只能根据资料回答”,模型跑偏了。
第二个故障是“召回结果噪声太多”。表现为检索回来的块里混着大量无关内容,污染了上下文。这时要检查 chunk_size 是不是设太大,导致一个块里装了好几个主题;或者相似度阈值太低,把低相关也捞进来了。解决方法是调小 chunk、提高重排门槛、严格限制进入 Prompt 的 top_n。
第三个故障是“上下文溢出或截断”。在线阶段拼进去的块太多,token 超出限制,框架默默截断了后面的内容,而正确答案恰好被截掉。排查方法是打开 Debug 日志看进入 Prompt 的文本块列表,以及每个块的长度。解决方法是减少 top_n、在切分阶段把 chunk_size 调小,或者换用 map_reduce 之类的链式处理方式。
5.2 我踩过的几个真坑
第一个坑是表格资料直接拍平。我最早处理公司的 Excel 报价单时,直接把每一行文本连起来切块,结果检索时召回的是零散单元格,根本拼不出完整产品信息。后来我把表格转成 Markdown 表格的形式,保留表头和结构,切分时再按表结构保留,效果立刻不一样。遇到表格时,先想“尽量保住结构信息”,别轻易拍平成纯文本。
第二个坑是切分器把 Markdown 标题和章节结构切碎了。技术文档通常很长,一个主标题下面有若干副标题,递归切分器如果没把 Markdown 标题符加入分隔符,就会把章节标题和正文内容离散成两块。后来我把分隔符调成["\n\n", "\n", "## ", "# ", "。", ";"],并尽量让每个 chunk 从章节标题开始,检索命中率明显提升。经验是:切分的边界最好天然对齐文档自身的语义边界,而不是对齐字符数。
第三个坑是只测了离线索引,没测在线检索。很多人的流程是“索引跑完,然后问一个问题觉得还行”就完事了。这远远不够。没有标注测试集,你就无法知道昨天的调参到底进步了还是退步了。我后来养成了一个习惯:每次改动索引或检索逻辑,都先重跑一遍 hit rate 和 MRR,用数据说话。
5.3 避坑速查表
把常见现象和对应处理整理成一张表,方便排查时直接对照:
| 现象 | 可能原因 | 优先排查项 |
|---|---|---|
| 检索结果和问题完全不相关 | Embedding 模型前后不一致 | 检查索引和查询是否用的是同一 Embedding |
| 答案对但引用来源不对 | 上下文过短,答案跨了多个块 | 调大 chunk_overlap 或 chunk_size |
| 答案明显编造 | Prompt 没有约束“仅根据资料回答” | 在生成 Prompt 里加反幻觉约束 |
| 上下文被截断导致答案丢 | 进入 Prompt 的块太多或太长 | 减小 top_n,调小 chunk_size |
| 检索命中但生成质量差 | 噪声块污染上下文 | 加重排器,或提高相似度阈值 |
| 专业名词查不到 | 向量模型不熟悉领域术语 | 混合 BM25 关键词召回 |
这张表只覆盖了高频问题。更复杂的场景,比如同名文档、版本覆盖、权限隔离导致的知识冲突,已经不是基础 RAG 能解决的,需要在管道上游加文档管理、版本控制和权限过滤。这些话题后面可以单独写一篇,但前提是先把这个基础管道调到稳定。
最后分享一个我自己的习惯:每搭一条 RAG 管道,都会先用手工标注的二十个问题压一遍最坏情况,而不是拿几个“设计得好”的例子自嗨。RAG 的难点从来不在接口调用,而在你是否真正理解自己的语料结构。资料是按章节组织还是按卡片组织、是长段落还是短句、有没有大量重复和过期内容,这些都直接决定参数怎么调。你在调试上花的时间,有一大半会花在“读懂你的知识库”,而不是“改模型”。把这一步做好,后续往 Agentic RAG、GraphRAG 升级时,你会少走很多弯路。