
1. 项目概述与核心思路1.1 为什么从 txt 到 Markdown 是 RAG 数据导入的第一道坎最近在做 RAG检索增强生成知识库的项目被数据导入这个环节卡了整整一周。相信很多和我一样踩过坑的朋友都有同感无论是个人笔记、爬虫抓下来的网页正文、还是公司内部的文档资产最原始的载体往往就是 txt 这种纯文本文件。但是 RAG 系统对输入数据有一个隐性的硬性要求——它得“结构化”。不是说你给模型丢进去一段连续的长文本就行碎片化、无标记、纯线性的 txt 内容在召回阶段很容易被切割成语义不完整的碎块直接拉低整个问答系统的准确率。这个系列的第一篇我选择拿“txt 转 Markdown”开刀。原因很直接Markdown 是 RAG 数据管线里性价比最高的中间格式。它既保留了纯文本的轻量级可读性又提供了标题、列表、表格、引用块等结构语义而这些语义恰好能成为文本切片Chunking的天然锚点。相比直接怼 PDF 或 HTMLMarkdown 的清洗成本和解析成本都要低一个量级。本文的内容主线围绕三件事展开一是把非结构化的 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 加上typetable的标签查询时单独检索。4. 常见问题与排查技巧实录4.1 编码错乱的 txt 怎么处理说实话真实世界的 txt 文件编码问题比你想的还要混乱。最常见的是 GBK 编码的文档被误当成 UTF-8 读取导致一屏幕乱码。我排查此类问题的通用流程是先尝试用 UTF-8 严格模式读取失败后用 GB18030 编码读取再失败则尝试utf-8-sig处理带 BOM 的文件。大部分工具都支持errorsreplace参数但你在做 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 框架。如果你现在用的是 LangChainMarkdownHeaderTextSplitter可以直接完美消费我们的 Markdown 输出如果用 LlamaIndex也有MarkdownNodeParser与之对应。唯一的建议是在接入之前先跑一个批次的数据亲自验证 Markdown 的渲染结构和分块结果是否符合预期不要盲目相信框架内置解析器。5.2 个人体会文本解析器的“灰度思维”在做这个项目之前我总觉得解析算法要做得很完美每个边界情况都要覆盖。现在我的看法变了一个能正确处理 80% 常规数据、并明确拒绝或跳过 20% 复杂数据的解析器比一个声称支持所有格式但每个格式都马马虎虎的解析器更有价值。原因在于 RAG 系统的鲁棒性其实不完全依赖单一样本的转换成功率而依赖知识库整体内容的干净度和结构一致性。宁可少导入一些脏数据也不能让错误解析的内容混进去污染向量索引。5.3 关于后续章节的一点预告这个系列的第二篇我计划专门聊“HTML 和 PDF 的多格式解析对比”以及如何在这些格式之间用统一的中间表示Markdown AST做数据融合。如果你已经按照本文的方法把 txt 类数据流转起来那后面要做的其实就是把相同的解析、清洗、组装逻辑迁移到新的输入格式上。工具和代码会更复杂但思维模型是完全一致的先恢复结构再考虑切分最后才是向量化。在动手之前我建议你把今天这套脚本先集成到你的数据流水线里跑一遍真实的数据记录下各类错误日志。遇到任何奇怪字符、异常结构都回来对照着这个问题速查表做一次规则增强——这才是最务实的迭代路径。如果你在实际转换过程中有其他奇葩案例欢迎评论区补充我尽量在后续篇目的结尾帮你整理成新的规则模式。