☰
AI Agent知识获取管道:RAG基础与混合检索实战
2026/9/29 23:27:20 网站建设 项目流程

1. 为什么知识获取管道是 AI Agent 的第一道生死线

做 AI Agent 的人迟早会撞上一堵墙:模型本身很聪明,但你问它公司内部某个产品的退货政策,它要么胡编,要么说“我无法访问你的数据”。这不是模型不行,而是它缺一条知识获取管道。RAG(Retrieval-Augmented Generation,检索增强生成)就是目前工程上最成熟、性价比最高的那条管道。

我做了几个 Agent 项目之后有个很深的体会:Agent 的能力上限,往往不取决于你选了哪个大模型,而取决于你喂给它的知识有多准、多快、多干净。一个用中等模型但 RAG 管道打磨得很扎实的 Agent,实际表现会碾压一个用顶配模型但知识管道稀烂的 Agent。原因很简单——模型负责推理和表达,知识管道负责事实供给,两者是乘法关系,任何一边趋近于零,整体就趋近于零。

这篇是“走进 AI Agent”系列的第四篇,专门讲 RAG 基础。我会把知识获取管道拆成几个关键环节:文档怎么进来、怎么切、怎么变成向量、怎么存、怎么检索、怎么塞回给模型。每个环节我都会说清楚为什么这么设计,以及我在实操中踩过的坑。适合正在从 0 到 1 搭建 AI Agent 的开发者,也适合已经跑通了 demo 但发现“检索命中率上不去”的同行。

先给一个整体认知:RAG 的本质是用检索替代记忆。大模型的参数里存的是通用世界知识,但存不下你私有的、实时的、细粒度的知识。与其花大价钱去微调,不如在推理时临时把相关资料“查出来”塞进上下文。这个思路听起来简单,但工程细节极多,下面逐个拆。

2. RAG 管道的整体设计与方案选型

2.1 一条完整的知识获取管道长什么样

我把 RAG 管道分成两个阶段:离线索引阶段和在线检索阶段。离线阶段负责把原始文档变成可检索的向量库,在线阶段负责根据用户问题把最相关的片段捞出来。

离线索引的流程是:文档加载 → 文本清洗 → 分块(chunking)→ 嵌入(embedding)→ 存入向量库。在线检索的流程是:用户提问 → 问题嵌入 → 向量相似度检索 → 重排序(可选)→ 组装上下文 → 交给 LLM 生成。

很多人一上来就纠结“用哪个向量库”,其实这是最不该先纠结的问题。真正决定 RAG 效果的是分块策略和嵌入模型的选择,向量库只是存储和检索的容器,主流几个性能差距没有想象中那么大。我见过太多项目在向量库选型上花了两周,结果分块用的是固定 1000 字符硬切,检索命中率惨不忍睹。

2.2 稠密嵌入与稀疏嵌入:不是二选一,而是搭配用

热词里提到的稠密嵌入(dense embedding)和稀疏嵌入(sparse embedding),是理解 RAG 检索质量的关键。

稠密嵌入把一段文本映射成一个几百到上千维的稠密向量,每一维都是浮点数。它的优势是语义匹配——用户问“怎么退款”,文档里写的是“如何申请退货”,字面不一样但语义接近,稠密向量能匹配上。代表模型有各种 text-embedding 系列。

稀疏嵌入则是高维稀疏向量,大部分维度是零,只有出现的词对应的维度有值。它的优势是关键词精确匹配——用户搜“订单号 A12345”,稀疏检索能精准命中,稠密向量反而可能因为语义泛化而漏掉。BM25 就是经典的稀疏检索算法。

我的实操结论是:两者搭配用,效果远好于单用任何一个。这就是所谓的混合检索(hybrid search)。做法是分别用稠密和稀疏各召回一批候选,然后用 RRF(Reciprocal Rank Fusion,倒数排名融合)把两路结果合并。RRF 的公式很简单,对每个文档,把它在各路检索中的排名取倒数再求和:

score(d) = Σ 1 / (k + rank_i(d))

