做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,每一行末尾都有两个空格和一个零宽空格(U+200B),肉眼完全看不出问题,但向量化之后语义就被切碎了。这类问题在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替换符(U+FFFD)或超高比例的不可打印字符,如果有就换编码重试。
还有个容易忽略的点:带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_size=500,overlap=50”,我建议先用自己的语料统计一下文本分布:平均段落长度是多少、最长段落多长、标题层级有多深。然后反推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则需要OCR,OCR之后还会引入识别误差和置信度概念,清洗逻辑会完全不同。
Word的docx本质是一堆XML文件打包而成,它比txt多了解析入口,但也多了样式混淆的问题。比如Word里的“标题”可能是手动加粗加字号,而不是真正应用了“标题样式”,这种情况下解析器很难准确识别层级。我的经验是,Docx解析优先尝试提取原生的样式标记,提取不到的再退回启发式规则。
但无论如何,转换的出口统一到Markdown schema这一点是不变的。也就是说,PDF、Word、HTML、txt最终都应该输出成同一套带front matter和标题层级Markdown结构。这样整个RAG数据管道就只依赖一种中间格式,检索端的适配成本被压到最低。这也是把这个系列叫作“全攻略”的原因——核心方法收敛,变化只在外层抽取器。
最后再分享一个我自己的习惯:每次做完一批批量导入,我会随机抽出20篇文档,把解析前后的文本并排打印出来人工过一遍,重点看标题、列表、表格这三类结构有没有损伤。这个过程虽然土,但比任何测试脚本都能更快地发现解析规则的“盲区”。很多莫名其妙的解析bug,都是在这种人工抽检里暴露出来的。下一篇我会接着讲PDF和扫描件怎么接入同一套解析链路,到时候这些基础规则的复用价值会更明显。