1. 为什么知识获取管道是 AI Agent 落地的第一道坎
做 AI Agent 开发的人,绕不开一个尴尬的现实:模型本身很聪明,但它对你业务里的私有知识一无所知。你问它公司报销流程,它给你编一个看起来很像那么回事的答案;你问它某个产品的技术参数,它张口就来一串根本不存在的数字。这不是模型不行,而是它的知识边界就停在那里——训练数据截止的那一天,以及公开互联网上能爬到的那些内容。
知识获取管道要解决的,就是把这个边界往后推。它的核心任务只有一句话:在模型生成回答之前,把和用户问题真正相关的那几条知识,准确、及时地塞进模型的上下文里。这件事听起来简单,做起来全是坑。检索回来的内容不相关,模型就被带偏;相关内容没检索到,模型就开始幻觉;检索到了但塞进去的顺序不对,模型又抓不住重点。
RAG(Retrieval-Augmented Generation,检索增强生成)就是目前解决这个问题最主流、也最工程化的一条路径。它的思路很朴素:把知识切块、向量化、存进索引,用户提问时先检索、再生成。但朴素不等于简单,从文档解析到切块策略,从嵌入模型选型到混合检索,从重排序到上下文组装,每一个环节都有大量需要拍板的细节。
这篇是"走进 AI Agent"系列的第四篇,专门聊知识获取管道的基础设施——RAG。我会把整条链路拆开,讲清楚每个环节在干什么、为什么这么干、实际做的时候容易在哪里翻车。不管你是用 LangChain、Spring AI 还是自己手写,底层的逻辑是相通的。读完你应该能自己搭出一条能用的 RAG 管道,并且知道它什么时候会不靠谱。
提示:RAG 不是银弹。它擅长的是"事实性知识的召回与引用",不擅长"复杂推理"和"多跳关系查询"。搞清楚它的能力边界,比盲目堆技术栈重要得多。
2. 把 RAG 拆成四段管道:从原始文档到可检索知识
很多人一上来就写vectorstore.similarity_search(),结果跑出来的东西驴唇不对马嘴,然后开始怀疑嵌入模型不行。问题往往不在检索那一步,而在前面的数据处理。RAG 是一条管道,上游没处理好,下游再怎么调参都是白费。
我习惯把整条链路拆成四段:摄取(Ingest)→ 切块(Chunk)→ 嵌入与索引(Embed & Index)→ 检索与组装(Retrieve & Assemble)。每一段都有独立的输入输出和质量标准,出了问题能快速定位是哪一段的锅。
2.1 摄取阶段:文档解析决定了知识的上限
摄取阶段做的事,是把各种格式的原始文档变成干净的纯文本。这一步最容易被低估。PDF 里的表格、扫描件里的文字、Word 里的多级标题、网页里的导航栏噪音——这些如果没处理好,后面所有环节都是在垃圾上做检索。
我踩过最典型的一个坑:一份产品手册是扫描版 PDF,直接丢给默认的 PDF 解析器,出来的文本是乱序的,段落之间还夹杂着页眉页脚。嵌入之后检索出来的内容全是碎片,模型拿到手里根本拼不出完整答案。后来换成带 OCR 的解析方案,并且加了页眉页脚的过滤规则,召回质量立刻上了一个台阶。
不同格式的解析策略差别很大,我整理了一张对照表:
| 文档类型 | 常见问题 | 推荐处理方式 |
|---|---|---|
| 原生 PDF | 分栏错乱、表格丢失 | 用支持版面分析的解析器,保留阅读顺序 |
| 扫描 PDF | 无文本层、识别错误 | OCR + 后处理纠错,注意中英文混排 |
| Word/HTML | 样式噪音、层级混乱 | 按标题层级切分,剥离导航和广告 |
| Markdown | 相对干净 | 直接按标题结构切块,保留代码块完整性 |
| 表格数据 | 行列关系丢失 | 转成结构化文本或单独建索引 |
这里有个经验:解析阶段宁可慢一点,也要保证文本质量。因为解析是一次性的,检索是高频的。花时间把文档洗干净,比后面反复调检索参数划算得多。
2.2 切块策略:块的大小和边界比你想的更重要
切块是 RAG 里最需要"手感"的一步。块太大,检索回来的内容里混着一堆无关信息,稀释了相关性;块太小,一个完整的知识点被切碎,模型拿到半句话没法用。
常见的切块方式有这么几种:
- 固定长度切块:按字符数或 token 数硬切,实现简单,但经常在句子中间断开。
- 递归字符切块:按段落、句子、词的优先级递归分割,尽量在自然边界断开,是大多数场景的默认选择。
- 语义切块:用嵌入相似度判断相邻句子是否属于同一主题,在语义转折处切分,效果好但成本高。
- 结构化切块:按文档本身的标题层级、章节结构切分,适合 Markdown 和技术文档。
我一般会先看文档结构。如果是结构清晰的技术文档,直接用标题层级切,块大小控制在 300 到 800 token 之间,并且让相邻块之间保留 10% 到 20% 的重叠。重叠的作用是防止关键信息正好卡在切分点上,被两个块各拿一半。
注意:重叠不是越多越好。重叠太多会导致检索结果高度重复,浪费上下文窗口。我实测下来,10% 到 15% 的重叠在大多数场景下够用。
还有一个容易被忽略的点:给每个块加上元数据。来源文件名、章节标题、页码、更新时间,这些信息在检索时可以参与过滤,在生成时可以附在引用里。用户看到答案能追溯到原文,信任度完全不一样。
2.3 嵌入与索引:稠密和稀疏不是二选一
嵌入这一步,是把文本块转成向量,存进向量数据库。这里的关键决策是选什么嵌入模型,以及用稠密嵌入还是稀疏嵌入。
稠密嵌入(Dense Embedding)把文本映射成一个固定维度的稠密向量,比如 768 维或 1024 维。它的优势是能捕捉语义相似性——"如何申请年假"和"休假流程怎么走"在向量空间里距离很近,即使字面完全不同。缺点是对于精确的关键词匹配不够敏感,比如产品型号、专有名词、错误码这类东西,稠密向量经常抓不准。
稀疏嵌入(Sparse Embedding)则是高维稀疏向量,本质上还是词袋模型那一套,比如 BM25。它的优势是精确匹配强,用户搜"ERR-5021"就能精准命中包含这个错误码的文档。缺点是无法理解语义,换个说法就找不到了。
我的做法是两者都用,做混合检索。稠密负责语义召回,稀疏负责精确召回,两路结果合并后再重排序。这套组合拳在实际项目里的召回率,明显比单用稠密要高。尤其是企业知识库这种既有自然语言问答、又有精确术语查询的场景,混合检索几乎是标配。
索引层面,向量数据库的选择也值得说两句。小规模场景用 FAISS 或 Chroma 就够了,本地跑、零依赖。上了规模、要支持过滤和更新,就得上 Milvus、Qdrant 或 pgvector 这类。选型时重点看三件事:是否支持元数据过滤、是否支持混合检索、更新和删除是否方便。很多团队一开始图省事用了纯向量库,后来发现要按部门、按时间过滤,只能推倒重来。
2.4 检索与组装:把对的上下文放到对的位置
检索阶段拿到候选文档后,还有几件事要做。
第一是重排序(Rerank)。向量检索是粗筛,返回的 top-k 里难免有噪音。用一个交叉编码器(Cross-Encoder)对候选做精排,把真正相关的排到前面,效果提升非常明显。代价是多一次模型推理,延迟会增加,但换来的是准确率的大幅提升,通常值得。
第二是上下文组装。检索回来的块怎么拼进 prompt,顺序很讲究。我的经验是把最相关的放最前面和最后面,中间放次相关的——模型对开头和结尾的内容注意力更集中,这是"中间遗忘"现象带来的实用技巧。
第三是引用标注。每个块带上来源信息,生成时让模型标注引用编号。这样用户能核对,也方便排查幻觉。
3. 稠密与稀疏嵌入的选型逻辑:别被"语义"两个字忽悠
聊到嵌入,很多人第一反应是"要语义理解,肯定选稠密"。这个判断对了一半。稠密嵌入确实强在语义,但它在某些场景下反而不如稀疏来得准。搞清楚两者的适用边界,比盲目追新模型重要。
3.1 稠密嵌入擅长什么、不擅长什么
稠密嵌入的核心能力是语义泛化。用户问"怎么退货",文档里写的是"商品退回流程",字面重合度很低,但稠密向量能把它们拉到一起。这种能力在面向 C 端用户的问答场景里价值巨大,因为用户不会用你的文档术语提问。
但稠密嵌入有几个明显的软肋。一是专有名词和编号,比如"SKU-8823"这种,稠密模型没见过,向量表示基本是随机的,检索全靠运气。二是否定和精确条件,"支持"和"不支持"在向量空间里可能很近,但语义完全相反。三是长尾低频词,训练数据里出现少的词,嵌入质量普遍不高。
我做过一个对比测试,同一个企业知识库,用纯稠密检索,问"XX 型号的保修期是多久",召回率只有六成左右;加上 BM25 稀疏检索做混合,召回率直接上到九成。差距主要就出在型号这种精确匹配上。
3.2 稀疏嵌入的现代形态:不只是 BM25
传统稀疏检索就是 BM25,基于词频和逆文档频率打分。它简单、快、可解释,但有个硬伤:词表外的词完全没法处理,同义词也匹配不上。
现在有一类学习型稀疏嵌入,比如 SPLADE,用模型来预测每个词的重要性权重,既保留了稀疏检索的精确性,又引入了一定的语义扩展能力。它会把"手机"这个查询,在"电话""移动设备"这些相关词上也给出非零权重,弥补了传统 BM25 的短板。
不过学习型稀疏嵌入的部署成本比 BM25 高,需要额外的模型推理。我的建议是:如果团队资源有限,先用 BM25 加稠密做混合,效果已经能覆盖大部分场景;等有精力了再上学习型稀疏。不要一上来就追求最复杂的方案。
3.3 混合检索的融合策略:RRF 是个好起点
两路检索各自返回一个排序列表,怎么合并成一个?最简单的是加权求和,但两路分数的量纲不一样,权重很难调。我更推荐RRF(Reciprocal Rank Fusion,倒数排名融合)。
RRF 的思路是只看排名不看分数:每个文档的得分等于它在各路结果中排名的倒数之和。公式大概是score = Σ 1/(k + rank),k 一般取 60。这样做的好处是完全绕开了分数归一化的问题,鲁棒性很好。
def rrf_fusion(dense_results, sparse_results, k=60): scores = {} for rank, doc_id in enumerate(dense_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) for rank, doc_id in enumerate(sparse_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)这段代码可以直接用。实测下来,RRF 融合后的结果比单独调权重的方案稳定得多,尤其是在两路检索质量参差不齐的时候。
4. 从零跑通一条最小可用 RAG 管道
理论讲完,动手跑一遍。我用 Python 生态里最常见的组合来演示:文档加载、切块、嵌入、检索、生成。这套流程换成 Java 的 Spring AI 或者 LangChain4j,逻辑是一样的,只是 API 名字不同。
4.1 环境准备与依赖选择
先装依赖。核心是文档加载器、文本分割器、嵌入模型和向量库。
pip install langchain langchain-community langchain-text-splitters pip install chromadb sentence-transformers嵌入模型我选sentence-transformers里的多语言模型,本地跑、免费、支持中文。如果追求更好的效果,可以换成 API 形式的嵌入服务,但要考虑成本和数据出境的合规问题——企业场景下这点很关键,很多团队最后都选择了本地部署的嵌入模型。
向量库用 Chroma,轻量、零配置、支持元数据过滤,适合起步阶段。等数据量上来了再迁移到 Milvus 或 pgvector。
4.2 文档加载与切块的完整代码
from langchain_community.document_loaders import DirectoryLoader, TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter # 加载文档 loader = DirectoryLoader("./docs", glob="**/*.md", loader_cls=TextLoader) documents = loader.load() # 切块:按段落优先,块大小 500 字符,重叠 80 字符 splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", " ", ""], ) chunks = splitter.split_documents(documents) # 给每个块补充元数据 for i, chunk in enumerate(chunks): chunk.metadata["chunk_id"] = i chunk.metadata["source"] = chunk.metadata.get("source", "unknown") print(f"共切出 {len(chunks)} 个块")这里separators的顺序很关键。它按优先级尝试:先按空行(段落)切,切出来还太大就按换行切,再不行按中文句号、感叹号、问号切。中文场景下一定要把中文标点加进去,否则会在句子中间硬断。
4.3 嵌入、入库与检索
from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma # 嵌入模型 embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-small-zh-v1.5", model_kwargs={"device": "cpu"}, ) # 建库 vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db", ) # 检索 query = "报销流程需要哪些材料" results = vectorstore.similarity_search_with_score(query, k=5) for doc, score in results: print(f"[{score:.4f}] {doc.page_content[:100]}...")跑通之后你会发现,检索结果的质量高度依赖切块质量。如果切出来的块是断句的,检索出来的内容读起来就很别扭。这时候回去调chunk_size和separators,比调检索参数有效得多。
4.4 把检索结果组装进 Prompt
def build_prompt(query, retrieved_docs): context = "\n\n".join( f"[{i+1}] {doc.page_content}" for i, doc in enumerate(retrieved_docs) ) return f"""基于以下资料回答问题,如果资料中没有相关信息,请明确说明"资料中未提及"。 资料: {context} 问题:{query} 回答:"""这个 prompt 模板有两个关键点。一是明确要求"资料中没有就说没有",这能大幅降低幻觉率。二是给每个资料编号,方便模型在回答里标注引用来源。别小看这两句话,它们对输出质量的提升立竿见影。
5. 检索质量调优:那些文档里不会写的实战经验
跑通管道只是开始,真正花时间的是调优。这一节我分享几个踩过坑才总结出来的经验,都是常规教程里不太会提的。
5.1 命中率上不去,先别急着换模型
很多人一发现检索不准,第一反应是换个更强的嵌入模型。但根据我的经验,八成的问题出在数据处理,而不是模型。排查顺序应该是这样的:
- 先看切块质量。把检索出来的块打印出来读一遍,如果块本身是断句的、缺上下文的,换什么模型都没用。
- 再看查询和文档的表述差异。用户用口语提问,文档用书面术语,这种鸿沟要靠查询改写或者混合检索来补。
- 然后看 top-k 设置。k 太小会漏,k 太大会引入噪音。一般先设 10 到 20,重排序后再取前 3 到 5 给模型。
- 最后才考虑换嵌入模型。
我见过太多团队跳过前三步直接换模型,结果问题依旧,白白浪费了时间和算力。
5.2 查询改写:让用户的问法和文档对齐
用户提问和文档表述之间的鸿沟,是检索失败的头号原因。解决办法之一是查询改写:在检索之前,先用一个小模型把用户的口语化问题改写成更接近文档表述的形式,或者生成多个查询变体分别检索再合并。
比如用户问"东西坏了能换吗",改写成"商品质量问题退换货政策",检索命中率立刻不一样。多查询变体(Multi-Query)的思路是让模型生成三到五个不同角度的查询,每个都去检索,最后合并去重。代价是多几次检索,但召回率提升明显。
5.3 重排序的性价比:什么时候值得上
重排序(Rerank)是提升精度的利器,但它有成本。交叉编码器要对每个"查询-文档"对做一次推理,候选有 20 个就要推理 20 次,延迟会明显增加。
我的判断标准是:如果 top-k 里噪音明显、模型经常被无关内容带偏,那就值得上重排序。反之,如果检索结果本来就挺准,重排序带来的边际收益有限,不如把资源花在别处。
实际部署时,重排序模型可以选小一点的,比如 bge-reranker-base,速度和效果的平衡比较好。别一上来就用最大的模型,延迟扛不住。
5.4 上下文窗口不是越大越好
有个常见的误区:既然模型上下文窗口大,那就多塞点检索结果进去。实际上,塞得越多,模型越容易"迷失在中间",关键信息反而被淹没。而且无关内容会稀释注意力,增加幻觉风险。
我的做法是精挑细选,宁少勿滥。重排序后取前 3 到 5 个最相关的块,每个块控制在合理长度,总上下文占用控制在窗口的 30% 到 50%。留出空间给系统提示和对话历史,效果反而更稳。
6. RAG 的能力边界与常见失效场景
把 RAG 用起来不难,难的是知道它什么时候会失效。这一节聊聊几个典型的翻车场景,以及应对思路。
6.1 多跳问题:RAG 的天然短板
用户问"我们公司去年营收最高的产品线,它的负责人是谁",这个问题需要两步:先查营收最高的产品线,再查这个产品线的负责人。单次检索很难同时命中这两条信息,因为它们在文档里可能相隔很远。
这类多跳问题,基础 RAG 基本无能为力。应对方案有几个方向:一是查询分解,把复杂问题拆成多个子问题分别检索;二是迭代检索,根据第一轮结果决定第二轮查什么;三是上GraphRAG,把知识建成图结构,通过关系遍历来回答。GraphRAG 效果确实好,但构建和维护成本高,不是所有场景都值得。
6.2 知识冲突:新旧文档打架怎么办
企业知识库经常有这种情况:同一件事,旧文档和新文档说法不一致。检索时两个都召回了,模型不知道该信哪个,输出就可能自相矛盾。
解决办法是在元数据里加时间戳和版本号,检索时优先返回最新的,或者在 prompt 里明确告诉模型"以时间较新的资料为准"。更彻底的做法是建立文档的生命周期管理,过期文档及时下架,别让它们留在索引里捣乱。
6.3 表格和结构化数据:纯文本检索的盲区
财务报表、参数对照表这类结构化数据,转成纯文本后行列关系就丢了,检索出来模型也读不懂。这类数据最好单独处理:要么转成自然语言描述再嵌入,要么用专门的表格检索方案,要么干脆走 Text-to-SQL 的路子,让模型生成查询语句直接查数据库。
我一般会判断数据的性质:叙述性知识走 RAG,结构化数据走 SQL 或专门的检索通道。硬把表格塞进向量库,效果通常很差。
6.4 评估:没有度量就没有优化
最后说一个容易被忽略但极其重要的事:建立评估体系。没有评估,你根本不知道调优有没有效果,全靠感觉。
评估分两块。一是检索质量,看命中率(Hit Rate)、召回率(Recall)、MRR 这些指标,需要标注一批"问题-正确文档"的测试集。二是生成质量,看答案的忠实度(是否基于检索内容)、相关性、完整性,可以用模型来打分,也可以人工抽检。
测试集不用很大,几十到上百条就够起步。关键是持续维护,每次改动都跑一遍,用数据说话。我见过太多团队凭感觉调参,改来改去反而越调越差,就是因为没有基准。
提示:评估集要覆盖典型问题和边界情况,包括那些你知道会失败的场景。只测简单问题,评估结果会虚高,上线后照样翻车。
7. 我在实际项目里的一些取舍
做了几个 RAG 项目之后,我最大的体会是:别追求一步到位,先跑通最小闭环,再逐步加料。
一开始就上混合检索、重排序、查询改写、GraphRAG,听起来很美好,但调试成本极高,出了问题根本不知道是哪一环的锅。我的做法是先搭一条最简管道——固定切块、纯稠密检索、直接拼 prompt——跑通之后,用评估集测出基线,然后一次只改一个变量,看指标变化。这样每一步的收益都清清楚楚。
另一个体会是数据质量决定天花板。再花哨的检索技术,也救不了一堆垃圾文档。与其在检索算法上反复折腾,不如花时间把文档整理干净、结构理清楚、元数据补全。这部分工作枯燥,但回报最实在。
还有一点:RAG 和微调不是对立的。RAG 解决的是"知识从哪来",微调解决的是"模型怎么说话"。企业场景下,通常是 RAG 提供事实,微调调整风格和格式,两者配合使用。别指望 RAG 能教会模型一种全新的表达方式,那是微调的活。
最后分享一个我常用的排查技巧:当模型答错时,先看检索结果对不对。如果检索回来的内容里根本没有正确答案,那是检索的问题,去调切块和检索;如果检索结果里有正确答案但模型没用,那是生成的问题,去调 prompt 和上下文组装。这个二分法能帮你快速定位问题所在,省下大量瞎调的时间。