
做RAG项目很多人第一步就被数据导入绊住了。模型选型可以抄榜单向量库可以看评测偏偏最基础的txt文本读进来之后乱码、断行、标题丢失还没走到检索那一步语料质量就已经垮了。这篇攻略要解决的正是这个环节怎么把最通用的txt格式清洗、解析、结构化成RAG能真正用起来的Markdown语料。我会把通用文本解析的完整链路拆开讲从编码识别、噪声清洗、标题还原到Markdown转换、元数据挂载和分块策略全程用实战视角补上那些常规文档里不会写明的坑。无论你是在搭个人知识库还是负责整条数据管道这篇都建议先读完再动手。1. 先想清楚RAG需要什么样的“数据底座”1.1 别让解析变成“为转换而转换”很多人拿到一批文本第一反应是找个工具转成某种格式觉得“转了就算解析完了”。这其实把因果搞反了。RAG链路里解析的目标从来不是“把A格式变成B格式”而是让语料能够被检索器有效切分、被嵌入模型理解、被生成模型引用。所以解析这件事天然要服务于两个下游动作一是切块二是召回。如果一段文本没有明确的语义边界比如一个1200字的段落从头写到尾切块时无论按固定长度还是按token切都会把中间的逻辑拦腰截断。检索时用户问“某个结论是怎么得出的”召回到的片段可能只有结论、没有前提生成出来的答案自然站不住脚。反过来如果解析时把标题、列表、表格这些结构信息保留下来切块就能贴着语义边界走召回质量会明显提升。我见过不少团队在解析阶段投入不足后面花大力气调向量检索参数效果始终不理想。本质问题不在检索而在数据没被解析成“可检索的形态”。这就是为什么这个系列的第一篇要先讲txt这种最基础格式——它没有复杂的页面布局但恰恰是暴露文本解析通用问题的最好样本。把txt嚼透了后面处理PDF、Word、扫描件时才不会被表象牵着走。1.2 为什么先从txt下手txt在RAG数据导入里是个被严重低估的“硬骨头”。表面看它就是一个纯文本文件没有任何样式读进来就能用。但真实项目里拿到的txt往往是从网页复制的、从PDF提取的、从旧系统导出的甚至是从扫描OCR之后二次生成的。这些文件的共同特征是没有统一的编码标准没有稳定的换行风格没有可靠的段落边界。我处理过的txt里最离谱的一批来自某个政府公开数据包文件名都是GBK编码内容却在部分段落混入了UTF-8字符用文本编辑器打开正常用程序读到一半就崩。还有一份从学术网站批量下载的txt每一行末尾都有两个空格和一个零宽空格U200B肉眼完全看不出问题但向量化之后语义就被切碎了。这类问题在PDF里反而少见因为PDF至少有比较固定的文本层结构而txt太“自由”了自由到很少有人认真对待它。所以我把txt作为通用文本解析的起点是因为它最能暴露一个解析管道的薄弱点编码检测、行尾归一、不可见字符过滤、段落边界判断、标题层级还原。这些能力一旦建立迁移到其他格式只是换一个内容抽取器的事后面的清洗、结构化和切块逻辑完全可以复用。1.3 结构化的三个层级你在哪一层聊“结构化”之前先给个统一框架。我认为文本解析的结构化程度可以粗略分成三个层级越往上越接近RAG真正需要的语料形态第一层是纯文本层。这一层只关心“把字符读出来”换行、空格、缩进、页码残留全都不管。很多人把解析停在这一层直接拿去切块效果当然随缘。第二层是语义结构层。这一层要把标题、段落、列表、表格、引用块识别出来并在Markdown里表达。此时“语料”已经从“字符串”升级成了“有边界的语义单元”切块可以贴着这些边界走检索也能按结构过滤。第三层是元数据层。除了结构每个文档还能挂上来源、创建时间、文档ID、内容指纹甚至是从标题层级推导出来的目录树。这样RAG系统就可以做来源溯源、增量更新、权限过滤回答问题时还能附上“引用自哪份文档的哪个章节”。大多数项目连第二层都没做扎实。而我的经验是解析阶段把三层一起规划好后面调整成本会小很多。你在设计解析管道的时候不要只想着“怎么把文本变干净”要多想一步“干净之后还能抽出哪些结构、挂上哪些属性”。这正是从txt转向Markdown时最有价值的一件事。2. 通用文本解析的完整链路设计2.1 解析管道从“污水进”到“清水出”我在项目里习惯把解析管道想象成一条处理线入口是原始文件出口是标准化语料中间每一道工序都有明确的输入输出和日志记录。这样做的好处是出问题时能够准确定位是编码环节坏了还是清洗环节误删了内容而不是在一大坨处理代码里瞎猜。标准的通用文本解析链路大致包含六步读取 → 编码识别 → 文本归一化 → 噪声清洗 → 结构识别 → 目标格式输出。RAG场景通常还会再加一步切块。每一步之间不要揉在一起尽量保持函数单一职责哪怕当前文件格式很规整也要把完整链路跑一遍为后续接入PDF、Word留出扩展位置。这里有一个很重要的工程心态解析管道不是一次性脚本而是长期运行的“基础设施”。你导入第一批数据时可能只有几百个txt看起来怎么写都能跑通等文档量涨到几十万格式五花八门再回过来补管道就很难了。所以哪怕初始版本写得简单也要把“可观测性”和“可配置性”留出来。2.2 编码识别读txt的第一个隐形门槛txt文件没有自带的编码声明所以读取阶段最核心的坑就是编码。UTF-8是事实标准但中文语料里GBK、GB2312、GB18030依然大量存在。直接按UTF-8读取GBK文件要么抛UnicodeDecodeError要么读出一堆替换字符反过来按GBK读UTF-8文件中英文混排内容会直接乱掉。我现在的做法是两步走。第一步用chardet或charset-normalizer对文件头部采样做编码检测检测样本不用太多前4096字节基本够了但要注意文件开头如果有BOM最好先做一次BOM探测因为BOM的优先级比统计检测高得多。第二步拿到候选编码后不要马上全量解码先解码一小段做冒烟测试看看有没有Unicode替换符UFFFD或超高比例的不可打印字符如果有就换编码重试。还有个容易忽略的点带BOM的UTF-8文件会把\ufeff留在第一个字符前面如果不清掉它会被算进文本里干扰标题匹配和切块。简单粗暴但有效的办法是统一解码后用.lstrip(\ufeff)做一次清理或者直接在读取时使用utf-8-sig编码。这个细节不会让你的程序崩溃但会影响后续每一步的字符判断。2.3 清洗不是“做减法”而是“去噪保真”清洗这一步最讲究分寸。很多新手容易把清洗理解成“删掉不想要的东西”结果把正文里的有效内容也删了。我的原则很简单能归一的不删除能保留语义的不裁剪。清洗优先级从字符层到版式层最后才是语义杂质层。字符层要处理的是BOM、零宽空格、软连字符、不间断空格这类看不见的字符。它们通常不表现为乱码而是悄然混在文本里干扰关键词匹配和编码判断。我常用一个白名单思路中文、英文、数字、标点、换行、Tab之外的可打印字符先查出来给人眼审一遍确认无害再保留。版式层的重点是空行和缩进。连续三个以上空行基本可以压缩成一个行首的多个全角空格在转Markdown之前先决定好怎么映射。这一步特别考验对文档类型的理解如果源文档是网页正文缩进往往没有意义如果是程序代码的txt缩进就是语法的一部分绝对不能动。所以清洗规则永远要配合文档来源来配参数不要写一个“通用清洗函数”就想打天下。最后是语义杂质层。从PDF或网页里导出的txt经常混入页眉页脚、页码、“目录”“参考文献”等辅助信息。这些内容在原始排版里有位置意义但落到纯文本里就成了噪声。处理时要做的是“识别并标记”而不是直接删掉。比如页眉页码可以统一替换成空行但目录部分如果删了会影响章节定位不如保留并转化为Markdown列表后续检索时还能利用。2.4 标题识别txt里没有“#”怎么还原层级纯文本没有Markdown的#标记全文也没有加粗、字体大小这些视觉线索标题识别只能用启发式规则。常见的信号源有三个数字序号、短行特征、上下文关系。数字序号是最容易捕捉的。中文文档里“第一章”“第2章”“一、”“二、”“1.2 项目背景”“2.1.3 模型评估”这些模式都有明确规律通过正则就能抓出来。但需要注意“注意”“提示”“总结”这类无序号标题它们经常独立成行后面跟一个长段落识别时要把“短行短行后续长段落”作为辅助特征。比较麻烦的是纯排版性短行比如一行居中显示的章节名它在txt里没有任何标记。我的做法是把它放到一个候选池里结合前后文判断如果这个短行后面直接跟着一个显著长的段落且它和上一段之间有空行分隔那就很可能是标题如果短行前后都是短行则更像列表项或签名。判断逻辑不必一次写完美先实现一个保守版本再用抽检语料倒逼规则迭代。这里有一个建议识别出来的标题层级直接映射为Markdown的#、##、###等标记。第一层对应#第二层对应##以此类推。如果原文档的序号已经表达了层级比如“2.1.3”那么根据点数映射如果序号不规范则根据缩进量或字号线索虽然txt没有字号但有些导出文本会用全角空格缩进表示层级来推断。2.5 段落重排把碎成渣的正文拼回去网页复制下来的txt最典型的问题就是“硬换行”——每行在浏览器排版宽度处断行行尾没有任何句读复制出来之后变成一段一段的短行。如果直接切块语义会被切得七零八落。段落重排的目标是把这些硬换行合并成真正的段落。判断某个换行符是不是“硬换行”核心看两点当前行的末尾有没有句号、问号、感叹号等终止标点下一行的开头是不是缩进开头或序号开头。如果行尾没有终止标点且下一行不是明显的新段落起点就大概率需要合并。英文文本还要再加一条行尾如果是连字符且单词被切断要先把连字符去掉再拼接。但是重排不要做成“无脑合并所有短行”。诗歌、口号、代码清单、表格每行在这种场景下都是独立语义单位一旦合错结构信息反而丢失。所以重排之前最好先判断段落块类型比如识别到整段缩进的行组谨慎保留。这块逻辑可以做成“可开启/关闭”的选项在配置里按文档来源指定而不是写死在处理流程里。3. 把txt“升级”成Markdown为什么锚定这个中间格式3.1 Markdown在RAG生态里的“中间件”价值既然解析的目标是服务检索为什么输出格式非选Markdown不可我的理由有三条。第一Markdown是结构信息密度很高的纯文本格式。标题、列表、表格、引用在Markdown里都有明确的语法标志既可以给人读也可以被程序解析。对于RAG链路而言它相当于一种“半结构化”的中间表示既不像JSON那样僵硬又不至于像裸txt那样没有任何标记。第二主流的检索器、向量库和Agent工具都对Markdown友好。很多文本加载器Loader原生支持Markdown格式拆分能识别标题层级向量化模型对带Markdown的文本也有更稳定的语义表征因为标题、列表结构给了模型额外的上下文线索。第三Markdown可以低成本双向转换。它转HTML、转JSON、转纯文本都很方便。这意味着解析管道不设死万一某天需要把语料导入到不支持Markdown的系统中间格式依然可以再处理。相比之下如果一开始就输出定制JSON后续适配成本会高不少。有人问过直接用JSON输出结构化内容不是更“结构化”吗问题是JSON把文本的块顺序打散了或者说为了表达嵌套关系增加了大量括号和键名这部分在向量化时会干扰语义密度。而Markdown保留了一种“自然的线性阅读顺序”模型理解起来更顺。我的实践结论是对典型文档类语料Markdown是“语义可读”和“机器可计算”之间性价比最高的平衡点。3.2 转换规则从纯文本到Markdown的映射实践从txt转Markdown最关键的是把之前识别到的结构信息稳定映射成Markdown语法。我通常维护一份“转换规范”文档明确不同类型内容的映射规则避免不同开发者在实现时各有各的理解。以下是几个核心映射策略。标题层级映射最简单识别到的第一层标题输出为#第二层输出为##第三层输出为###。但如果原文档的标题序号已经包含层级信息如“1.2.1”则以序号点数为准甚至可以不依赖人工判断直接生成。两种方式各有取舍我建议优先保留原文档序号而不是重新编号因为重新编号会让后续引用文档章节变得困难。列表项识别要注意缩进关系。纯文本里列表项通常以-、•、1.等符号开头层级由前方空格数决定。转Markdown时把符号统一成-和数字序号用两个空格或四个空格的缩进表达嵌套关系。不要试图识别所有列表遇到歧义时宁可降级成普通段落也不要生成错误的嵌套结构因为错误的嵌套比没有嵌套更影响检索语义。纯文本表格转换时先用分隔符比如连续多个空格、Tab或|判断是否成列再检测横线行----、作为表头分隔线。能够可靠识别时转换为Markdown管道表格识别不可靠时我的建议是保留为代码块或普通文本不要硬转。后面第5章会专门讲这个坑。引用块在中文文档里往往以“注”“提示”开头而不是Markdown的标记。识别时可以按关键词匹配匹配成功则在正文前加。不过引用块的粒度很难把握先用关键词触发一个最小实现再根据语料迭代才是正路。3.3 元数据与内容指纹给语料挂上“身份证”结构化的最后一环是元数据。我的做法是在每个Markdown文件的顶部加一个YAML格式的front matter块记录title、source、doc_id、created_at、updated_at、content_hash等字段。这样做有几个直接好处一是检索结果可以带上来源信息回答引用更可靠二是增量更新时能快速判断文件是否变化三是做权限过滤和数据审计时有据可依。doc_id我用文件名相对路径的哈希生成保证同一个文档在任何时候计算出的ID都是稳定的。content_hash则是正文内容归一化之后的MD5或SHA1值不包含时间戳等易变信息。这两者配合就能让“新增文件”“修改文件”“未变文件”在导入时被迅速区分。front matter里还可以挂结构化目录树比如把识别到的标题层级序列化成JSON或列表。这个信息在检索阶段很有价值可以实现“先定位章节再检索内容”的粗排策略。比如用户问“第三章里关于某方法的内容”系统可以先找到“第三章”的位置再在限定范围内做细粒度检索准确率会明显提升。这套元数据设计不复杂但对后续系统化扩展非常关键。它相当于把解析结果从“一段文本”升级成了“一条带身份的数据”也是从txt到Markdown这个转换动作真正产生长期价值的地方。4. 实操案例批量把txt文档库转成RAG可用的Markdown语料4.1 技术选型不追框架够用就好做这个案例时我的选型原则是“用最基础的库先把链路跑通”而不是上来就上框架。对标准txt解析Python标准库加几个轻量第三方依赖已经足够满足多数需求。常用的就这几样pathlib遍历目录re做模式匹配chardet识别编码unicodedata处理Unicode字符hashlib生成指纹。有人可能会问为什么不用LangChain或LlamaIndex里的Loader我的回答是工具可以用但不要被工具代替思考。框架里的Loader往往针对“比较干净”的网页正文做了优化对编码混乱、版式复杂的本地txt并不友好而且框架的抽象会让排查问题变得困难。先把核心解析逻辑用自己的代码跑通再决定要不要让框架融入链路做起事来心里才有底。还有个小建议准备好一批“验收样本”人工整理5到10个典型txt标记出期望的Markdown输出。每改一次解析逻辑就拿着这批样本回归测试能显著减少后续的“修好东墙倒西墙”问题。我把它叫作“金样测试”效果比依赖代码review强很多。4.2 核心流程与关键代码逻辑整个批量转换流程可以用这样一段Python伪代码概括实际生产代码可以在此基础上扩展。import chardet from pathlib import Path import re import hashlib def detect_encoding(file_bytes): # 优先探测BOM再使用chardet做统计识别 if file_bytes.startswith(b\xef\xbb\xbf): return utf-8-sig result chardet.detect(file_bytes[:4096]) return result.get(encoding, utf-8) def normalize_text(text): # 统一换行符为\n移除BOM压缩多余空行 text text.replace(\r\n, \n).replace(\r, \n) text text.lstrip(\ufeff) text re.sub(r\n{3,}, \n\n, text) return text def parse_heading(text): # 识别第X章和X.Y.Z这类标题 patterns [ r^\s*第[一二三四五六七八九十百\d][章篇节]\s*.*$, r^\s*\d(\.\d)\s.*$, r^\s*[一二三四五六七八九十]、.*$, ] for pattern in patterns: if re.match(pattern, text, re.MULTILINE): return True return False编码识别和标题识别只是其中一部分。真正落地的时候还需要逐行处理流程先按行读入对每一行做噪声过滤判断是否为候选标题再根据上下文决定是保留为段落、列表还是标题输出。每一段输出前把该段的元数据如来源文件、章节路径记录到内存结构里最终在写文件时统一序列化到front matter。这里有个关键的性能问题如果文件很大几十MB以上不要一次性把整个文件读入内存用re.sub处理要改用流式读取按块处理并维护一个跨块的上下文状态。我之前处理过一份2GB的日志型txt一次性读入直接把内存撑爆了换成逐行流式处理后耗时反而可控。所以解析实现时必须考虑文件规模的伸缩性。4.3 分块策略让“切片”贴近RAG的检索粒度解析完成后就进入分块环节。分块没有银弹核心是让每一块尽量成为一个“自包含的语义单元”。字符切块最朴素但容易切断句子token切块对模型更友好但需要引入分词器语义切块质量上限高但实现复杂。我的建议是从“标题段落”的混合策略开始优先按标题层级形成章节块章节过大时再按段落切开段落仍过长时再按句子边界补切。重叠窗口极其重要。相邻块之间保留10%到20%的重叠可以在一定程度上缓解切块造成的信息断层。比如一个长段落讲了因果链前一刀切断后一句话重叠机制能保证后一块仍然保留前因。这个做法不会让检索质量质变但能显著降低“答非所问”的概率。参数上很多人直接照搬网上的“chunk_size500overlap50”我建议先用自己的语料统计一下文本分布平均段落长度是多少、最长段落多长、标题层级有多深。然后反推chunk大小。粗略估算一个汉字约等于1到2个token如果你目标块在800到1000 token之间那么中文语料可以先用800到1200字符的窗口做初版再在评测集上微调。分块之后还有一个容易被忽视的动作给每一块分配一个全局唯一ID并在ID中携带父文档信息比如doc_xxx_part_003。这样检索到任意小块时都能追溯到父文档和章节路径RAG回答的引用环节才会扎实。5. 常见问题与排查技巧实录5.1 解码失败与乱码“家族”乱码问题在txt解析中十个项目九个会遇到。最典型的是以UTF-8编码读取GBK内容抛出UnicodeDecodeError或者以GBK读取UTF-8内容出现中文“锟斤拷”“烫烫烫”这类经典替换乱码。排查路径其实不复杂先用工具检测编码再把编码结果和实际打开效果交叉验证就能准确定位。比较隐蔽的是混合编码文件同一文件内不同段落使用了不同编码这种文件通常来自老系统拼接导出。一个可行的处理方式是先用错误容忍度高的编码如cp936整体读取再把无法映射的字节段单独提取用chardet二次识别后拼接。这个方案不能保证100%还原但能救回大部分内容。还有一个教训不要相信txt文件扩展名的“所见即所得”。很多txt的内容其实是XML、JSON或CSV只是被改了扩展名。导入之前先看文件头部几个字节能避免大量无效解析。这也是为什么解析管道里编码识别必须放在读取之后的第一个环节而不是先按固定规则切分。5.2 标题识别失误与结构错位标题识别最常见的两个错误是“把正文短行误判为标题”和“把有序号标题漏掉”。前者多发生在诗歌、标语、注释类文本里后者多发生在标题只有加粗没有序号时。针对误判我的排错思路是给标题候选加“三层过滤”长度过滤标题一般不超过50字、独立成行过滤标题行前后应有段落分隔、序号匹配过滤优先信任序号标题其次才信任无序号短行。如果某个候选不符合全部条件就降级为普通段落。这里宁可漏标也不可错标因为错误的标题层级比缺失标题对后续分块的破坏更大。还有一类情况是“结构错位”识别的标题比实际层级高了一级或低了一级。比如原文档只有“一、二、三”三个一级标题但识别逻辑把其中某一行如“1.1”当成了二级标题导致Markdown里出现了##下面的###空壳。出现这种问题时建议做一个“层级一致性检查”统计每个层级的数量、上下层级出现顺序如果出现断层或倒挂就打印警告日志。这个检查逻辑不复杂但能在批量导入时自动标出疑点数据。5.3 表格在纯文本里的识别难点纯文本表格的转换是结构化解析的重灾区。固定宽度表格在txt里往往只是若干列文本的近似对齐看起来像表格但列与列之间可能既有多个空格又有Tab无法可靠分割。分隔符表格用|、,等分隔相对好处理但也存在表头行、合并单元格等问题。我在实践中定了一条原则识别不了就别硬转。所谓“硬转”就是拿正则强行把文本拆成多个字段一旦拆错语义就散架了。比如一份txt里的数据列顺序是“名称 类型 数量”但某个单元格内容本身包含多个空格强行按空格拆分会把单元格内容裂成两列。Markdown表格一旦错位后续检索时模型看到的就是一份错乱数据还不如保留为代码块至少能维持原始排布。当然如果有明确的格式依据比如“每行两列以Tab分隔”那么转成管道表格是没问题的。问题只出在“看起来像但无法确认”的情形。所以我会在转换规则里预设一个“置信度阈值”只有满足列数一致、分隔符稳定、无内嵌分隔符三个条件时才转表格否则降级保留原样。这样处理既不会丢失原始信息也不会给下游制造噪声。5.4 从txt迁移到PDF、Word等其他格式的思路这个系列后续会讲PDF和Word的解析这里先给一个方向性的说明。处理PDF时要考虑两个世界有文本层的数字PDF和只有扫描图像的图片PDF。数字PDF的难点在于文本块提取顺序和版面分析图片PDF则需要OCROCR之后还会引入识别误差和置信度概念清洗逻辑会完全不同。Word的docx本质是一堆XML文件打包而成它比txt多了解析入口但也多了样式混淆的问题。比如Word里的“标题”可能是手动加粗加字号而不是真正应用了“标题样式”这种情况下解析器很难准确识别层级。我的经验是Docx解析优先尝试提取原生的样式标记提取不到的再退回启发式规则。但无论如何转换的出口统一到Markdown schema这一点是不变的。也就是说PDF、Word、HTML、txt最终都应该输出成同一套带front matter和标题层级Markdown结构。这样整个RAG数据管道就只依赖一种中间格式检索端的适配成本被压到最低。这也是把这个系列叫作“全攻略”的原因——核心方法收敛变化只在外层抽取器。最后再分享一个我自己的习惯每次做完一批批量导入我会随机抽出20篇文档把解析前后的文本并排打印出来人工过一遍重点看标题、列表、表格这三类结构有没有损伤。这个过程虽然土但比任何测试脚本都能更快地发现解析规则的“盲区”。很多莫名其妙的解析bug都是在这种人工抽检里暴露出来的。下一篇我会接着讲PDF和扫描件怎么接入同一套解析链路到时候这些基础规则的复用价值会更明显。