做 RAG 应用,第一步卡在数据导入和解析上。很多人一上来就调 Embedding 模型、调向量库,结果召回效果稀烂,回头查才发现源文档压根没解析干净——标题没识别出来、正文带着乱码、表格整个被拆碎,这锅还真不能全甩给检索。我做了几轮 RAG 项目之后最大的感受是:RAG 的上限由底座决定,底座的起点就是“从一个 txt 变成一份干净的 Markdown”。
这篇是“RAG 数据导入与解析”系列的第一篇,专门讲通用文本怎么读、怎么洗、怎么从纯 txt 整理成带结构的 Markdown。适合两种人:一是刚接触 RAG 知识库、准备搭个人文档检索系统的开发者;二是已经在用 LangChain / LlamaIndex 这类框架,但觉得“官方 loader 不够用”想自己写解析管线的同学。读完你至少能回答三个问题:txt 为什么不能直接切?Markdown 作为中间格式到底好在哪里?解析完之后的切片和数据血缘怎么设计?
1. 数据导入的路由策略与解析分层
1.1 为什么把 txt 作为起点:非结构化文本的普遍性
RAG 官方文档、技术博客、会议纪要、审计报告,这些看起来八竿子打不着的资料,落到本地仓库里反而是最像的:它们全都以 txt、md、docx 导出后的纯文本为最终形态。docx 和 pdf 解析到最后,本质上也是抽文本块;真正让我坚持先把 txt 链路做扎实的,是那两个原因:
一是 txt 没有任何“隐藏结构”,你不会被 XML 标签、字体样式、表格边框之类的附带信息骗了。很多解析库一看到 docx 里的字体加粗就自动生成标题,但实际上那只是 Word 主题字体的默认样式,跟语义标题毫无关系。反而是纯文本里那些约定俗成的编号、空行、缩进,才是真正可靠的切分信号。
二是 txt 是绝大多数非结构化数据的“退路格式”。日志导出是 txt,爬虫抓到网页转纯文本也是 txt,连不少老旧系统的导出接口也只能给 txt。把 txt 这条链路打通,你就相当于拥有了一把万能钥匙,其余格式最后都可以归约到它身上再走同一套管道。
1.2 解析流水线的整体分层
做数据导入,千万别上来就写一个“txt_parse()”函数把什么都干了。我自己踩过这个坑:前期一个函数解决所有问题,后面要支持新格式或者调整清洗规则时,动一处崩全线。现在我的固定做法是把导入流程分成四层:
第一层:物理读取层。负责打开文件、判断编码、按字节流转成 Unicode 字符串。这一层只管“读得对不对”,不管内容。
第二层:语法解析层。把纯文本按规则转换成结构化中间表示。我习惯用 Markdown 作为中间表示,因为它可读、可调试、能直接给 LLM 看,本身又携带层级信息。
第三层:语义清洗层。去噪声、纠正 OCR 错字(如果来源是扫描件)、统一术语、过滤广告尾巴和页眉页脚。注意,清洗必须在解析之后做,因为有些噪声(比如页眉里的章节名)要先靠结构才能判断是不是该删。
第四层:索引入库层。切片、抽元数据、算 embedding、写向量库和文档库。很多人把这一层的工作跟解析层混在一起,导致切片逻辑绑死在具体文件格式上,换个来源就得重写。
四层各管各的,层与层之间只通过标准的数据结构(str 或 Markdown 内容块)通信。这也是为什么我极力推荐中间产物一定要落盘、一定要人类可读——排障的时候打开中间文件一眼就能看见是哪一层出了问题,而不是对着黑盒调参。
2. 读取与预处理:从原始字节到干净文本
2.1 编码识别是第一个坑,别直接 open()
写 RAG 导入脚本的人,八成都在这里吃过亏。你以为 Python 的open(path, encoding='utf-8')是万能的,结果 Windows 导出的 txt 一堆是 GBK,Mac 上另存出来的可能是 UTF-8-BOM 或 Latin-1。直接解码失败还算是好的,更怕的是GBK 字节被强行当 UTF-8 解码,出来一堆“锟斤拷”类的替换字符,这种错误不会抛异常,但会让后续检索结果全是垃圾。
我的建议是读取阶段统一走“探测 + 兜底”策略:先用charset-normalizer(比 chardet 快且准)探测编码,探测置信度低于阈值就用errors='replace'兜底,同时把出问题的文件路径记进日志。
from charset_normalizer import from_bytes def read_text_file(path: str) -> str: raw = open(path, 'rb').read() # 先看有没有 BOM,有 BOM 的按 BOM 解码最稳 if raw.startswith(b'\xef\xbb\xbf'): # UTF-8 BOM return raw.decode('utf-8-sig') if raw.startswith(b'\xff\xfe') or raw.startswith(b'\xfe\xff'): return raw.decode('utf-16') # 无 BOM 时做统计探测 best = from_bytes(raw).best() if best is None: return raw.decode('utf-8', errors='replace') return str(best)这一步很重要:编码问题必须在最前面解决,越往后拖,错误越难追溯。等到你切片入库了才发现有乱码段落,想定位到原始文件已经非常痛苦。
2.2 脏数据清洗与统一规范
读进来之后,先不要急着识别标题,做一轮“无脑清洗”能省掉后面大量麻烦。我把无脑清洗总结成四个固定动作:
统一换行符。这一步十个人有九个会忽略。Windows 的 \r\n、老 Mac 的 \r、Unix 的 \n,混在一个文件里会让段落切割逻辑发疯。全部转成 \n 是第一个动作。
清控制字符。保留 \n、\t 和常用中文标点,剩下的小于 U+0020 的字符(除了 \n \t)全部干掉,尤其是 \x00、\x0b、\x0c 这些。用str.translate做批量替换比正则快得多:
import re def clean_control_chars(text: str) -> str: # 把 \r\n、单独的 \r 统一成 \n;清掉其余控制字符 text = text.replace('\r\n', '\n').replace('\r', '\n') # 构建控制字符映射表,U+0000-U+001F 除了 \n(0x0A)、\t(0x09) 全部清除 control_chars = dict.fromkeys( i for i in range(0x20) if i not in (0x09, 0x0A) ) return text.translate(control_chars)全角半角统一。这个要谨慎。中文标点转半角会把“句子结束”的语义弄丢吗?不会,因为中文句号本身就是全角字符(。),我会把全角数字、字母、空格转半角,但标点符号保持原样。方法就是 Unicode 规范化的 NFKC 只对字母数字生效,标点不做归一。
空行折叠。连续三个以上的空行收缩为一个,这样可以避免后续用空行分段时产生大量空片段。
清洗这层没有标准答案,核心思路是“宁可保守,不要激进”。你永远可以依赖后面的语义清洗层做更精准的处理,前面只要把“机械错误”消灭掉就行。
2.3 段落切分的基础操作
txt 文档最大的弱点是没有结构标签,段落边界几乎全靠空白和编号。这时候就要引入“物理行”和“逻辑段”的概念:
- 物理行:按 \n 切出来的每一行。
- 逻辑段:若干物理行合并后的语义单元。
我常用的一种简单可靠的做法是:先按空行切块,再把块内不以句号、冒号、逗号结尾的短行(比如标题、列表项)单独抽出来。下面这个函数是一段非常经典的初始逻辑:
def split_paragraphs(text: str) -> list[str]: # 按空行切块,再处理块内部的短行为后续 Markdown 解析做准备 blocks = re.split(r'\n\s*\n', text.strip()) paragraphs = [] for blk in blocks: lines = [ln.strip() for ln in blk.split('\n') if ln.strip()] if len(lines) <= 1: paragraphs.append(lines[0]) else: # 多行合并:考虑中英文混排时不需要额外加空格 merged = ''.join(lines) paragraphs.append(merged) return paragraphs这种“按空行分段 + 行内规则”的粗粒度分割,在后续转 Markdown 时就能派上大用场:标题行、列表行、正文段,在大结构上已经被物理行隔开了。
3. 把普通文本改写成 Markdown 的规则引擎
3.1 标题识别的几种启发式方法
拿到一个 txt,里面明明写着第一章 绪论、1.1 研究背景、1.1.1 国内外现状,它们天然就是层级结构,只不过没有 Markdown 标记。我们这一步的目标就是把这些“隐性结构”变成#、##、###。
我用过的标题识别方案从简单到复杂有三档:
第一档:正则硬匹配。匹配中文多级编号(第[一二三四五六七八九十百]+章)、数字编号(\d+(\.\d+)*[、..\s])、英文编号(Chapter\s+\d+)。优点是快、无依赖;缺点是误报率不低,比如正文里出现“1. 结论是…”就容易被当成标题。
第二档:统计特征 + 规则。短行优先:标题行一般不超过 30 个字符;独立成行:标题后面跟的是空行或正文,而不是同一行继续写;无句末标点:标题末尾极少出现句号。把这三条作为硬条件,误报率能降不少。
第三档:LLM 辅助识别。直接把前两档的结果作为候选,让 LLM 判断“以下几行是否是标题并给出对应级别”。这个方案很准,但会给每条文档增加额外耗时和成本,一般只在文档格式极其混乱时才启用。
我的工程实践是:优先用第二档,所有被判定为标题的行先不直接转成#,而是先输出一个[TITLE] Level=2的中间标记,经过人工抽查或规则确认后,再一次性替换成 Markdown。这么做的好处是,你能在中间产物里快速看见哪些行被误判了,而不是等全部转完才发现某个编号段被切成了碎片。
3.2 行内标记:加粗、斜体、行内代码不要乱来
从 txt 到 Markdown,除了标题,还要考虑行内样式。纯文本里最常见的行内标记是星号、下划线和反引号。很多人会写一个大正则把**、*、_全转成 Markdown 语法,这个我强烈不建议。
因为txt 本身就是个没有转义机制的环境,原文里的*很可能只是普通的乘号或强调符号。我把行内转换规则收敛成了几条:
- 只有
**包围的短语且前后是空白或标点时才转加粗; - 单个
*或_默认不转换,除非明确能匹配到对称的两个; - 反引号内容如果包含空格,大概率是行内代码,保留反引号;
- 双下划线在中文语境下几乎不可能是斜体,直接忽略。
def convert_inline_markdown(line: str) -> str: # 保守策略:只有成对出现且内容不含空白时才转换 line = re.sub( r'(?<![*\w])\*\*([^*\n]{1,60})\*\*(?![*\w])', r'**\1**', line) line = re.sub( r'(?<![`\w])`([^`\n]{1,100})`(?![`\w])', r'`\1`', line) return line核心原则只有一句:宁可不转,不要错转。一个没转成加粗的句子不影响检索和理解,但一个被错误标记的标题级别会直接毁掉切分层级。
3.3 列表、引用与代码块的还原
txt 里的列表通常是-、*、·、数字加点或全角数字。处理方法是先看整行是否以列表标记开头,再看连续几行是否保持同样的前缀,如果是,整体识别成一个 list block,再统一成 Markdown 的-或1.。
引用在 txt 里往往表现为行首的>或全角>,以及缩进段落。识别引用块时,我只看行首是不是>(可带空格),连续三行以上是>就合并成一个引用块,不然容易把普通缩进段落误判成引用。
代码块的识别是 txt 转 Markdown 里我最头疼的部分。纯文本里代码块通常靠缩进(4 个空格或 Tab)表示,但跟引文缩进很难区分。我的经验是:只有当连续 N 行缩进块内含明显的程序语言关键字(import、def、function、class、{、}、;)时才转成代码块;否则就留在正文里。你可以在解析配置里加一个code_keyword_hits参数,调这个阈值来适应不同文本来源。
3.4 表格与图片信息保留
很多据称结构化的 txt 其实包含制表符分隔的伪表格。这时候要把\t分隔的行组转换成 Markdown 表格吗?我试过几次,效果不太理想——因为纯文本表格往往有缺列、跨行、内容里混着制表符的问题,硬转容易产生列错位。
我的方案分两步:先检测连续多行是否都包含制表符或等宽空格分隔,再统计每行分隔符数量。如果分隔符数量一致且大于等于 2,就尝试转 Markdown 表格;如果不一致,我宁愿把整个表格块转成一个段落保留原文,同时加一行隐藏元数据<!-- original_table_len=8 -->,方便后面切片时做特殊处理。
图片信息在纯 txt 里通常只剩一个路径或描述文字,比如图片1:xxx.png或[图片]。不要试图去还原真实图片,保留占位文本即可。真正的图片处理应该由专门的 pdf/docx 解析链路负责,而不是这一层。如果你确实需要从文本里保留图片证据,可以顺手在 Markdown 里用 HTML 注释记录路径:
<!-- IMAGE: assets/fig1.png 图1:系统架构 -->这样既不影响纯文本阅读,又不会丢失图片线索,后面如果做多模态 RAG 还能顺着路径把图片重新加载进向量库。
3.5 输出校验:拿到 Markdown 后先验证再入库
解析完成后,我强烈建议写一个独立的validate_markdown()检查函数,自动做三件事:
标题层级连续性检查:不允许出现##下面直接跳####,遇见了就自动补一个###或者记 warning。因为下游切片要看层级,跳级会让父子关系断裂。
未闭合标记检查:全文扫描有没有成对的**或反引号。如果数量为奇数,说明 markdown 语法被破坏,极端情况下会在切片后污染 embedding,需要回退到原始段落。
空块检查:连续的空白标题、只有控制字符的段落,直接过滤掉。
这一步规模小的时候用脚本跑没问题,规模大了就适合做成 CI 到数据目录里,每次跑一批数据先给你出一份质量报告,再决定要不要进向量库。我见过太多团队把脏数据直接灌进生产库,结果线上检索出问题,排查半天才发现源头解析就有错。
4. 结构化之后的切片与入库设计
4.1 Markdown 结构如何指导切片
很多人问:为什么非要从 txt 转成 Markdown,直接按长度硬切不行吗?答案在于切片的语义质量。
硬切的话,800 个 token 一刀切下去,可能把一个标题跟它的正文切开了,也可能把列表项拦腰斩断。而 Markdown 之后,你就有了三个可用的结构信号:
标题层级的父子关系。这是最直接的。一个###标题及其下所有内容直到下一个同级标题,天然是一个信息块。按照这个边界切,检索到一个片段时,你能把它的父标题串成上下文路径,比如第一章 绪论 > 1.1 研究背景 > 1.1.1 国内外现状。
列表和表格的聚合边界。一个列表如果没有被空行打断,就整体作为一个 chunk,因为拆开之后每个列表项往往缺乏独立语义。
行内标记的语义提示。加粗文本往往包含关键词,代码块往往对应技术细节。切片的时候可以给这些 block 打上标签,比如doc_type=code、doc_type=table,后面召回阶段可以按类型过滤。
我现在用的切片算法版本是:先按 Markdown 标题把文档切到二级标题,然后在每个二级标题内部,按三级标题或者段落进一步细切,最后控制每个 chunk 在 300–500 token 之间。超过上限就再往下切一层;低于下限就与前一个或后一个合并。这个逻辑看起来简单,但给检索质量带来的提升非常明显,因为每条片段的边界天然对齐了语义边界。
4.2 元数据与数据血缘的记录
解析做的再好,如果入库之后不知道数据从哪来,后面更新和排查都是地狱。我一直建议在写入向量库之前,给每个 chunk 带上至少五类元数据:
| 字段 | 示例 | 作用 |
|---|---|---|
| source_file | docs/tech/01_intro.txt | 定位原始文件 |
| source_hash | sha256:ab12... | 判断文件是否变更 |
| chunk_path | 第1章 > 1.1 背景 > 第2段 | 还原上下文 |
| parse_version | v1.2.0 | 追踪解析器版本 |
| charset_detected | utf-8 | 排查乱码时用 |
来源哈希特别关键。增量更新时,如果不需要重算,直接比较源文件哈希就能跳过。我第一次做 RAG 导入时没记录这个字段,结果每次更新都要全量重灌,5000 份文档跑一晚上,纯浪费算力。
4.3 增量更新要提前设计
数据导入流程不应该只跑一次。文档库会持续增加新文件,也会修改删除旧文件。我建议在导出的 Markdown 文件头部写一个 YAML front matter:
--- source_file: docs/tech/01_intro.txt source_hash: sha256:ab12... parse_version: v1.2.0 updated_at: 2025-01-15T10:00:00+08:00 --- # 第一章 绪论 ...这样好处很多:你可以写一个简单的 diff 脚本,对每个 Markdown 文件算哈希,跟上次入库的记录比对,变了才重新切、重算向量。等于把“RAG 数据导入”这个看起来像一次性批处理的事,真正变成了可持续增量维护的常态任务。
5. 常见问题与排查实录
5.1 编码识别翻车,批量文件一半变乱码
现象:脚本没报错,但向量库里有一批片段全是“锟斤拷”。
排查:先看charset_detected元数据——凡是识别成ascii或windows-1252的中文文件都很可疑。后来发现是用户导出的文件,部分用了 GB18030 超集编码,charset-normalizer 只识别成 GB2312,一些生僻字就变成了替换符。
修复:把编码表放宽,优先检测gb18030而不是gb2312;同时在清洗层加一道“中文字符占比检测”,如果一段文本里中文标点比例异常低,就触发二次探测。这个经验后来救了我好几次。
5.2 标题层级跳级,下游切片父子关系断裂
现象:一个## 3.2后面直接跟#### 3.2.1.1,四层跳两层。
排查:原始 txt 里其实是有### 3.2.1的,但是那行被误判成正文,原因是那行以数字开头但末尾跟着一段很长的说明,行长度超过了 30 字阈值,被标题识别规则漏掉了。
修复:标题识别时把“是否以数字编号开头”的权重提高,行长度只做软限制;另外在validate_markdown()里加跳级告警,不能只记日志不处理——我在这个坑上返工过两次,全是跳级导致切片后上下文丢失。
5.3 超大文本文件内存爆掉
现象:1GB 的日志文件直接跑死脚本。
排查:解析时用了text = f.read()一次性读入。
修复:改成流式读取,每次读一行,先做行内清洗,攒够一个逻辑段就输出以及释放。千万别在超大文件上追求“全量在内存中转 Markdown”,罗技上省下来的内存不如用来并行跑多个文件。代码模式大概是:
def stream_parse(filepath: str, buffer_limit: int = 4096): current_block = [] with open(filepath, 'r', encoding='utf-8', errors='replace') as f: for line in f: if line.strip() == '': if current_block: yield clean_and_structure(current_block) current_block = [] else: current_block.append(line.rstrip('\n')) if len(current_block) >= buffer_limit: yield clean_and_structure(current_block) current_block = [] if current_block: yield clean_and_structure(current_block)5.4 现成解析库“够用就行”的错觉
很多 RAG 框架自带 loader,比如 LangChain 的TextLoader,传个路径就返回 Document 列表。方便是真方便,但你要记住它的默认逻辑通常是“按行读、按块拼”,对标题层级、列表结构一无所知。用它导入一批格式规整的文档没问题,但一旦遇到混合格式、乱码、半结构化文本,就得自己接管。
我的建议是:框架自带的 loader只用于快速验证 demo,生产环境的数据导入管线一定要自己掌控解析层,至少在中间产出 Markdown 文件。你可以把数据导入做成一个独立服务,输出一份统一的中间格式(Markdown + YAML front matter),再给 RAG 框架的 loader 消费。这样框架升级换代,你的数据资产不会作废。
5.5 表格转 Markdown 后列错位,检索结果张冠李戴
现象:一段表格内容被检索出来,但检索到的单元格和问题对不上。
排查:转 Markdown 表格时,某一行缺了一个|,导致后面所有行的列都右移了一位。
修复:给表格转换加断言:每行的分隔符数量必须一致,不一致就放弃转表、降级为纯文本段落,宁可不转也不要错转。这跟 3.4 说的策略一致。
结语
我自己前后折腾了三个月才把“ txt → Markdown ”这条链路打磨到相对顺手。中间也怀疑过“不就是解析个文本吗,至于搞这么复杂?”,直到某次因为标题识别漏了一个二级标题,导致一个 6000 字的章节被切成了 12 个无上下文的碎片,下游问答系统拿着碎片一本正经地编答案,才彻底明白——RAG 里没有小问题,所有解析层的草率都会被放大成检索层的灾难。
最后分享一个我很受用的小技巧:解析管线的每一步都要把中间产物落盘。跑一批 1000 个文件,你能打开中间 Markdown 随机抽查 20 个,就足以发现绝大部分规则缺陷;把抽查脚本写成一个自动质量检查,比任何测试都管用。这个系列后续还会聊 docx 和 pdf 的结构化解析,以及切片参数调优,那都是后话了,先把 txt 这层打结实再说。