其中 k 通常取 60,rank_i(d) 是文档 d 在第 i 路检索中的排名。这个公式的好处是不需要归一化两路分数,直接融合排名,工程上非常省心。

2.3 为什么我不建议一上来就上 GraphRAG

热词里有 graphrag、ontology rag、rag graphrag llm wiki 本体 rag 这些词,说明大家都在关注知识图谱增强的 RAG。我的建议是:除非你的知识本身高度结构化、实体关系密集,否则别一上来就上 GraphRAG。

GraphRAG 的核心思路是把文档抽成实体和关系,构建知识图谱,检索时沿着图走。它在处理“A 和 B 是什么关系”“跨文档的多跳推理”这类问题上确实强。但代价是:构建图谱需要额外的 LLM 抽取成本,图谱维护复杂,检索链路长,调试困难。我做过一个对比,同样一批文档,朴素 RAG 从零到能用大概两天,GraphRAG 从零到能用至少两周,而且中间会遇到实体消歧、关系抽取错误等一堆问题。

所以我的选型原则是:先用朴素 RAG 跑通,把分块和嵌入调好,等遇到明确的多跳推理瓶颈,再考虑引入图谱。不要为了技术先进性而牺牲工程可控性。

3. 核心细节解析与实操要点

3.1 文档加载与清洗:脏数据是命中率的第一杀手

文档加载看起来是最没技术含量的环节,但它决定了后面所有环节的上限。我踩过最惨的坑是:PDF 里的表格被解析成了一堆乱序文本,导致检索出来的片段完全没法用。

不同格式的文档要用不同的加载器。纯文本和 Markdown 直接读;PDF 建议用能保留版面结构的解析器,比如基于版面分析的方案,而不是简单的文本抽取;Word 和 HTML 要先把标签和样式剥掉;扫描件必须先做 OCR。

清洗环节我通常会做这几件事:

  • 去掉页眉页脚、页码、水印文字,这些内容会在每个 chunk 里重复出现,严重干扰检索
  • 合并被硬换行打断的句子,PDF 解析经常把一句话拆成好几行
  • 统一全角半角、去除多余空白
  • 把表格转成结构化的 Markdown 表格或键值对,而不是让它散成一堆词

注意:清洗不要过度。我见过有人把标点符号全删了,结果嵌入模型对语义边界的判断变差。清洗的目标是去噪,不是把文本变成一坨没有结构的词。

3.2 分块策略:RAG 效果的分水岭

分块是 RAG 里最需要花心思的地方。切得太大,一个 chunk 里混了好几个主题,检索出来噪声多;切得太小,一个完整的语义单元被拆散,模型拿到手也拼不出完整答案。

我常用的分块策略是递归字符分割 + 语义边界优先。具体做法是:先按段落分,段落太长再按句子分,句子还长才按字符硬切。分隔符的优先级是:\n\n→\n→。→!→?→;→ 空格。这样能最大程度保证每个 chunk 是一个语义完整的单元。

chunk 大小我一般从500 到 800 字符起步,overlap(重叠)设成 chunk 大小的 10% 到 15%。overlap 的作用是防止关键信息正好卡在两个 chunk 的边界上被切断。比如一句话前半段在 chunk A 结尾,后半段在 chunk B 开头,有了 overlap,两个 chunk 都能包含完整句子。

但这里有个反直觉的点:chunk 大小没有万能值,要按文档类型调。技术文档、法律条文这种逻辑密集的,chunk 可以小一点,400 到 600 字符;叙事性的、上下文依赖强的,chunk 要大一点,800 到 1200 字符。我的做法是先跑一批测试问题,看命中率,再微调。

还有一种进阶做法是父子分块(parent-child chunking):索引时用小的子 chunk 做检索,命中后返回它所属的大的父 chunk 给 LLM。这样检索精度高,同时给模型的上下文又足够完整。这个技巧在长文档场景下特别好用。

3.3 嵌入模型选型:别只看排行榜

嵌入模型决定了文本被映射到向量空间后的质量。选型时我会看三个维度:语义区分度、维度成本、语言支持。

