
1. 为什么文本导入是 RAG 系统最容易被低估的一环做过 RAG 项目的人都有一个共识模型选型、向量库选型、检索策略这些话题热度很高但真正让一个知识库“能不能用”的往往是数据导入和解析这一步。我见过太多团队在检索效果上反复调参最后发现问题根本不在检索端而是在最开始把 PDF、Word、TXT 塞进 Loader 的时候就已经把结构丢干净了。这个系列我打算把 RAG 数据导入与解析的完整链路拆开讲第一篇聚焦在最基础但也最通用的部分纯文本txt和结构化文本Markdown的加载与解析。别小看这两种格式它们是整个 RAG 数据管道的“地基”——你后面接 PDF、HTML、Excel本质上都是在往这两种形态上做转换。txt 代表的是无结构纯文本Markdown 代表的是轻量结构化文本把这两端吃透中间那些复杂格式的解析思路就都通了。这篇文章适合谁看如果你正在用 LangChain 搭 RAG 知识库或者你手头有一堆零散的 txt、Markdown 文档不知道怎么高效导入再或者你已经跑通了 demo 但发现检索出来的内容总是“缺胳膊少腿”那这篇内容应该能帮你省下不少试错时间。我会从 Loader 的选型逻辑讲起把 Document 对象的结构、文本切分的参数计算、Markdown 结构保留的技巧、以及实际项目中踩过的坑都摊开来说。先明确一个核心概念在 LangChain 的体系里Document是贯穿整个 RAG 流程的基本数据单元。它不只是一个字符串而是page_content文本内容加metadata元数据的组合体。很多人导入数据时只关心文本内容对不对完全忽略 metadata 的设计结果到了检索阶段想做过滤、想做溯源、想做重排序的时候发现手里什么信息都没有。这个坑我在后面会详细展开。2. LangChain Document Loader 的选型逻辑与核心机制2.1 Loader 到底在做什么从文件到 Document 对象的转换LangChain 的 Document Loader 本质上是一个“翻译器”它把各种格式的原始文件翻译成统一的 Document 对象列表。这个翻译过程包含三个关键动作读取原始内容、提取文本、附加元数据。听起来简单但每个动作都有讲究。读取原始内容这一步不同格式差异很大。txt 文件直接读就行但要注意编码问题——GBK 和 UTF-8 混用是中文项目里最常见的翻车点。Markdown 文件虽然也是文本但它的结构信息标题层级、代码块、表格需要通过解析器来识别不能当纯文本一刀切。提取文本的时候Loader 需要决定“保留什么、丢弃什么”。比如 Markdown 里的 HTML 注释要不要保留代码块里的内容要不要单独处理表格要不要转成自然语言描述这些决策直接影响后续的检索质量。附加元数据是最容易被忽视的一步。一个设计良好的 metadata 应该包含来源文件路径、文件创建/修改时间、文档在原始文件中的位置页码、章节、内容类型正文/代码/表格。这些信息在检索阶段可以用来做过滤和排序在生成阶段可以用来做引用溯源。2.2 通用文本 Loader 的几种形态与适用边界LangChain 提供了多个层级的文本加载器从最底层的TextLoader到封装好的DirectoryLoader选哪个取决于你的数据规模和目录结构。TextLoader是最基础的一次加载一个文件返回一个 Document 对象。它的参数很少核心就是file_path和encoding。适合处理单个配置文件、日志文件这种场景。但如果你有几百个 txt 文件一个个加载就太蠢了。DirectoryLoader是批量加载的入口它接受一个目录路径和一个 loader 类自动遍历目录下所有匹配的文件。关键参数是glob模式比如**/*.txt和loader_cls。这里有个细节DirectoryLoader默认是单线程的文件多了会很慢可以用use_multithreadingTrue开启多线程但要注意线程安全——如果你的 loader 类里有共享状态多线程会出问题。还有一个UnstructuredLoader它底层用的是 unstructured 库能自动识别文件类型并做智能解析。对于格式混杂的目录txt、md、pdf 混在一起用它可以省去手动分类的麻烦。但代价是依赖比较重安装包很大而且解析速度比专用 loader 慢不少。我的建议是格式统一用专用 loader格式混杂且量不大用 UnstructuredLoader量大的话还是先按格式分类再批量处理。2.3 Markdown 解析的特殊性为什么不能当纯文本读Markdown 文件如果直接用 TextLoader 读你会得到一个包含所有#、*、|符号的纯字符串。这些符号对 LLM 来说是噪音会干扰语义理解。更严重的是标题层级信息丢失后你无法知道某段文字属于哪个章节检索出来的片段可能完全脱离上下文。正确的做法是用UnstructuredMarkdownLoader它会把 Markdown 解析成带结构信息的元素列表。每个元素有类型标记Title、NarrativeText、ListItem、CodeSnippet、Table这些类型信息可以写进 metadata在检索时用来做内容过滤。但UnstructuredMarkdownLoader也有坑。它默认会把所有元素合并成一个大 Document除非你设置modeelements。设置之后每个元素变成独立的 Document粒度太细又会导致检索碎片化。所以实际项目中我通常是在 Loader 之后接一个自定义的合并逻辑把同一标题下的连续元素合并成一个语义完整的块。3. Document 对象的结构设计与 Metadata 实战3.1 page_content 与 metadata 的职责划分Document 对象的两个字段各有明确职责。page_content是给 LLM 看的它应该是一段语义完整、自包含的文本。metadata是给检索系统看的它应该包含所有用于过滤、排序、溯源的结构化信息。很多人把不该放 page_content 的东西塞进去比如文件路径、页码、章节编号。这些信息对 LLM 理解内容没有帮助反而占用 token。正确的做法是把它们放进 metadata在需要的时候通过 prompt 模板注入。反过来也不要把本该在 page_content 里的上下文信息剥离得太干净。比如一个表格如果只保留表格内容而丢掉表头LLM 根本看不懂每列是什么意思。这时候要么把表头转成自然语言描述放进 page_content要么在 metadata 里保留表头信息并在检索后拼接。3.2 metadata 字段设计的最佳实践一个经过实战检验的 metadata 设计应该包含以下几类字段字段类别字段名用途示例来源标识source溯源引用/docs/guide/intro.md位置信息chunk_index排序拼接3结构信息section_title上下文补充安装配置层级信息heading_level过滤排序2内容类型content_type检索过滤code / text / table时间信息last_modified时效性排序2024-01-15这些字段不是每个都要用但设计的时候要预留。我见过太多项目后期想加过滤功能发现 metadata 里什么都没有只能重新跑一遍数据导入。还有一个技巧metadata 的 key 命名要保持一致。不要一会儿用source一会儿用file_path检索的时候做过滤会非常痛苦。建议在项目初期就定一个 metadata schema所有 loader 的输出都往这个 schema 上靠。3.3 自定义 Loader 的扩展点LangChain 的 BaseLoader 抽象类只要求实现一个load方法返回List[Document]。这意味着你可以完全自定义加载逻辑。我常用的扩展方式有三种。第一种是继承 TextLoader在load方法里加后处理逻辑比如自动检测编码、自动提取标题作为 metadata。第二种是写一个组合 Loader内部调用多个专用 Loader 然后合并结果适合处理一个目录下多种格式混存的场景。第三种是完全从零实现比如从数据库查询结果构造 Document这种在对接内部系统时很常见。自定义 Loader 的时候要注意异常处理。一个文件解析失败不应该让整个批量导入挂掉应该记录错误日志并跳过最后汇总报告哪些文件失败了。这个在数据量大的时候特别重要。4. 从 txt 到 Markdown 的完整实操流程4.1 环境准备与依赖安装先把基础环境搭起来。Python 版本建议 3.9 以上LangChain 的版本迭代很快太老的 Python 会有兼容性问题。pip install langchain langchain-community pip install unstructured markdown pip install chromadb # 如果要用向量库做后续测试这里有个版本坑要提醒langchain-community和langchain的版本要匹配不然会出现 import 错误。建议用pip install langchain0.1.* langchain-community0.0.*这种带版本约束的方式安装避免自动升级到不兼容的版本。如果要用UnstructuredMarkdownLoader还需要安装unstructured的 markdown 额外依赖pip install unstructured[md]这个包比较大因为它包含了很多文档解析的底层库。如果只是处理 Markdown可以只装markdown和beautifulsoup4然后自己写解析逻辑这样依赖会轻很多。4.2 纯文本 txt 的加载与编码处理先看最基础的 txt 加载。假设你有一个data/目录里面是若干 txt 文件。from langchain_community.document_loaders import TextLoader, DirectoryLoader # 单个文件加载 loader TextLoader(data/faq.txt, encodingutf-8) docs loader.load() print(docs[0].page_content[:200]) print(docs[0].metadata)TextLoader的encoding参数默认是None会使用系统默认编码。在中文 Windows 环境下这通常是 GBK如果文件是 UTF-8 就会乱码。所以显式指定 encoding 是必须的。批量加载用DirectoryLoaderloader DirectoryLoader( data/, glob**/*.txt, loader_clsTextLoader, loader_kwargs{encoding: utf-8}, use_multithreadingTrue, show_progressTrue, ) docs loader.load() print(f共加载 {len(docs)} 个文档)这里loader_kwargs会把参数透传给每个TextLoader实例。show_progressTrue在文件多的时候很有用能看到进度条。编码问题如果实在搞不定可以用chardet库自动检测import chardet def detect_encoding(file_path): with open(file_path, rb) as f: raw f.read(10000) return chardet.detect(raw)[encoding] encoding detect_encoding(data/faq.txt) loader TextLoader(data/faq.txt, encodingencoding)但自动检测不是万能的短文件检测准确率不高。生产环境建议统一转成 UTF-8 存储从源头消灭编码问题。4.3 Markdown 结构化解析与元素提取Markdown 的解析要复杂一些。先用UnstructuredMarkdownLoader看看效果from langchain_community.document_loaders import UnstructuredMarkdownLoader loader UnstructuredMarkdownLoader( docs/guide.md, modeelements, ) elements loader.load() for el in elements[:10]: print(el.metadata.get(category), |, el.page_content[:50])modeelements会把每个 Markdown 元素拆成独立 Documentmetadata[category]标记了元素类型。常见的 category 有Title、NarrativeText、ListItem、CodeSnippet、Table。但直接这样用有两个问题。第一元素太碎一个段落可能被拆成多个 Document。第二标题和正文分离后正文的 Document 里没有标题信息检索出来不知道属于哪个章节。我的做法是写一个后处理函数按标题层级把元素重新组装def merge_by_heading(elements): merged [] current_heading current_content [] for el in elements: category el.metadata.get(category, ) if category Title: if current_content: merged.append({ heading: current_heading, content: \n.join(current_content), }) current_heading el.page_content current_content [] else: current_content.append(el.page_content) if current_content: merged.append({ heading: current_heading, content: \n.join(current_content), }) return merged这样每个合并后的块都带着自己的标题检索出来上下文是完整的。4.4 文本切分的参数计算与策略选择切分是 RAG 数据导入里最需要动脑子的一步。切太大检索精度下降切太小语义不完整。RecursiveCharacterTextSplitter是 LangChain 里最常用的切分器它的核心参数是chunk_size和chunk_overlap。chunk_size怎么定一个经验公式是chunk_size ≈ 检索时希望返回的上下文长度。如果你希望每次检索返回 500 字左右的上下文那 chunk_size 就设 500 左右。但还要考虑 embedding 模型的输入限制大部分模型是 512 token对应中文大约 350-400 字。所以中文场景下 chunk_size 设在 300-500 之间比较稳妥。chunk_overlap的作用是防止语义在切分点被截断。一般设 chunk_size 的 10%-20%。比如 chunk_size500overlap 设 50-100。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[\n## , \n### , \n\n, \n, 。, , , , ], length_functionlen, ) chunks splitter.split_documents(docs) print(f切分后得到 {len(chunks)} 个块)separators的顺序很重要。RecursiveCharacterTextSplitter 会按顺序尝试用分隔符切分先试\n##二级标题不行再试\n\n段落再不行试\n换行最后才按字符切。对于 Markdown 文档把标题符号放在分隔符列表前面可以保证切分点尽量落在章节边界上。对于代码块建议单独处理。代码被从中间切断基本就废了所以可以用Language分割器或者自定义逻辑保证代码块完整。5. 常见问题排查与避坑经验实录5.1 编码乱码与特殊字符处理中文项目里编码问题出现的频率极高。典型症状是加载出来的文本里出现\ufeffBOM 头或者一堆我这样的乱码。BOM 头问题可以用utf-8-sig编码解决loader TextLoader(data/faq.txt, encodingutf-8-sig)如果文件里混有全角空格、零宽字符可以在加载后做一次清洗import re def clean_text(text): text text.replace(\ufeff, ) text text.replace(\u200b, ) text re.sub(r\s, , text) return text.strip()但要注意清洗不要过度。Markdown 里的换行和缩进是有意义的全删了会破坏结构。建议只清洗明显的异常字符保留正常的空白。5.2 Markdown 表格与代码块的解析陷阱Markdown 表格用UnstructuredMarkdownLoader解析后category是Table但page_content里是 HTML 格式的table标签不是原始的 Markdown 表格语法。这对 LLM 来说反而更难理解。我的处理方式是把 HTML 表格转回自然语言描述from bs4 import BeautifulSoup def table_to_text(html_table): soup BeautifulSoup(html_table, html.parser) rows [] for tr in soup.find_all(tr): cells [td.get_text(stripTrue) for td in tr.find_all([td, th])] rows.append( | .join(cells)) return \n.join(rows)这样表格变成类似列1 | 列2 | 列3的文本LLM 理解起来更自然。代码块的坑在于缩进丢失。Markdown 里用四个空格缩进的代码块解析后缩进可能被规范化掉。如果代码对缩进敏感比如 Python这会导致代码语义错误。解决办法是在 metadata 里标记content_typecode检索到代码块时用原始文本而不是解析后的文本。5.3 大文件加载的内存与性能优化处理几百 MB 的 txt 文件时一次性load()会把整个文件读进内存容易 OOM。这时候要用流式加载def stream_load(file_path, chunk_size10000): with open(file_path, r, encodingutf-8) as f: while True: chunk f.read(chunk_size) if not chunk: break yield Document(page_contentchunk, metadata{source: file_path})或者用LazyLoader模式LangChain 的部分 loader 支持lazy_load()方法返回一个迭代器而不是列表。批量加载时DirectoryLoader的use_multithreadingTrue能显著提速但线程数不要设太高一般 4-8 个就够了。线程太多反而会因为 IO 竞争变慢。5.4 常见问题速查表问题现象可能原因排查方法解决方案文本乱码编码不匹配用 chardet 检测显式指定 encoding标题丢失用了 TextLoader检查 metadata换 UnstructuredMarkdownLoader检索碎片化chunk_size 太小查看切分结果增大 chunk_size 或合并代码被截断切分器不识别代码检查切分点自定义代码块分割逻辑加载速度慢单线程 大文件计时测试开多线程 流式加载metadata 为空loader 不支持打印 metadata自定义 loader 补充6. 从导入到入库的衔接要点数据加载和切分完成后下一步就是写入向量库。这一步虽然不属于“导入解析”的范畴但有几个衔接点必须提前考虑否则后面会返工。第一个是 embedding 的批量大小。大部分 embedding API 有单次请求的 token 限制一次塞太多 chunk 会报错。建议按 100-500 个 chunk 一批分批调用。第二个是 metadata 的过滤字段。向量库一般支持按 metadata 过滤但只支持特定类型的字段字符串、数字、布尔。如果你在 metadata 里放了列表或嵌套字典写入时可能会报错。提前把 metadata 扁平化。第三个是去重。同一份文档多次导入会产生重复 chunk检索时会出现重复结果。可以在写入前用内容哈希做去重import hashlib def content_hash(doc): return hashlib.md5(doc.page_content.encode()).hexdigest() seen set() unique_docs [] for doc in chunks: h content_hash(doc) if h not in seen: seen.add(h) unique_docs.append(doc)这个逻辑在增量更新场景下特别有用——只导入新增或修改过的文档跳过没变的。我在实际项目里还遇到过一个情况Markdown 文档里的图片引用在解析后变成了纯文本但图片本身没有被处理。如果 RAG 知识库需要支持图片检索这部分要单独走一条图片处理链路把图片 OCR 或 caption 后作为文本补充进去。这个在后续讲多模态数据导入的时候会展开。最后分享一个我常用的调试技巧在数据导入完成后随机抽 10 个 chunk 打印出来人工检查切分质量。重点看三个地方——切分点是否落在语义边界上、metadata 是否完整、有没有异常字符。这个习惯帮我提前发现过很多问题比等到检索效果不好再回头排查要高效得多。