1. 为什么知识获取管道是 AI Agent 的分水岭
做 AI Agent 的人迟早会撞上一堵墙:模型本身很聪明,但你问它公司内部的报销标准、上周刚更新的产品参数、某个客户的特殊约定,它要么一本正经地胡说,要么直接摊手说不知道。这不是模型不行,而是它的知识被冻结在训练截止的那一天,而且压根没见过你的私有数据。知识获取管道要解决的,就是把这个断层补上,让 Agent 在回答问题之前,先去"查资料"。
RAG(Retrieval-Augmented Generation,检索增强生成)就是目前最主流、最工程化的一条知识获取管道。它的核心思路用一句话说清楚:把外部知识切块、向量化、存进索引,用户提问时先检索出最相关的几块,再连同问题一起塞给大模型,让它"看着材料答题"。听起来简单,但真正落地时,从文档切分、嵌入模型选型、检索策略到重排,每一步都有坑。
这篇是"走进 AI Agent"系列的第四篇,专门啃知识获取管道这块硬骨头。我会把 RAG 的基础链路拆开讲透,包括稠密嵌入和稀疏嵌入到底差在哪、为什么很多团队最后都上了混合检索、切块大小怎么定、命中率上不去该怎么排查。适合正在从 0 到 1 搭建 AI Agent 的开发者,也适合已经跑通了 demo 但发现效果拉胯、想搞清楚瓶颈在哪的人。读完你应该能自己搭一条能用的 RAG 管道,并且知道每个参数背后的取舍逻辑。
2. RAG 基础链路的整体设计与选型思路
2.1 一条完整的知识获取管道长什么样
很多人对 RAG 的理解停留在"向量数据库 + 大模型"两个框,实际工程里它是一条至少五段的流水线。我把它拆成下面这几步,每一步都是一个可以独立优化、也独立出问题的地方。
- 文档加载(Loading):把 PDF、Word、Markdown、网页、数据库记录等各种来源的原始内容读进来,统一成纯文本或结构化文本。
- 切块(Chunking):把长文档切成一段段适合检索和塞进上下文的小块,这是最容易被低估、却最影响效果的一步。
- 嵌入(Embedding):把每个文本块转成一个高维向量,语义相近的文本在向量空间里距离更近。
- 索引与存储(Indexing & Storage):把向量和原文、元数据一起存进向量数据库,建立可快速检索的索引。
- 检索与重排(Retrieval & Rerank):用户提问时,把问题也向量化,找出最相似的若干块,必要时再用重排模型精排一遍。
- 生成(Generation):把检索到的上下文和用户问题拼成提示词,交给大模型生成最终答案。
这条链路里,加载和切块决定了"原料质量",嵌入和索引决定了"能不能找得到",检索和重排决定了"找得准不准",生成决定了"答得好不好"。任何一环拉胯,最终体验都会崩。我见过太多团队一上来就纠结用哪个向量库,结果真正的问题出在切块把一句话从中间劈开了。
2.2 为什么是 RAG,而不是微调或者长上下文
在动手之前,值得先想清楚一个选型问题:让 Agent 掌握私有知识,到底该用 RAG、微调(Fine-tuning),还是干脆靠超长上下文硬塞?
微调的本质是改变模型的权重,让它"记住"知识。它适合的是风格、格式、特定任务的模式学习,比如让模型学会用你公司的口吻写邮件。但用它来记事实性知识有几个硬伤:更新成本高(知识一变就得重训)、容易产生幻觉(模型会"自信地记错")、无法追溯来源(你没法告诉用户这个答案出自哪份文档)。而 RAG 的知识存在外部索引里,改一条文档就更新一条,来源可追溯,成本也低得多。
长上下文看起来更省事——把整本手册塞进去不就行了?问题是成本和精度。上下文越长,推理越贵越慢,而且模型对长上下文中间部分的注意力会衰减,也就是常说的"lost in the middle"。你把 200 页文档塞进去,模型可能恰恰漏掉了最关键的那一段。RAG 的价值就在于先做一轮筛选,只把最相关的几块喂给模型,既省钱又提精度。
所以结论很明确:事实性、易变、需要溯源的私有知识,优先用 RAG。微调和 RAG 也不是互斥的,很多成熟方案是 RAG 负责知识、微调负责风格,两者叠加。
2.3 稠密嵌入与稀疏嵌入:两条腿走路才稳
这是本篇最核心的一个技术点,也是热词里反复出现的"稠密嵌入、稀疏嵌入"。搞懂这两个,你就理解了检索的半壁江山。
稠密嵌入(Dense Embedding)把一段文本压成一个固定长度的稠密向量,比如 768 维或 1024 维,每一维都是浮点数。它的强项是语义匹配:你搜"怎么退钱",它能召回写着"退款流程"的文档,哪怕一个字都不重合。这是传统关键词搜索做不到的。代表模型有各种开源的 sentence-transformers 系列,以及各家提供的 embedding API。
稀疏嵌入(Sparse Embedding)则是高维但绝大多数维度为零的向量,本质上还是词袋模型那一套,每个维度对应词表里的一个词,值是该词的权重。经典代表是 BM25,以及后来的 SPLADE 这类学习式稀疏模型。它的强项是精确匹配:搜产品型号"XR-2000"、人名、错误码、专有名词,它能精准命中,而稠密嵌入反而容易把这些"低频但关键"的词糊掉。
两者的短板正好互补。稠密嵌入对专有名词、数字、罕见词不敏感;稀疏嵌入对同义改写、语义泛化无能为力。所以工业界的主流做法是混合检索(Hybrid Retrieval):两路并行召回,再用一个融合算法(常见的是 RRF,Reciprocal Rank Fusion,倒数排名融合)把两路结果合并。RRF 的公式很朴素,对每个文档在每路结果里的排名取倒数再求和:
score(d) = Σ 1 / (k + rank_i(d))其中 k 是个平滑常数,通常取 60。这个公式的好处是不依赖两路分数的绝对量级(稠密是余弦相似度,稀疏是 BM25 分数,量纲根本不一样),只看排名,天然可融合。实测下来,混合检索相比单用稠密,在包含大量专有名词的场景里命中率能提升一截,这也是为什么"rag hit rate"成了大家天天念叨的指标。
3. 核心细节解析与实操要点
3.1 切块策略:决定 RAG 上限的隐形之手
切块(Chunking)是整条链路里最不性感、却最要命的一步。切得好,检索精准;切得烂,神仙难救。核心矛盾在于:块太大,噪声多、塞进上下文浪费 token、语义被稀释;块太小,上下文不完整、一句话被劈成两半、检索到的碎片拼不出完整意思。
我的经验是,块大小没有万能值,但有一个合理的起步区间:中文 300 到 500 字,英文 200 到 400 词,配合 10% 到 20% 的重叠(overlap)。重叠的作用是防止关键信息正好落在切割边界上被劈开,代价是存储和检索时会有一些冗余。
比固定长度切分更好的做法是按语义结构切。Markdown 按标题层级切,代码按函数切,合同按条款切,网页按段落切。这样每个块天然是一个语义完整的单元。我一般会优先用递归字符切分(Recursive Character Splitting),它按"段落 → 句子 → 词"的优先级依次尝试,尽量在自然边界断开。
还有一个容易被忽略的点:给每个块加上下文元数据。光切出一段"退款需在 7 日内申请",检索时它可能匹配到任何跟退款相关的提问,但你不知道它属于哪个产品线。所以要在块里带上来源文档标题、章节路径、产品名等元信息,检索时可以用元数据做过滤,生成时也能给用户标注来源。这一步做扎实,后面排查问题会轻松很多。
提示:切块前一定要先做清洗。PDF 提取出来的文本常带页眉页脚、乱码、断行,这些噪声会污染嵌入向量,直接拉低检索质量。宁可多花时间清洗,也别指望模型能忽略噪声。
3.2 嵌入模型选型:别只看排行榜
嵌入模型决定了"语义能不能被正确表达",选型时我关注这几个维度。
第一是语言适配。如果你的知识库以中文为主,务必选中文或多语言表现好的模型。很多英文榜单上的明星模型,中文语义空间是塌的,直接拿来用效果惨不忍睹。选型时一定要用你自己的真实数据做小规模评测,别信通用榜单。
第二是维度与成本。维度越高,表达能力通常越强,但存储和检索成本也越高。1024 维和 768 维在实际效果上往往差不了多少,但存储能差三分之一。如果知识库规模很大(百万级以上块),维度就是真金白银。
第三是是否支持稀疏或混合输出。现在有些模型能同时输出稠密和稀疏向量,一次调用搞定两路召回,工程上省事不少。如果你的框架支持,优先考虑这类。
第四是归一化与相似度度量。绝大多数嵌入模型输出后要做 L2 归一化,然后用余弦相似度或内积检索。归一化后两者等价。这一步如果漏了,检索结果会莫名其妙地差,而且很难排查。
3.3 检索参数:top-k、阈值与重排
检索阶段有几个关键参数,调不好就是"明明库里有,就是搜不出来"。
top-k是召回多少个候选块。太小(比如 3)容易漏,太大(比如 50)会把噪声一起带进来,还可能超出上下文预算。我的起步值是稠密和稀疏各召回 20 个,融合后取前 10 个进入重排,重排后取前 3 到 5 个喂给模型。这个数字要根据你的块大小和模型上下文窗口动态调。
相似度阈值用来过滤掉明显不相关的块。如果所有召回块的相似度都低于某个阈值,说明知识库里可能根本没有相关内容,这时候与其硬答,不如让 Agent 老实说"我没找到相关资料"。这个"拒答"机制对降低幻觉极其重要,很多团队忽略了它。
重排(Rerank)是提升精度的利器。检索阶段用的是双塔模型(问题和文档分别编码),快但精度有限;重排用的是交叉编码器(cross-encoder),把问题和文档拼在一起过一遍模型,精度高但慢。所以典型架构是"检索粗筛 + 重排精排"。重排模型通常只对前 20 到 50 个候选做,成本可控,但命中率提升明显。这也是"rag hit rate"优化的关键一招。
3.4 生成阶段的提示词工程
检索做得好,生成阶段也不能掉链子。核心原则是约束模型只依据检索到的上下文回答,并明确告诉它"如果上下文里没有答案,就说不知道"。
一个我常用的提示词骨架是这样的:
你是一个严谨的问答助手。请仅根据下面提供的【参考资料】回答用户问题。 规则: 1. 如果参考资料中没有相关信息,直接回答"根据现有资料无法回答",不要编造。 2. 回答时尽量引用资料中的原文依据。 3. 不要使用参考资料之外的知识。 【参考资料】 {context} 【用户问题】 {question}这个骨架看着简单,但"仅根据资料""无法回答就说不知道"这两句能挡掉大量幻觉。另外,把检索到的块按相关性排序后拼接,最相关的放最前面,也能提升模型对关键信息的注意力。
4. 从零搭一条 RAG 管道的实操过程
4.1 环境与依赖准备
下面用 Python 生态走一遍最小可用链路,思路是通用的,换成 Java 的 Spring AI、LangChain4j 或者别的框架,逻辑完全一样。先装依赖:
pip install langchain langchain-community langchain-text-splitters pip install sentence-transformers pip install chromadb pip install rank-bm25 pip install pypdf这里选 Chroma 做向量库是因为它轻量、能本地跑、适合验证;选 sentence-transformers 是因为它开源、可控、方便换模型。生产环境可以换成 Milvus、Qdrant、pgvector 等,接口思路一致。
4.2 文档加载与清洗
from langchain_community.document_loaders import PyPDFLoader loader = PyPDFLoader("handbook.pdf") raw_docs = loader.load() # 清洗:去掉多余空白、页眉页脚等噪声 def clean(text: str) -> str: lines = [ln.strip() for ln in text.splitlines()] lines = [ln for ln in lines if len(ln) > 2] # 丢掉过短的行 return "\n".join(lines) for d in raw_docs: d.page_content = clean(d.page_content)清洗这一步别偷懒。我踩过的坑是:PDF 提取出来的文本里混着页码和页眉,这些高频重复的噪声会被嵌入模型当成重要特征,导致检索时一堆无关块被召回。清洗后效果立竿见影。
4.3 切块与元数据注入
from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=60, separators=["\n\n", "\n", "。", "!", "?", " ", ""], ) chunks = splitter.split_documents(raw_docs) # 注入元数据 for i, c in enumerate(chunks): c.metadata["chunk_id"] = i c.metadata["source"] = c.metadata.get("source", "unknown")注意 separators 里我把中文标点也放进去了,这样切分时会优先在句号、问号处断开,避免把一句话劈成两半。chunk_size 用 400 字是中文场景的稳妥起步值,overlap 取 60 约 15%。
4.4 稠密嵌入与向量入库
from sentence_transformers import SentenceTransformer import chromadb model = SentenceTransformer("BAAI/bge-base-zh-v1.5") # 中文表现好的模型 client = chromadb.PersistentClient(path="./rag_db") collection = client.get_or_create_collection( name="handbook", metadata={"hnsw:space": "cosine"}, ) texts = [c.page_content for c in chunks] embeddings = model.encode(texts, normalize_embeddings=True).tolist() collection.add( ids=[str(c.metadata["chunk_id"]) for c in chunks], embeddings=embeddings, documents=texts, metadatas=[c.metadata for c in chunks], )这里normalize_embeddings=True是关键,它做了 L2 归一化,配合 cosine 距离度量,检索才准。忘了这一步,相似度计算会失真。
4.5 稀疏检索与混合融合
稠密检索有了,再补一路 BM25 稀疏检索,然后融合。
from rank_bm25 import BM25Okapi import jieba # 中文需要先分词 tokenized = [list(jieba.cut(t)) for t in texts] bm25 = BM25Okapi(tokenized) def sparse_search(query, top_k=20): q_tokens = list(jieba.cut(query)) scores = bm25.get_scores(q_tokens) ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True) return ranked[:top_k] def dense_search(query, top_k=20): q_emb = model.encode([query], normalize_embeddings=True).tolist() res = collection.query(query_embeddings=q_emb, n_results=top_k) return [int(i) for i in res["ids"][0]] def rrf_fuse(dense_ids, sparse_ids, k=60): scores = {} for rank, doc_id in enumerate(dense_ids): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank) for rank, doc_id in enumerate(sparse_ids): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank) return sorted(scores, key=scores.get, reverse=True)RRF 融合的好处前面讲过,只看排名不看分数,天然规避了稠密和稀疏分数量纲不一致的问题。实测在专有名词多的场景,混合检索比单路稠密召回率明显更高。
4.6 重排与生成
from sentence_transformers import CrossEncoder reranker = CrossEncoder("BAAI/bge-reranker-base") def retrieve(query, top_k=5): dense_ids = dense_search(query) sparse_ids = sparse_search(query) fused = rrf_fuse(dense_ids, sparse_ids)[:20] pairs = [(query, texts[i]) for i in fused] scores = reranker.predict(pairs) ranked = sorted(zip(fused, scores), key=lambda x: x[1], reverse=True) return [texts[i] for i, _ in ranked[:top_k]] def answer(query): ctx = "\n\n".join(retrieve(query)) prompt = f"""你是一个严谨的问答助手。请仅根据下面提供的【参考资料】回答用户问题。 如果参考资料中没有相关信息,直接回答"根据现有资料无法回答",不要编造。 【参考资料】 {ctx} 【用户问题】 {query}""" # 这里调用你的大模型接口 return call_llm(prompt)到这里,一条完整的 RAG 管道就跑通了:加载 → 清洗 → 切块 → 稠密嵌入 → 稀疏索引 → 混合召回 → RRF 融合 → 重排 → 生成。每一步都可以单独替换和优化,这也是 RAG 相比微调更灵活的地方。
5. 常见问题与排查技巧实录
5.1 命中率上不去的排查顺序
"rag hit rate"低是最常见的问题,我一般按下面的顺序排查,从便宜到贵:
| 排查项 | 典型症状 | 处理方式 |
|---|---|---|
| 文本清洗 | 召回一堆无关块 | 去页眉页脚、乱码、断行 |
| 切块大小 | 答案被劈开或噪声多 | 调 chunk_size 和 overlap |
| 嵌入归一化 | 相似度普遍偏低 | 确认做了 L2 归一化 |
| 嵌入模型语言 | 中文语义匹配差 | 换中文/多语言模型 |
| 检索路数 | 专有名词搜不到 | 加稀疏检索做混合 |
| 重排缺失 | 相关块排不进前几 | 加 cross-encoder 重排 |
| 阈值缺失 | 无答案时硬编 | 加相似度阈值和拒答 |
按这个顺序走,八成问题在前三步就能定位。我见过最离谱的一次,是团队把 PDF 的页眉"内部资料 请勿外传"切进了每个块,结果所有查询都优先召回这些噪声块,排查了半天才发现是清洗没做。
5.2 几个高频踩坑点
坑一:块太小导致语义碎片化。有人为了"精准"把块切到 100 字,结果检索到的都是半句话,模型拼不出完整答案。块大小要保证一个块能独立表达一个完整意思。
坑二:忽略元数据过滤。知识库里有多个产品线的文档,用户问 A 产品,却召回了 B 产品的相似条款。加元数据过滤(按产品名、版本、时间)能大幅提升精度。
坑三:重排模型和嵌入模型不匹配。重排模型最好和嵌入模型来自同一系列或同一语言适配,否则精排效果打折。
坑四:把 RAG 当万能药。有些问题本质是推理问题,不是检索问题。比如"对比 A 和 B 两个方案的优劣",需要的是跨文档综合,单靠检索几块拼不出答案。这类场景要考虑 GraphRAG 或者 Agentic RAG,让 Agent 多轮检索、自己规划查询。
注意:RAG 的效果上限由知识库质量决定。如果原始文档本身就过时、矛盾、残缺,再好的管道也救不回来。上线前一定要做知识库治理。
5.3 从基础 RAG 到 Agentic RAG 的演进方向
基础 RAG 是"一问一检索一答"的单轮模式,遇到复杂问题就力不从心。往上的演进方向有几个:
一是查询改写(Query Rewriting),让模型先把用户的口语化问题改写成更适合检索的形式,甚至拆成多个子查询分别检索。
二是多轮检索(Iterative Retrieval),Agent 先检索一轮,看结果不够就换个角度再检索,直到信息足够再回答。这就是 Agentic RAG 的核心思想——把检索变成 Agent 的一个可调用工具,由它自己决定什么时候检索、检索什么。
三是图增强(GraphRAG),把知识建成实体关系图,检索时沿着图遍历,适合需要跨文档推理的场景。热词里出现的"ontology rag""graphrag"说的就是这一类。
四是和 Skill 结合,把 RAG 检索封装成一个标准化的 Skill,让 Agent 在需要时调用,这样知识获取就成了 Agent 能力体系里的一个模块,而不是硬编码在流程里。
这些方向都是在基础 RAG 之上做加法,但前提是基础链路得先跑稳。基础不牢,上再花哨的架构也是空中楼阁。
6. 我在实际项目里的一些体会
搭 RAG 这件事,最反直觉的一点是:决定效果的不是你用了多牛的模型,而是数据处理的脏活累活。我做过好几个项目,换更贵的嵌入模型带来的提升,往往不如老老实实把文档清洗干净、把切块策略调对来得明显。模型是放大器,原料不行,放大出来的还是垃圾。
另一个体会是,评测必须尽早做。别等到上线才发现效果差,那时候改起来牵一发动全身。我的做法是准备一批真实问题加标准答案,每次改动链路都跑一遍,看命中率和答案准确率的变化。没有评测,调参就是盲人摸象。
最后分享一个小技巧:检索到的块在拼进提示词之前,可以按相关性分数做个简单的去重和截断,把明显重复或过长的块处理掉。这一步能省不少 token,还能让模型注意力更集中。RAG 这条管道没有银弹,靠的就是一环一环抠细节,抠到最后你会发现,稳比炫重要得多。