ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RAG技术详解:从知识库搭建到Agent检索优化全指南

RAG技术详解:从知识库搭建到Agent检索优化全指南

要说现在的 AI Agent 项目,真正拉开体验差距的,往往不是模型选得多新、Prompt 写得有多花哨,而是它到底能不能拿到“对的知识”。前面几篇聊了 Agent 的编排框架、记忆设计,到了第四篇,必须把知识获取管道讲清楚。RAG(Retrieval-Augmented Generation,检索增强生成)说白了就是给 Agent 装一条正式的知识管道,从外部文档、数据库、知识库里把和用户问题相关的内容捞出来,再交给模型组织答案。它解决的是大模型三大天生缺陷:幻觉、知识滞后、无法读取私域数据。这篇内容适合两类人:一类是正准备从 0 到 1 搭建自己的 Agent、但不知道知识库部分从哪入手的初学者;另一类是已经在做 RAG 检索但效果不理想、想搞清楚切分和检索那块到底该怎么调优的工程师。我会把 RAG 的完整链路拆开讲,再给一套我在实际项目中反复验证过的搭建和排查方案。

1.1 先理清 Agent 对“知识”的真实需求

很多朋友第一次接触 Agent,会把“知识库”和“记忆”混为一谈,这个必须掰开。记忆是会话级的,负责记住用户上一句说了什么、之前聊过什么偏好,它的载体通常是对话历史摘要或向量化的记忆片段。而知识是领域级的,比如一份产品操作手册、一整套企业规章制度、一个售后 FAQ 库,这些东西和“聊了多久”没关系,属于长期稳定的外部事实源。Agent 要回答“我们公司请假流程是什么”“这个路由器怎么恢复出厂设置”这类问题,光靠模型训练时学到的通用知识根本不够,必须去查资料。RAG 就是这个“查资料”的动作本身。

理解了这一点,再看 Agent 的知识获取管道,其实无非四种方式:第一种是把知识写死在 Prompt 里,适合上下文很短、总量小于几千字的场景;第二种是外挂 RAG,动态检索插入上下文,这是目前最通用的方案;第三种是干脆对模型做领域微调,把知识压缩进参数里,但成本高、更新慢,适合知识极其稳定且需求量极大的场景;第四种是这两年兴起的让 Agent 自己调用工具取数,本质上是把知识获取行为变成了一个工具调用决策。RAG 处在“长期外部知识”和“灵活更新”的交集上,性价比最高。

1.2 为什么不是微调,也不是无脑塞长上下文

有个高频问题:我把整本手册塞进上下文行不行?短文档可以,文档一长就出问题。上下文窗口再大,模型对中间位置信息的注意力会自然衰减,而且塞入大量无关内容会明显干扰生成质量。成本也在那里,每轮对话都带着几十万字跑一遍,Token 费用完全吃不消。微调则更像“把书背进脑子里”,适合行为习惯的塑造,不适合事实类的精确问答——今天业务规则变了,你不可能天天微调一次。RAG 的做法是“现用现查”,每次只把最相关的几段内容喂给模型,更新知识只需要替换索引里的文档,成本低、反馈快。

我实际跑过的项目里,有一个印象很深的对比:同一批售后文档,纯 Prompt 硬答的准确率在 62% 左右,接入 RAG 之后直接跳到 89%,而微调模型在新增产品的问答上反而出现了严重幻觉——它太“自信”了,把旧知识泛化到了新产品上。从那以后,凡是面向动态知识的场景,我都默认先上 RAG。

2.1 先把整条链路画清楚:从文档到答案的六步

RAG 看起来名字唬人,拆开其实就六个环节:加载、切分、向量化、存储、检索、生成。前四个环节属于“离线建索引”,也就是提前把文档处理成可供检索的形态;后两个环节属于“在线服务”,用户每次发问时实时执行。理解这种“离线在线分离”的架构非常重要,它决定了系统的扩展方式——文档量大了就横向加索引节点,用户量大了则要关注检索服务的吞吐。

加载环节负责把 PDF、Word、Markdown、HTML 等不同格式的内容读成纯文本;切分环节把长文档切成适当大小的片段,也就是 Chunk;向量化环节用 Embedding 模型把每段文本转成一个高维向量;存储环节把这些向量连同原文、元数据一起写进向量数据库。到了在线阶段,用户的问题同样会被向量化,然后在库里做相似度检索,捞回最相关的 Top-K 段文本,最后把问题和这些文本拼成一个 Prompt 交给大模型生成答案。这里面的每一步都有大量可调参数,调整空间甚至比模型本身还大。

2.2 文档加载与清洗:索引质量的地基

