RAG数据导入全攻略:从txt到Markdown的结构化解析 1. 为什么数据导入与解析在我这永远是 RAG 项目的第一优先级1.1 一句大白话RAG 是给模型配了一位图书馆管理员这几年我落地过的 RAG 项目不算少有一个规律特别明显每次迭代一开始大家喜欢调模型、改 prompt、换 vector store折腾到最后才发现真正决定问答质量的往往是“喂进去的文档到底被解析成什么样了”。RAG 的原理听起来很简单——把资料切碎、向量化、塞进向量库检索时把相关片段捞出来拼给模型。但“把资料切碎”这四个字落地到真实数据上就是一场没完没了的格式战争。我喜欢把 RAG 比作图书馆的借阅流程模型是读者向量库是书架解析器是图书管理员。管理员如果把书页撕得稀烂、目录标错读者翻遍几层书架也找不到想要的信息模型就只能一本正经地胡说八道。所以我现在越来越笃定一个观点RAG 项目里的坑八成不在推理侧而在数据导入与解析侧。这个系列的标题叫“RAG 数据导入与解析全攻略”今天先把第一块硬骨头解决掉从 txt 到 Markdown 的通用文本与结构化解。1.2 为什么第一篇文章就盯上 txt 和 Markdown很多人一听“RAG 数据导入”第一反应是解析 PDF、Word、扫描件甚至直接上多模态模型做 OCR。我通常劝他们先把 txt 和 Markdown 这种最朴素格式处理明白。原因很实在txt 和 Markdown 足够轻没有 PDF 那一堆坐标、字体、图层、扫描页干扰几乎所有 RAG 框架自带的分块器核心都是围绕纯文本和 Markdown 结构设计的那些看起来高级的格式最后基本也要先转成纯文本或类 Markdown 结构再进分块环节编码清洗、换行归一化、标题识别、表格边界这些底层问题在 txt 上最容易暴露也最容易一次学透。把这条通用文本链路打通你已经能覆盖企业知识库里相当大一部分场景了。非要一上来啃 PDF 双栏排版大概率是被“复杂”两个字带跑偏先练基本功反而走得快。1.3 这一篇解决什么、解决不了什么这篇要解决三件事。第一把一份编码不明、换行混乱、夹杂乱码的 txt变成一份结构清晰的 Markdown。第二从纯文本里识别出标题、列表、表格、代码块这些语义边界而不是让它们糊成一片普通文本。第三转换完之后天然衔接后续向量化分块让切出来的 chunk 不至于语义稀碎。至于 PDF 版式还原、图片理解、OCR、多模态入库这些属于系列后面几期的内容这篇先不展开。先把通用文本这条线吃透你会发现后续遇到“看起来复杂”的格式时很多思路是共通的。2. 通用文本与结构化到底在解什么2.1 “解析”不是把文件读成字符串就完事“解析”这个词在 RAG 圈子里被用得太泛了。很多同学觉得用open()把文件读成一个字符串就是解析完成。实际上一次合格的解析至少得经过四层处理编码层搞清楚文件是 UTF-8、GBK 还是 UTF-16把字节流变成正确的字符版式层处理换行、空行、缩进把“视觉上被拆成多行”的连续文字恢复成真实段落结构层识别标题、列表、表格、代码块、引用块的边界语义层为标题编号、章节层级、文档来源、术语注释等附加元信息。如果只做前两层你拿到的是“能读但不懂”的文本只有做到第三层和第四层文本才真正变成可以被分块、被检索、被模型利用的结构化数据。这一篇重点展开第三层顺带把第四层的一些接口留出来因为后续向量化分块正好需要用。2.2 为什么马克标记成了“通用”中间格式我见过不少团队折腾自研中间表示什么 XML 树、JSON 嵌套结构最后维护成本都高得离谱。Markdown 之所以是理想的通用中间格式是因为它刚刚好卡在“人可读”和“机器可解析”之间语法足够少只有十几种模式看到#知道是标题看到连续用|连起来的行大概率是表格表达力足够覆盖知识库的多数需求标题层级、列表、表格、代码块、引用、链接、图片全有现成语法主流工具对 Markdown 有原生偏好LangChain 的MarkdownHeaderTextSplitter、LlamaIndex 的MarkdownNodeParser默认你喂给它的就是合法 Markdown它可以随时降级回纯文本也可以升级到 HTML、LaTeX 或 PDF转换成本极低。换句话说Markdown 就是 RAG 数据管道里的普通话。不管原始文件说的什么方言先翻译成普通话后面所有环节都好对接。2.3 结构化不是形式主义是给模型划重点很多人看到“结构化”就联想到 JSON、CSV、数据库表觉得必须把文档拆成一堆字段才算完。我个人的理解是RAG 场景下的结构化核心目的是让检索器和模型知道“信息的边界在哪里”。对文本来说边界就是这些问题这一段是普通正文还是一段代码这个表格是三行五列还是被错误切成了零散文本这个“1.1”是二级标题还是正文里随手写的序号如果这些边界识别对了检索器召回时就能精准命中“包含完整上下文”的片段而不是把代码片段、表格碎片、标题行混在一起扔给模型。打个比方文本里的结构就是地图上的地标和分界线没地标的连续文本模型检索的时候只能靠猜猜输的概率非常高。3. 实操第一步先解决编码、换行和隐藏字符3.1 编码检测与统一别信“看起来正常”处理企业 txt 的真实情况第一个坎就是字符编码。同一个“产品FAQ.txt”可能是 ANSIGBK、UTF-8、UTF-8 with BOM偶尔还有 UTF-16。如果你直接用utf-8硬读报错其实算运气好的更常见的是读出满屏乱码然后你把乱码又做了清洗和分块数据早就被污染了。我推荐的做法是在进入任何解析逻辑之前先做一次多层尝试。核心思路是准备一条 fallback 链先试 UTF-8失败后试 GB18030再试 UTF-16最后用 Latin-1 兜底保证不会中断流水线。from pathlib import Path def read_text_file(path: Path) - str: raw path.read_bytes() candidates [] if raw.startswith(b\xef\xbb\xbf): candidates [utf-8-sig] elif raw.startswith(b\xff\xfe) or raw.startswith(b\xfe\xff): candidates [utf-16] else: candidates [utf-8, gb18030, utf-16, latin-1] for enc in candidates: try: return raw.decode(enc) except UnicodeDecodeError: continue return raw.decode(utf-8, errorsreplace)这里有个细节utf-8-sig和utf-8是有区别的。前者会吃掉文件开头的 BOM后者会把不可见的\ufeff一并读进来。BOM 留在文本开头进了向量库以后就是个奇怪的干扰项某些模型还可能把它当特殊 token。所以我倾向于用utf-8-sig专门处理带 BOM 的文件。补充一句chardet这类工具我偶尔也用但它对短文本和中文编码的误判率不低我更多把它当作“候选来源”而不是最终结论。完全依赖它等于是把命运交给出题人。3.2 换行符归一化以及最头疼的“假换行”Windows 导出的文本默认\r\nLinux 和 macOS 是\n老式 Mac 还有单独\r。不统一换行符后面按行解析时你会看到一堆\r残留在字符串里正则匹配行首行尾全部要踩坑。先做一次机械归一化def normalize_newlines(text: str) - str: return text.replace(\r\n, \n).replace(\r, \n)机械归一化简单真正麻烦的是“假换行”。很多 txt 是从 PDF、网页或 OCR 工具里复制出来的为了排版每行末尾都加了换行但语义上它是一段连续的话。这种文件如果不处理直接按行分块每一行只有十几个字检索时不仅召回割裂embedding 质量也差。我的经验是先用空行划分大段落没有空行时启用启发式规则如果当前行以中文句号、问号、感叹号结尾允许断行如果当前行以逗号、分号、冒号结尾大概率合并到下一行如果下一行以大写字母或中文序号开头保留断行。实际操作里我特别强调一个原则原始文档如果已经有空行就先尊重它的段落边界只有在通篇没有空行的地方才动用行尾标点规则合并。这样能最大限度避免误伤原本正常的文档结构。3.3 清掉看不见的隐藏字符还有一类问题容易被忽略全角空格、不间断空格\xa0、零宽空格\u200b、软连字符\u00ad以及各种控制字符。这些字符屏幕上几乎看不见但进入字符串后会干扰正则、影响 token 统计甚至让向量化产生无用噪声。我习惯在规范化阶段统一处理def clean_invisible_chars(text: str) - str: text text.replace(\xa0, ).replace(\u200b, ).replace(\ufeff, ) return .join(ch for ch in text if ch.isprintable() or ch in \n\t)注意isprintable()会把换行和制表符也过滤掉所以必须显式保留\n和\t。隐藏字符清掉之后后面写正则匹配段落、表格和代码块的时候会省心很多。4. 从 txt 到 Markdown 的关键转换环节4.1 先定清楚要识别哪些结构不管你是用正则、手写状态机还是用语言模型做解析第一步都得明确目标结构清单。我给通用文本定的清单是这六类标题以#、数字编号1.1、第一章、一、开头的行按层级转成#到######列表以-、*、、1.开头的行保留嵌套缩进表格连续多行用|或 Tab 分隔、字段数量固定的内容转成 Markdown 表格语法代码块围栏包裹或连续缩进的内容转成围栏代码块引用以开头的连续行转成引用块链接和图片裸 URL、[文字](地址)、图片说明尽量保留下标信息。很多 txt 是纯手写笔记一点 Markdown 痕迹都没有那么前四类全靠启发式猜测。我的经验是宁可少转不要错转。一个错误识别的表格或代码块对后续分块和检索的破坏性远大于“继续当普通段落”处理。4.2 标题识别与层级还原的几种套路标题识别是纯文本转 Markdown 里最重要的环节因为标题层级直接决定了后续分块边界。常见情况分三种原文本身就是 Markdown只被存成了 .txt 后缀这种用^#{1,6}\s直接匹配就能拿层级原文是中文排版常见的编号标题比如“一、二、三”“1.1”“第一章”需要启发式规则判断层级原文完全没有标题标记只能靠字体大小、加粗、编号推断最费劲我一般建议配合人工抽查。具体实现上我通常先跑 Markdown 标题正则再跑编号标题启发式import re MD_HEADING re.compile(r^(#{1,6})\s(.*)$) NUM_HEADING re.compile(r^(\d{1,2}(?:\.\d{1,2}){0,2})\s(.{4,})$) CN_HEADING re.compile(r^(第[一二三四五六七八九十百千][章篇节]|[一二三四五六七八九十][、.)])\s*(.{2,})$) def detect_heading(line: str): m MD_HEADING.match(line) if m: return len(m.group(1)), m.group(2).strip() m NUM_HEADING.match(line) if m: num m.group(1) level num.count(.) 1 if num.replace(., ).isdigit() else 1 return level, f{num} {m.group(2).strip()} m CN_HEADING.match(line) if m: return 1, line.strip() return None, None这里有一个取舍我明确说一下编号类标题我会把“3.2 环境配置”中的编号也保留在标题文本里。好处是用户搜索“环境配置”能命中搜索“3.2”也能命中坏处是某些模型喜欢反复引用编号让答案看起来啰嗦。对大多数知识库场景保留编号利大于弊尤其适合需要引用出处的系统。4.3 列表与表格看着简单坑不少列表识别的坑主要在嵌套层级和误伤编号段落。比如这种1. 打开设置 2. 选择网络 请确保网络畅通后再继续“请确保网络畅通后再继续”和上面两行之间没有空行按列表规则它会被当成正文倒是没问题但如果规则写得太宽把“1.”后面跟的普通段落也误判成列表分块时就麻烦了。我的建议是列表识别要求连续两行以上才认定为列表块单行孤立的-或1.不要急着转先看上下文。表格识别比列表更麻烦。很多“txt 表格”其实是从 Excel、HTML 或 PDF 里复制出来的分隔符五花八门有竖线|、有空格对齐、有 Tab、有制表符网格。我给出一个实用的优先级策略先处理“竖线分隔 第二行是---分隔线”的标准 Markdown 表格然后处理 Excel 复制出来的 Tab 分隔文本最后才用空格对齐去猜。Tab 分隔文本转表格相对规则化def tab_to_markdown_table(block: str): rows [line.split(\t) for line in block.strip().splitlines()] if not rows or len(rows) 2: return None col_count len(rows[0]) if any(len(row) ! col_count for row in rows): return None header rows[0] sep [---] * col_count body rows[1:] return \n.join([ | | .join(header) |, | | .join(sep) |, *[| | .join(row) | for row in body], ])空格对齐的表格我反而建议人工处理掉。原因很简单对齐信息一旦丢失无法判断哪些连续空格是“列分隔”、哪些只是“排版留白”。靠正则强行猜十次里能有四次把相邻列内容拼到一起得不偿失。4.4 代码块与转义两个最容易翻车的地方先说代码块。很多 txt 里粘贴的代码是整体缩进了 4 个空格符合老式 Markdown 的缩进代码块规范但缩进内容一旦混入普通段落容易被列表识别误伤。我的做法分三层递进优先识别围栏代码块也就是以开头和结尾的内容原样保留其次识别“连续缩进且包含明显编程符号{}、;、def、import”的行组转成围栏代码块最后才考虑纯缩进转代码块。这样做能显著减少误判正常中文正文里极少出现def、import这种前缀所以误伤概率很低。再说转义。这是新手最容易“过度处理”的地方。我见过一个同学把整篇文档里所有*、_、[都加上反斜杠结果标题和链接全被破坏了整个 Markdown 看起来像被反斜杠弹幕刷屏。我的转义原则很简单只在“普通段落”里对必要的字符做转义标题、链接地址、表格单元格、代码块内部一律不动。具体判断依据有三个如果字符前后都是空格或标点大概率是普通符号转义如果它和前后文字组成词比如c、C#不要转转了反而影响阅读如果它在 URL、邮箱、文件路径里跳过。这说明一个现实纯正则是处理不了所有歧义的。所以我的最终建议是第一版转换脚本先做保守处理不转义只做结构识别跑完一轮人工抽查再针对出现的问题追加转义规则。不要试图一步到位。4.5 保命设计识别失败就当普通段落无论你的启发式规则写得多么周密总有文本会出人意料。所以转换器必须留一个兜底逻辑识别不出结构的时候老老实实把它当成普通段落输出而不是硬塞进某个结构里。我会在每一行判定完结构之后记录一个分类结果方便后续统计和定位问题def classify_line(line: str) - str: if detect_heading(line): return heading if is_fenced_code_start(line): return code_start if is_table_row(line): return table if is_list_item(line): return list return paragraph不要小看这个分类输出。真到线上跑脏数据时你能快速看到每类结构的数量比如“表格识别了 300 次”然后抽查这些位置是不是真的表格。这比写完脚本一跑了之强得多。5. 实操过程一个轻量级 txt 到 Markdown 转换脚本5.1 为什么没直接甩给你一段 LangChain 代码看到这里你可能会问LangChain、LlamaIndex 都有现成的 loader为什么还要自己写解析器我的回答是框架的 loader 解决的是标准场景恰恰解决不了企业脏数据场景。你让TextLoader去读一个 GBK 编码的 txt默认就是报错你让MarkdownHeaderTextSplitter去处理一个根本没有 Markdown 标题的 txt它也无从切起。所以我倾向于把“格式清洗 结构识别”这个环节握在自己手里后面再对接框架的分块器和向量库。这样两边职责清晰脏活累活自己干标准化的分块和检索交给框架问题定位起来最顺。听起来工程量不小其实核心代码很少。我把一个能跑通的生产级脚本拆成两步文本规范化、结构分类与输出。下面的代码已经能应对绝大多数纯文本知识库场景。5.2 脚本主流程与结构识别的完整骨架from pathlib import Path import re # ---------- 第一步读取与规范化 ---------- def read_text_file(path: Path) - str: raw path.read_bytes() candidates [] if raw.startswith(b\xef\xbb\xbf): candidates [utf-8-sig] elif raw.startswith(b\xff\xfe) or raw.startswith(b\xfe\xff): candidates [utf-16] else: candidates [utf-8, gb18030, utf-16, latin-1] for enc in candidates: try: return raw.decode(enc) except UnicodeDecodeError: continue return raw.decode(utf-8, errorsreplace) def normalize_text(text: str) - str: text text.replace(\r\n, \n).replace(\r, \n) text text.replace(\ufeff, ).replace(\xa0, ) text re.sub(r\n{3,}, \n\n, text) return text # ---------- 第二步结构识别 ---------- MD_HEADING re.compile(r^(#{1,6})\s(.*)$) NUM_HEADING re.compile(r^(\d{1,2}(?:\.\d{1,2}){0,2})\s(.{4,})$) TABLE_ROW re.compile(r^\s*\|.*\|\s*$) LIST_ITEM re.compile(r^\s*([-*]|\d{1,3}[.)])\s) FENCE_START re.compile(r^\s*(|~~~)) FENCE_END re.compile(r^\s*(|~~~)\s*$) def detect_line(line: str): m MD_HEADING.match(line) if m: return heading, len(m.group(1)), m.group(2).strip() m NUM_HEADING.match(line) if m: num m.group(1) level num.count(.) 1 if num.replace(., ).isdigit() else 1 return heading, level, f{num} {m.group(2).strip()} if FENCE_START.match(line): return code_start, 0, line if TABLE_ROW.match(line): return table_row, 0, line.strip() if LIST_ITEM.match(line): return list_item, 0, line.rstrip() return paragraph, 0, line.rstrip() # ---------- 第三步把行序列组织成 Markdown ---------- def build_markdown(text: str) - str: lines text.split(\n) out [] in_code False for line in lines: if FENCE_START.match(line) and not in_code: in_code True out.append(line) continue if in_code: out.append(line) if FENCE_END.match(line): in_code False continue kind, level, content detect_line(line) if kind heading: out.append(f{# * level} {content}) elif kind code_start: out.append(content) else: out.append(content) return \n.join(out) def convert_txt_to_markdown(src: Path, dst: Path): text read_text_file(src) text normalize_text(text) markdown build_markdown(text) dst.write_text(markdown, encodingutf-8) print(f转换完成: {src.name} - {dst.name}, 源字符数 {len(text)})这个版本是“可读大于完整”的。真正上生产我还会加表格 block 聚合、列表嵌套缩进、目录导出这些功能。但核心思路已经能说明白先用行级分类器把 Markdown 骨架搭好再针对特殊情况做修正最后出来的文本就能进分块环节了。5.3 转换后必须做一次“三轮检查”我吃过不少“以为转换完就能向量化”的亏后来总结成三轮检查法第一轮结构统计。数一数最终 Markdown 里有多少标题、表格、代码块、列表项和源文件里目测的数量对比。如果文档里明显有 30 个表格只识别出 5 个说明表格识别规则漏了。第二轮抽样目检。不要全文逐行读太累了从开头、中间、结尾各抽 50 行快速扫一眼标题层级是否连续、表格是否对齐、有没有满屏反斜杠。这一步十分钟内搞定能拦下 80% 的明显问题。第三轮端到端检索验证。把一个最小 RAG 管道跑起来用几个你心里有标准答案的问题去检索看命中的 chunk 有没有包含正确上下文。这一步最真实因为很多解析问题只有检索时才暴露。比如你问“库存周转率怎么算”召回结果里连表头都不完整说明表格切块策略有问题。5.4 转换完接入 RAG 分块时的三个关键经验转换完的 Markdown 可以直接交给分块器。我目前的常用组合是MarkdownHeaderTextSplitter先按标题分区间再用RecursiveCharacterTextSplitter把大标题下的长段落切成固定长度块。这里有三个经验分块器要能感知表格不要把一张三行五列表格拦腰截断。实在要切也要给表格单独一个“整体保留”标志chunk 的 overlap 不要机械地复制上一段末尾最好在标题或段落边界处补上下文。我在代码里会给每个 chunk 附加section元数据比如sectionh2:实施细节标题层级本身是极好的检索上下文。把h1 h2 h3拼到 chunk 头部召回准确率会有肉眼可见的提升。这套流程下来不管是几十页的手写笔记还是从网页扒下来的长文都能稳定转成结构清晰的 Markdown为后续向量化省掉大量返工。6. 高频问题与避坑实录6.1 通篇硬换行转完段落碎成一地现象转换后的 Markdown 每行都是孤零零的半句话检索时一个完整知识点被拆成七八个碎片。原因源头是 PDF 或网页复制产生的“视觉换行”每个物理行末尾都有\n但语义上是一段连续文字。解法进 Markdown 转换前先做段落恢复。我是先按空行分大段然后把段内所有物理行用空格拼起来再根据句末标点和下一行类型重新断行。特别提醒中文合并时不要加空格否则 embedding 计算时会多出很多无意义字符。6.2 表格识别失败行数据变成普通文本现象从 Excel 复制的数据在 Markdown 转换后变成一堆 Tab 连接的文本分块时切得乱七八糟。原因Tab 分隔文本和普通正文之间没什么明显标记正则分不清。解法把“表格识别”放到段落兜底之前并加一个强条件连续三行以上字段数量一致才认定是表格。宁可漏一点也不要误判。6.3 过度转义全文出现一堆反斜杠现象转换后的文本里\*、\[、\(到处都是连标题和链接也被转义了视觉效果像被弹幕刷屏。原因转义逻辑写得太粗暴没有区分“正文普通文本”和“Markdown 结构语法”。解法转义只作用于paragraph类型的行标题、表格、代码块内部全部跳过。遇到c、C#中跟字母黏在一起的符号直接保留原样。6.4 代码块被列表识别误伤现象一段缩进 4 个空格、内部有- item样式的代码被转成了列表。原因列表识别的优先级高于代码块识别缩进规则先拦截了。解法调整识别顺序先处理围栏代码块再处理连续缩进代码最后才做列表识别。这个顺序非常重要一定要写对。6.5 中文编码识别失败变成乱码现象源文件是 GBK但某些工具把它识别成 ISO-8859-1直接乱码。原因中文编码识别对短样本和混合文本经常判断不准。解法不要只信单一检测结果。准备 fallback 链优先尝试 GB18030它是 GB2312、GBK 的超集中文 Windows 平台导出的文本基本都能吃下。再失败才用 Latin-1 兜底。6.6 特殊字符在分块后被截断现象token 被截断后一个代码块或表格只保留了一半另一半落到下一个 chunk。原因固定长度分块时没有感知结构边界。解法在分块前做一次结构保护把代码块和表格整体包装成一个单元分块时优先切段落边界。实在要跨结构切就给两个 chunk 都补上“该内容属于 XX 表格”的元信息。6.7 文件名和路径信息被丢掉现象源文件是“2025年产品发布会QA.txt”但向量库里的 chunk 完全没带文件信息。原因导入时没有把文件路径作为元数据写入。解法转换脚本里把source_path、file_name、converted_at这类字段一并写入元数据。RAG 检索返回时用户至少能知道答案来自哪份文档这对知识库类产品尤其重要。7. 先聊到这儿把接口留给下一期写到这里“通用文本与结构化解”的核心链路算是讲完了。从 txt 的编码清洗、换行归一化、隐藏字符清理到 Markdown 的标题识别、列表表格转换、代码块处理再到分块前的三轮检查每步都有实打实的坑最后沉淀下来的方案相对稳定。我个人做这类数据管道有个习惯每一步都留检查点不管是统计输出、抽样文件还是日志文件。解析脚本跑完不算完人工抽查两三处才算完。这个习惯看着慢但能帮你把脏数据带来的风险降到最低尤其在知识库数据量越来越大的时候很值得。下一篇我会接着讲 PDF 和 Word 的版式还原重点拆解扫描件、双栏排版、页眉页脚这些“看似不复杂实际上很要命”的场景。如果你在 txt 或 Markdown 转换上遇到过别的怪异现象也可以试着往这个四层模型里套一套。大多数问题都能在这里找到位置。现在这个骨架已经足够稳了下一期见。