RAG 系统里最不起眼、但最容易翻车的一环,就是数据导入与解析。很多人把精力全砸在向量库选型、检索策略调优、重排序模型上,结果上线一跑,召回的内容驴唇不对马嘴——回头一查,原始文档在切分之前就已经被解析得七零八落:PDF 里的表格变成了一堆乱序数字,Word 里的多级标题全被拍平成正文,扫描件干脆整段丢失。这类问题在 RAG 项目里占比高得离谱,而且排查起来极其痛苦,因为错误发生在链路最前端,后面所有环节都在为这个错误买单。
这篇内容聚焦 RAG 数据导入链路的第一段:从最朴素的 txt 文件,到带层级结构的 Markdown,怎么用一套通用、可复用的方式把文本读进来、把结构保留下来。关键词覆盖 RAG、LangChain、Document、Loader、Markdown,适合正在搭 RAG 知识库、准备做文档解析模块、或者被各种格式折磨过的开发者参考。不管你是刚接触 LangChain 的新手,还是已经踩过几轮坑的老手,这里关于 Loader 选型、Document 对象结构、Markdown 层级保留的细节,应该都能对上你的实际场景。
1. 为什么数据导入决定了 RAG 的上限
1.1 检索效果差,八成问题出在导入阶段
我见过太多团队在 RAG 效果不理想时,第一反应是换 embedding 模型、加 rerank、调 top_k。这些手段当然有用,但如果导入阶段就把文档结构破坏了,后面再怎么调都是治标不治本。举个很典型的例子:一份产品手册里有大量"功能点—参数—注意事项"的三层结构,如果解析时把标题和正文混在一起切成等长的块,那么用户问"某个参数是多少"时,检索到的块里可能只有参数值,没有它属于哪个功能点,模型拿到这段孤立文本根本没法给出准确回答。
RAG 的本质是"先找对,再答对"。找对的前提是每个文本块都携带足够的上下文信息,而这个上下文信息在导入阶段就得被保留下来。切分策略、元数据设计、层级关系,这些都是在 Loader 和解析环节决定的,一旦这一步做砸了,向量库里存的就是一堆语义残缺的碎片。
1.2 导入链路里三个最容易被忽视的环节
第一个是编码问题。txt 文件看着简单,但 GBK、UTF-8、UTF-8 with BOM、Latin-1 混在一起是常态,尤其是从 Windows 环境导出的文件。编码读错不会报错,只会读出乱码,而乱码进了向量库就是永久污染。
第二个是结构丢失。纯文本没有显式结构,但 Markdown 有。很多人用最粗暴的方式读 Markdown,把#、##这些标记当普通字符处理,结果标题层级信息全没了。实际上这些标记恰恰是切分时最有价值的边界信号。
第三个是元数据缺失。一个 Document 对象如果只有 page_content 没有 metadata,那它在检索时就失去了"来源、章节、层级"这些可过滤、可追溯的信息。后面想做按来源过滤、按章节聚合,全都无从下手。
1.3 本篇要解决的边界
这篇只处理纯文本和 Markdown 这两类"结构相对清晰"的格式,不涉及 PDF、Word、扫描件这些需要 OCR 或复杂布局分析的场景。原因很简单:txt 和 Markdown 是 RAG 数据导入的地基,把这两类吃透,理解 Document 对象和 Loader 的工作机制,后面处理复杂格式时思路是相通的。而且实际项目里,很多知识库的原始素材本身就是 Markdown(比如技术文档、Wiki 导出),把这块做扎实,收益非常直接。
2. LangChain 里 Document 与 Loader 的真实工作方式
2.1 Document 对象到底装了什么
LangChain 的Document是整条链路的基本单元,它只有两个核心字段:page_content和metadata。page_content是字符串,就是这段文本本身;metadata是字典,装的是这段文本的"身份信息"。
很多人低估了 metadata 的价值。它不只是记录来源路径那么简单,你可以往里塞任何对检索有用的信息:章节标题、层级深度、文件类型、创建时间、甚至自定义的标签。检索时可以用 metadata 做过滤(比如只在某个章节范围内搜),生成时可以把 metadata 拼进 prompt 帮助模型定位上下文。
from langchain_core.documents import Document doc = Document( page_content="向量检索的核心是把文本映射到高维空间。", metadata={ "source": "rag_notes.md", "section": "检索原理", "level": 2, "chunk_index": 0, } )这里有个实操细节:metadata 的键值对会被序列化存储,值最好是字符串、数字、布尔这类简单类型,别塞嵌套的复杂对象,否则在某些向量库写入时会报错或者被静默丢弃。
2.2 Loader 的职责边界:它只负责"读进来"
新手常有的误解是以为 Loader 会帮你切分、清洗、结构化。实际上 LangChain 的 Loader 职责非常单一:把外部数据源读成Document列表。切分是 TextSplitter 的事,清洗是你自己的事。
这个边界很重要,因为它决定了你该在哪里做处理。比如你想去掉页眉页脚,那是在 Loader 之后、切分之前自己写逻辑;你想按标题切分,那是 TextSplitter 配合 Markdown 结构来做。Loader 只管把文件变成 Document,别的它不管。
TextLoader是最基础的 Loader,读纯文本文件:
from langchain_community.document_loaders import TextLoader loader = TextLoader("notes.txt", encoding="utf-8") docs = loader.load()load()返回的是一个列表,即使文件只有一个,也是[Document]的形式。这个设计是为了统一接口——有些数据源(比如一个目录、一个数据库查询)天然会返回多个 Document。
2.3 编码参数:一个不写就等着踩坑的地方
TextLoader的encoding参数默认是系统默认编码,在 Linux 上通常是 UTF-8,在 Windows 上可能是 GBK 或 cp1252。这意味着同一份代码在不同机器上跑,结果可能不一样。我强烈建议永远显式指定encoding="utf-8",并且在读取前确认源文件确实是 UTF-8。
如果源文件编码不确定,可以用chardet先探测:
import chardet with open("unknown.txt", "rb") as f: raw = f.read() result = chardet.detect(raw) print(result) # {'encoding': 'GB2312', 'confidence': 0.99}探测出来之后再决定用什么编码读。注意chardet对短文本的探测准确率一般,长文本更可靠。如果探测置信度低于 0.8,最好人工确认一下。
2.4 autodetect_encoding 的适用场景
TextLoader还有个autodetect_encoding参数,设为 True 时会尝试自动检测编码。这个功能在批量处理来源不明的文件时有用,但它有性能开销,而且检测失败时会回退到默认编码,反而可能引入乱码。我的建议是:能确定编码就显式指定,确定不了再用 autodetect,并且对结果做抽样校验。
3. 纯文本导入:看似简单,坑都在细节里
3.1 大文件读取的内存问题
TextLoader默认是一次性把整个文件读进内存。对于几 KB 到几 MB 的文本没问题,但如果遇到几十 MB 甚至上百 MB 的日志文件或语料,一次性读取可能导致内存飙升。这种情况下要么先做预处理切分文件,要么用流式读取的方式自己实现 Loader。
一个实用的做法是:超过一定大小(比如 10MB)的文件,先用命令行工具切成小块,再逐块导入。这样既控制了内存,也方便并行处理。
split -b 5M bigfile.txt chunk_切出来的文件按顺序导入,metadata 里记录原始文件名和分片序号,检索时如果需要可以按序号还原顺序。
3.2 空行与空白字符的清洗时机
纯文本里大量存在连续空行、行尾空格、制表符混用的情况。这些噪声如果不处理,会直接影响切分质量——比如按段落切分时,连续三个空行可能被当成两个段落边界,导致切出来的块要么太碎要么粘连。
清洗的时机很关键。我的经验是在 Loader 之后、切分之前做一次统一清洗,而不是在切分之后。因为切分算法依赖文本的空白结构来判断边界,如果带着噪声切分,边界判断就会出错。
import re def clean_text(text: str) -> str: text = re.sub(r'\r\n', '\n', text) # 统一换行符 text = re.sub(r'[ \t]+\n', '\n', text) # 去掉行尾空白 text = re.sub(r'\n{3,}', '\n\n', text) # 多个空行压成一个 return text.strip()这段清洗逻辑看着简单,但能解决大部分纯文本的格式噪声问题。注意\r\n转\n这一步,Windows 换行符如果不统一,后面按\n切分时会残留\r,在某些向量库里可能引发奇怪的匹配问题。
3.3 用 metadata 给纯文本补上"身份"
纯文本没有结构,但你可以通过文件命名、目录结构来补。比如文件放在docs/产品手册/目录下,文件名是安装指南.txt,那导入时就可以把这些信息写进 metadata:
from pathlib import Path def load_with_metadata(file_path: str): path = Path(file_path) loader = TextLoader(file_path, encoding="utf-8") docs = loader.load() for doc in docs: doc.metadata.update({ "source": path.name, "category": path.parent.name, "file_type": path.suffix, }) return docs这样即使文本本身没有结构,检索时也能按 category 过滤,或者把 category 拼进上下文帮助模型理解这段文本属于哪个业务域。
3.4 一个真实的乱码排查过程
之前有个项目,导入的 txt 在向量库里检索时总是匹配不到中文查询。排查了半天,最后发现是文件用 GBK 编码保存,但 Loader 用 UTF-8 读,读出来的中文全是乱码,embedding 自然对不上。更坑的是这个过程不报错,只是效果差,很容易被误判成"模型不行"。
排查思路是这样的:先打印docs[0].page_content[:200],看前 200 个字符是否正常。如果显示成\ufffd或者奇怪的符号,基本就是编码问题。然后用chardet探测真实编码,改 Loader 的 encoding 参数重试。这个检查应该成为导入流程的标准动作——导入后立刻抽样打印,确认内容可读。
4. Markdown 导入:把层级结构变成检索优势
4.1 为什么 Markdown 是 RAG 的优质数据源
Markdown 相比纯文本最大的优势是显式结构。#到######标记了标题层级,列表、代码块、引用块都有明确语法。这些标记在切分时是天然的边界信号,在检索时是天然的上下文标签。
一份结构良好的 Markdown 文档,天然就是一棵树:一级标题是根,二级标题是枝,正文是叶。如果切分时能保留"这个块属于哪个标题下",那么检索到的每个块都自带路径信息,模型理解起来就准确得多。这也是为什么很多技术文档、Wiki 系统都优先用 Markdown 存储——它对 RAG 太友好了。
4.2 UnstructuredMarkdownLoader 的实际表现
LangChain 提供了UnstructuredMarkdownLoader,底层依赖unstructured库。它会解析 Markdown 的语法结构,把内容按元素类型提取出来。
from langchain_community.document_loaders import UnstructuredMarkdownLoader loader = UnstructuredMarkdownLoader( "doc.md", mode="single", ) docs = loader.load()这里mode参数有两个值:single把所有内容合成一个 Document,elements则按元素拆成多个 Document,每个元素带category元数据(如 Title、NarrativeText、ListItem)。
实测下来,elements模式在需要精细控制时更有用,因为它把标题、正文、列表项分开了,你可以据此做更精准的切分。但它也有个问题:拆得太碎,一个完整的段落可能被拆成多个元素,需要你自己再合并。
4.3 用 MarkdownHeaderTextSplitter 保留标题路径
如果不想引入unstructured这个较重的依赖,LangChain 还有个更轻量的方案:MarkdownHeaderTextSplitter。它专门按 Markdown 标题切分,并且会把标题路径写进每个块的 metadata。
from langchain_text_splitters import MarkdownHeaderTextSplitter headers_to_split_on = [ ("#", "h1"), ("##", "h2"), ("###", "h3"), ] splitter = MarkdownHeaderTextSplitter( headers_to_split_on=headers_to_split_on, strip_headers=False, ) with open("doc.md", encoding="utf-8") as f: content = f.read() chunks = splitter.split_text(content) for chunk in chunks[:3]: print(chunk.metadata) print(chunk.page_content[:100]) print("---")strip_headers=False表示保留标题文本在内容里,这样每个块开头都带着自己的标题,检索时语义更完整。如果设为 True,标题只进 metadata 不进正文,块会更干净但可能丢失上下文。
这个 splitter 的输出 metadata 长这样:
{'h1': 'RAG 数据导入', 'h2': 'Markdown 解析', 'h3': '标题切分'}有了这个路径,检索时就能做很多事:按 h1 过滤、把路径拼进 prompt、按层级聚合统计。这是纯文本导入完全做不到的。
4.4 标题层级与切分粒度的权衡
MarkdownHeaderTextSplitter只按标题切,不控制块大小。如果某个二级标题下内容特别长,切出来的块可能超过 embedding 模型的输入限制。所以实际用的时候,通常是两级切分:先用MarkdownHeaderTextSplitter按标题切,再用RecursiveCharacterTextSplitter按长度切。
from langchain_text_splitters import RecursiveCharacterTextSplitter md_splitter = MarkdownHeaderTextSplitter( headers_to_split_on=headers_to_split_on, strip_headers=False, ) text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, ) md_chunks = md_splitter.split_text(content) final_chunks = text_splitter.split_documents(md_chunks)注意split_documents会保留原有的 metadata,所以标题路径不会丢。这样切出来的块既有标题上下文,又控制了长度,是比较稳妥的方案。
4.5 代码块和表格的特殊处理
Markdown 里的代码块和表格是两类特殊内容。代码块用 ``` 包裹,表格用|分隔。如果按普通文本切分,代码块可能被从中间切断,表格可能被拆成几行散落各处,检索出来完全没法用。
RecursiveCharacterTextSplitter默认的分隔符列表里包含\n\n、\n、空格等,但不认识代码块和表格。一个实用的做法是自定义分隔符,把代码块标记也加进去:
text_splitter = RecursiveCharacterTextSplitter( chunk_size=800, chunk_overlap=80, separators=["\n```\n", "\n\n", "\n", "。", " ", ""], )这样切分时会优先在代码块边界断开。表格的处理更麻烦一些,如果表格不大,最好整块保留;如果表格很大,可以考虑转成其他格式(比如把每行转成"列名: 值"的形式)再切。这块没有银弹,得根据实际数据特点调。
5. 从文件到向量库:一条可复用的导入流水线
5.1 流水线的四个阶段
把前面的内容串起来,一条完整的导入流水线大致分四段:读取 → 清洗 → 切分 → 写入。每段的职责要清晰,不要混在一起。
| 阶段 | 职责 | 关键点 |
|---|---|---|
| 读取 | 文件转 Document | 编码正确、metadata 完整 |
| 清洗 | 去噪声、统一格式 | 在切分前做、保留结构 |
| 切分 | 按结构+长度切块 | 保留标题路径、控制块大小 |
| 写入 | 向量化入库 | 批量写入、metadata 可过滤 |
这个分段的思路是让每一步都可测试、可替换。比如你想换切分策略,只动第三段就行,不影响其他部分。
5.2 一个统一的导入函数
下面这个函数把 txt 和 Markdown 的处理统一起来,根据扩展名走不同分支:
from pathlib import Path from langchain_community.document_loaders import TextLoader from langchain_text_splitters import ( MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter, ) def load_and_split(file_path: str): path = Path(file_path) suffix = path.suffix.lower() if suffix == ".md": with open(file_path, encoding="utf-8") as f: content = f.read() md_splitter = MarkdownHeaderTextSplitter( headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")], strip_headers=False, ) docs = md_splitter.split_text(content) elif suffix == ".txt": loader = TextLoader(file_path, encoding="utf-8") docs = loader.load() else: raise ValueError(f"不支持的文件类型: {suffix}") # 统一补 metadata for doc in docs: doc.metadata.setdefault("source", path.name) doc.metadata.setdefault("file_type", suffix) # 统一按长度二次切分 text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, ) return text_splitter.split_documents(docs)这个函数的好处是接口统一,调用方不用关心文件类型。txt 走 TextLoader,Markdown 走标题切分,最后都过一遍长度切分,输出格式一致的 Document 列表。
5.3 批量导入时的目录遍历与去重
实际项目里通常是整个目录批量导入。遍历目录时要注意两点:一是跳过隐藏文件和非目标格式,二是处理重复文件(比如同一份文档有多个版本)。
def iter_files(root_dir: str, exts=(".txt", ".md")): root = Path(root_dir) for p in root.rglob("*"): if p.is_file() and p.suffix.lower() in exts and not p.name.startswith("."): yield str(p)去重可以用文件内容的哈希值做判断,避免同一份内容重复入库导致检索时出现大量冗余结果。
5.4 导入后的抽样校验清单
导入完成后,别急着庆祝,先做几项校验:
- 随机抽 5 个块,打印
page_content,确认内容可读、无乱码 - 检查 metadata 是否包含预期的键(source、h1、h2 等)
- 统计块的数量和平均长度,看是否符合预期
- 用几个典型查询做一次检索,看召回内容是否相关
这几步花不了几分钟,但能提前发现 80% 的导入问题。我踩过的坑里,有一半以上如果当时做了抽样校验,根本不会拖到上线才发现。
6. 那些文档里不会写的实操经验
6.1 编码问题的排查顺序
遇到乱码,按这个顺序排查:先看文件本身的编码(用file -i命令或chardet),再看 Loader 的 encoding 参数,最后看终端或日志的输出编码。三层里任何一层不匹配都会显示乱码,但根源可能不在同一层。我遇到过终端显示乱码但实际数据正常的情况,差点误判成导入问题。
6.2 标题切分的边界情况
MarkdownHeaderTextSplitter有个容易忽略的行为:如果文档开头在第一个标题之前就有正文,这部分内容会被单独切出来,metadata 里没有任何标题信息。处理办法是在文档开头补一个占位标题,或者对这部分内容单独打标签。
另外,如果标题层级跳跃(比如从#直接到###),切分器不会报错,但 metadata 里会缺 h2 这一层。检索时如果按 h2 过滤,这部分内容就会被漏掉。所以导入前最好检查一下标题层级的连续性。
6.3 chunk_size 到底设多少
这个问题没有标准答案,但有几个参考因素:embedding 模型的最大输入长度、内容的平均段落长度、查询的平均长度。一般中文内容 300 到 800 字符是比较常见的区间。太小会导致上下文不足,太大会稀释语义。
我的做法是先按 500 试,然后看检索效果。如果召回的内容经常"差一点",可能是块太小;如果召回的内容里有一半是无关信息,可能是块太大。这个调优过程得结合具体数据和查询来,别照搬别人的参数。
6.4 metadata 别塞太多东西
metadata 虽然灵活,但不是越多越好。每个键值对都会增加存储和传输开销,而且某些向量库对 metadata 的字段数或总大小有限制。只放对检索和过滤真正有用的字段,比如 source、section、level 这几个核心的,其他能省则省。
6.5 增量导入的坑
知识库不是一次导入就完事,后续会有新增和更新。增量导入时最大的坑是重复:同一份文档更新后重新导入,旧版本还在库里,检索时新旧混在一起。解决办法是用 source + 内容哈希做唯一标识,导入前先删除同 source 的旧记录,或者用 upsert 语义覆盖。
这块涉及向量库的具体操作,不同库的 API 不一样,但思路是通用的:先按 source 清理,再写入新版本。
7. 结构保留带来的检索收益
把 Markdown 的标题路径保留下来之后,检索能做的事情明显变多了。最直接的是按章节过滤:用户问的是"安装"相关的问题,就只在 h1 为"安装指南"的块里搜,召回精度立刻提升。
其次是上下文增强:把标题路径拼进块的文本里再向量化,比如把RAG 数据导入 > Markdown 解析 > 标题切分拼在正文前面。这样即使正文本身没提到"Markdown",向量也会带上这个语义,匹配更准。
再进一步是层级聚合:检索到某个叶子块后,可以顺着 metadata 里的标题路径找到它的父级内容,把父级摘要一起喂给模型。这样模型拿到的不是孤立的碎片,而是带上下文的完整信息。
这些收益在纯文本导入里都拿不到,因为纯文本根本没有层级信息可保留。这也是为什么我一直建议:只要数据源允许,优先用 Markdown 而不是纯文本。多花一点解析功夫,检索效果和可维护性都会好很多。
实际项目里,我通常会把导入模块单独抽成一个服务,输入是文件路径,输出是切好的 Document 列表,中间所有清洗、切分、metadata 补全的逻辑都封装在里面。这样无论是离线批量导入还是在线增量更新,都走同一套逻辑,行为一致,排查也方便。后面如果要接入 PDF、Word 这些格式,只需要在读取阶段加分支,清洗和切分逻辑可以复用。这套结构跑下来,导入环节基本不会再成为 RAG 效果的瓶颈。