文档加载看似无聊,却是整条管道里坑最多的地方。以最常用的 PDF 为例,用 PyMuPDF 这类工具读取,文本往往带着页眉页脚、换行符错乱、目录页码混排的垃圾内容。有些 PDF 的表格区域会被读成完全错乱的字符串,明明是一个三列的参数表,读出来变成一坨不明所以的字符。我处理数据时有个习惯,拿到原始文档先做三层清洗:去噪,把页眉页脚、页码、超链接标记、无意义的空白符清掉;合并,把因为排版断开的句子按语义重新拼接;丢弃,对乱码严重、扫描件且没有 OCR 的页面,宁可删掉也不要放进索引里污染效果。

清洗完还要做“结构感知”。Word 和 Markdown 文档天然带章节层级,PDF 里通常也有目录信息,这些结构是切分时的上等原料。比如按标题层级切分,能保证每个片段对应一个相对完整的主题,而不是机械地按字符数掰开。我在做企业知识库的时候,常常会用 LangChain 里的 MarkdownHeaderTextSplitter 先按章节拆一遍,再对特别长的章节做二次切分,效果比单一切分策略好得多。

2.3 Chunk 切分:最容易被低估的调优点

很多人第一次搭 RAG,直接选了默认的固定长度切分,chunk_size 设成 1000 就完事了。实际测试下来,切分策略对检索命中的影响,往往比换 Embedding 模型还显著。切太细,单个片段可能语义不完整,一个问题的答案被拆到两个 Chunk 里,检索就召回不齐;切太粗,片段里混入无关内容,相似度被稀释,而且喂给模型的有效上下文利用率也会下降。

我自己常用的经验值是这样:通用文档切 400 到 600 字,重叠区设 50 到 80 字。为什么中文要设重叠?因为切分点恰好落在一句话中间时,重叠区能保证后半段不会完全丢失上下文。对于代码片段、JSON 这类结构文本,要按代码块边界切;对于问答对,要保证“问+答”在一个 Chunk 里,否则检索只捞到问题没捞到答案就尴尬了。

from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], keep_separator=False, ) chunks = text_splitter.split_documents(documents)

像上面这段,separators 里特意加了中文标点,这是 LangChain 默认配置没有的。默认的递归切分器主要按换行和空格切,英文友好,中文文本会被切得七零八落。把中文句号、问号、分号、逗号按优先级加进去,切出来的片段会自然落在语义边界上。这一行配置,值几千个测试样本的经验。

2.4 Embedding 模型与向量库选型:不同量级不同答案

Embedding 模型决定了文本之间的距离感,选错了,后面检索做再多优化都是隔靴搔痒。现在的中文场景里,BGE 系列(比如 bge-large-zh-v1.5、bge-m3)是开源里口碑很稳的选择,m3e 和 text2vec 也有一批用户。如果完全不想自建,直接用云厂商的 Embedding API 也行,但要注意两点:一是中文场景尽量用专门优化过的中文模型,不要拿通用英文模型硬扛;二是查询文本和入库文本的指令格式要一致,比如 BGE 要求查询侧加“为这个句子生成表示以用于检索相关文章”的前缀,漏了会掉几个点的命中率。

向量数据库这一层,我的选择逻辑很简单:项目原型和文档量在百万级以内,直接上 Chroma 或 FAISS,部署成本低,内存里就能跑;数据量上了千万级、需要分布式和实时写入,再考虑 Milvus 或 Qdrant。还有一个常被忽略的点,是多模态和过滤需求——如果你的文档需要按部门、按时间、按文档类型过滤,向量库的元数据过滤能力会比纯粹的距离计算重要得多。向量化之后,每个片段都带上 source 和 page 这样的元数据,才能在检索阶段做精确圈定。

3.1 技术选型:用 Python + LangChain 快速跑通,还是其他?

搭建 RAG 管道,目前最省力的路线是用 LangChain 生态把各个组件串起来。虽然有朋友会质疑 LangChain 抽象层太多、不够透明,但对大多数业务项目来说,它内置的文档加载器、切分器、检索器、集成封装能省掉一多半的对接工作。如果你在 Java 生态,Spring AI 也在快速发展,RAG 相关组件已经比较完整,LangChain4j 也值得关注。但作为入门和排查问题的基础,Python 生态的调试效率和社区资料仍然是最好的。

我之前给一个内部项目搭知识库问答,需求是把 30 多份产品手册做成一个可问答的“数字客服”。技术路线选的是 PyMuPDFLoader 读 PDF、BGE 做 Embedding、Chroma 做向量存储、OpenAI 兼容接口的大模型做生成。全套跑通加调优,大概花了一个下午。下面把核心代码拆开讲。

3.2 完整实现:加载、切分、入库、检索、生成一条龙

先处理文档加载和切分。这里有个细节:加载 PDF 之后,不仅要拿到文本,还要把页码信息保留到 metadata 里,这样检索结果还能溯源定位到原文第几页,用户或 Agent 后续核查起来也方便。

