
RAG 系统落地时最容易被低估的环节不是向量检索也不是大模型选型而是数据导入与解析。我见过太多项目卡在第一步一堆格式各异的原始文件丢进来txt、md、json、pdf 混在一起解析出来的文本要么结构全丢要么元数据残缺后面检索效果怎么调都上不去。这篇就从最基础的纯文本和结构化文本入手把 txt 到 Markdown 这条链路讲透顺带把 LangChain 里 Document、Loader 这套体系的底层逻辑拆开来看。1. 为什么数据导入决定了 RAG 的上限1.1 检索质量差八成问题出在切分之前很多人调 RAG 效果时习惯性去改 embedding 模型、调 top-k、换 rerank 策略但实际排查下来问题往往在更早的环节。原始文档在导入阶段就已经被毁了——标题层级丢失、表格被压成一行、代码块和正文混在一起这种文本喂给 embedding 模型向量本身就携带了错误的语义信息后面再怎么调都是治标不治本。我自己的经验是一个 RAG 项目的效果天花板在数据导入阶段就基本定死了。解析阶段丢掉的语义结构检索阶段是找不回来的。所以与其在检索层反复折腾不如先把导入解析这一层做扎实。1.2 通用文本与结构化文本处理思路完全不同原始文件大致可以分成两类。一类是通用文本比如纯 txt、无格式的日志、口语化的记录这类内容没有显式结构重点在于怎么合理地切分和保留上下文。另一类是结构化文本比如 Markdown、JSON、HTML它们自带层级、字段、标签处理重点变成了怎么把这些结构信息提取出来转成检索时能用的元数据。这两类的处理逻辑差异很大混在一起讲容易乱。这篇先聚焦 txt 和 Markdown 这两种最基础也最常见的格式把它们的解析思路、LangChain 里的实现方式、以及实际踩过的坑讲清楚。JSON 这类强结构化数据放到后面单独说。1.3 LangChain 的 Document 抽象一切数据的统一容器在动手之前得先理解 LangChain 里最核心的一个抽象——Document。不管你的原始数据是 txt、Markdown 还是数据库里的一行记录进入 LangChain 体系后都会被统一成Document对象。它只有两个核心字段page_content字符串存的是这段文本的实际内容metadata字典存的是这段文本的来源、位置、层级等附加信息这个设计看着简单但它是整个 RAG 数据流的基石。page_content决定检索时匹配什么metadata决定检索后能不能做过滤、能不能溯源、能不能按来源聚合。很多项目后期想做只在这个文档里搜按章节过滤这类功能靠的就是导入阶段往metadata里塞的信息。from langchain_core.documents import Document doc Document( page_content这是正文内容, metadata{source: intro.md, heading: 第一章, level: 1} )理解了这个容器后面所有的 Loader 本质上都在做同一件事把各种格式的文件转换成一批Document对象。2. 纯文本 txt 的加载与切分策略2.1 TextLoader 的默认行为与它的局限LangChain 里加载 txt 最直接的方式是TextLoaderfrom langchain_community.document_loaders import TextLoader loader TextLoader(notes.txt, encodingutf-8) docs loader.load()跑完你会发现docs列表里只有一个Documentpage_content是整篇文本metadata里只有source字段。这就是TextLoader的默认行为——整篇读进来不做任何切分。这个行为在小文件上没问题但一旦文件超过几千字直接整篇丢给 embedding 模型就会出问题。一是超出模型的 token 上限二是整篇文本的向量会变得非常平均检索时匹配精度极低。所以 txt 加载之后几乎必然要接一步切分。2.2 按字符切分RecursiveCharacterTextSplitter 的工作逻辑切分器里最常用的是RecursiveCharacterTextSplitter。它的核心思路是按优先级依次尝试分隔符默认的分隔符顺序是[\n\n, \n, , ]意思是先尝试用双换行段落切如果切出来的块还是太大就用单换行切再不行就用空格切最后实在没办法就按字符硬切。这个递归的过程保证了切分点尽量落在语义边界上而不是把一句话从中间劈开。from langchain_text_splitters import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , ] ) chunks splitter.split_documents(docs)这里有两个参数需要重点说。chunk_size是每块的目标长度chunk_overlap是相邻块之间的重叠字符数。重叠的作用是防止关键信息正好落在切分点上被割裂——比如一句话的前半段在上一块、后半段在下一块检索时两块都只能匹配到半句话语义就残缺了。加 50 到 100 字符的重叠能让边界处的语义保持完整。2.3 中文场景下分隔符必须改默认分隔符是给英文设计的中文文本里没有空格分词直接套用默认配置切分效果会很差。我的做法是在分隔符列表里加入中文标点separators[\n\n, \n, 。, , , , , , ]注意顺序标点要按语义强度从大到小排。句号、感叹号、问号代表一个完整句子结束优先级高逗号只是句内停顿优先级低。这样切出来的块边界大多落在句子之间读起来是完整的。提示如果你的 txt 是代码、日志这类特殊内容分隔符要单独定制。代码按\n\n和\n切通常够用但日志往往需要按时间戳或固定行数切硬套通用配置会切得乱七八糟。2.4 chunk_size 到底设多少合适这个问题没有标准答案但有几个约束可以参考。embedding 模型一般有 token 上限常见是 512 或 8192chunk_size 不能超过这个上限。同时块太小会导致语义不完整块太大又会让向量过于笼统。我的经验值是中文文本 300 到 800 字符是个比较舒服的区间。低于 300很多块只有一两句话检索时噪声大高于 800单块包含的主题太多向量被稀释。具体数值还要看你的文档密度——技术文档信息密度高可以小一点叙述性文本可以大一点。这里有个容易被忽略的点chunk_size统计的是字符数不是 token 数。中文一个字大约对应 1 到 2 个 token所以 500 字符的中文块实际 token 数可能在 500 到 1000 之间。设参数时要把这个换算考虑进去别卡着模型上限设。3. Markdown 解析把结构信息变成检索资产3.1 为什么 Markdown 不能当纯文本处理Markdown 最大的价值在于它自带结构——标题层级、列表、代码块、表格、链接。如果直接当纯文本读进来这些结构就全丢了。而结构恰恰是检索时最有用的信息用户问第三章讲了什么如果你在导入时保留了标题层级就能精准定位到对应章节如果结构丢了只能靠语义模糊匹配。所以 Markdown 的解析目标很明确既要拿到正文内容又要把结构信息提取到 metadata 里。3.2 用 MarkdownHeaderTextSplitter 按标题切分LangChain 提供了专门处理 Markdown 的切分器MarkdownHeaderTextSplitter。它的逻辑是识别 Markdown 的标题行#、##、###然后按标题层级把文档切成块同时把标题内容写进每块的 metadata。from langchain_text_splitters import MarkdownHeaderTextSplitter headers_to_split_on [ (#, h1), (##, h2), (###, h3), ] splitter MarkdownHeaderTextSplitter(headers_to_split_onheaders_to_split_on) with open(doc.md, encodingutf-8) as f: md_text f.read() chunks splitter.split_text(md_text)切完之后每个 chunk 的 metadata 里会带上它所属的各级标题。比如一段落在第二章 2.1 节下面的内容它的 metadata 就是{h1: 第二章, h2: 2.1 节}。这个信息在检索时极其有用——你可以直接按标题过滤也可以把标题拼进正文一起做 embedding提升匹配精度。3.3 标题信息要不要拼进正文这是个值得讨论的取舍。MarkdownHeaderTextSplitter默认只把标题放进 metadata正文里不含标题。但实际检索时如果正文里没有标题一段孤立的内容可能语义不完整——比如正文写如上所述该方案有三个优势这个该方案指代什么只有标题能说清。我的做法是切分后手动把标题拼回正文开头for chunk in chunks: header_path .join( v for k, v in chunk.metadata.items() if k.startswith(h) ) chunk.page_content f{header_path}\n{chunk.page_content}这样每块文本都自带上下文embedding 出来的向量更准确。代价是文本变长了一点但这点开销换来检索精度的提升很划算。3.4 代码块和表格Markdown 解析的两个硬骨头MarkdownHeaderTextSplitter只认标题对代码块和表格是无感的。如果一篇文档里有大段代码按标题切分后代码可能和说明文字混在一块或者被从中间切断。处理代码块我的经验是切分后做一次后处理识别出以 开头结尾的块尽量保证代码块完整。如果代码块本身超过 chunk_size那就只能按行切但要在 metadata 里标记content_type: code方便后续检索时区分。表格更麻烦。Markdown 表格在纯文本层面就是一堆|分隔的行语义信息很弱。如果表格不大建议整块保留不要切如果表格很大可以考虑把表格转成字段: 值的键值对形式或者转成自然语言描述。这块没有银弹得看具体数据。注意如果你的 Markdown 里包含数学公式$...$或$$...$$切分时一定要把公式当成不可分割的整体。公式被切断后两边都是无意义的符号检索时纯属噪声。4. 从文件到 DocumentLoader 选型与元数据设计4.1 不同 Loader 的适用边界LangChain 的 Loader 生态很丰富但常用的就那么几个。选错了 Loader后面全是坑。下面这张表是我实际项目里总结的选型参考Loader适用场景关键特性TextLoader纯 txt无结构整篇读入需手动切分UnstructuredMarkdownLoaderMarkdown需保留元素能识别标题、列表、代码块JSONLoaderJSON / JSONL按 jq schema 提取字段DirectoryLoader批量加载整个目录可指定文件类型和 loaderUnstructuredFileLoader格式混杂的通用场景自动识别格式但精度一般DirectoryLoader特别值得说因为实际项目里很少只处理一个文件通常是一整个目录。它可以配合不同的子 loader 使用from langchain_community.document_loaders import DirectoryLoader, TextLoader loader DirectoryLoader( ./docs, glob**/*.md, loader_clsTextLoader, loader_kwargs{encoding: utf-8} ) docs loader.load()glob参数决定匹配哪些文件loader_cls决定用什么 loader 读。这里有个坑如果目录里混着多种格式用统一的loader_cls会读失败。正确做法是按格式分组分别用对应的 loader 加载最后合并。4.2 metadata 里到底该放什么metadata 的设计直接决定了后期检索的灵活度。我一般会保证每块至少包含这几类信息来源信息source文件路径、file_type格式位置信息page页码PDF 场景、chunk_index块序号结构信息h1/h2/h3标题层级、content_type正文/代码/表格业务信息根据项目定制比如department、version、publish_date这些字段在检索阶段能派上大用场。比如用户问最新版本的接口文档里怎么说的你可以先用version过滤再做向量检索精度提升非常明显。for i, chunk in enumerate(chunks): chunk.metadata[chunk_index] i chunk.metadata[file_type] markdown chunk.metadata[content_type] text4.3 批量导入时的性能与去重数据量一上来导入就成了性能问题。几个实测有效的优化点第一并行加载。DirectoryLoader支持use_multithreadingTrue对 IO 密集的文件读取有明显加速。但要注意多线程下 metadata 的写入要保证线程安全别在 loader 里做复杂的后处理。第二增量导入。全量重跑一次导入在大文档库上可能要几十分钟。我的做法是给每个文件算一个 hash内容 hash 或修改时间存到一张表里导入前先比对只处理新增和变更的文件。第三去重。同一份文档可能被重复导入导致检索时返回一堆重复结果。去重可以在块级别做用page_content的 hash 做唯一键导入前查一下是否已存在。import hashlib def content_hash(text: str) - str: return hashlib.md5(text.encode(utf-8)).hexdigest()提示去重不要只按内容 hash最好结合source一起判断。不同文档里出现相同的一句话是正常的不该被误删。5. 实操中踩过的坑与排查思路5.1 编码问题中文乱码的根源txt 文件最常见的坑就是编码。Windows 下默认可能是 GBKLinux 和 Mac 下多是 UTF-8。如果加载时编码指定错了读出来就是一堆乱码而且这种乱码在后续切分、embedding 阶段不会报错只会静默地污染整个知识库。排查方法很简单加载后打印前 200 个字符看一眼docs loader.load() print(docs[0].page_content[:200])如果看到锟斤拷或者一堆问号就是编码错了。稳妥的做法是先用chardet检测编码再传给 loaderimport chardet with open(notes.txt, rb) as f: raw f.read() encoding chardet.detect(raw)[encoding]5.2 切分后块太碎或太大怎么定位切分参数没调好表现很直观块太碎时检索返回一堆一两句话的片段拼起来上下文断裂块太大时检索返回的内容里只有一小部分相关其余全是噪声。定位方法是统计切分后每块的长度分布lengths [len(c.page_content) for c in chunks] print(fmin{min(lengths)}, max{max(lengths)}, avg{sum(lengths)/len(lengths)})如果 min 和 max 差距悬殊比如 min5max2000说明分隔符设置有问题某些地方没能正确切分。这时候要回去检查分隔符列表看看是不是漏了某种常见的分隔模式。5.3 标题层级丢失的隐蔽表现用MarkdownHeaderTextSplitter时如果headers_to_split_on里没配置某一级标题比如只配了#和##没配###那###下面的内容会被归到最近的上级标题下层级信息就错了。这种错误很隐蔽因为切分本身不报错只是 metadata 里的标题不对。验证方法是抽查几块的 metadata看看标题路径是否和原文一致。我一般会随机抽 5 到 10 块人工比对一下。5.4 空块和超短块的处理切分后经常会出现一些空块或只有几个字符的块这些块对检索毫无价值反而会增加索引体积和噪声。我的做法是在切分后加一步过滤chunks [c for c in chunks if len(c.page_content.strip()) 20]阈值设多少看情况一般 20 到 50 字符比较合适。低于这个长度的块基本不可能承载完整语义。6. 一条完整的导入链路长什么样把前面这些串起来一个从 txt 和 Markdown 到可检索 Document 的完整流程大致是这样from langchain_community.document_loaders import DirectoryLoader, TextLoader from langchain_text_splitters import ( RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter, ) # 1. 加载 txt txt_loader DirectoryLoader( ./docs, glob**/*.txt, loader_clsTextLoader, loader_kwargs{encoding: utf-8} ) txt_docs txt_loader.load() # 2. 切分 txt txt_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , , ] ) txt_chunks txt_splitter.split_documents(txt_docs) # 3. 加载并切分 Markdown md_splitter MarkdownHeaderTextSplitter( headers_to_split_on[(#, h1), (##, h2), (###, h3)] ) md_chunks [] for path in Path(./docs).rglob(*.md): text path.read_text(encodingutf-8) for chunk in md_splitter.split_text(text): chunk.metadata[source] str(path) md_chunks.append(chunk) # 4. 合并、过滤、补元数据 all_chunks txt_chunks md_chunks all_chunks [c for c in all_chunks if len(c.page_content.strip()) 20] for i, c in enumerate(all_chunks): c.metadata[chunk_index] i这段代码不复杂但每一步的取舍都有讲究。txt 用递归切分是因为它没有结构只能靠分隔符找语义边界Markdown 用标题切分是因为它的结构本身就是最好的切分依据。两种策略不能互换换了效果就崩。导入完成后建议做一次抽样检查随机抽十几块看看内容是否完整、metadata 是否齐全。这一步花不了几分钟但能提前发现大部分问题比等到检索效果差再回头排查要省事得多。后续如果要接入 JSON、PDF 这些格式思路是一样的先想清楚这类数据的结构特点是什么结构信息怎么提取到 metadata正文怎么切分才能保持语义完整。把 txt 和 Markdown 这两类基础格式吃透剩下的格式都是在这个框架上做扩展。