资讯动态

RAG数据导入与解析:从txt到Markdown的结构化处理全指南

发布时间:2026/10/6 18:14:25 来源:尧图企业网站定制
做 RAG 的人应该都有一个共同的感受顶层设计再花哨模型选得再大最后跑起来效果不好十有八九是卡在了数据导入这一步。我之前接过好几个所谓的“知识库问答”需求对方上来就问用哪个向量库、哪个 embedding 模型等我把他们发来的原始资料打开一看——有的是从网上爬下来带一堆标签的 HTML有的是扫描版 PDF 转出来的纯文本还有的是毫无规律的聊天记录 txt。这种数据直接进 RAG 管线别说检索效果了切块那一步就乱成一锅粥。这篇系列文章的第一篇我打算把最基础也最容易被糊弄过去的环节彻底讲透通用文本txt 类和结构化文本Markdown 类的导入与解析。核心关键词就三个RAG、数据导入、解析。我会按实际项目里的处理顺序来拆解从拿到原始文件的第一个动作到产出可喂给向量化模块的标准片段每一步都讲清楚“为什么这么做”以及“踩过的坑是什么”。1. 为什么数据解析决定了 RAG 的成败先明确一个观点RAG 不是检索模型不行而是喂进去的数据根本没法检索。你可以把 RAG 应用想象成一家餐厅模型是厨师向量库是冰箱而数据解析就是后厨的择菜、洗菜、切菜环节。菜不洗、不切厨师技术再好也做不出一盘像样的菜。1.1 原文档与检索单元之间的鸿沟绝大多数企业内部的存量文档长什么样txt 里的回车换行大量缺失段落和段落之间用全角空格代替Markdown 文件看起来规整实际上标题层级混乱、代码块误用、表格里塞了图片。这些原始形态和检索单元之间存在一条巨大的鸿沟。检索单元是什么向量库里存的是有一定语义边界的文本块通常是几百 token 一段。原始文档是什么是一堆连续字符流或者只有少量格式标记的文本。解析环节的价值就是从连续字符流中切出有语义边界的 chunk同时保留必要的标题层级信息让每个 chunk 自带“出身背景”。这一步不做后面用再强的 Rerank 模型也救不回来。1.2 我见过的典型失败案例很多人直接拿 LangChain 的TextLoader和RecursiveCharacterTextSplitter来处理全部文档本地测试感觉还行一上生产就露馅。一个典型的失败案例有人把一份 500 页的运维操作手册导出的 HTML 转 txt 格式直接按 1000 字符切块。结果是什么第 47 块的标题明明写着“如何重启数据库”内容里却混着上一节“备份策略”的尾巴。用户的提问是“数据库重启时需要注意什么”系统把这堆脏块全部召回Rerank 之后找出来的内容左右矛盾回答质量惨不忍睹。问题出在哪出在整个管线没有一个环节“理解”文档的结构。解析器只是做了字符层面的切分完全没有感知到标题、段落、列表这些结构边界。所以我始终坚持一个原则先做结构化解再做切块结构化解的程度直接决定切块的上限。2. 通用文本解析先把 txt 变成“干净的长文”txt 类文件是 RAG 导入中最常见也最容易被轻视的输入。很多人认为 txt 无非是open()读进来、按字符切一切就完事真实处理起来远没有这么简单。编码混乱、字符污染、无效换行、段落粘连每一项都足以把后续的解析链路带偏。2.1 编码识别与统一第一步就翻车是家常便饭我接手过一个词典类 txt打开一看全是类似鍥句功鍩虹 鏂囦欢的乱码。原因很简单——文件是 GBK 编码但读取时用了 UTF-8。这类问题在生产环境里发生率极高尤其是从旧系统导出的文档。我的建议是不要用open(path, encodingutf-8)一把梭。稳妥的做法是用charset-normalizer或者cchardet先做编码探测把输入统一转成 UTF-8。# 编码识别与统一读取 from charset_normalizer import from_path def load_text_auto(path: str) - str: result from_path(path).best() if result is None: raise ValueError(f无法识别文件编码: {path}) # 统一转为 UTF-8 字符串 return str(result)实测下来charset-normalizer对 GBK、BIG5、Latin-1 的识别准确率比老的 chardet 高不少尤其是在短文本场景下。转换之后建议再对内容做一次强制的 UTF-8 校验避免中英文混排时出现非法码点。2.2 清洗规则不可见字符与异常换行一起处理编码搞定之后另一个高频问题是文件里混了大量肉眼看不见的脏数据。比如从 PDF 转出来的 txt 会自动插入一些制表位字符、零宽空格U200B、不换行空格U00A0还有 Windows 和 Unix 混用的换行符号。这些字符不会让程序直接报错但到了切块和向量化阶段容易残留成孤立 token检索时反而制造噪声。我习惯用一套正则预处理分三步走把\r\n、\r全部统一成\n。剔除所有控制字符和零宽字符但保留\n和\t。把全角空格统一转半角多行空行压缩成单行空行。import re def normalize_text(raw: str) - str: # 统一换行符 text raw.replace(\r\n, \n).replace(\r, \n) # 去掉控制字符与零宽字符保留 \n \t text re.sub(r[\x00-\x08\x0b\x0c\x0e-\x1f\u200b\u00a0], , text) # 全角空格转半角并合并连续的空白行 text text.replace(\u3000, ) text re.sub(r[ \t]\n, \n, text) text re.sub(r\n{3,}, \n\n, text) return text.strip()这一步看着基础但真的能解决很多下游玄学问题。之前有次做知识库问答用户问“如何申请退款”系统老是召回一些奇怪的片段兜兜转转查到最后发现是因为原文本里夹杂了全角空格和零宽字符导致语义被硬生生切断。清洗完之后召回质量立刻上了一个台阶。2.3 语义段落聚合不要急着切块清洗干净的 txt还得经过一道“段落聚合”。纯文本不比 Markdown它没有标题结构只有模糊的段落感。文档里的“章节”往往是通过空行、缩进、连续大写标题来暗示的。这时候如果直接按固定字符数切块就会无视这些语义边界。我通常是先按空行把文本切成段落列表再把过短的段落与相邻段落做聚合最后输出一版“语义化长文”。聚合规则不玄乎核心就两条如果某段字数小于 30 字且不是列表项特征则合并到前一段。如果某段以数字编号或“第x章”“前言”“概述”开头单独标记为标题段不要合并。def paragraphs_to_doc(paragraphs: list[str], min_chars: int 30): merged [] for para in paragraphs: para para.strip() if not para: continue if len(para) min_chars and merged and not _looks_like_title(para): merged[-1] para else: merged.append(para) return merged def _looks_like_title(text: str) - bool: return bool(re.match(r^(第[一二三四五六七八九十百千0-9][章节篇]|前言|概述|附录|结语|references), text))这一步的意义在于给后续的切分器提供更“完整”的语义块。短段落单独成块很容易变成无头无尾的碎片合并之后才能保证一个 chunk 内部至少有一个完整论点。3. Markdown 结构化解析把标题变成检索的骨架如果说 txt 处理是“洗干净”那 Markdown 处理就是“搭骨架”。我对 Markdown 情有独钟因为它是目前少有的、人类可读且机器可解析的轻量结构化格式。做 RAG 解析时从 Markdown 里提取标题层级、代码块、表格、列表比从 PDF 或 HTML 里抽结构要省太多力气。3.1 为什么选 Markdown 作为中转格式很多项目的数据源是 HTML或者是从各种爬虫工具导出的富文本。我的建议是统一转成 Markdown 再做结构化解析而不是直接在 HTML 上切块。原因有三Markdown 把复杂的 DOM 树压缩成了线性文本配合markdown或markdown-it这类解析器可以无损还原标题结构。HTML 里大量无语义的div嵌套和 inline 样式对切块没有任何帮助转成 Markdown 后这些噪声自动消失。现代 LLM 对 Markdown 的理解能力很强后面做片段摘要、父子切块时Markdown 片段直接可以当作优质上下文喂给模型。我在项目中常写一个html2md的预处理函数内部用markdownify把 HTML 转成 Markdown然后再走结构化解析。这一步跑通后整个知识库的文档形态就统一了。3.2 构建 Markdown AST从线性文本到嵌套树解析 Markdown 不能靠正则逐行猜最稳的方式是用语法树AST。Python 生态里我推荐用markdown_it配合自定义 renderer或者直接用mistune。这两个库都能把 Markdown 解析成节点树每个节点带类型heading、paragraph、code、table、list和层级H1-H6。拿到 AST 之后我才真正开始做文章结构理解。我的做法是遍历 AST把 heading 节点作为分段的“锚点”。每个 heading 及其后续兄弟节点归为一个结构块。结构块内部再细分段落节点、列表节点、代码块节点、表格节点。from mistune import create_markdown def md_to_struct_blocks(md_text: str) - list[dict]: md create_markdown(rendererast) nodes md(md_text) blocks [] current None for node in nodes: if node[type] heading: # 遇到新标题开启新的结构块 current { title: node[text], level: node[attrs][level], children: [], } blocks.append(current) else: if current is not None: current[children].append(node) else: # 无标题直接开头的段落放在“文档首部”块 blocks.insert(0, {title: 文档首部, level: 0, children: [node]}) return blocks实际输出示例简化后 [ {title: 环境准备, level: 2, children: [ {type: paragraph, text: 建议使用 Python 3.10}, {type: list, text: [pip install langchain, pip install chromadb]} ]}, {title: 数据导入, level: 2, children: [ {type: paragraph, text: 本节介绍数据导入流程} ]} ]这段逻辑是整个 Markdown 解析的核心分水岭——从此之后文本不再是字符串而是有层级、有归属的节点流水线。后续切块时每个块都能自豪地说“我是从‘环境准备’这个标题下面切出来的”。3.3 特殊节点处理代码块、表格、数学公式不能一刀切这里必须先提醒一句不要把所有节点都无缝拼成长文本后切块。代码块是按行组织的连续性文本表格是按行和列组织的二维数据数学公式是有着严格语义的 LaTeX 字符串。这三类内容一旦被中间横插一刀语义完整性就彻底碎了。我的处理策略如下代码块保留整体不拆分。代码块本身的语义边界的完整度高于字符数如果代码太长优先按换行处的函数或类边界去切而不是按字符数硬切。表格转成一种“自然语言化”的文本格式再把整表作为单独块。例如把表格转成列名: 值的枚举文本保留可读性同时便于向量化。数学公式分两派。如果无需精确计算直接保留 LaTeX 源文本块不转图片如果下游展示层需要渲染则单独抽出来走渲染服务但向量化时仍然用 LaTeX 源文本。def format_table_node(node: dict) - str: header node[attrs][header] rows node[attrs][rows] lines [] for row in rows: pairs [f{h}: {v} for h, v in zip(header, row)] lines.append( | .join(pairs)) return \n.join(lines)实测中我发现表格转成自然语言化文本后检索效果往往比保留原始 Markdown 管道符写法好很多。因为管道符和多余的空格在向量化时会带来无意义的 token 噪声而语义化的电压: 220V | 频率: 50Hz这种格式更像 LLM 能直接消化的知识表达。3.4 链接与图片该丢就丢该留标记留标记Markdown 里的链接和图片处理上经常引发纠结。链接的标题文本往往就是一句话的精华比如[环境搭建文档](./docs/setup.md)保留环境搭建文档作为正文是有价值的但保留完整 URL 对向量化通常是噪声。我的原则是标题文本转成普通文本URL 剥掉图片直接抽取路径或 alt 文本不把图片二进制喂给文本解析器。如果你需要处理“rag知识库能存储图片嘛”这类问题我的建议是文本链路里不放图片但保留图片引用路径和 alt 描述后续做多模态检索时图片走独立的向量化通道在结果融合阶段再和文本片段关联。这一步的解析目标是为将来留好“钩子”而不是现在就把图片塞进文本模型。4. 边界场景与实测中容易翻车的细节结构化解析框架搭起来之后真正的考验在于边界场景。我把自己在这一系列项目中反复踩过、也最终解决的几个问题集中写出来给同行们做个参考。4.1 短标题、流水号标题的误判Markdown 或 txt 转出来的文档里常见一种现象正文行首刚好碰上了井号或数字比如“#1 一次生产事故复盘”“第 1 条不要用 root 跑服务”。这些根本不是标题但解析器很容易把它们当成 H1/H2 锚点导致一个文档被切出几十个语义碎片。我的解法是在生成结构块之前先做一道“标题可信度”过滤。标题长度不能小于 4 个字符。标题不能以纯数字、时间戳、序号开头除非后续跟着中文字词。标题不能以句号、逗号、分号结尾。4.2 嵌套列表压扁成一行字Markdown 列表在视觉上很清晰但解析进 AST 之后嵌套列表的父子关系处理不好就会被粗暴地拼成一个长段落。比如第一章1.1 安装依赖用 pip 安装如果压扁成“第一章 1.1 安装依赖 用 pip 安装”层次感就丢了。我的处理方式是把每级缩进转成固定前缀符号例如两空格或 a b 这类路径式前缀然后按列表项逐项切块。这样每个列表项都保留了“第一章 1.1 安装依赖 用 pip 安装”这样的路径上下文。4.3 CSV 被误判为 Markdown 表格这是“数据导入”环节经常遇到的边界问题。很多业务系统导出的文件是 CSV扩展名却是 txt内容看起来又特别像 Markdown 表格。解析器若按 Markdown 表格去解析通常能跑通但对字段内的逗号、引号处理不当就会把一行拆成多行。我建议在解析之初先做格式嗅探如果文件里前几行出现明显的逗号分隔且字段数量一致就按 CSV 解析器处理否则按 Markdown 或纯文本处理。两个解析器走同一套“语义化文本”出口后续链路无需关心来源差异。4.4 HTML 标签残留导致的脏标记从网页保存的 Markdown即便经过了 html2md 转换仍可能残留部分span、div或 style 属性。这些内容在向量化时会被当成普通文本产生大量无效 token。我在解析流水线末端加了一个正则清扫扫描所有文本节点把[^]以及class...、style...这类属性剥掉再做最终清洗。有一种比较隐蔽的情况是Markdown 代码块里的 HTML 标签是合法内容比如一份技术文档的代码示例里就写了div。所以清扫标签一定要在 AST 节点的“正文文本”层做而不是在原始 Markdown 全文做。层级一错连代码示例也被污染了。4.5 从 Markdown 转档时丢失的文档元信息还有一类问题容易被忽略原始文档的创建时间、作者、版本号、文号等信息。这些信息在解析阶段如果被丢弃到了检索阶段想按时间过滤或按部门过滤就无能为力了。我的习惯是解析阶段维护一份“文档元信息”字典把文件名、首段描述、最近修改时间、来源路径一并带上。元信息和文本 chunk 是分开存储的但在切块时允许把某些元信息如版本号拼到对应标题下形成检索端可用的过滤字段。这一步不是必须但在企业级知识库场景下能省下大量返工成本。5. 结构化信息如何与切块策略联动解析环节完成之后接下来就到了切块。很多教程把切块说成“按 token 数切就行”但真正决定切块质量的是你前面解析时保留了哪些结构信息。5.1 不要按固定字符数硬切固定字符数切块的问题在于它无视标题边界和段落边界。即使你前面已经把 Markdown 分成了结构块最后硬切一刀下去依然会把一个结构块从中间斩断。所以我强烈建议在结构块的基础上做“语义最小单元”切分而不是“字符固定长度”切分。对一个结构块我先看它内部的段落、列表项、代码块的数量。如果数量只有一个且长度适中比如小于 800 token整个块可以作为一个 chunk如果块内内容过长我再按二级标题或段落进一步递归细分。这其实就是用解析得到的层级做了一次“有感知的切块”。5.2 把标题层级写进 chunk 元数据切块之后每个 chunk 必须带上它从哪个标题层级下切出来的。比如chunk: 系统要求建议使用 Python 3.10 及以上版本 metadata: { h1: 快速开始, h2: 环境准备, h3: 系统要求, source_file: docs/quickstart.md }这样一个 chunk 在检索时即使匹配到的只是片段也能通过元数据把完整的层级路径呈现给最终用户。很多生产级 RAG 项目里这一步直接决定了“回答的可追溯性”好不好。5.3 父子切块与标题前缀拼接更进阶一点的方案是做父子切块父块是某个二级标题下的完整章节子块是按段落细分的片段。检索时先召回子块再根据元数据往上挂载父块两者一起给 LLM 当上下文。这种方式能显著缓解“片段太碎、丢失大语境”的问题。但请注意父块的长度不能失控。如果某个二级标题下有 10 屏内容父块就可能超出模型上下文窗口。因此我会给父块设置一个软上限比如 3000 token超过就自动提升一个标题层级再分。标题前缀拼接也是另一种解法在子块文本前面加上从 H1 到当前标题的路径字符串让 chunk 自带上下文效果也比较稳。5.4 解析后的验证清单解析流程跑完后不要直接去调 embedding。先做一轮质量抽检我自己的验证清单大致这样文本中不应该存在长段无换行的粘连内容。标题层级路径应该覆盖绝大部分 chunk。表格和代码块应保持完整没有在中间被切开。元数据与原始文件能一一对上定位无歧义。这一轮抽检通常能发现引入脏乱数据源的问题避免把问题带进向量库。6. 通用解析模块的工程化落地从脚本到服务到这里解析逻辑的原理和关键细节我们都过了一遍。最后聊聊工程化落地因为很多人写完脚本就跑忽略了部署和维护周期里的几个关键点。我见过太多“本地跑着没问题一上线数据多了就卡死”的情况。6.1 解析模块的可插拔设计我把解析模块拆成三个独立环节Loader负责读取不同格式、Preprocessor负责清洗与格式嗅探、Structurer负责结构化切块与元数据生成。每一环都面向接口编程不互相耦合。这样做的好处是后续如果新增一种格式比如 epub、docx我只需要新写一个 Loader复用后面的 Preprocessor 和 Structurer。如果某类文本有特殊清洗逻辑我也只需要新增一个 Preprocessor 实现不用动主干链路。6.2 性能与并发别在解析上出瓶颈解析逻辑以 IO 和正则为主性能瓶颈一般不在解析本身而在读取大文件。几百 MB 的 txt硬读进内存再处理内存占用会一下子飙高。我的做法是对大文件先做分块读取按文件大小动态调整读取块大小解析后的中间结果写临时文件或对象存储避免全部堆积内存。6.3 失败重试与脏数据隔离数据解析属于典型的“输入不可控”场景。有的文件编码诡异有的文件内容损坏。因此在工程实现上一定要对每个文件的解析结果做“成功/失败/部分成功”三类标记。失败的文件不是直接丢弃而是落入待人工复核队列。部分成功的文件要把解析成功的 chunk 先入库同时输出一份“问题摘要”给运维人员。这套机制在长期运行的知识库系统里极其重要。没有它任何一个角落里的脏文件都会成为检索回答出错时最难排查的隐藏故障源。至此从 txt 到 Markdown 的通用文本与结构化解整条链路已经完整呈现。我个人的体会是数据解析很难靠一次性到位它更像一个持续迭代的打磨过程——每一次新的数据来源都会带来新的坑结构化解析的价值就是把这些坑提前在清洗和分层阶段排掉而不是留给检索阶段“随机爆炸”。下一篇系列文章里我打算接着写 PDF 和 Word 这类富格式文档的解析方案比 txt 和 Markdown 的复杂程度又要高出一个档次。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