语义区分度就是模型能不能把语义相近的文本映射到相近的位置。这个不能只看 MTEB 排行榜,因为排行榜的测试集未必和你的领域匹配。我的做法是:拿 20 到 30 个自己领域的真实问题,配上正确答案所在的文档片段,手动测一下检索命中率。这个自建测试集比任何排行榜都靠谱。

维度成本方面,向量维度越高,存储和检索开销越大。768 维和 1536 维在效果上可能只差几个百分点,但存储成本差一倍。如果数据量不大,用高维没问题;如果上百万文档,就要权衡了。

语言支持上,如果你的文档是中英混合,一定要选多语言模型,否则中文检索会明显拉胯。我实测过,纯英文模型处理中文文档,检索命中率能掉一半以上。

提示:嵌入模型和 LLM 是两回事,可以分开选。嵌入模型不需要生成能力,所以可以选小而专的,成本低、速度快。别用生成模型去做嵌入,那是杀鸡用牛刀,还慢。

3.4 向量库选型:够用就好

向量库的选择我按数据规模分:

数据规模推荐方案理由
万级以下内存向量库或本地文件零运维,够快
十万到百万级单机向量数据库支持持久化和索引优化
百万级以上分布式向量库需要水平扩展和副本

我特别想说的是:别过早引入分布式向量库。很多项目文档才几千条,就上了集群方案,结果运维成本比开发成本还高。向量检索在百万级以下,单机方案完全扛得住。等真的到了瓶颈再迁移,迁移成本也没想象中那么高,因为向量数据本身是标准格式。

索引类型上,HNSW(分层可导航小世界图)是目前最常用的近似最近邻索引,查询快、召回率高,代价是内存占用大。如果内存紧张,可以用 IVF 系列索引,用召回率换内存。这个取舍要看你的场景:如果对召回率要求极高(比如法律、医疗),用 HNSW;如果数据量巨大且能接受少量漏召回,用 IVF。

4. 实操过程与核心环节实现

4.1 从零搭一条最小可用管道

我用 Python 生态举例,因为这是目前最成熟的。整体流程分五步。

第一步,加载文档。假设文档是 Markdown 格式,直接读文件内容,按标题层级做初步切分。

import os def load_docs(root_dir): docs = [] for dirpath, _, filenames in os.walk(root_dir): for fn in filenames: if fn.endswith(".md"): path = os.path.join(dirpath, fn) with open(path, "r", encoding="utf-8") as f: docs.append({"path": path, "text": f.read()}) return docs

第二步,分块。实现递归字符分割,按分隔符优先级逐级降级。

def split_text(text, chunk_size=600, overlap=80): separators = ["\n\n", "\n", "。", "!", "?", ";", " "] chunks = [] start = 0 while start < len(text): end = min(start + chunk_size, len(text)) if end < len(text): best = -1 for sep in separators: pos = text.rfind(sep, start, end) if pos > best: best = pos if best > start: end = best + 1 chunks.append(text[start:end]) start = end - overlap if end - overlap > start else end return chunks

这段代码的逻辑是:先按 chunk_size 划一个窗口,然后在窗口内从后往前找优先级最高的分隔符,把切点定在那里。这样能保证尽量在语义边界处切开。overlap 通过回退 start 实现。

第三步,嵌入。调用嵌入模型把每个 chunk 转成向量。这里要注意批量处理,一次传一批文本比逐条传快得多。

def embed_chunks(chunks, embed_fn, batch_size=32): vectors = [] for i in range(0, len(chunks), batch_size): batch = chunks[i:i+batch_size] vectors.extend(embed_fn(batch)) return vectors

第四步,存入向量库。以本地方案为例,把向量和原文、元数据一起存。

import json def save_index(chunks, vectors, path="index.json"): data = [{"text": c, "vector": v} for c, v in zip(chunks, vectors)] with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False)

第五步,检索。用户提问后,把问题嵌入,和库里所有向量算余弦相似度,取 top-k。

