☰
RAG数据导入与解析:从txt到Markdown的结构化处理全指南
2026/10/6 18:14:05 网站建设 项目流程

做 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, encoding="utf-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 会自动插入一些制表位字符、零宽空格(U+200B)、不换行空格(U+00A0),还有 Windows 和 Unix 混用的换行符号。这些字符不会让程序直接报错,但到了切块和向量化阶段,容易残留成孤立 token,检索时反而制造噪声。

我习惯用一套正则预处理,分三步走:

  1. 把\r\n、\r全部统一成\n。
  2. 剔除所有控制字符和零宽字符,但保留\n和\t。
  3. 把全角空格统一转半角,多行空行压缩成单行空行。
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 上切块。原因有三:

  1. Markdown 把复杂的 DOM 树压缩成了线性文本,配合markdown或markdown-it这类解析器,可以无损还原标题结构。
  2. HTML 里大量无语义的<div>嵌套和 inline 样式,对切块没有任何帮助,转成 Markdown 后这些噪声自动消失。
  3. 现代 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 之后,我才真正开始做文章结构理解。我的做法是:

  1. 遍历 AST,把 heading 节点作为分段的“锚点”。
  2. 每个 heading 及其后续兄弟节点,归为一个结构块。
  3. 结构块内部再细分:段落节点、列表节点、代码块节点、表格节点。
from mistune import create_markdown def md_to_struct_blocks(md_text: str) -> list[dict]: md = create_markdown(renderer="ast") 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 的复杂程度又要高出一个档次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询