1. 项目概述与核心思路
1.1 为什么从 txt 到 Markdown 是 RAG 数据导入的第一道坎
最近在做 RAG(检索增强生成)知识库的项目,被数据导入这个环节卡了整整一周。相信很多和我一样踩过坑的朋友都有同感:无论是个人笔记、爬虫抓下来的网页正文、还是公司内部的文档资产,最原始的载体往往就是 txt 这种纯文本文件。但是 RAG 系统对输入数据有一个隐性的硬性要求——它得“结构化”。不是说你给模型丢进去一段连续的长文本就行,碎片化、无标记、纯线性的 txt 内容,在召回阶段很容易被切割成语义不完整的碎块,直接拉低整个问答系统的准确率。
这个系列的第一篇,我选择拿“txt 转 Markdown”开刀。原因很直接:Markdown 是 RAG 数据管线里性价比最高的中间格式。它既保留了纯文本的轻量级可读性,又提供了标题、列表、表格、引用块等结构语义,而这些语义恰好能成为文本切片(Chunking)的天然锚点。相比直接怼 PDF 或 HTML,Markdown 的清洗成本和解析成本都要低一个量级。
本文的内容主线围绕三件事展开:一是把非结构化的 txt 文字,通过一套可复用的规则引擎变成带层级结构的 Markdown;二是梳理这个过程中的边界情况,比如表格识别、重复标题、编码错乱怎么处理;三是把转换结果和常见的 RAG 分块策略结合起来,给你一条不用返工的数据接入路径。
1.2 这套方案适合谁,能解决什么问题
我把这套流程的应用场景分成三类,你可以对号入座。
第一类是个人知识库搭建者,你在 Obsidian、Logseq 或者任何支持 Markdown 的笔记工具里积累了大量历史 txt 迁移文件,想把他们变成可以被 RAG 搜索和引用的结构化内容。第二类是做企业文档问答开发的工程师,手里拿到一堆历史遗留的文本资产,没有统一的转换工具,需要在几小时内完成格式清洗和层级划分,输送给向量数据库。第三类是刚接触 RAG 技术栈的新手,想理解“数据导入与解析”这个环节到底在干什么,为什么它比模型选型更决定最终效果。
这套方案不会依赖重型框架,核心就是 Python 标准库加少量正则表达式。无论你后续选择 LangChain、LlamaIndex 还是自研管线,这篇文章给出的 Markdown 中间产物都是通用的。我实测的结论是:txt 到 Markdown 这个环节解决之后,后续的分块(Chunking)和向量化(Embedding)都能省掉大量无效的预处理分支。
2. 为什么说 Markdown 是 RAG 知识库的“通用语”
2.1 结构化标签对切分和召回的直接价值
很多人第一次做 RAG 数据导入时,最容易犯的错是直接把 txt 文件拆成等长的字符片段,然后塞给 Embedding 模型。我一开始也这么干过,结果问答效果惨不忍睹——因为等长切割会切断句子,甚至把两个完全不相关的话题硬拼进同一个片段,检索引擎召回的内容一会是上半句没头没尾的,一会是包含两个主题的噪声片段。
Markdown 之所以关键,在于它把“文本块”变成了“语义块”。当你把标题语法#作为切分锚点时,分块器可以精确知道一个主题从哪里开始、到哪里结束。比如一篇包含“产品概述”“安装步骤”“常见问题”的文档,按##分块后得到的就是三个各自完整的语义单元。这不是技巧问题,而是数据结构的正确性问题。
更实际的效果体现在召回精度的提升上。向量检索的核心是计算语义相似度,而语义相似度依赖文本内容的完整性和聚焦度。一个只讲安装步骤的片段,和一个同时包含安装步骤又掺杂售后政策的片段,前者被正确召回的概率明显更高。Markdown 提供的结构信息,可以打包成元数据一起存入向量库,这样在检索时还能做基于标题的过滤,把搜索范围限定到具体章节,效果提升是立竿见影的。
2.2 从纯文本中“抢救”结构的三个层面
txt 文件虽然没有显式的格式标记,但大多数真实文本的书写习惯里,其实暗含着可以被规则识别出来的结构。我在做解析器时把它分成三个层面。
第一层是视觉层:空行分段、缩进、项目符号(-、•、*)这些排版特征,反映了作者心里的段落划分和列表关系。第二层是语义层:以“第X章”“一、二、三、”“引言”“结论”等模式出现的词汇,暗示了标题和小标题的身份。第三层是内容层:规整排列的多行文本,并且各行通过制表符或空格对齐,往往是表格;每行以数字加句点开头的,通常是编号列表。
我的转换思路就是把这三种信号全部考虑进去,用优先级从高到低的规则逐一识别。先找标题,因为标题决定了大块语义的边界;再合并段落,保留空行作为段落分隔;最后处理行内特征,比如加粗、行内代码、链接等。这样产出的 Markdown 文档不是“看起来像 Markdown”,而是逻辑层级可以和原文写作意图对应的 Markdown。
2.3 为什么不用现成库一把梭
GitHub 上确实有一堆 txt 转 Markdown 的现成脚本,比如pandoc可以直接把 txt 当 Markdown 处理,markdownify可以处理 HTML 转 Markdown。但实际用下来,这些工具解决不了真实场景里 30% 以上的脏数据问题。
pandoc的输入侧面向的是“已经写得很规范的 Markdown 或富文本”,对带有全角字符、无规律缩进、混乱的编码来源的国内存量 txt 文件处理能力有限。markdownify只做 HTML 的转换,而很多 txt 根本没有任何 HTML 痕迹。更关键的是,现成库都是黑盒,转换规则不可控。你在 RAG 管线里需要对切分粒度有精确掌控,如果某个标题没被识别出来,你在后面调试分块效果时会非常被动。
所以我选择用一小段自己可控的规则引擎做转换,不需要很复杂,只需要把所有关键逻辑暴露成可修改的正则规则和优先级表。这套方案的另一个好处是随时可以增加规则——比如我发现有些文本会用全角字符的“.”作为标题序号,那我就把这个 pattern 追加进去。这种迭代能力,是静态工具做不到的。
3. 通用文本解析器的完整设计与实现
3.1 整体架构:解析器、清洗器、组装器三段式
我把整个 txt 转 Markdown 的过程设计成三个独立模块,彼此之间通过标准数据结构传递。
解析器(Parser)负责按行扫描原始 txt,输出一个中间态的行对象列表,每个行对象包含该行的文本内容、级别(标题/正文/列表/表格/空行)、缩进深度。清洗器(Cleaner)接收行对象列表,做字符级别的净化处理,比如全角转半角、去除诡异空白字符、修复损坏的列表符号。组装器(Assembler)把处理好的行对象组合成 Markdown 字符串,并对标题层级冲突、表格未闭合等异常做最后修正。
这样拆分最大的好处是调试方便。你只需要在任意一步输出中间结果,就能快速定位问题出在“识别不准”还是“清洗过度”。我强烈建议你按这个三段式来组织代码,而不是写一个几百行的 main 函数把所有逻辑揉在一起,那样后期维护成本很高。
3.2 标题识别:如何从文本中找出真正的层级
标题识别是整个转换器最核心也最容易出错的环节。我做的工作是定义一组“标题模式”,按优先级逐条匹配。基础模式包括:
- 以
#开头且#后紧跟空格的行(已经被标记为 Markdown 的标题)。 - 以“第X章”“第X节”“附录”开头的行。
- 以中文数字序号开始的行,比如“第一章 ”“一、”“二、”。
- 以阿拉伯数字加顿号或点号开始的行,比如“1." “1、” “2.3 ”。
- 单独成行且长度小于 25 个中文字符,且下一行是空行或紧随正文段的行(短句启发式)。
这里有一个实用经验:标题识别的优先级很重要。如果一个行既匹配“第X章”,又匹配“短句启发式”,那必须确保前者先生效。我在实际代码里用的是一个级联的if-elif链,而不是一次性合并正则,就是为了保证优先顺序完全可控。
标题的层级级别也要配置化。我规定第X章或#映射为一级标题,一、或##映射为二级标题,数字序号映射为三级标题,短行映射为四级标题。这个映射关系不是绝对的,如果你的文档体系中“一、”代表一级标题,那就改配置即可。我之前踩过一个坑:某份文档的统一模式是“此条级别:三”,结果系统把所有标题都识别成了一句正文,后来加了一个关键词匹配规则才修复。
3.3 段落合并与列表识别:保住文本的呼吸感
纯 txt 的段落经常因为换行被硬切断,尤其在 Windows 系统生成的 txt 里,很多段落只是在视觉上换行,并不是真正的分段。我把这个识别逻辑归纳为:如果一行的结尾没有句号、冒号、问号、感叹号等终止符,且下一行不是列表符号、不是标题、且缩进相同,那大概率是同一段落被换行符硬拆开,需要自动合并。
列表识别要特别注意混合型列表。真实文档中经常出现“1. 介绍背景 - 包含蓝本 - 细节描述”这种混合标记,如果把1.和-识别为两种列表,组装出来会断裂为两层结构。我的方案是记录每个列表项的缩进和符号类型,如果同一逻辑块内符号混用,就把后续的符号统一为第一个符号,保证 Markdown 列表的连续性。这样做的好处是列表块在分块时可以作为一个整体被切分,避免列表项被切得七零八落。
3.4 表格识别与清洗:最难啃的硬骨头
表格是纯文本转换里最难自动化的部分,但只要你识别出来,Markdown 表格的分块价值非常大——因为 RAG 问答中表格问答是最常见的场景之一。
我采取“先识别候选区域,再对齐列数”的策略。如果连续多行的文本中都包含|\t或至少两个以上的连续空格对齐标记,并且各行之间的“列分隔位置”基本一致,我就认为这是一个表格候选区。然后解析每行的列片段,统一用|作为列分隔符补全到 Markdown 表格格式。
这里要提醒一个常见误区:把 Markdown 表格要求的分隔行(|---|---|)想得太复杂。分隔行的作用只是让渲染器识别表头,数量必须和列数一致。如果原始数据列数不齐,会自动填充空单元格。表头的保留取决于原始是否有第一行特别明显的列名行,如果没有,就自动加一个列1、列2的占位表头。
表格清洗时另一个要注意的问题是单元格内换行。有些文本的表格单元格内容特别长,会跨多行,如果不处理,组装出来的表格会乱掉。我的做法是先把跨行单元格的解析行合并回逻辑表格行,再输出为单行 Markdown 表格,这样虽然视觉上会显示超长,但至少 Markdown 结构是对的,后续分块时还可以针对超长单元格做二次拆分。
3.5 代码实现:一个 150 行以内的最小可用版
下面给出一份我自己在用的核心代码,你可以直接复制运行调试,也可以按自己的场景调整正则规则。这个版本省略了一些极端复杂情况,但足以处理 80% 的日常 txt 转换任务。
import re from typing import List, Dict, Any # 标题正则:从高优先级到低优先级 TITLE_PATTERNS = [ ("chapter", re.compile(r"^\s*第[一二三四五六七八九十百\d]+[章节篇部].*$")), ("cn_num", re.compile(r"^\s*[一二三四五六七八九十]+、.*$")), ("num_dot", re.compile(r"^\s*\d{1,2}[.、].*$")), ("hash", re.compile(r"^\s{0,3}#{1,6}\s+.+$")), ("shortline", re.compile(r"^\s*.{1,25}\s*$")), # 短行启发式,放在最后 ] LEVEL_MAP = { "chapter": 1, "cn_num": 2, "num_dot": 3, "shortline": 4, "hash": None, # 根据#数量动态确定,由assemble阶段处理 } def parse_txt_to_lines(raw_text: str) -> List[Dict[str, Any]]: lines = raw_text.split("\n") parsed = [] for line in lines: stripped = line.strip() if not stripped: parsed.append({"type": "blank", "level": 0, "text": "", "raw": line}) continue matched = None for key, pat in TITLE_PATTERNS: m = pat.match(line) if m: matched = key break if matched and matched == "hash": hash_count = len(line) - len(line.lstrip("#")) level = hash_count if hash_count <= 6 else 6 parsed.append({"type": "heading", "level": level, "text": stripped.lstrip("#").strip(), "raw": line}) elif matched: level = LEVEL_MAP[matched] parsed.append({"type": "heading", "level": level, "text": stripped, "raw": line}) elif re.match(r"^\s*[-*•]\s+", line): parsed.append({"type": "list", "level": 0, "text": re.sub(r"^\s*[-*•]\s+", "", stripped), "raw": line}) elif re.match(r"^\s*\d+[.、]\s+", line): parsed.append({"type": "ordered_list", "level": 0, "text": re.sub(r"^\s*\d+[.、]\s+", "", stripped), "raw": line}) else: parsed.append({"type": "para", "level": 0, "text": stripped, "raw": line}) return parsed def clean_line_objects(parsed: List[Dict[str, Any]]) -> List[Dict[str, Any]]: # 全角转半角,去除多余控制字符 for obj in parsed: text = obj["text"] text = text.replace("\u3000", " ") # 全角空格 # 如需其他清洗规则在此扩展 obj["text"] = text return parsed def assemble_markdown(parsed: List[Dict[str, Any]]) -> str: md_lines = [] last_heading_level = 0 list_stack = [] for obj in parsed: t = obj["type"] if t == "blank": if md_lines and md_lines[-1] != "": md_lines.append("") elif t == "heading": level = obj["level"] # 动态调整级别:如果级别跳跃过大则降级,保证合理层级 if last_heading_level and level - last_heading_level > 1: level = last_heading_level + 1 md_lines.append(f"{'#' * level} {obj['text']}") last_heading_level = level list_stack = [] elif t in ("list", "ordered_list"): symbol = "-" if t == "list" else "1." md_lines.append(f"{symbol} {obj['text']}") list_stack.append(obj) elif t == "para": # 简单段落合并逻辑 if md_lines and md_lines[-1] != "": md_lines.append("\n") md_lines.append(obj["text"]) list_stack = [] # 去除多余的空行 result = [] prev_blank = False for line in md_lines: if line == "": if prev_blank: continue prev_blank = True else: prev_blank = False result.append(line) return "\n".join(result) def txt_to_markdown(raw_text: str) -> str: parsed = parse_txt_to_lines(raw_text) cleaned = clean_line_objects(parsed) return assemble_markdown(cleaned) if __name__ == "__main__": sample = "" print(txt_to_markdown(sample))这段代码保留了完整的骨架逻辑。我建议你在自己的项目里给parse_txt_to_lines增加表格识别的分支(我在上面的版本中为了控制篇幅没有列出表格逻辑),并针对你的语料库跑一轮回归测试,观察哪些 pattern 产生误报。
3.6 把 Markdown 结构作为 RAG 分块的输入
txt 转 Markdown 完成的瞬间,RAG 管线的后半段就可以开始工作了。我最常用的分块策略是按标题层级切分:设定一个max_chunk_size字符阈值,按一级标题、二级标题依次递归填充。遇到超过阈值的段落,再按句子边界切分,并把段落所属的标题路径作为元数据存储。
举个例子,一份关于“产品手册.md”的文档,结构是“## 安装 ## 配置 ## 故障排查”,按二级标题切分后输出三个 chunk,元数据分别记录为["产品手册", "安装"]、["产品手册", "配置"]、["产品手册", "故障排查"]。当用户问“如何解决连接超时”时,向量检索可以在全库中优先匹配故障排查目录下的内容,再结合向量相似度做排序,效果会比裸切 txt 好非常多。
这里还有一个实操细节:如果某个表格被识别出来,我建议把它单独切成一个 chunk,并配上表格内容的摘要元数据。因为表格本身是自包含的高密度信息块,如果强制拆行会被坏表意。如果你的向量数据库支持 filter 字段,那就给表格 chunk 加上type=table的标签,查询时单独检索。
4. 常见问题与排查技巧实录
4.1 编码错乱的 txt 怎么处理
说实话,真实世界的 txt 文件编码问题比你想的还要混乱。最常见的是 GBK 编码的文档被误当成 UTF-8 读取,导致一屏幕乱码。我排查此类问题的通用流程是:先尝试用 UTF-8 严格模式读取,失败后用 GB18030 编码读取,再失败则尝试utf-8-sig(处理带 BOM 的文件)。大部分工具都支持errors=replace参数,但你在做 RAG 数据导入时不要用 replace,因为替换掉乱码字符后,语义信息已经损坏,后续识别标题、段落都会出错。宁可跳过无法解码的行,也不要把垃圾数据灌进知识库。
另外一个容易被忽略的点是:txt 文件里的全角符号。比如逗号、句号、空格用了全角版本,这对自然语言模型的语义理解影响不大,但对正则规则匹配的影响是致命的。我的清洗器里有一个大小写不敏感的规则,把所有全角 ASCII 等价字符映射为半角,但在转换标题序号时要格外小心,因为“一、”的全角顿号是合法的,不能把它替换掉。我的经验是只针对标点半角化,不对中文标点动刀。
4.2 标题层级跳跃过大怎么办
真实文档经常出现这种情况:一个大章节下直接跟一个三级标题,中间没有二级标题。如果分块器严格按层级嵌套去构造块,会有很多空的中间层,增加不必要的复杂度。
我的解决思路是在组装 Markdown 时就把跳跃展平。更具体地说,在assemble_markdown里我有一个逻辑片段,如果当前标题的 level 比上一个标题的 level 大超过 1,就将它“吸附”到上一个标题的下级,即 level 被强制设置为last_heading_level + 1。这对于 Markdown 渲染器是合法的,层级从 1 跳到 3 虽然不符合教科书规范,但至少语义分组没有垮掉。
还有些文档会在正文里塞入一个=或-下划线式标题(老式 Markdown 风格)。我在正则里专门加了一个规则:如果某一行顶格是===或---,且上一行是正文,就将上一行提升为二级标题,并把这一行忽略。这种规则看起来简单,但处理老文档时极其实用。
4.3 表格列数不齐和非法字符怎么兜底
表格识别中最常见的问题是:前几行有 5 列,后面某一行只有 4 列,因为原始文本的制表符被合并或删除了。组装成 Markdown 之后渲染器会按第一行的列数截断或补齐。在 RAG 场景下,这通常不会致命,但会丢失部分信息。
我的兜底策略是:检测到表格列数不一致时,自动用空单元格补齐到最大列数,并在输出时把列分隔符统一为|。同时,把表格里的换行符和|字符做转义或替换,因为|在 Markdown 表格里是语法分隔符,如果单元格内容里本来就是竖线,不做转义会导致表格结构完全崩坏。我这里的方案是把单元格内竖线替换为全角竖线|,这样既保证了渲染安全,又不影响阅读。
4.4 实际项目中调试规则的原型方法
当你开始调试自己的解析规则时,强烈建议采用“样例回归测试”的姿势。维护一个test_cases.txt文件,里面放 20 个左右的有代表性的真实段落,每次改完代码,跑一遍脚本,对比输出和期望结果的 diff。我之前的一个批量转换任务里,就是因为只盯着转换成功的文件,没做回归测试,结果改了一个正则把原来正确的章节编号也吞掉了,批量跑完之后目录索引乱了,数据返工了好几个晚上。
调试时还可以顺手把中间结果 dump 出来看结构识别情况。我在parser加了一个 debug 参数,输出每个行对象的type和level,肉眼扫一遍就能看出哪些行被归类错了。这个习惯让我在排查“为什么某个章节没有被识别为标题”时特别高效。
整理一份常见问题速查表,方便你对照排查:
| 问题现象 | 常见原因 | 排查建议 |
|---|---|---|
| 中文乱码 | 编码识别错误 | 按 UTF-8 → GB18030 → utf-8-sig 顺序重试 |
| 标题全部丢失 | 标题正则优先级不对 | 检查 TITLE_PATTERNS 的顺序是否合理 |
| 段落被错误合并 | 换行终止符规则太宽松 | 增强句末标点判断,排除时间、数字等边界情况 |
| 列表符号混杂 | 混合型列表 | 记录列表栈,按第一个符号统一 |
| 表格结构错乱 | 列数不齐或含竖线 | 补齐列数并对竖线做全角替换 |
| 层级跳跃过大 | 原始文档不规范 | 在组装阶段强制吸附最近标题层级 |
5. 后续扩展空间与经验总结
5.1 从“过时格式”到“现代知识库”的迁移建议
txt 转 Markdown 只是第一步,但它解决了 RAG 数据导入中“数据从哪来、拍平到哪里”的核心问题。基于这一步的产出,你可以顺畅地接入各类 RAG 框架。如果你现在用的是 LangChain,MarkdownHeaderTextSplitter可以直接完美消费我们的 Markdown 输出;如果用 LlamaIndex,也有MarkdownNodeParser与之对应。唯一的建议是,在接入之前先跑一个批次的数据,亲自验证 Markdown 的渲染结构和分块结果是否符合预期,不要盲目相信框架内置解析器。
5.2 个人体会:文本解析器的“灰度思维”
在做这个项目之前,我总觉得解析算法要做得很完美,每个边界情况都要覆盖。现在我的看法变了:一个能正确处理 80% 常规数据、并明确拒绝或跳过 20% 复杂数据的解析器,比一个声称支持所有格式但每个格式都马马虎虎的解析器更有价值。原因在于 RAG 系统的鲁棒性其实不完全依赖单一样本的转换成功率,而依赖知识库整体内容的干净度和结构一致性。宁可少导入一些脏数据,也不能让错误解析的内容混进去污染向量索引。
5.3 关于后续章节的一点预告
这个系列的第二篇,我计划专门聊“HTML 和 PDF 的多格式解析对比”,以及如何在这些格式之间用统一的中间表示(Markdown AST)做数据融合。如果你已经按照本文的方法把 txt 类数据流转起来,那后面要做的其实就是把相同的解析、清洗、组装逻辑迁移到新的输入格式上。工具和代码会更复杂,但思维模型是完全一致的:先恢复结构,再考虑切分,最后才是向量化。
在动手之前,我建议你把今天这套脚本先集成到你的数据流水线里,跑一遍真实的数据,记录下各类错误日志。遇到任何奇怪字符、异常结构,都回来对照着这个问题速查表做一次规则增强——这才是最务实的迭代路径。如果你在实际转换过程中有其他奇葩案例,欢迎评论区补充,我尽量在后续篇目的结尾帮你整理成新的规则模式。