from langchain_community.document_loaders import PyMuPDFLoader loader = PyMuPDFLoader("docs/产品手册.pdf") documents = loader.load() # 清洗与元数据补充 for doc in documents: doc.page_content = clean_text(doc.page_content) # 去页眉页脚、处理乱码 doc.metadata["source"] = "产品手册.pdf"

切分器用上一节讲到的中文优化配置:

from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], ) chunks = splitter.split_documents(documents) print(f"切分成 {len(chunks)} 个片段")

然后是 Embedding 模型和向量库。这里用 BGE 模型,注意查询侧的指令前缀。

from langchain_community.embeddings import HuggingFaceBgeEmbeddings embedding_model = HuggingFaceBgeEmbeddings( model_name="BAAI/bge-large-zh-v1.5", query_instruction="为这个句子生成表示以用于检索相关文章:", )
from langchain_chroma import Chroma vectorstore = Chroma.from_documents( documents=chunks, embedding=embedding_model, persist_directory="./chroma_db", ) retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

这段代码执行完,RAG 的索引部分就建好了。注意 Chroma 会持久化到本地磁盘目录,下次启动可以直接加载,不用重复入库。Top-K 我这边设成 4,这是经过测试的折中值——太少容易漏信息,太多则把无关内容也塞进上下文,反而干扰生成。如果你的文档比较长、问题拆解较细,可以试试 5 到 6。

生成环节直接接大模型。OpenAI 兼容接口的好处是,国内很多模型服务商都提供兼容协议,换模型只要改 base_url 和 api_key 就行。

from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.0, base_url="你的接口地址", api_key="你的API Key") prompt = ChatPromptTemplate.from_template( "你是一个知识库问答助手。请仅根据以下资料回答问题,如果资料中没有相关信息,就说'资料中未找到',不要编造。\n\n资料:\n{context}\n\n问题:{question}" ) rag_chain = ( {"context": retriever, "question": lambda x: x["question"]} | prompt | llm | StrOutputParser() ) answer = rag_chain.invoke({"question": "产品 X 的保修政策是什么?"}) print(answer)

这里 temperature 设成 0,尽可能减少自由发挥的空间。资料中未找到就直说,这是抑制幻觉的关键一步。很多人做完这一步就开始欢呼“我有了 RAG”,其实这才是起点。检索质量、切分粒度、文档质量这些才是决定上限的地方。

3.3 把 RAG 接入 Agent:把它变成 Agent 的一项工具

上面这套 RAG 管道,本质上还是一个“你问我答”的检索问答链。放进 Agent 里,正确的做法是把检索能力封装成工具,让 Agent 根据用户问题自行判断要不要调用。为什么要绕这么一层?因为真实的用户问题往往不是直接针对知识库的,可能是闲聊、可能是要执行动作,也可能是先要检索再基于检索结果做推理。如果每次对话都强制走 RAG,既浪费 Token,又可能在不需要知识的时候引入干扰。

用 LangChain 的 @tool 装饰器可以把检索问答函数变成一个 Agent 可调用的工具:

from langchain_core.tools import tool @tool def search_knowledge_base(query: str) -> str: """当用户询问产品功能、参数、保修、故障排查等信息时,调用此工具检索产品知识库。""" docs = retriever.invoke(query) if not docs: return "知识库中没有找到相关信息" context = "\n\n".join( f"[来源: {d.metadata.get('source', '未知')},第 {d.metadata.get('page', '?')} 页]\n{d.page_content}" for d in docs ) return rag_chain.invoke({"question": query, "context": context})

然后把工具传给 Agent 的绑定函数。这样一来,Agent 收到的用户消息会先经过意图判断,只有需要知识库支持时才会触发检索,整个交互更像真人客服。我实际体验下来,这种设计还能把“查文档”和“查历史订单”“计算报价”这类其他工具组合在一起,实现更复杂的任务流。

4.1 检索不准:七成问题出在 Query 和 Chunk,而不是模型

我见过太多人一遇到效果不好就想着换更大的模型、换更强的 Embedding,结果优化半天毫无起色。这里我总结过一条经验:先看召回,再看排序,最后才是生成。召回不准,答案天花乱坠也没用。而且召回的问题,七成出在 Query 和 Chunk 身上。

Query 问题最常见的表现是用户问题太口语化,和文档里的表述对不上。比如用户问“电脑老卡怎么办”,文档里写的是“系统性能优化”,字面上完全不像,语义相似度也难以拉近。解决办法是在检索之前加一道 Query 改写:让大模型把口语化的问题转换成适合检索的关键词组合,或者扩展出多个角度的子查询。Agent 场景下这一步更自然——Agent 本来就会对用户意图做拆解,把改写后的 Query 喂给检索器即可。

