资讯动态

RAG数据导入解析:txt与Markdown清洗及结构化切分实践

发布时间:2026/10/8 21:22:53 来源:尧图企业网站定制
1. 先别急着搭RAG数据解析这一步决定了上限1.1 RAG链路里最容易被低估的环节做了几个企业级RAG项目之后我发现一个特别有意思的现象大家搭RAG的时候注意力几乎全放在向量化、检索策略、Prompt编排上模型换成GPT-4还是国产开源召回用BM25还是向量讨论得热火朝天。但真正上线跑一个月翻车的地方往往很朴素——PDF导出来是乱码Word表格被拆得七零八落txt里的目录和正文混在一起Markdown里代码块的格式把分块器搞到崩溃。检索质量直接崩盘。这背后的逻辑很直接RAG的效果上限很大程度取决于进入知识库的数据长什么样。你让再强的embedding模型去处理一团乱麻的纯文本它也只能把你那份混乱编码进向量里。你说垃圾进垃圾出在RAG场景下真的不是一句空话。我见过太多团队在解析和导入阶段草草了事后面花几倍时间在调召回、拼Prompt最后发现根子还是数据没洗干净。所以我想把RAG数据导入与解析这件事单独拿出来写一个系列。第一期先把最通用的两种文本形态讲透txt这种完全无结构的文本和Markdown这种半结构化文本。两者是RAG知识库最常见的信息载体也可能是大多数人打开Notion/语雀/GitHub仓库之后第一个要处理的东西。1.2 解析的目标不是读文件而是生成检索友好的中间格式先明确一个概念解析器读完文件之后不能只给出一大段连续的字符串然后让分块器Chunker去硬切。那样的话语义边界几乎完全靠运气标题、段落、列表、代码块这些结构信息全部丢失后续检索就失去了最重要的上下文锚点。真正合理的做法是解析完成后先产出一种结构化的中间表示文件被拆成若干带元数据的节点节点之间保留父子层级关系。每个节点有自己的类型标题、段落、列表项、代码块、表格、自己的来源路径、自己在原文中的位置信息。后面做分块、做召回、做引用溯源全部基于这个中间表示。这个设计思路不是我的发明很多成熟的文档解析框架比如Unstructured、LlamaIndex的各类Reader都是这么做的。但问题在于很多人直接把框架的默认输出当成最终答案没有理解中间表示的意义结果遇到特殊文档还是不会处理。我建议你从原理上搞明白这件事再决定是自己实现一个轻量解析器还是去魔改现成框架。1.3 为什么选择Markdown作为中间格式这里有一个我自己的偏好无论原始文件是txt、Docx还是PDF我最终都会尽量把内容统一转成Markdown再做结构化切分。原因很简单文本文件有富文本表达能力虽说有各种格式标签但Markdown正好是那个既能表达结构又不至于太复杂的折中方案。Markdown天然是人类可读的调试解析问题时你可以直接打开中间文件看效果不用借助二进制查看器。标题、列表、表格、代码块都有明确语法便于程序自动化解析出层级关系。现在主流RAG框架和知识库工具对Markdown的解析支持都相当成熟用它作为中间格式后面接什么处理流程都不费劲。所以本文的核心路子就是把txt处理成干净、有段落边界的纯文本把Markdown处理成带语义骨架的结构化文本然后统一进入分块与元数据标注环节。下面每一节都会给到可直接复用的实现方案。2. 无结构txt的清洗与切分从能看到能检索2.1 txt文件里那些看不到的坑先别急着写分块函数。txt虽然看起来最简单但实际处理起来坑一点都不少。我在项目里踩过最典型的几类编码混乱。国内拿到的txt文件UTF-8无BOM、UTF-8带BOM、GBK、GB18030、ANSI、UTF-16混合存在。只按utf-8去读分分钟抛UnicodeDecodeError用errorsignore硬读收获一堆乱码。有些老文件甚至是Big5编码纯靠猜。不可见字符。\u3000全角空格、零宽空格、Windows换行符\r\n、老的Mac换行符\r不统一处理分块时会出现莫名其妙的断句。连续空行和缩进噪声。有些txt是从网页直接复制下来的段落之间有两三个空行段首还有全角空格有些是从PDF转换来的每行末尾带着换行符但语义上其实是同一个段落。章节标题与正文混在一起。纯txt没有任何格式标记书名号、数字编号、第一章这种人工约定是唯一的结构线索。如果你把这些原样扔给分块器结果就是一个段落切成八段或者几个无关段落被粘在一起。向量化之后检索器根本分不清哪句是标题、哪句是正文。2.2 清洗规则先做正则再做统一化我的清洗流程一般分成四步每一步都有明确目的统一换行。把\r\n和\r统一成\n避免Windows和Linux文件混用时出现半个换行符。清理控制字符和特殊空白。去除零宽字符、全角空格视情况转半角、保留正常空格和换行。折叠多余空行。连续三个及以上空行压缩成一个作为段落分隔信号。规范标题格式。如果检测到类似第x章x.x一、二、三这样的人工编号按规则在其前后补空行为下一步检测做准备。这里给一段我常用的清洗代码注释已经写得很详细import re def clean_txt(text: str) - str: # 1. 统一换行兼容 Windows(\\r\\n) 和老 Mac(\\r) text text.replace(\\r\\n, \\n).replace(\\r, \\n) # 2. 清理常见不可见字符 text text.replace(\\u3000, ) # 全角空格转半角 text text.replace(\\ufeff, ) # 去掉 BOM text re.sub(r[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f], , text) # 3. 压缩连续空行为一个分隔空行 text re.sub(r\\n\\s*\\n, \\n\\n, text) # 4. 对人工标题进行规范如“第一章 xxx”“3.2 xxx” text re.sub(r(?m)^(第[一二三四五六七八九十百0-9][章节部篇]|[0-9]\\.[0-9])([^\\n]*)$, r\\n\\n\\1\\2\\n\\n, text) return text.strip()注意第4步里我用了一个很保守的正则只匹配最典型的中文章节标题和数字编号。为什么保守因为纯txt里没有可靠的标题标记宁可不拆也不要误拆。误拆比不拆更伤它会把一个完整段落切出两个伪标题后续分块时语义断裂。2.3 分块策略固定长度只能兜底自适应才是正解清洗完之后txt仍然是无结构的连续文本。这时候决定怎么切直接关系到检索命中率。最粗暴的做法是固定窗口切分比如每512个token切一块加128个token重叠。这种方案实现简单但它完全无视段落边界和语义完整性——一个自然段被从中间劈开检索时你只能拿到半截信息LLM生成答案时必然缺上下文。我在项目中更推荐段落优先的自适应分块先按\\n\\n把清洗后的文本切成段落。每个段落单独计算token数。如果段落小于目标块大小比如800 token就保持独立成块相邻的短段落可以合并直到接近上限。如果段落本身超过目标块大小再在这个段落的句子边界处句号、问号、感叹号做二次切分并保留一定重叠。这样做的好处是绝大多数情况下每个chunk内部都是一个语义完整的段落或几个有关联的短段落。向量检索时查询和chunk的相关性更容易匹配上即使匹配不上也不会因为半句话导致答案残缺。2.4 从文本到文档节点的封装清洗和分块只是表层工作。真正落到代码层面我建议把每个块定义成一个文档节点结构大致如下dataclass class DocumentNode: doc_id: str # 全局唯一ID chunk_index: int # 在原始文档中的块序号 content: str # 清洗后的文本内容 heading_path: str # 层级标题路径txt场景多为空字符串 source_file: str # 来自哪个文件 page_number: int # 页码txt场景值为-1 meta: dict # 其他自定义元数据如作者、日期等后面做向量化时heading_path和meta可以和content一起拼接进embedding文本也可以存成向量库的metadata字段用于过滤和引用溯源。这个习惯建议从一开始就养成别等到检索要按来源过滤时才后悔没存这些字段。3. Markdown结构化解把层级标题变成可检索的骨架3.1 Markdown解析为什么不能靠按行读坦白说我第一次处理Markdown时也犯过懒直接按\\n逐行读看到#开头就当标题看到-就当列表。遇到简单文档没问题但稍微复杂一点就露馅了——代码块里的#是注释表格里也有|符号标题下面紧跟列表时层级关系很难理清嵌套引用和嵌套列表更是直接崩。Markdown本质上是一种格式化文本它的结构由块级元素标题、段落、列表、引用、代码块、表格和行内元素加粗、斜体、链接、行内代码共同决定。要正确解析必须按块来做而不是按行来做。好在Python生态里有markdown-it-py这个超级好用的解析器它能按CommonMark规范生成token流我再用mdformat和自定义renderer把token流转成结构化的节点树。这里贴一个最核心的思路from markdown_it import MarkdownIt md MarkdownIt(commonmark).enable(table) # 拿到解析后的 token 流 tokens md.parse(markdown_text) for token in tokens: if token.type heading_open: # 此时可以读取 token.tag比如 h2 print(遇到二级标题属性, token.attrs) elif token.type fence: # 代码块 print(代码块语言, token.info)需要注意的是token.stream的顺序是文档顺序但heading_open和heading_close之间夹着的是标题内容token而下一个块级token才是标题下的内容。你要把标题A和标题A之后的内容关联起来必须自己在解析循环里维护一个栈。3.2 构建文档骨架标题树与内容块我自己的做法是先在内存里建一棵骨架树。步骤如下初始化一个根节点当前所在层级为level0。遍历token流遇到heading_open时提取标题级别从token.tag可以拿到比如h2对应level 2然后把它作为一个节点挂到树的合适位置级别大于当前节点的作为当前节点的子节点级别小于等于当前节点的回到对应的祖先节点。遇到非标题块段落、列表、代码块、表格等把它挂到当前最近的一个标题节点下面。完成整个文档的遍历后骨架树就成型了。这个树结构特别有用。比如后面检索时你希望一个代码块能用自己的上级标题做补充上下文或者你想按二级标题为边界对整篇文章做粗粒度切分这个树都给你提供了现成的父子关系。我用一个简化的代码来表达这个维护过程class Node: def __init__(self, level: int, title: str , content: list None): self.level level self.title title self.children [] self.blocks content or [] def build_skeleton(tokens): root Node(level0, titleroot) stack [root] # 用栈维护当前层级链 for token in tokens: if token.type heading_open: level int(token.tag[1]) # 由 h1/h2 得到 1/2/3... title_tokens [] # 这里应该继续读到 heading_close 为止省略 node Node(levellevel, title标题文本) # 按标题级别选择插入位置 while stack and stack[-1].level level: stack.pop() stack[-1].children.append(node) stack.append(node) elif token.type in (paragraph_open, fence, table_open, bullet_list_open, ordered_list_open): current stack[-1] current.blocks.append(token) return root注意这只是一个教学级示例真正落地时你还要考虑heading_inline、inline、softbreak等token的类型转换把标题内容和段落文本正确提取出来而不是只存token对象。3.3 把结构语义化给每个节点打上类型标签建出骨架树之后如果只是知道层级关系还没完成语义化。我通常会给每个节点打上类型标签方便后续做元数据索引和检索过滤。比较常见的标签有这么几类heading标题节点细分级别paragraph普通段落list有序或无序列表code代码块带编程语言信息table表格带行列数quote引用块为什么要打类型标签举个例子企业内部知识库里经常有大量代码片段检索时如果用户问这段代码怎么调用你希望把代码块附近的中文说明文字也一并召回。有了类型标签你就可以在构建chunk时把code节点和它的相邻paragraph节点合并成一个chunk而不是机械地各切各的。还有一种做法是给节点保存标题上下文路径比如一篇技术博客的层级是安装指南 - 环境准备 - 下载Python那么下载Python这个段落节点的heading_path就是安装指南 环境准备 下载Python。检索召回下载Python这个chunk时把这串路径拼在文本前面LLM能立刻明白这段内容属于哪个章节回答的定位准确度会高不少。3.4 从Markdown到chunk一种推荐的自适应打包策略骨架树和标签都齐了最后一步是生成chunk。这里我建议在树结构上做粒度自适应打包而不是简单按长度硬切。思路是这样先确定你向量模型的每次最大输入token数比如max_tokens1000。从根节点开始按文档顺序深度优先遍历。把连续的标题内容块累积起来。如果累积的token数快超上限就找一个合适的切分点优先在标题边界切比如一个h2的完整小节其次在块边界切段落、代码块、表格之间最后才考虑在句子中间切。给每个生成的chunk拼接heading_path和类型标签作为元数据。这样做出来的chunk一方面上下文完整另一方面因为带标题层级检索阶段可以直接配合按标题过滤或者按来源过滤在复杂知识库里非常实用。4. 通用文件格式适配层一个函数搞定txt、Markdown和其他文本4.1 为什么不给每种文件单独写一套代码文件类型多的时候最容易犯的错误就是每来一种新格式就写一个解析函数最后项目里出现一堆parse_txt.py、parse_md.py、parse_html.py彼此还互相复制粘贴。问题是分块策略是通用的元数据逻辑是通用的只有读取并转成骨架树这一步不同。你完全可以抽象出一层适配器把差异隔离在一个函数里。我比较推荐的方法是解析器注册表PARSER_REGISTRY {} def register_parser(extensions): def wrapper(func): for ext in extensions: PARSER_REGISTRY[ext] func return func return wrapper register_parser([.txt, .md, .markdown]) def parse_plain_text(filepath: str): ...每次支持一个新格式只要实现一个读文件 - 输出中间节点树的函数然后把它注册到表里。后续的清洗、分块、向量化全部走统一管线。这个设计可以让你在处理Docx、PDF、HTML甚至扫描件OCR结果时都不需要改后面的代码。4.2 统一输出的数据结构从Reader到Chunk这层适配器需要遵守一个约定好的数据结构契约。我通常会定义这样几个类RawDocument读出来的原始文本以及文件路径、文件类型等基础信息。StructuredNode骨架树节点包含标题、层级、内容块、元数据。Chunk最终生成的检索单元包含文本内容、heading_path、来源文件、块序号、额外元数据。管线变为RawDocument - 解析器注册表 - StructuredNode树 - 清洗清洗 - 自适应分块 - 元数据标注 - Chunk列表这样改动的好处是显而易见的你想把PDF接进来只需让PDF解析器输出StructuredNode树清洗和分块逻辑一行都不用改。4.3 代码层面的一次完整落地用代码把这条管线串起来大约只需要三个核心函数我按层次写下def read_file(filepath: str) - RawDocument: 读取原始文件尽量保留足够信息 ext os.path.splitext(filepath)[1].lower() parser PARSER_REGISTRY.get(ext) if not parser: raise NotImplementedError(f不支持的文件类型: {ext}) raw_text, meta parser(filepath) return RawDocument(textraw_text, sourcefilepath, metameta) def to_structured(raw_doc: RawDocument) - list[StructuredNode]: 根据文件类型和文本内容构建骨架树节点列表 # 这里内部会判断是纯文本还是Markdown分别调用清洗和骨架构建逻辑 ... def to_chunks(structured_nodes: list[StructuredNode], max_tokens: int 1000, overlap_tokens: int 100) - list[Chunk]: 在骨架树上做自适应打包生成chunk ...这个三层设计的核心是Reader负责读取Parser负责构建结构Chunker负责切块。各管各的测试起来也方便。我建议你把这三个函数的单元测试写全测试文件不必多几类典型文档就够了带代码块的Markdown、带表格的Markdown、中文txt、英文txt、乱码txt。后面再接入新格式时你会发现这个底子帮你省了大量调试时间。5. 实测中的坑与调优乱码、表格、代码块和分块噪声5.1 编码识别不要只信chardet还要有兜底策略txt的编码问题在真实项目里比教程里严重得多。chardet可以猜但猜错的概率也不低尤其面对短文本、混合编码时。我自己的经验是自建一个带优先级的识别链而不是完全依赖第三方库。先按统计概率取前几个候选再用能否被目标编码完整解码 解码后的文本是否包含常见乱码特征来二次校验。例如用GBK解码后出现大量锟斤拷、烫烫烫这种特征词基本就是编码猜错了。我有一套实用优先级优先尝试UTF-8含BOM变体因为绝大多数新文件都走这个。UTF-8失败后尝试GB18030能覆盖中文简体和繁体的大部分情况。再往后是GBK、Big5最后才是16位Unicode。如果你手头文件不多最快的办法其实是用编辑器比如VSCode打开看一眼乱码情况比对选择正确编码如果是批量导入那还是老老实实写识别链并记录每条文件的编码结果方便事后抽查。5.2 Markdown里表格和代码块是分块器的重灾区表格是Markdown里最特殊的一块。它本质上是二维结构一旦把一个多行表格切成两个chunk表头和数据行就分离了检索时根本不知道这个数字代表什么。我的处理方式是把一张表格当成一个原子块不参与跨表格切分。如果一张表格整体token数超过上限就把它单独成一个chunk并保留完整表头上下文。如果表头的token已经接近上限那我会提醒自己原始文档不太适合直接进RAG得考虑转成问题-答案对或先做摘要而不是继续硬塞。代码块也有类似的坑。代码块里的//、#这些字符会被某些分块器误判为标题或注释导致代码被拆得稀碎。用Markdown解析树来做的话fence类型是一整个块天然不会被拆散。如果代码块太长就只能按函数/类的边界去拆单纯按行数硬切会把一个函数劈成两半后续检索命中都不完整。另外还有一个非常隐蔽的问题Markdown里大量使用的行内代码和行内公式。清洗时如果不小心把反引号删了代码语义就变了。所以清洗Markdown和清洗txt不一样正则必须基于解析树而不是直接在全文上跑。5.3 分块质量怎么看三个量化指标很多人调分块参数全靠感觉。我建议至少定三个可量化的指标来评估分块质量语义完整率抽样检查chunk是否以完整句子/完整段落结束目标大于90%。标题上下文覆盖率检查最终chunk附带的heading_path是否都能追溯到根节点有没有孤儿节点。检索命中效果建几条代表性问题跑一轮检索看TopK结果的命中文档是否包含预期章节。这个指标最直接。有条件的话可以在这三点之上加一个人工评审集准备30到50个真实问题标注好标准答案来自哪个文件的哪个章节。每次改解析策略就在这个集上跑一遍对比命中率变化。有了这个基线调参就不会变成瞎猜。5.4 我在几个真实项目里看到的典型表现这里分享几组我在实际项目中遇到的情况。某企业制度文档大部分是txt和Markdown混存。最初直接按512 token硬切检索命中率只有55%左右改成段落优先自适应分块后命中率接近80%。某个开源项目的README和Wiki页面转成Markdown后代码块占比接近40%。如果对代码块不做保护经常召回到大段无关源码把代码块视为原子块并按函数边界二次切分之后相关问题命中率明显提高。还有一批从老系统导出、编码为GBK的txt直接用utf-8读取时乱码率接近100%。搞定编码识别后这批文档的可用率才真正提上来。这些数字不算是严谨的学术评测但足以说明一个问题解析和分块的策略选择对RAG效果的影响是立竿见影的值得你在前期多花时间。5.5 一个小技巧给chunk落库前做一次可读性检查不管用了多复杂的解析流程最终往向量库里写之前我会对每个chunk做一次文本可读性检查规则很简单文本去空白后长度必须大于20个字符太短的丢弃或合并。不能包含大量重复字符比如!!!!!!、…………这种一般是文档损坏或复制粘贴的噪声。不允许存在未闭合的高亮字符比如Markdown里明显的**符号缺失。编码错误导致的反色字符比例不能超过阈值。这个检查成本很低却能在海量导入阶段帮你揪出相当一部分脏数据。它不解决所有问题但能把明显的问题挡在向量库外面。6. 写给刚起步的RAG项目这套流程怎么落地6.1 最小可用版本的实现顺序如果你现在才刚开始搭RAG知识库我不建议一开始就追求完美的解析器。先跑通最小闭环按照这个顺序来选定一个目录把手头的txt和Markdown文件放进去。实现clean_txt和build_skeleton两个最核心的函数目标是让常见文件不出错。采用保守的段落自适应分块先不追求最优。向量化、建索引、跑一次检索确认链路能通。用第5节提到的三个指标做一轮抽样评估找出最大的问题点。针对最痛的问题去优化解析策略比如加编码识别、做表格保护。很多团队栽在想把每一步都做到完美结果每一步都没做完。RAG项目最优先的目标永远是先端到端跑通解析细节可以后续迭代。6.2 不要过度解析有些文档该走别的路还要给一个反常识的建议并不是所有文件都适合用通用文本解析器处理。如果你的知识库里大量是扫描版PDF、图片型表格或者强格式的合同、票据那通用解析器做得再好也是白费。这类文件要么先走OCR流程要么走专门的文档理解模型要么人工整理成结构化数据再导入。再比如纯代码仓库你直接把它当Markdown解析效果也不会好到哪去。代码文件有自己的语义结构模块、类、函数、调用关系更适合用代码专用的索引方式比如按函数粒度建立索引而不是靠Markdown标题找层级。通用文本解析的适用范围非常明确以自然语言为主的文档。技术博客、产品手册、制度文件、教学设计、会议纪要——这些场景用本文的txt到Markdown方案能覆盖80%以上的需求。超出这个范围就应该考虑专门的解析方案而不是强行套用。6.3 下一步我会继续写什么这一篇把txt和Markdown这层讲完了后面这个系列我准备沿着RAG数据导入的路径继续往下挖包括PDF和Word的解析细节以及表格抽取的套路。将多级标题和表格内容映射成带语义标签的结构化知识块专门聊一聊知识图谱和RAG怎么配合使用。分块参数的自动选择方法以及如何用标注集来驱动参数迭代。向量库选型和元数据过滤设计把检索性能再做一轮优化。如果你正在做RAG项目、知识库系统或者文档中台手头又正好有一批txt和Markdown格式的历史资料可以按照本文的流程先在自己的数据上跑一遍。我建议你把解析得到的chunk样本打印出来看几段你会立刻发现哪些地方需要调整——这个看中间产物的习惯比直接相信任何框架的默认配置都管用。最后说个细节我自己的经验是把heading_path拼在chunk内容最前面用换行分隔比把标题放在metadata里参与过滤对检索效果的提升更明显。原因也很简单——当用户输入的问题涉及具体章节时标题本身是最强的匹配信号之一。这个操作成本为零但很多时候能救回一两个原本会漏掉的命中结果。你可以试一试看看在你的数据上是否同样有效。

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

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

免费获取报价 →
↑