多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

RAG数据导入与解析:txt与Markdown结构化处理全攻略

RAG数据导入与解析:txt与Markdown结构化处理全攻略 最近好几个准备搭 RAG 知识库的朋友跑来问我为什么检索效果总是不稳定命中内容经常答非所问。我把他们数据导入和解析这一步的代码截图要过来看了一眼问题几乎都出在最前面——文件读进来之后要么是乱码要么是整篇塞进向量库要么 Markdown 的标题层级完全丢失。RAG 这条链路听起来很简单导入、分块、向量化、检索、生成。可只要输入是 txt、Markdown 这类文本解析环节的处理态度就直接划出了知识库“能用”和“不能用”的分界线。这份攻略是 RAG 数据导入与解析系列的第一篇我专门来讲通用文本与结构化解析覆盖从 txt 到 Markdown 的读取、清洗、结构提取、分块切分的完整流程。适合两类人看一类是零基础、想给个人笔记搭知识库的照着做就能跑通另一类是在企业里做 RAG 落地的工程师编码翻车、结构丢失、分块错乱这些坑我都踩过下面这些记录可以帮你少走很多弯路。1. 把数据导入链路先拉通解析在 RAG 里到底站什么位置1.1 从文件到可检索知识其实要过五道关很多人以为 RAG 就是“把文档丢进去问它就行”但真实的数据导入链路远不止一个文件读取动作。完整的流程大致是文件采集、内容解析、分块切分、向量化入库、索引写入。后面才能接检索和生成。采集阶段解决的是“文档从哪来”可能是本地目录、网盘同步文件夹、爬虫抓取的网页转存也可能是企业内部系统导出的 Markdown 备份。解析阶段做的是“把文件变成干净的、带语义单位的文本”这一步我见过太多人直接拿open().read()完事这是最大的认知误区。分块阶段决定“哪些文本作为一个检索单元送入向量模型”它高度依赖解析结果。向量化和索引写入相对标准化一般用 Embedding 模型和向量数据库就能解决。这个链路里最不被重视、却最影响效果的是第二步。原因是检索喂给大模型的上下文质量上限由 chunk 决定chunk 的质量上限又由解析决定。下游模型再聪明也没法从一段乱码里读出正确结论。1.2 脏文本和丢结构代价到底有多大我把脏文本进入链条后的连锁反应给你捋一遍。如果文本里混着大量无关字符比如网页导航栏文字、零宽字符、编码错误的乱码符号Embedding 模型会把整段向量的语义重心拉偏。举个例子一份技术文档中反复出现“点击这里下载”“精品推荐”之类残留文本用户问“这个接口有哪些参数”向量检索时命中的可能是广告语所在的 chunk而不是真正的参数表格。结构丢失的问题同样严重。Markdown 文档如果不解析标题层级只把#符号剥掉再整篇切分等于把一本有目录的书拆成散页再揉进碎纸机。散页之间的章节关系、所属主题、逻辑顺序全部丢失大模型拿到的只是孤立片段的集合。还有一层隐蔽的成本解析做得差后续所有环节都要返工。分块策略要重调、向量库要重建、检索效果要重新评测这些时间远超一开始好好写解析逻辑的投入。1.3 为什么第一篇先盯住 txt 和 Markdown不是 PDF、Word 不重要而是 txt 和 Markdown 是整个解析体系的基座。几乎所有其他格式的文档最终都能导出成这两种格式网页可以另存为纯文本或 MarkdownWord 可以另存为纯文本PDF 也可以通过转换工具先转成 Markdown 再做结构化处理。把 txt 和 Markdown 的解析做扎实等于掌握了文件解析领域的“基本功”。另外这两种格式有非常典型的互补特征。txt 是纯文本几乎没有自带结构考验的是编码处理、文本清洗、段落切分Markdown 是轻量级标记语言自带标题、列表、表格、代码块等语义线索考验的是结构化提取能力。把这两者搞定后面遇到 HTML、PDF、Word多数处理思路都是相通的只是解析器的复杂度不同。2. txt 文本读取与清洗编码、换行、隐藏字符一个都不能放过2.1 先过编码这一关不然通篇都是“锟斤拷”纯文本文件在中文场景下最经典的翻车现场就是编码问题。文件本身是 GBK 编码代码里用 UTF-8 去读轻则部分中文变成乱码重则直接抛UnicodeDecodeError。还有一种更隐蔽的情况文件带 BOM字节顺序标记read 出来的字符串第一个字符可能是个不可见的\ufeff这个字符会跟着内容一起向量化检索时成为噪声。我的标准做法是读取前先做编码探测不猜、不赌。优先使用charset-normalizer或chardet做探测然后按探测结果解码如果探测结果置信度低就按实际业务最常见的编码顺序逐一尝试用“解码不报错”和“乱码比例低”双重条件筛选。def read_text_with_encoding(path): raw path.read_bytes() # 先用 BOM 判断BOM 比统计模型更可靠 if raw.startswith(codecs.BOM_UTF8): return raw.decode(utf-8-sig) if raw.startswith(codecs.BOM_UTF16_LE) or raw.startswith(codecs.BOM_UTF16_BE): return raw.decode(utf-16) # 再走编码探测 result charset_normalizer.from_bytes(raw).best() if result and result.encoding: return str(result) # 最后兜底常见中文编码按顺序尝试 for enc in (utf-8, gb18030, big5): try: return raw.decode(enc) except UnicodeDecodeError: continue raise ValueError(f无法识别文件编码: {path})这里有个容易忽视的细节GBK 的完整超集是 GB18030所以遇到中文编码不明的文件用gb18030比gbk更稳妥它兼容 GBK还能处理部分生僻字。BIG5 主要在繁体场景用到按业务场景决定要不要放进来。2.2 换行符、全角空格、零宽字符文本里的“隐形刺客”编码过关之后下一层坑在控制字符和不可见字符。不同系统生成的 txt 换行规则完全不同Windows 用\r\nLinux 和 macOS 用\n老 Mac 系统用\r。如果不统一换行符切分段落时就会出现莫名其妙的空行错位、片段粘连。还有一类更隐蔽的字符值得专门处理。网页复制过来的文本经常带\xa0不换行空格它在显示效果上是空格但 ASCII 码不同正则表达式\s不一定能匹配到Markdown 文件里还可能出现零宽空格\u200b、零宽连接符\u200d这些字符肉眼看不见却会混进向量化文本制造隐性噪声。再比如全角空格\u3000在中文排版的文本中很常见如果不转成半角空格分句切分时容易判断失误。我通常会在清洗阶段做一次字符规范化把全角空格替换为普通空格把\xa0替换为空格剔除零宽字符和不可见控制字符。注意这里不能简单地全部替换要看目标文本的语义比如代码内容中的全角空格可能是刻意保留的这一点在 Markdown 的代码块解析时要特别留意。2.3 清洗原则最小干预别把原文削成骨架清洗的逻辑不是“越干净越好”。RAG 核心是保真你要保留的是原文的知识密度而不是把文本标准化到面目全非。我见过有人把全部标点符号去掉、把所有空白压成一行结果向量检索时句子结构完全丧失语义严重漂移。清洗要分清主次段落结构必须保留段落是中文文本最小的语义单位之一多余的空行可以合并但不能全部消除空行是天然的切分边界网页残留的导航、广告、页眉页脚这类无关文本可以剔除但要保证剔除的边界准确不要对正文做“改写式清洗”比如替换同义词、调整语序这些是后续模型干的事解析阶段越老实越好对于从网页转换来的 txt常见残留是每行末尾跟着的日期、作者名、来源链接。这些可以筛但先确认它们是否重复出现在所有文本块中如果不是宁可不删。3. Markdown 结构化解析把标题层级变成可检索的上下文3.1 Markdown 本身就是一套轻量结构不能把#直接剥掉Markdown 之所以在 RAG 场景里有价值是因为它在纯文本之上叠加了一层可解析的语义结构标题层级表达文档大纲列表表达并列关系表格表达结构化数据代码块表达程序片段引用表达来源引用。这些结构对检索和生成都有直接的帮助。最错误的做法就是text.replace(#, )把标题符号全删掉然后当普通文本处理。这等于主动丢弃了文档最重要的章节信息。正确思路是把 Markdown 解析成 AST抽象语法树再基于 AST 提取标题树、正文块、代码块、表格等元素。生产环境我推荐用markdown-it-py做解析它把整个文档转成 token 流每个 token 都标注了类型比如heading_open、inline、fence、table_open等。解析时遍历 token 流就能精确还原结构比自己写正则靠谱得多。理解核心思路比记住某个库的 API 更重要。3.2 重建标题树等于把一本书的目录重新做出来标题树是 Markdown 结构化解析的核心产出。它的逻辑很简单根据标题级别#到######把章节组织成一棵多叉树每个标题节点下面挂着属于它的正文内容子标题挂在父标题下面。构造这棵树我用一个栈结构来实现。遍历每一行遇到标题行时先判断级别凡是从栈顶开始级别大于等于当前级别的节点都弹出然后把新节点挂到栈顶节点的 children 下面。遇到正文行时把它收集到当前栈顶节点的 text 列表里。这样一趟扫描下来文档大纲就完整还原了。def build_heading_tree(text): lines text.split(\n) root [] stack [] in_code False for line in lines: stripped line.strip() if stripped.startswith(): in_code not in_code continue if in_code: continue m re.match(r^(#{1,6})\s(.)$, line) if m: level len(m.group(1)) title m.group(2).strip() node {level: level, title: title, children: [], text: []} while stack and stack[-1][level] level: stack.pop() if stack: stack[-1][children].append(node) else: root.append(node) stack.append(node) else: if stack: stack[-1][text].append(line) return root看到那个in_code状态判断了吗这是最容易漏掉的细节。代码块里的行可能以#开头但它只是程序注释不是文档标题。不加代码围栏判断解析出来的标题树必然会混入大量伪标题。我最初就是用纯正则在处理后来遇到一份 Python 教程的 Markdown里面代码块的注释全是# 这是配置项解析结果惨不忍睹。3.3 表格、列表、代码块和数学公式需要区别对待标题之后Markdown 里还有几类特殊元素处理策略完全不同。表格要整体保留。表格是典型的半结构化数据RAG 在回答数据类问题时依赖表格的完整性。把一个表格的行拆散、分到不同 chunk检索时上下文对不上模型很容易把第二行的值安到第一行的列名上。我的做法是遇到table_opentoken 后把整个表格语法块原样截取作为一个独立块保存并且把表头行作为该块的元数据。列表的处理要看情况。目录型列表、步骤型列表保持整体性更有用如果列表项太长切成几个小组比散成单个 bullet 更合理。简单列表项散开后语境断裂效果也不好。代码块必须避免被切碎。程序代码一旦被拆开语法结构和逻辑完整性就破坏了。向量化整段可执行代码比向量化碎片更有效检索时命中代码片段可以直接用。我在解析时会把 fence 代码块完整保留并记录语言类型作为元数据。数学公式同理。$...$行内公式和$$...$$块级公式如果被普通文本切分逻辑拦腰截断公式的意义就完全丧失。建议把公式块作为独立单元保留不参与文本级 chunk 切分。3.4 从结构到统一文档模型让下游只管消费结构解析的最终产物应该是一个统一文档模型而不是“txt 用一套 dict、Markdown 用另一套结构”。我设计了一个轻量的 Block 模型每种格式的解析器都输出同一种结构。dataclass class Block: content: str # 块内容 level: int # 标题层级正文为 0 heading_chain: list # 父级标题链比如 [第一章, 配置说明] block_type: str # heading / paragraph / table / code / formula metadata: dict # 来源文件、行号、语言、表头等这个模型的好处是下游的 chunking、向量化、索引逻辑完全不用关心上游是 txt 还是 Markdown只消费统一的 Block 列表。标题链字段尤其重要它把“一级标题 二级标题 正文”的上下文直接编码进了每个块检索命中时能一并提供给模型回答质量会有明显提升。4. 实操写一个能用、能扩展的通用解析框架4.1 框架整体设计一个入口吃两种格式这个框架我用 Python 实现入口函数parse_file(path)根据文件扩展名分发到不同的解析器。每个解析器接收原始文件内容返回统一的 Block 列表。框架内部只依赖标准库和charset-normalizer没有重型外部依赖方便在任意环境里快速跑起来。设计上的核心取舍有三个。第一按扩展名分发逻辑简单但不完全可靠有些文件扩展名是.txt实际内容是 Markdown所以我在分发前会先做一次内容嗅探检测是否存在标题语法。第二读取层和解析层分离读取层统一处理编码、BOM、换行符解析层只管内容结构职责清楚。第三所有解析器输出统一模型后续加.html、.docx解析器不用改任何下游代码。4.2 txt 分支清洗完直接按段落生成 Blocktxt 分支的逻辑分三步编码读取、清洗、段落切块。编码读取用第 2 节的方法这里不再重复。清洗时处理换行符统一、全角空格转换、零宽字符剔除然后按空行切分成段落。def parse_txt(path): text read_text_with_encoding(path) text clean_plain_text(text) blocks [] for para in split_paragraphs(text): blocks.append(Block( contentpara, level0, heading_chain[], block_typeparagraph, metadata{source: str(path)} )) return blockssplit_paragraphs的实现要注意一点不能只按单个空行切。很多 txt 的段落之间只有一个换行而没有空行尤其从 PDF 转换来的文本每行都硬换行。我一般先把行合并成逻辑段落规则是“只有结束标点或行尾无明显断句信号时才算段落结束”。中文场景可以用句号、问号、感叹号做判断英文看句子结束符。这块逻辑不复杂但影响很大直接决定后续 chunk 的边界是否干净。4.3 Markdown 分支标题树生成后收集叶子块和长文本拆分Markdown 分支是重点。我用markdown-it-py解析 token 流识别标题、段落、表格、代码块、公式五种元素然后按标题树的层级关系把正文挂到对应标题下。关键步骤是遍历 token 流时维护一个标题栈遇到heading_open就更新栈遇到paragraph_open、table_open、fence等就把内容做成 Block并把当前栈里的标题链赋值给heading_chain。def parse_markdown(path): text read_text_with_encoding(path) tokens MarkdownIt().parse(text) blocks [] heading_stack [] # 存标题链 current_level 0 for i, token in enumerate(tokens): if token.type heading_open: level int(token.tag[1]) while heading_stack and heading_stack[-1][level] level: heading_stack.pop() heading_stack.append({level: level, title: tokens[i 1].content}) current_level level elif token.type in (paragraph_open, fence, table_open): content extract_token_content(tokens, i) chain [h[title] for h in heading_stack] blocks.append(Block( contentcontent, levelcurrent_level, heading_chainchain, block_typetoken_type(token), metadata{source: str(path), line: token.map[0] if token.map else 0} )) return blocks长度控制放在 Block 生成之后。一级标题下的正文可能很长比如一个“安装步骤”章节有 3000 字超过向量模型输入上限。此时需要在 Block 内部做拆分拆分要优先在段落边界或句子边界切而不是硬截字符。拆出来的每个子块继承同一个标题链保证搜索引擎级上下文不丢。4.4 一个直观对比带标题链和不带标题链的检索差异我用一份实际的技术文档做过一个简单测试。文档结构是“第一章 配置说明 第二节 输入格式支持”里面有一句“支持 Markdown、HTML、PDF 和 Word 格式”。用户输入问题是“这个工具能导入哪些格式的文件”方案 A 是不解析标题整篇文本按 500 字符硬切。结果命中可能在某个中段chunk 内容是一堆关于参数的解释那句“支持多种格式”的文字可能被切到其他 chunk 里或单独出现但不带章节上下文。模型看到只有孤立一句回答容易含糊。方案 B 是带标题链的结构化 chunk。命中的块自带heading_chain: [第一章 配置说明, 第二节 输入格式支持]模型不仅能回答问题还能补充“该工具支持的格式范围在配置说明章节中有明确定义”。这种差异在真实项目中非常明显尤其文档章节多、上下文依赖强的内容。所以我的结论很直接解析阶段多花的半小时能在检索阶段节省十倍调优时间。5. 常见问题与排查实录真实项目里踩过的坑5.1 编码检测结果不可信怎么办charset-normalizer在小文件上准确率尚可但有些极端情况下会产出荒谬结果比如把 GBK 文件判定为 KOI8-R。我的补救办法是读文件后先看解码文本中正常中文字符的比例如果低于阈值就按业务常见编码列表依次尝试。对于中文知识库建议尝试顺序是utf-8-sig、gb18030、big5。还有一个实用技巧如果文本中有特定的领域词比如技术文档里的“API”“RAG”可以通过这些词在解码结果中是否出现来辅助判断编码正确性。5.2 Markdown 解析中几个最隐蔽的边界 case第一个是代码围栏内的内容被误判为标题解决办法就是前面代码里那个in_code状态标记。第二个是缩进式代码块四空格缩进在 Markdown 规范里也表示代码块但它没有围栏标记正则很难识别。遇到这种文档我建议统一做预处理把缩进式代码块转成围栏式再进解析器。第三个是表格和标题之间没有空行导致的解析错位。很多工具导出的 Markdown 格式不严格| 表头 |下面紧跟着### 标题解析器容易把表格行和标题混在同一个段落 token 里。这类问题我通常在解析前做规范化检查表格行前面是否有空行没有就补上减少错位概率。第四个是 Markdown 中嵌套列表的层级问题。二级列表项前面的空格数量不同不同解析器的处理结果不一致生成的 AST 也可能不同。对 RAG 来说这不太影响检索但如果你要按列表层级做结构化最好提前定好预期的解析行为并写测试用例锁定。5.3 分块与解析配合时最容易翻车的地方分块参数要和解析结构匹配。有人用了标题树解析却只用固定 800 字符切块结果一个章节被切成三块中间两块把标题链搞丢掉等于结构白做了。正确做法是先按结构生成大块再在大块内部按max_len做子切分子切分保留标题链。还有一个坑是 overlap 设置过大导致语义重复。overlap 的初衷是避免关键信息被切断但重叠部分过多会让向量数据库里出现大量近似重复的 chunk检索时既增加噪声也浪费存储空间。我建议 overlap 控制在 50 到 150 字符之间而且要尽量让 overlap 落在句子边界而不是字符边界。另外要提醒一点数学公式和代码块千万不要参与 overlap 切分。公式切重叠会直接生成残缺公式代码更是如此重叠复制出来的半截代码对检索没有任何帮助。这类特殊块就应该保持完整如果超长就直接报错或跳过不要硬切。从最开始折腾 RAG 到现在我自己最大的体会就是解析这件事值得当作基建来做而不是一次性脚本。编码处理、清洗策略、结构化逻辑、分块边界每一步都值得写单元测试锁定行为。后续你做 PDF、Word、HTML 解析时会发现这套 Block 模型和解析流程可以直接复用只是换了解析器而已。最后再分享一个小技巧解析完的中间产物建议缓存成 JSON 文件不管是调试还是后续微调解析逻辑都不需要重新读原始文件省下来的时间足够你测试好几次检索效果了。下一篇我会继续写 PDF 和 Word 这类富格式文档的解析方案到时候咱们接着聊。
返回列表