import numpy as np def cosine_sim(a, b): a, b = np.array(a), np.array(b) return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) def retrieve(query_vec, index, top_k=5): scored = [(cosine_sim(query_vec, item["vector"]), item["text"]) for item in index] scored.sort(key=lambda x: x[0], reverse=True) return scored[:top_k]

这套代码在几千条文档的规模下完全够用。数据量上去之后,把最后一步换成向量库的检索接口即可,前面的流程不变。

4.2 混合检索的落地实现

单用稠密检索,遇到专有名词、编号、代码标识符容易漏。加上稀疏检索能补上这块。我用 BM25 做稀疏那一路。

from rank_bm25 import BM25Okapi def build_bm25(chunks): tokenized = [list(c) for c in chunks] # 中文按字切,英文按词切需另处理 return BM25Okapi(tokenized) def hybrid_retrieve(query, query_vec, chunks, bm25, index, top_k=5, k=60): dense_ranked = retrieve(query_vec, index, top_k=20) dense_ids = [chunks.index(t) for _, t in dense_ranked] bm25_scores = bm25.get_scores(list(query)) sparse_ids = sorted(range(len(chunks)), key=lambda i: bm25_scores[i], reverse=True)[:20] fused = {} for rank, idx in enumerate(dense_ids): fused[idx] = fused.get(idx, 0) + 1 / (k + rank) for rank, idx in enumerate(sparse_ids): fused[idx] = fused.get(idx, 0) + 1 / (k + rank) ranked = sorted(fused.items(), key=lambda x: x[1], reverse=True)[:top_k] return [(chunks[i], s) for i, s in ranked]

这里中文的 BM25 分词是个坑。中文没有天然空格,按字切虽然能用但效果一般,更好的做法是接一个中文分词器。如果不想引入额外依赖,按字切也能凑合,因为 BM25 主要靠词频和逆文档频率,单字也有区分度。

4.3 上下文组装:把检索结果喂给 LLM

检索出 top-k 片段后,不能直接一股脑塞给 LLM,要组装成结构清晰的上下文。我的模板是这样的:

根据以下资料回答问题。如果资料中没有相关信息,请明确说明不知道,不要编造。 【资料 1】 {chunk_1} 【资料 2】 {chunk_2} 【问题】 {user_query}

这个模板有两个关键点。一是明确告诉模型“不知道就说不知道”,这能大幅降低幻觉。二是给每个片段编号,方便模型引用,也方便你调试时定位是哪个片段起了作用。

top-k 取多少也有讲究。取太少,可能漏掉关键信息;取太多,噪声增加,还会挤占上下文窗口。我一般取 3 到 5 个,如果 chunk 比较小可以取到 8 个。判断标准是:把检索结果拼起来,看总长度是否超过模型上下文窗口的 30%。超过就说明取多了。

5. 常见问题与排查技巧实录

5.1 检索命中率上不去,先查这五个地方

命中率是 RAG 的核心指标。我遇到命中率低的时候,会按这个顺序排查:

排查项常见问题解决方向
分块chunk 太大混主题,太小断语义调整大小和 overlap,试父子分块
嵌入模型领域不匹配,语言不支持换模型,自建测试集验证
清洗页眉页脚噪声,表格乱序加强清洗,表格结构化
检索方式单路检索漏召上混合检索
查询用户问题太短或太口语做查询改写或扩展

我特别想强调查询改写这一项。用户问“那个退款的事咋弄”,直接拿这句去检索,效果往往很差。做法是用 LLM 先把问题改写成更规范的检索式,比如“如何申请退款,退款流程是什么”,再去检索。这一步能明显提升命中率,成本也不高。

5.2 检索到了但答案不对,问题出在重排序

有时候检索确实把相关片段捞出来了,但排在了后面,top-k 截断时被切掉了。这时候要引入重排序(rerank)。

重排序的思路是:先用向量检索快速召回一批候选(比如 20 个),再用一个更精细的模型对这 20 个逐一打分,重新排序,取前几个。重排序模型通常比嵌入模型大,但只对少量候选打分,所以总开销可控。