Chunk 问题多表现为切分不当导致信息碎片化。有一次我排查一个效果极差的问答,发现答案分散在第 13 和 14 两个 Chunk 里,每个都是 400 字,单独看都不是完整的答案,合并起来才算完整。这就要么调大 chunk_size,要么做“父子切分”:父片段保语义完整,子片段做精确检索,命中后把父片段喂给模型。

另一个性价比极高的手段是混合检索。向量检索擅长语义相似,但精确的词面匹配(比如型号、编码)往往不如 BM25。把两者用 EnsembleRetriever 加权组合,大多数场景都有明显提升:

from langchain.retrievers import EnsembleRetriever from langchain_community.retrievers import BM25Retriever bm25_retriever = BM25Retriever.from_documents(chunks, k=4) ensemble_retriever = EnsembleRetriever( retrievers=[bm25_retriever, retriever], weights=[0.4, 0.6], )

加权比例我一般给 BM25 三到四成、向量六到七成。具体可以跑一批测试问题调一下,但 0.4/0.6 作为起点很稳。

如果混合检索之后,还是觉得排序不对,再加重排(Rerank)。重排是拿一个专门训练的 Cross-Encoder 模型,对候选结果做精细的二度打分,效果非常明显,但对性能有要求。可以使用 bge-reranker-base 这类模型。重排模型可以单独部署成服务,Agent 检索时只对 Top-20 的候选做重排,取前 4 个喂给大模型,成本可控。

4.2 常见问题排查速查表

我把自己和身边朋友踩过的坑整理成一张速查表,排查时可以按图索骥。

症状大概率原因排查方向与解法
回答经常说“资料中未找到”召回率太低,相关文档没被捞出来检查切分粒度,尝试混合检索,加 Query 改写
回答引用了无关段落排序不准,相似但无关的内容排在前面引入 Rerank,调整 Top-K,检查是否有元数据污染
同一问题每次回答不一致检索到的 Chunk 不稳定排查向量库是否被并发写入,确认 Top-K 是否固定,降低温度参数
答案明显编造检索内容不足但模型强行作答Prompt 中强调“没有资料就直说”,降低 temperature,必要时加“拒答”分支
相似问题效果差异巨大切分点破坏了答案的完整性尝试父子切分,或按文档结构切分
公司内部专有名词频繁出错通用 Embedding 不理解领域语义积累领域平行语料,对 Embedding 做领域微调;或引入同义词表

测试下来如果 hit_rate 低于 0.7,说实话别急着调生成侧的 Prompt,先把索引侧问题解决。命中率的意思是:测试集里有多少比例的问题,其标准答案所对应的片段出现在检索 Top-K 结果中。这个指标和最终问答质量强相关,而且不需要大模型参与评估,手工标注一轮就能算出来。

4.3 从 RAG 到 Agentic RAG:知识管道的下一步

RAG 管道做到这一步,已经可以支撑不少业务了。但真实场景里还有一类问题它搞不定:需要多步推理才能完成的检索。比如用户问“A 型号和 B 型号都支持什么充电协议,哪个兼容我的笔记本”,这个问题的答案需要从多个文档片段中分别找到 A 的参数、B 的参数、充电协议列表,最后再做对比。普通 RAG 一次检索捞回来的内容不一定能覆盖全部信息,这时候就需要 Agentic RAG——让 Agent 自己决定查几次、先查什么再查什么,每查一次就检查当前信息够不够,不够就再发起一轮新的检索。

这也是为什么前面强调“把 RAG 封装成工具”而不是“让 RAG 直接处理所有问题”。工具化之后,Agent 天然具备多轮检索能力。作为知识管道的进阶方向,还有两条路径值得关注:一条是 GraphRAG,适合实体关系密集的文档,先构建知识图谱再做检索,回答“谁和谁有关系”这类问题时远胜纯向量检索;另一条是本体增强的 RAG,用 Ontology 定义领域概念和关系约束,在检索前做语义对齐,适合术语体系严谨的专业领域。这些属于后续深入的方向,但底色还是今天这套基础管道,把地基打牢再往上盖楼不迟。

把 RAG 管道完整跑通之后,我个人的体会是:它不是一个“跑通就行”的组件,而是一个需要持续度量、持续迭代的子系统。我自己踩过几次坑之后,最大的心得其实就一句话——先在检索评估上投入时间,建立二三十个代表性问题的测试集,每次改动都拿 hit_rate 说话,而不是凭感觉调参。这套方法虽然朴素,但能少走很多弯路。希望这篇基础篇能帮你把知识管道立起来,后面我们再往下钻,聊多轮检索、聊知识图谱,也聊聊更复杂的 Agent 记忆和工具的协同。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进