2. Loader 与 Splitter 在 RAG 系统中的位置
先说个结论:不少人搭 RAG 流水线的时候,把大把精力花在向量模型选型、 rerank 调参上,结果检索效果一直上不去。其实问题往往出在最前面——文档加载和文本拆分这两个环节。我见过太多项目, Embedding 模型换了三四个,最终发现是 Splitter 切得乱七八糟,语义全被切断了。
Loader 和 Splitter 是 RAG 管线的入口,也是决定检索质量上限的两道关卡。Loader 负责把各种乱七八糟格式的文档(PDF、 Word、 Markdown、 HTML、甚至扫描件里的文字)变成统一的纯文本;Splitter 负责把这些文本切成合适大小的块( chunk),每一块将来都会变成向量库里的一个条目。说白了,检索时拿到什么内容,完全取决于这两步做成了什么样。
如果把 RAG 比作一个图书馆, Loader 就是图书管理员,负责把不同出版社、不同装帧的书统一拆封上架;Splitter 则是编目员,决定一本书按章节拆、按段落拆、还是按语义拆,拆得不好,读者找资料的时候就找不到想要的内容。
这篇文章我会按实际项目里的使用顺序,把 Loader 和 Splitter 的核心逻辑、选型思路、参数调优踩过的坑,一次性讲清楚。适合正在搭 RAG 应用、或者已经把 RAG 跑通但检索效果不理想的开发者参考。
3. Loader:把“不可读”变成“可读”
3.1 Loader 到底在解决什么问题
很多人觉得 Loader 不就是读文件嘛, open 一下、 read 一下,有什么好讲的。但真实场景远比这复杂。我归纳过, Loader 至少要解决三类问题:
第一类是格式解析。PDF 看着是文本,实际上里面的文字可能是嵌入式字体、扫描图片、或者本身就是空有排版没有文本层。Docx 本质是个 zip 包,里面塞了一堆 XML。HTML 里混着标签、脚本、样式。这些格式如果直接按纯文本读取,出来的要么是乱码,要么是满屏标签,要么是一堆没用的噪声——这时候加载质量已经废了,后面 Embedding 再强也救不回来。
第二类是内容清洗。真实文档里总有页码、页眉页脚、水印、目录、参考文献。这些内容混入向量库之后,检索时会频繁命中噪声片段。比如你在一个技术手册里问“这个参数怎么配”,结果 Top-5 里三条都是页眉上的“XXX 用户手册版本 3.2”——这检索结果谁看谁崩溃。
第三类是结构保留。一级标题、二级标题、表格、列表、代码块,这些都是含语义信息的。Loader 如果在解析过程中把这些结构信息全部抹平成一个连续的字符串,那 Splitter 后续就失去了重要的分段依据。这也是为什么我说 Loader 和 Splitter 必须放在一起考虑的原因——Loader 解析时保留的信息越完整, Splitter 的切分质量就越高。
3.2 几种主流 Loader 的选型思路
目前生态里最常用的是 LlamaIndex 和 LangChain 这两套框架,它们各自都内置了大量 Document Loader。我分别说下常用场景下的选型建议。
PDF Loader:PDF 是最绕不开的格式。单纯文字版 PDF,用 PyPDF2 或者 pypdf 就能读;带复杂排版的技术手册、论文,建议上 PyMuPDF(fitz),它对文本块的位置信息保留得很好,能告诉每个文本块在页面上的坐标。扫描件必须走 OCR 路线,开源方案里 PaddleOCR 效果比较稳,中文识别率明显好于 Tesseract。这里有个实操建议:如果你的 PDF 是论文、技术文档这种双栏排版的,一定要把“按栏读取”考虑进去,不然文字会从左栏串到右栏,读出来完全不成句子。
以 LangChain 生态为例,实际项目里我会按文档类型去做路由:
- 扫描版 PDF:用载荷含 OCR 能力的 Loader,或先离线转成文本再进管线
- 文字版 PDF:PyMuPDFLoader,速度快,且能保留文本块位置
- Word 文档:Docx2txtLoader,能处理标题层级和表格
- HTML 页面:先做标签清理,再用自定义 Loader 提取正文,不要直接把整个 HTML 喂进去
- Markdown / 纯文本:直接读就行,但要注意编码问题
表格类数据:Excel、CSV 这类结构化数据,千万别直接按行拆。按行拆的 chunk 哲学上就错了——表格的语义在表头和列关系里,脱离了表头,单独一行数据没有任何检索价值。我见过一个项目把 CSV 每行作为一个 chunk 灌进向量库,用户问“上个月华东区的销售额是多少”,检索出来的是包含“华东”“销售额”这个关键词的那行,但它没有表头上下文,回答根本不对。表格类数据要么先转成自然语言描述,要么把表头拼接到每个单元格上再入向量库。
网络内容:网页转纯文本,需要做正文提取。直接用 BeautifulSoup 去标签后,你会发现还有一堆 cookie 弹窗、页脚导航、推荐阅读的噪声。尽量用 Trafilatura 这类正文提取库,它对各大站点的正文区域识别得不错,能大幅减少噪声。
4. Splitter:切得“好”比切得“多”重要
4.1 为什么 split 这么关键
检索效果差,十次里有八次是 chunk 切得不合理。向量检索本质上是在向量空间里找“距离最近”的文本块。 chunk 太大,向量会被整段的主题稀释,局部细节淹没在长文里,查询很难命中精准片段; chunk 太小,语义不完整,单个块根本表达不出一个独立的含义,检索结果自然也是残缺的。
4.2 拆分策略怎么选
不同 Splitter 的核心区别在于“在哪个位置切”以及“按什么规则切”。
- 固定长度拆分(FixedSizeSplitter):按字符数或者 token 数硬切,最简单。优点是速度快,缺点是经常把一句话从中间切开,导致语义破裂。
- 递归结构拆分(RecursiveCharacterTextSplitter):这是 LangChain 生态最常用的方案。思路是先按段落切,太长再按句子切,还太长再按标点切——递归地把文本逐层拆分,直到满足 size 要求。它尽量保证大块的结构完整,而不是在任意位置硬切。
- 文档结构感知拆分(MarkdownHeaderTextSplitter、PythonCodeSplitter 等):适合有明确结构的分类型文档。
- 语义拆分(Semantic Splitter):基于 embedding 相似度,在内容主题变化的地方切开,效果最好但速度最慢,适合对耗时和成本都不敏感的离线场景。
4.3 chunk_size 和 chunk_overlap 怎么定
chunk_size 控制块的大小, chunk_overlap 控制相邻块之间的重叠字符数。两个参数高度依赖文本类型、Embedding 模型的上下文长度、想要达到的检索精度。
实操建议:以“句子完整”为底线,再求数据指标。先把 256、 512、 768(token 级)各跑一组,对比检索命中率。中文场景下,我实测下来 300-500 个汉字(约 400 token)是技术文档类内容比较稳的区间。chunk_overlap 一般取 chunk_size 的 10%-15% 就够,太少了丢失跨块上下文,太多了造成大量冗余。
4.4 几种特殊场景的切分要点
- 代码仓库的检索:代码按函数/类切分,而不是按行切。类级别的 Splitter 能保留上下文。
- 对话记录:按轮次切,保留说话人角色信息。不要把多轮对话直接拼成一个长文本,会导致每条消息之间互相干扰。
- 表格类:把表头、表的内容、或转成文本后的描述一起打包成一个 chunk,保持表格信息的完整性。
- 混合文档(图文混排、多级标题):优先用 Markdown 结构的递归拆分,不能只用纯长度切分。
4.5 拆完之后的必要动作
拆分完不是直接灌向量库就完事了,有几件检查工作建议做成固定流程。尤其是第一项,我认为这是很多项目检索效果不理想的隐藏根因。
建议做一个“chunk 质量检查”:抽样打印 50 条拆分结果,肉眼把以下问题过一遍——有没有被切断的句子?有没有上下文不完整的块?有没有混入页眉、水印、脚本代码等噪声?这些问题的检出率如果超过 5%,说明 Loader 或 Splitter 的配置需要回炉调整。
5. Loader 与 Splitter 配合的实操流程
这个环节我给出一份可复用的实操流程和参考代码。我会用一个模拟项目“某技术知识库”来演示,这种文档类型在 RAG 项目里最典型:排版复杂、混合了标题、表格、代码块、段落等结构。代码使用 LangChain 生态作为示例,如果你用的是 LlamaIndex,思路完全一致。
5.1 完整的流程拆解
实测下来,Loader 与 Splitter 的最优配合流程是:加载 → 清洗 → 结构化 → 按类型分流拆分 → 检查 → 入库。每一步都有对应操作。
流程环节是:加载,根据文档格式路由到不同 Loader,统统一转成“带基础元数据的文本块”。清洗,抽掉页眉页脚、水印、导航、重复空行等噪声。结构化识别,判断文档类型——技术手册、代码库、表格类、还是纯文本段落,为后面的拆分方式提供依据。拆分,按类型选 Splitter,固定好 chunk_size / chunk_overlap。检查入库,抽样检查 chunk 质量,再写向量库。
5.2 代码示例(配置好可直接复现)
下面是我在一个知识库类项目里的核心代码,精简了无关部分,保留主流程。
from langchain.document_loaders import PyMuPDFLoader, Docx2txtLoader from langchain.text_splitter import ( RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter, MarkdownTextSplitter, TokenTextSplitter, ) from langchain.schema import Document # 1. 加载阶段,按格式路由到对应 Loader def load_document(file_path: str) -> list[Document]: # 根据文件后缀选择加载器 if file_path.endswith(".pdf"): loader = PyMuPDFLoader(file_path) docs = loader.load() # 清洗页眉页脚等噪声 for doc in docs: doc.page_content = clean_noise(doc.page_content) elif file_path.endswith(".docx"): loader = Docx2txtLoader(file_path) docs = loader.load() elif file_path.endswith(".md"): with open(file_path, "r", encoding="utf-8") as f: docs = [Document(page_content=f.read())] elif file_path.endswith(".txt"): with open(file_path, "r", encoding="utf-8") as f: docs = [Document(page_content=f.read())] else: raise ValueError(f"不支持的格式: {file_path}") return docs def clean_noise(text: str) -> str: # 去掉常见噪声:页码、页眉、特殊字符 import re # 去掉分页符 text = text.replace("\f", "") # 去掉可能存在的页眉 / 页脚关键字 lines_to_remove = ["内容提要", "目录", "技术支持热线"] lines = text.splitlines() lines = [line for line in lines if line.strip() not in lines_to_remove] return "\n".join(lines) # 2. 拆分阶段,先识别文档结构,再选择合适的 Splitter def split_document(docs: list[Document]) -> list[Document]: all_chunks = [] # Markdown 标题结构优先用 MarkdownHeaderTextSplitter for doc in docs: markdown_headers = [ ("#", "一级标题"), ("##", "二级标题"), ("###", "三级标题"), ] try: splitter = MarkdownHeaderTextSplitter( headers_to_split_on=markdown_headers ) md_splits = splitter.split_text(doc.page_content) except Exception: md_splits = [doc] # 长度切分 size_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", ";", ",", " ", ""], keep_separator="end", ) for md_split in md_splits: # 保留标题作为元数据,避免分割后丢失上下文 metadata = md_split.metadata for chunk in size_splitter.split_text(md_split.page_content): all_chunks.append( Document(page_content=chunk, metadata=metadata) ) return all_chunks # 3. 去重(可选但推荐) def deduplicate_chunks(chunks: list[Document]) -> list[Document]: seen = set() unique_chunks = [] for chunk in chunks: text_hash = hash(chunk.page_content) if text_hash not in seen: seen.add(text_hash) unique_chunks.append(chunk) return unique_chunks # 4. 主流程 if __name__ == "__main__": file_path = "某跨平台系统使用手册.pdf" docs = load_document(file_path) chunks = split_document(docs) chunks = deduplicate_chunks(chunks) print(f"共拆分成 {len(chunks)} 个文本块")5.3 参数选择的推演过程
在 Demo 项目里,文本块为什么定成 500 个字符、重叠定 80 个字符?实际推演过程是这样的:
先观察文档内容:说明书里大部分是 3-5 行的段落,夹杂部分技术参数表格和步骤列表。段落平均 200-400 字。设 500 字的目标块大小,能覆盖一个主题下的 1-2 个段落,又不至于跨主题。同时因为语言模型 Embedding 模型对中文的支持在 512 token 附近, 500 汉字约等于 700-800 token,如果一个问题能在这 500 字内找到答案,检索粒度是足够的。
chunk_overlap 取 80 字,约 15% 重叠。这个重叠值的意义在于:当用户问题跨越两段之间时,语义不至于被截断。但重叠也不是越多越好——嵌入了边界,延长入库、检索的耗时,且两条相邻 chunk 高度相似可能导致检索时互相干扰。
5.4 我踩过的一个典型坑
代码里有一段“让 MarkdownHeaderTextSplitter 和 RecursiveCharacterTextSplitter 组合”的逻辑,这背后其实是我踩过坑之后的修正。
早期项目中,我直接用 HTML 标签标题切割后,再把子块按固定长度拆。结果“标题”这个语义字段只出现在了第一个包含标题的 chunk 上,后续 chunk 完全丢失上下文。检索时返回了后续 chunk,用户只看到一句参数解释,却不知道它属于哪个章节、哪个功能模块。
后来改为把标题塞入 metadata,并保证每个子块保留所属标题的元数据。这样即使检索命中的是实体解释片段,也能把“它属于哪一节”呈现出来。信息完整性明显改善,检索返回的命中内容基本都能做到“自解释”。
6. 常见问题与排查技巧实录
这一节记录高频排障经验。我挑了几个代表性的坑,配合排查思路,希望帮你少走弯路。
6.1 Loader 加载 PDF 后文本乱序或乱码
表现为 PDF 解析出来后段落顺序错乱、文字断行。
原因: PDF 没有内嵌文字的语义流, PyMuPDF 按块读取时如果遇到双栏排版、文字坐标重叠,会按坐标顺序吐文本,导致左栏右栏交叉。
对策:优先用布局分析能力更强的 Loader,或先看 PDF 内文字块坐标,再依据坐标分组。双栏文档建议按页面坐标把文字切成两列后分别拼接。文字乱码大概率是字体编码问题,可尝试加 encoding 参数或让 Loader 以“原始字符串”模式输出再做编码转换。
6.2 RecursiveCharacterTextSplitter 把一句话切成了两半
原因是 chunk_size 的判定是基于字符或 token 长度,而中文里没有天然空格作为分词边界。
对策:把分隔符列表加进“。 ! ? ;”这类句子结束符号,让递归拆分优先在句子边界结束,而不是在字符串中间断开。同时把 keep_separator 设置为 True,这样切分后句号能保留在块末尾,方便下一段拼接。
6.3 Markdown 拆分后标题丢失
前面提过的元数据丢失问题。
对策:拆分时不要只返回 page_content,必须同步返回对应标题链。LangChain 的 MarkdownHeaderTextSplitter 天然做了这件事,但如果自己做文档解析,需要手动维护标题栈。
6.4 检索命中内容重复度高
表现是 Top-5 命中内容几乎都在说同一件事,因为 chunk_overlap 设得过大,导致大量重叠块混入向量库。
对策:检查 chunk_overlap 是否超过 chunk_size 的 20%,如果是,把它降下来。另外可在入库时对相邻 chunk 做一次文本相似度去重,去掉有大量重复内容的块。
6.5 检索返回内容过短、缺乏上下文
有时命中块只有二三十字,用户看不明白。
对策:把“检索到 chunk 之后”的步骤改成“返回 chunk 及其相邻上下文的补充拼接”。我会在数据库里为每个 chunk 存一个相邻 chunk 的引用,或者干脆存一个“带上下文的扩展片段”字段,检索时优先返回扩展片段。代价是消耗更多 token,但回答质量会显著提升。
6.6 表格数据处理不当
把表格转换成 Markdown 文本后再拆分,这可能是最省事的做法。
对策:表格不要直接按行拆,更不要按单元格拆。先转成“表头+每行数据”的语义化描述,例如把三行 Excel 转成“销售明细表,列:月份、地区、金额,数据:1月,华东,100 万”。然后把整个描述当作一个 chunk 处理。这样检索命中的内容是自解释的。
7. 根据个人经验的补充建议
再分享两条我自己的习惯。
第一,Loader 和 Splitter 的配置不要一套打天下。文档类型变化时,哪怕是同一份文档换了个版本,拆分效果都可能大不一样。建议在管线里加一个“文档指纹”环节:对文档类型做自动识别,然后动态选用不同的拆分配置。我实现的简单方案是给每个文档类型存一组参数(splitter 类型、chunk_size、chunk_overlap、清洗规则),运行时按类型加载。
第二,建议做一次“检索回归集”。挑 30-50 个有代表性的用户问题,手工标注正确答案所在的原文片段。每次改动 Loader 或 Splitter 后,跑一遍回归集,算一下命中率变化。没有这个回归集,调参会非常盲目,而且很容易陷入“这次改完感觉好了一点,但说不上好在哪”的困境。
Loader 和 Splitter 这两个组件,听起来基础,但整个 RAG 管线的效果上限实际上由它们决定。写这篇内容的时候,我重新审视了自己之前项目里的配置,发现很多问题其实完全可以在早期阶段通过更细致的 Loader 和 Splitter 设计规避掉。希望我踩过的这些坑能帮你把入口端的质量把控好。如果你在实操中遇到不一样的问题,也欢迎带着具体现象来交流。