1. 为什么数据预处理才是 RAG 系统的隐形地基
做过 RAG 项目的人大概都有过这种体验:模型选了半天,向量库调了又调,检索策略换了好几轮,结果回答质量还是上不去。最后回头一查,问题出在最不起眼的地方——文档加载和切分。我见过太多团队把 80% 的精力花在检索和生成上,却只给数据预处理留了不到 20% 的时间,然后困惑于为什么 hit rate 始终卡在及格线附近。
RAG 的本质是"先找对,再答好"。检索增强生成这条链路里,LLM 再强,如果喂给它的上下文本身就是残缺的、错位的、语义断裂的,那它只能基于垃圾给出垃圾。Document Loader 和 Text Splitter 这两个环节,决定了进入向量库的"知识单元"长什么样。单元切得好,检索时一找一个准;切得烂,要么召回一堆无关片段,要么把完整答案拦腰截断。
这篇内容适合正在搭 RAG 知识库的开发者、做 langchain 入门实践的同学,以及那些已经跑通流程但效果不理想的团队。我会把 Document Loader 和 Text Splitter 这两块拆开揉碎,讲清楚每个选择背后的逻辑,给出可以直接抄的配置,也把踩过的坑一并倒出来。全文围绕 LangChain 生态展开,但思路对 langchain4j、自研 RAG 流程同样适用。
2. Document Loader 深度拆解:把各种格式的文档变成统一文本
2.1 Document Loader 到底在解决什么问题
原始文档的形态五花八门:PDF、Word、Markdown、HTML、CSV、数据库记录、甚至 Notion 页面和飞书文档。LLM 和向量模型只认纯文本,所以 Loader 的核心任务就一句话——把异构数据源统一成 LangChain 的Document对象。这个对象只有两个关键字段:page_content(文本内容)和metadata(元数据)。别小看 metadata,后面做过滤检索、溯源引用、权限控制全靠它。
很多人第一次用PyPDFLoader加载 PDF,发现出来的文本顺序全乱了,表格变成一堆散落的数字。这不是 LangChain 的锅,而是 PDF 本身的存储方式决定的——PDF 记录的是每个字符的坐标位置,不是阅读顺序。Loader 只能按坐标去猜,猜错了就乱。理解这一点,你就知道为什么有些格式必须换工具、有些场景必须做后处理。
2.2 常见 Loader 选型对照与实操要点
不同格式对应不同 Loader,选错了轻则丢内容,重则整个知识库报废。下面这张表是我在实际项目里反复验证过的选型参考。
| 文档类型 | 推荐 Loader | 关键参数 | 适用场景与注意点 |
|---|---|---|---|
| 纯文本/Markdown | TextLoader | encoding、autodetect_encoding | 最简单,注意编码,中文务必显式指定 utf-8 |
| PDF(文本型) | PyPDFLoader | extract_images | 逐页加载,metadata 带页码,适合溯源 |
| PDF(扫描/复杂版式) | UnstructuredPDFLoader | mode、strategy | 依赖 unstructured 库,能处理表格,但慢 |
| Word | Docx2txtLoader | 无 | 轻量,但丢失样式和表格结构 |
| HTML | UnstructuredHTMLLoader | mode | 自动去标签,保留段落结构 |
| CSV | CSVLoader | source_column | 每行一个 Document,适合结构化问答 |
| 网页 | WebBaseLoader | bs_kwargs | 可指定 CSS 选择器只抓正文 |
| 目录批量 | DirectoryLoader | glob、loader_cls | 配合上面任意 Loader 批量处理 |
选型的第一原则是:能用结构化 Loader 就别用通用 Loader。比如 CSV 就用CSVLoader,别先转成文本再加载,那样会丢掉列名这个天然的语义标签。第二原则是:metadata 能多带就多带。文件名、页码、章节标题、创建时间,这些在检索过滤时都是宝贝。
from langchain_community.document_loaders import PyPDFLoader, DirectoryLoader # 单个 PDF,保留页码 metadata loader = PyPDFLoader("manual.pdf") docs = loader.load() print(docs[0].metadata) # {'source': 'manual.pdf', 'page': 0} # 批量加载整个目录的 PDF dir_loader = DirectoryLoader( "./docs", glob="**/*.pdf", loader_cls=PyPDFLoader, show_progress=True, use_multithreading=True ) all_docs = dir_loader.load()注意:
DirectoryLoader的use_multithreading=True在加载大量文件时能显著提速,但如果你的 Loader 本身不是线程安全的(部分自定义 Loader 会出问题),建议先小批量测试。
2.3 加载环节最容易踩的三个坑
第一个坑是编码问题。中文文档用TextLoader不指定encoding="utf-8",加载出来全是乱码,而且不报错,等到检索时才发现向量全是噪声。我的习惯是永远显式写编码,并且开启autodetect_encoding=True兜底。
第二个坑是PDF 分页与语义割裂。PyPDFLoader按页切,但一个完整的论述可能跨页。如果你后面不再做合并处理,检索时可能只召回半句话。解决办法是在切分阶段用较大的 chunk 或者做跨页合并,这个后面会讲。
第三个坑是metadata 丢失。自定义 Loader 时很多人只填page_content,忘了metadata,结果检索出来的片段无法溯源,用户问"这个结论哪来的"你答不上来。记住:metadata 是 RAG 可解释性的命根子。
3. Text Splitter 核心原理:切分策略决定检索上限
3.1 为什么不能直接把整篇文档塞进向量库
有人会想,我把整篇文档作为一个 Document 存进去不就行了?理论上可以,实际上灾难。原因有三:第一,嵌入模型有 token 上限,超长文本会被截断,截断的部分等于没存;第二,就算模型支持长文本,把整篇文档压成一个向量,语义被平均化了,检索时精度极低——你搜"第三章的某个细节",它可能因为整篇文档的主题而匹配不上;第三,LLM 的上下文窗口有限,检索回来一大坨无关内容,既浪费 token 又干扰生成。
所以切分的本质是在语义完整性和检索精度之间找平衡。切得太碎,语义不完整,检索到的片段答非所问;切得太大,噪声多,精度下降。这个平衡点没有标准答案,取决于你的文档类型和查询模式。
3.2 RecursiveCharacterTextSplitter 的工作机制
LangChain 里最常用的就是RecursiveCharacterTextSplitter,它的设计思路非常聪明——按优先级依次尝试分隔符,直到每块都小于 chunk_size。默认分隔符顺序是["\n\n", "\n", " ", ""],也就是先按段落切,段落还太大就按行切,行还太大就按空格切,最后按字符硬切。
这个"递归"的过程保证了:尽可能在语义边界处切分。段落边界 > 行边界 > 词边界 > 字符边界,优先级从高到低。对于中文,默认分隔符不太够用,因为中文没有空格分词,句子以标点结尾。所以中文场景我一般会自定义分隔符。
from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], length_function=len, is_separator_regex=False ) chunks = splitter.split_documents(docs)这里几个参数值得掰开说。chunk_size=500是字符数不是 token 数,中文一个字算一个字符,500 字大概对应 300-400 token,是个比较稳妥的值。chunk_overlap=50是相邻块的重叠字符数,目的是防止关键信息正好卡在切分点上被切断——重叠让边界信息在两块里都出现,检索时至少有一块能命中。separators里我把中文标点加进去了,让切分尽量落在句子边界。
3.3 chunk_size 与 chunk_overlap 的取值逻辑
这两个参数是 Text Splitter 的灵魂,取值没有万能公式,但有一套推导逻辑。
chunk_size的确定要考虑三个约束:嵌入模型的最大输入长度、LLM 上下文窗口、以及你期望的检索粒度。假设你用某个嵌入模型,最大支持 512 token,那 chunk_size 换算成中文大概 350-400 字比较安全,留出余量。如果你期望检索粒度是"一个段落能回答一个问题",那 chunk_size 就设成典型段落的长度。
chunk_overlap一般是 chunk_size 的 10%-20%。太小起不到防切断作用,太大则冗余严重、存储和检索成本上升。我实测下来 15% 左右是个甜点区。但有个例外:如果你的文档信息密度极高(比如法律条文、API 文档),重叠比例可以提到 25%,因为每个字都可能是关键。
| 文档类型 | 建议 chunk_size | 建议 overlap | 理由 |
|---|---|---|---|
| 通用文章/博客 | 500-800 | 50-100 | 段落完整,语义自洽 |
| 技术文档/API | 300-500 | 50-80 | 信息密度高,需精确匹配 |
| 法律/合同 | 400-600 | 100-150 | 条款不能断,重叠要足 |
| 对话记录 | 200-400 | 30-50 | 单轮对话短,切太大会混入无关轮次 |
| 书籍/长文 | 800-1200 | 100-200 | 上下文依赖强,块要大 |
提示:这些值是起点不是终点。真正靠谱的做法是准备一批真实查询,用不同参数跑检索,看 hit rate 和 MRR 指标,用数据说话。
4. 从加载到切分的完整实操流程
4.1 搭建一个可复用的预处理管道
把 Loader 和 Splitter 串起来,形成一个标准管道,是工程化的第一步。我习惯把它写成一个函数,输入是文件路径或目录,输出是切好的 chunk 列表,中间带上完整的 metadata。
from langchain_community.document_loaders import PyPDFLoader, TextLoader, DirectoryLoader from langchain.text_splitter import RecursiveCharacterTextSplitter def build_chunks(source_path, is_dir=False): # 1. 加载 if is_dir: loader = DirectoryLoader( source_path, glob="**/*.pdf", loader_cls=PyPDFLoader, show_progress=True ) else: loader = PyPDFLoader(source_path) docs = loader.load() # 2. 清洗:去掉多余空白和页眉页脚噪声 for doc in docs: doc.page_content = " ".join(doc.page_content.split()) # 3. 切分 splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=75, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], length_function=len ) chunks = splitter.split_documents(docs) # 4. 补充 metadata:给每个 chunk 加序号,方便溯源 for i, chunk in enumerate(chunks): chunk.metadata["chunk_id"] = i chunk.metadata["chunk_size"] = len(chunk.page_content) return chunks这段代码里有几个细节值得说。清洗那一步用" ".join(text.split())是个小技巧,它能一次性把连续空白、换行、制表符都规整成单个空格,同时去掉首尾空白。对于 PDF 提取出来的文本,这一步能去掉大量无意义的换行噪声。补充chunk_id是为了后续做引用溯源,用户看到答案时能定位到具体是第几块。
4.2 中文文档的分隔符调优实战
中文切分是很多人的痛点。默认分隔符对中文不友好,导致切出来的块经常在句子中间断开。我做过一组对比测试,用同一篇 8000 字的技术文章,分别用默认分隔符和中文优化分隔符切分,然后跑 20 个真实查询看召回质量。
| 分隔符配置 | 平均块长 | 句子完整率 | 检索命中率 |
|---|---|---|---|
默认["\n\n","\n"," ",""] | 480 | 62% | 65% |
| 中文优化(加标点) | 495 | 91% | 84% |
| 中文优化 + 段落优先 | 510 | 94% | 88% |
差距非常明显。句子完整率从 62% 提到 94%,命中率跟着涨了 20 多个点。原因很简单:句子完整的块,语义自洽,向量表达准确;被切断的块,语义残缺,向量漂移,检索自然不准。
中文分隔符的顺序也有讲究。我一般用["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]。段落和换行优先,然后是句末标点(句号、感叹号、问号),再是分号、逗号,最后才是空格和字符。这个顺序保证了切分点尽可能落在语义边界上。
4.3 特殊场景的切分策略
表格数据:表格被切碎是灾难。如果文档里有表格,建议先用UnstructuredPDFLoader的mode="elements"把表格单独提取出来,作为一个完整的 Document 存,不要参与常规切分。或者把表格转成 Markdown 格式,用MarkdownHeaderTextSplitter按标题切。
代码文档:代码块不能按标点切,否则函数被拦腰截断。LangChain 提供了Language枚举,可以按编程语言的语法结构切分。
from langchain.text_splitter import RecursiveCharacterTextSplitter, Language code_splitter = RecursiveCharacterTextSplitter.from_language( language=Language.PYTHON, chunk_size=800, chunk_overlap=100 )Markdown 文档:用MarkdownHeaderTextSplitter按标题层级切,能保留章节结构,metadata 里带上标题路径,检索时可以做层级过滤。
from langchain.text_splitter import MarkdownHeaderTextSplitter headers_to_split_on = [ ("#", "h1"), ("##", "h2"), ("###", "h3"), ] md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on) md_chunks = md_splitter.split_text(markdown_text)这样切出来的每个 chunk 的 metadata 里都有h1、h2、h3字段,检索时可以精确到"某个章节下的内容",对结构化文档的问答效果提升巨大。
5. 常见问题与排查技巧实录
5.1 检索效果差的排查顺序
当 RAG 效果不理想时,别急着换模型,按这个顺序排查预处理环节:
- 先看加载是否完整:随机抽几个 Document,对比原文,看有没有丢内容、乱码、顺序错乱。
- 再看切分是否合理:随机抽几个 chunk,读一遍,看语义是否完整、有没有在句子中间断开。
- 然后看 metadata 是否齐全:能不能溯源到具体文件和位置。
- 最后才看检索和生成。
我遇到过最典型的一个案例:某团队反馈检索命中率只有 40%,排查发现他们的 PDF 是双栏排版,PyPDFLoader按坐标提取时把左右两栏的文字交错混在一起,出来的文本根本读不通。换成UnstructuredPDFLoader并指定strategy="hi_res"后,命中率直接到 80%。
5.2 高频问题速查表
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 加载出来是乱码 | 编码未指定 | 显式设encoding="utf-8",开autodetect_encoding |
| PDF 文本顺序错乱 | 多栏/复杂版式 | 换UnstructuredPDFLoader,用 hi_res 策略 |
| 检索召回无关内容 | chunk 太大,语义被平均 | 减小 chunk_size,提高切分精度 |
| 答案被截断 | chunk 太小或切分点不当 | 增大 chunk_size 和 overlap |
| 表格内容检索不到 | 表格被切碎 | 表格单独提取,转 Markdown 存 |
| 中文切分在句中断开 | 分隔符不含中文标点 | 自定义 separators 加中文标点 |
| 无法溯源 | metadata 缺失 | 加载时保留 source、page,切分时加 chunk_id |
| 检索慢 | chunk 数量过多 | 适当增大 chunk_size,减少总块数 |
5.3 几个反直觉的实操心得
心得一:不是所有文档都值得进知识库。我见过有人把整个公司网盘都灌进去,结果检索质量一塌糊涂。预处理的第一步其实是筛选——哪些文档是真正会被查询的,哪些是噪声。宁可少而精,不要多而杂。
心得二:overlap 不是越大越好。有人觉得重叠多保险,设成 chunk_size 的 50%,结果存储翻倍、检索时同一内容反复出现、LLM 被重复信息干扰。15% 左右足够,特殊场景最多 25%。
心得三:切分后要做一次"体检"。写个脚本统计 chunk 长度的分布,如果出现大量极短(<50 字)或极长(>chunk_size)的块,说明分隔符或参数有问题。健康的分布应该是集中在 chunk_size 附近,两端少。
import numpy as np lengths = [len(c.page_content) for c in chunks] print(f"平均长度: {np.mean(lengths):.0f}") print(f"中位数: {np.median(lengths):.0f}") print(f"最短: {min(lengths)}, 最长: {max(lengths)}") print(f"过短块(<50): {sum(1 for l in lengths if l < 50)}")心得四:metadata 里的 source 字段要规范。别用绝对路径,用相对路径或文档 ID,否则换台机器就失效。如果做多租户,metadata 里一定要带租户 ID,检索时做过滤,避免越权。
6. 进阶方向:让预处理更聪明
6.1 语义切分与自适应策略
RecursiveCharacterTextSplitter是基于规则的,它不知道语义。进阶做法是用嵌入模型计算相邻句子的相似度,在相似度骤降的地方切分,这叫语义切分。LangChain 里有SemanticChunker可以试。代价是慢,因为要对每个句子做嵌入,适合文档量不大但对质量要求极高的场景。
另一个方向是自适应 chunk_size。不同章节的信息密度不同,统一 chunk_size 未必最优。可以根据段落长度动态调整,短段落合并,长段落细分。
6.2 预处理与检索策略的联动
切分策略要和检索策略配套设计。如果你用Parent Document Retriever(小块检索、大块返回),那切分时要同时生成小块和大块两套,小块用于精确匹配,大块用于提供上下文。如果你用Multi-Vector Retriever,那每个 chunk 除了原文向量,还要生成摘要向量、假设问题向量等多个表示。
from langchain.retrievers import ParentDocumentRetriever from langchain.storage import InMemoryStore from langchain_community.vectorstores import Chroma # 小块用于检索,大块用于生成 child_splitter = RecursiveCharacterTextSplitter(chunk_size=200) parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000) retriever = ParentDocumentRetriever( vectorstore=vectorstore, docstore=InMemoryStore(), child_splitter=child_splitter, parent_splitter=parent_splitter ) retriever.add_documents(docs)这种模式下,预处理阶段就要把两套切分都做好,metadata 里建立父子关联。检索时用小块精准命中,返回时用大块提供完整上下文,兼顾精度和完整性。
6.3 增量更新与版本管理
知识库不是一次性的,文档会更新。预处理管道要支持增量:新文档加载切分入库,修改的文档先删旧 chunk 再入新 chunk,删除的文档清理对应向量。metadata 里带上文档版本号和更新时间,方便做增量判断。这块做不好,知识库会越来越脏,检索质量随时间衰减。
我个人的做法是给每个文档算一个内容哈希,存进 metadata。更新时对比哈希,变了才重新处理,没变就跳过。这样大批量文档更新时能省下大量重复计算。
预处理这两个环节看起来简单,实际上决定了整个 RAG 系统的天花板。Loader 决定你"能拿到什么",Splitter 决定你"怎么组织"。把这两步做扎实,后面的检索和生成才有发挥空间。我踩过的坑基本都写在上面了,参数和代码可以直接拿去改,但记住——没有万能配置,只有针对你数据的配置。先跑通,再体检,最后用真实查询调优,这个顺序别乱。