我用过的重排序方案里,交叉编码器(cross-encoder)效果最好。它把问题和文档拼在一起输入模型,直接输出相关性分数,比向量点积精细得多。代价是慢,所以只用在召回后的精排阶段。

提示:重排序是性价比很高的优化。如果你的 RAG 已经跑通但效果差口气,加一层重排序往往能立竿见影,比换嵌入模型省事。

5.3 知识更新了但检索还是旧内容

这是索引没更新的问题。RAG 的离线索引和在线检索是分离的,文档更新后必须重新索引。我的做法是给每个文档记录一个内容哈希,更新时只重新处理哈希变化的文档,避免全量重建。

如果是增量更新,还要注意删除旧向量。我见过有人只加新向量不删旧的,结果同一个文档出现两个版本,检索时新旧混在一起,模型都懵了。

5.4 长文档检索效果差,试试分层索引

一份几百页的手册,直接切块检索,往往捞不到最相关的部分。这时候可以用分层索引:先对文档做摘要,用摘要建一层粗索引;检索时先命中相关文档,再在文档内部做细检索。这相当于先定位到章节,再定位到段落,符合人查资料的习惯。

5.5 几个我踩过的坑

第一个坑是过度依赖默认参数。很多框架的默认 chunk_size 是 1000,overlap 是 200,这个值对某些文档合适,对另一些就是灾难。一定要按自己的数据调。

第二个坑是忽略元数据。chunk 除了文本和向量,还应该存来源、章节、时间等元数据。检索时可以按元数据过滤,比如只搜某个产品线的文档。没有元数据,后面想加过滤功能就得重建索引。

第三个坑是不做评估。RAG 效果好不好,不能靠感觉。我建议至少维护一个 30 到 50 条的问题集,每次改动后跑一遍,看命中率和答案质量的变化。没有评估,优化就是盲人摸象。

第四个坑是把 RAG 当万能药。有些问题根本不适合 RAG,比如需要精确计算的、需要实时数据的、需要多步推理的。这些场景要么接工具调用,要么走别的方案。RAG 擅长的是“从已有文档里找事实”,别让它干它不擅长的活。

6. 从基础 RAG 到 Agentic RAG 的演进方向

把基础 RAG 跑通之后,下一步自然是让它和 Agent 结合,也就是热词里的 agentic rag。基础 RAG 是“一问一检索一答”的固定流程,而 Agentic RAG 把检索变成了 Agent 的一个工具,Agent 可以自己决定什么时候检索、检索几次、要不要换个查询再检索。

这个演进的价值在于多跳推理。比如用户问“我们去年销量最好的产品的退货政策是什么”,基础 RAG 一次检索很难同时命中“销量最好”和“退货政策”两个信息。Agentic RAG 可以分两步:先检索出销量最好的产品是哪个,再拿这个产品名去检索退货政策。

实现上,就是把检索封装成一个函数,注册给 Agent 作为工具。Agent 的规划能力会决定它怎么调用。这里的关键是给检索工具写清楚描述,告诉 Agent 这个工具能查什么、返回什么格式,Agent 才能用对。

另一个方向是查询分解。把复杂问题拆成几个子问题,分别检索,再综合。这个可以在检索前用 LLM 做,也可以让 Agent 在推理过程中动态做。

我个人在实际操作中的体会是:基础 RAG 解决的是“知识从哪来”的问题,Agentic RAG 解决的是“知识怎么用”的问题。前者是管道,后者是调度。管道没修好,调度再花哨也没用。所以我的建议始终是:先把基础 RAG 的每个环节打磨扎实,再往上叠 Agent 的能力。分块、嵌入、混合检索、重排序这几样调好了,你的 Agent 就已经超过了市面上大部分 demo 级产品。

最后分享一个小技巧:调试 RAG 时,把每次检索的 query、召回的 chunk、最终的答案都打日志存下来。跑一段时间后,你会从这些日志里发现大量优化线索——哪些问题总是检索不到、哪些 chunk 总是被误召回、哪些查询改写效果差。这些真实数据比任何理论分析都有价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询