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

文章详情

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

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

RAG数据导入实战:txt与Markdown结构化解析全攻略 做RAG项目我见过太多团队在数据导入这一步就翻车。模型选得再贵、向量化方案再先进喂进去的文档要是解析得乱七八糟召回质量照样稀碎。尤其当你面对一批txt和Markdown格式的存量资料时看起来简单真处理起来全是细节txt没有结构Markdown有结构但我们经常不会用稍不留神就给知识库埋了一堆雷。这篇攻略要解决的就是这个痛点。我会从一个真实可落地的角度把RAG场景下txt和Markdown的通用文本解析与结构化解讲透。内容包括为什么要在这步花力气、txt和Markdown各自的解析重点、实际动手怎么写解析与分块代码、以及我在项目里踩过的坑和排查技巧。适合正在搭建RAG知识库的开发者、数据工程师以及那些被“召回不准”折磨得想换模型的朋友——看完你会明白很多时候瓶颈根本不在模型而在数据进门的那一步。1. 解析这件事为什么决定了RAG的天花板1.1 先看RAG流水线里解析发生的位置一条典型的RAG链路长这样数据导入 → 内容解析 → 文本分块 → 向量化 → 索引存储 → 检索召回 → 大模型生成。绝大多数人会把注意力放在向量化和检索策略上但我想先强调一个常被忽略的事实解析和分块这两个环节决定了天花板后面的检索和生成只是在这个天花板下面做优化。举个例子。你有100份技术文档里面有标题、表格、代码块、嵌套列表。如果解析阶段把表格压成一行行互不关联的碎片把代码块和上下文拆散那不管之后用多好的embedding模型检索时都很难把完整语义找回来。这就像盖楼数据清洗是地基地基歪了楼上装修再豪华也没用。所以在RAG项目里“数据导入”从来不是复制粘贴文件那么简单。它至少包含四个动作读取原始文件、识别文档结构、清洗文本噪声、按语义切出合理单元。这四个动作的集合就是我们常说的“结构化解析”。1.2 解析翻车现场三个真实例子我说三个自己或同事真实遇到过的场景你对照一下是否眼熟。第一件某个知识库直接把一大本txt说明书整块塞给向量模型。5000多字的文本切分时因为没有任何分隔只能硬按固定字符长度切结果第二章的标题和第一章结尾被切到同一个块里。用户问“第二章怎么操作”召回回来的却是第一章的操作步骤答案自然完全跑偏。第二件从Notion导出的Markdown文档里面大量使用嵌套列表和表格。直接把Markdown当纯文本丢掉标记符号后分块导致每行的缩进和列表父子关系消失。原来“步骤A-子步骤1-子步骤2”的逻辑链变成了一堆平铺的句子召回时模型根本看不出层级关系回答经常前言不搭后语。第三件Markdown图片相对路径失效。文档里的图片引用是./images/架构图.png但知识库导入时图片没有跟着走解析后只留下一句“架构图.png”。用户问“系统架构是什么样”模型只能根据文件名猜多模态信息彻底丢失。你看这些问题都不涉及高深算法纯粹是解析阶段没有想清楚。但它们对最终效果的影响比换一个embedding模型大得多。1.3 txt与Markdown知识库里的两种典型输入做RAG知识库你手里拿到的存量资料说白了就两大类。一类是txt日志、旧版说明书、爬下来的纯文本资料、从某个老旧系统里导出的数据。另一类是MarkdownGitHub仓库的README、技术博客导出、语雀或Notion的文档备份、各种笔记软件的原生格式。当然还有PDF和Word但那是另一个话题这篇先不展开。txt和Markdown放在一起看很有意思它们代表了两端。txt是完全没有结构的纯文本连标题都只能靠人工约定去猜。Markdown则是一种轻量级标记语言标题、列表、表格、代码块这些结构信息就摆在那里问题是你怎么把它们利用起来。我习惯用一张表来对比这两种格式的解析特点维度txtMarkdown结构信息几乎没有需启发式重建丰富标题/列表/表格/代码块自带语义解析难点段落划分、标题猜测、编码繁乱树结构维护、表格保真、代码与公式保护对RAG的价值适合整段语义召回适合层级化切分召回精准度更高常见来源老系统导出、日志、资料库GitHub、技术博客、笔记软件备份核心结论是txt你要给它“造结构”Markdown你要让它“别丢结构”。下面两部分我分别展开讲。2. 格式决定思路txt与Markdown的解析重点2.1 txt没有结构就替它造结构解析txt的第一步是解决编码问题。国内存量txt文档最常见的编码是GBK和UTF-8偶尔还有GB2312和BIG5。如果用UTF-8的默认方式去读GBK文件直接乱码。这个我放在后面的排查章节细说但你要记住解析txt的第一个动作永远是检测编码。第二步是识别段落和标题。txt也不是完全没有边界信号空行就是最朴素的段落边界。而标题往往有一些约定俗成的特征整行文字居中或加粗在纯文本里表现为短行、不换行、以“第X章”“X.X节”开头、全大写、后面跟一个短横线和数字编号比如“1.1 系统架构”。但我要提醒一点基于正则的启发式标题识别准确率能做到80%就不错了剩下一部分需要人工标注或后续用模型补。我的做法是把标题识别的风险控制在“宁可漏不要错”。因为错把正文当标题会让分块边界彻底乱掉而漏掉标题最多是某个块大了一点召回时还不至于出大问题。第三步是把段落拼接成完整句子。txt里经常存在硬换行比如每行40个字就换行但这不是语义上的段落结束。我通常会做一步“行合并”先按空行切出段落块再把块内部所有硬换行拼成一段连续文本最后按中文标点重新划分句子边界。这一步对后续语义分块特别重要——否则一个完整句子会被切进不同的块里。2.2 Markdown结构信息别浪费Markdown解析的核心是结构树。Python生态里我常用mistune或markdown-it-py做解析它们会把整个文档转成token或节点树比如heading_open、inline_code、table_open、blockquote_open。我们需要做的不是把Markdown转成HTML再剥掉标签而是直接从token树里读出结构。为什么要强调这一点因为直接剥掉Markdown标记符号得到的纯文本会丢信息。比如## 2. 系统模块 - 模块A负责登录 - 子模块A1负责SSO - 模块B负责报表如果剥掉标记得到的是2. 系统模块 模块A负责登录 子模块A1负责SSO 模块B负责报表看起来似乎差不多但“子模块A1”和“模块A”的父子关系没了用户问“登录模块有哪些子功能”时检索系统就不知道A1属于A。所以在做RAG分块之前我建议先用解析器把文档建树把每个块都挂在对应的标题路径下。这个标题路径后面就是我们的元数据也是召回时的重要上下文。另外Markdown里的表格、代码块、公式、图片这四类元素需要特殊处理。表格不能当成普通文本切分要保留表头和行列关系代码块要保留语言标识公式要防止被切碎图片至少要把alt文本和图片路径转成可检索的文字。这些我放在章节3.4详细说。2.3 结构化解的中间格式设计不管输入是txt还是Markdown我最终都会把解析结果转成一种统一的中间格式方便后续分块和向量化。这个格式我用JSON表示核心就三块内容文本内容、语义类型、元数据。下面是我常用的输出结构{ id: chunk_0001, text: 系统采用微服务架构核心模块包括用户服务、订单服务和网关。, metadata: { source: docs/系统设计.md, title_hierarchy: [系统设计, 总体架构], element_type: paragraph, table_info: null, chunk_seq: 1 } }你可能发现这个设计和OpenAI的prompt无关纯粹是数据工程的一部分。但实际效果上结构化元数据能带来几个明显好处第一召回时可以按title_hierarchy做粗粒度过滤第二多路召回时可以用element_type区分段落、表格、代码块针对性设计召回策略第三给大模型生成时提供上下文来源方便溯源。别怕定义这个格式的过程麻烦它是一次投入、长期受益的工程决策。3. 实操从txt到Markdown的通用解析方案3.1 环境准备与工具选型我统一用Python来演示依赖不多核心就这几个库chardet编码检测mistune或markdown-it-pyMarkdown解析re正则表达式用于启发式标题识别、清洗如果后面接向量化可以配langchain-text-splitters但核心逻辑我们手动实现更可控安装很简单pip install chardet mistune markdown-it-py工具选型我有几句经验。mistune的优点是轻量、扩展性好适合自己写插件处理表格和代码块markdown-it-py的优点是社区包多能支持更多Markdown扩展语法。个人建议项目里两个都装解析需求多的时候互为兜底。不要用python自带的正则硬剥Markdown太脆碰到嵌套列表基本就崩了。3.2 第一步读取文件并检测编码不管是txt还是Markdown读文件时的流程统一先读字节做编码检测再用正确的encoding打开。代码很简单import chardet def read_text_file(file_path): with open(file_path, rb) as f: raw_data f.read(1024 * 4) result chardet.detect(raw_data) encoding result.get(encoding, utf-8) # 有些老文件是 GBK/GB2312chardet 可能识别为 GB2312转成兼容编码统一处理 if encoding.lower() in (gb2312, gbk): encoding gbk try: with open(file_path, r, encodingencoding) as f: return f.read(), encoding except UnicodeDecodeError: # 兜底用 utf-8 忽略错误解码 with open(file_path, r, encodingutf-8, errorsignore) as f: return f.read(), utf-8(ignore)注意不要只读4KB就让chardet下结论有些文档前面是英文注释、后面才是中文正文检测容易误判。我一般会读文件大小的前64KB或者直接全量读入再检测。另外识别为GB2312的时候你按GBK去解码往往更稳妥因为GB2312是GBK的子集这样能避免生僻字解码失败。3.3 第二步基础文本清洗清洗这一步我给的心得是“少即是多”。别做太激进的清理容易把有用信息删了。我通常只做四件小事统一换行符为\n把\r\n和\r统一掉。删除连续空行中的多余空行最多保留一个作为段落边界。清理行尾的空白字符。把全角空格转成普通空格。其余像“去除特殊符号”“压缩空白”这类操作我基本不做。因为你不知道哪些特殊符号是不是代码片段的一部分。比如一个Markdown代码块里可能就有一个表情符号或全角符号你把它删了代码就缺东西了。清洗函数def clean_text(text: str) - str: if \r\n in text: text text.replace(\r\n, \n) text text.replace(\r, \n) # 连续空行合并为单空行 while \n\n\n in text: text text.replace(\n\n\n, \n\n) lines [] for line in text.split(\n): lines.append(line.rstrip()) text \n.join(lines) text text.replace(\u3000, ) return text.strip()3.4 第三步Markdown解析与结构树构建Markdown解析我直接用markdown-it-py拿到token流但token流是扁平的需要自己栈式建树。我贴一段简化的建树逻辑from markdown_it import MarkdownIt md MarkdownIt(commonmark, {html: False}).enable(table) def build_tree(tokens): root {type: root, children: []} stack [root] for token in tokens: if token.type heading_open: node { type: heading, level: int(token.tag[1]), content: , children: [], } stack stack[: token.level 1] if len(stack) token.level else stack # 简化按实际层级裁剪栈 parent stack[-1] parent[children].append(node) stack stack[: token.level] # heading之后新节点挂到该heading下 stack.append(node) elif token.type inline: if stack: stack[-1][content] token.content elif token.type fence: parent stack[-1] parent[children].append({ type: code_block, lang: token.info, content: token.content, }) elif token.type table_open: # 遇到表格就累积直到 table_close ... return root这个代码我做了大量简化真实场景还需要处理list_item_open、blockquote_open等节点。但核心思路你get到就行通过栈结构维护父子关系得到一棵“标题树内容节点”的文档语义树。得到树之后分块就非常直观了遍历树以“标题路径”作为每个块的锚点每遇到一个二级或三级标题就把之前的文本收拢成一个候选块。块太小则向上合并到父级。块太大则递归按段落再切。这就是LangChain里MarkdownHeaderTextSplitter的原理自己实现一遍会有底很多。3.5 第四步txt的启发式标题识别对txt而言没有解析器可用我们只能靠启发式规则造标题。我常用的规则按优先级排序以“第X章”“第X节”“X.X.X”开头且该行长度不超过30字符的行。全大写且长度不超过50字符的行。后面紧跟空行、且整行没有句号结尾的短行。用“一、二、三”或“1. 2. 3.”数字序号开头的行。提醒一个经验txt标题识别千万别匹配“纯数字点”的模式太容易误判。像“2024.3.1”这种日期、像“版本1.2.3”这种版本号都会被误当成标题。我加了两个过滤条件排除以“版本”“V”开头的行排除包含“年/月/日”的行。宁可不识别不要瞎识别。3.6 第五步表格、代码块与公式的专项处理这三类内容加起来是Markdown解析里最容易翻车的地方我分开说。表格的解析策略是“转成自然语言描述或key-value文本”。不要想着保留Markdown表格原样那对向量检索和LLM生成都不友好。看这个例子| 模块 | 负责人 | 状态 | | --- | --- | --- | | 用户服务 | 张三 | 已完成 | | 订单服务 | 李四 | 开发中 |转成表格模块状态列表。模块用户服务负责人张三状态已完成。模块订单服务负责人李四状态开发中。这样转的好处是语义完整检索“订单服务的负责人是谁”时能直接命中。而且行与行之间不会因分块被切散。转换代码不复杂遍历token树里的table节点拼成自然语言段落即可。代码块的解析策略是保留语言标签和代码内容把它作为一个独立大块处理。注意代码块内的换行是硬换行不能在分块时自主合并否则代码语义就废了。如果你用固定长度切分一定要优先把整个代码块当作不可分割的单元。在向量召回时代码块的语言标签可以作为元数据字段这样用户搜索“python如何实现循环读取”时可以额外对代码块做加权。数学公式的解析策略是“保护”。Markdown中的行内公式$...$和块级公式$$...$$在分块时经常被拦腰切断导致公式变成乱码。我自己的做法是先做“占位符替换”把整个公式提取出来替换成__MATH_1__这种标记正常清洗和分块之后再换回去。这样可以确保一个公式完整存活在一个块里。公式如果很多建议整篇文档单独配一个公式库索引不要让公式靠全文检索硬扛。图片的解析策略则要分情况。最基础的做法是提取![alt](path)中的alt文本和图路径拼成“图片alt文本位置path”存入块中。如果图片本身是关键内容比如架构图、流程图而你的RAG链路里接入了视觉模型或图片向量化的能力那就不要丢弃图片文件。先把相对路径解析成绝对路径再把图片转存到对象存储或本地目录最后在解析结果里保留一个图片ID。检索时如果发现text命中还可以把图片预览一起返回给用户。这一步投入不小但对文档型知识库提升明显。3.7 第六步元数据注入与落盘解析完每个块最后一步是打元数据并落盘。元数据字段我按“通用专项”两类来维护通用字段包括source_file原文件名、absolute_path、file_formattxt/md、import_time、chunk_id。专项字段按文件类型变化。Markdown文档加title_hierarchy、heading_path、element_type、table_summary、num_code_linestxt文档加detected_encoding、para_seq、estimated_titles。这些字段不用太全够用即可关键是方便排查问题。落盘格式我用JSONL一行一个块处理方便后续接向量库也好走增量。落盘时有个小坑JSONL里如果text包含换行符缩进和可视化调试会比较乱。我建议在text里保留\n毕竟这是语义的一部分但打印时转为⏎显示方便肉眼检查。4. 常见问题与排查技巧实录4.1 txt乱码与编码误判这是存量txt导入时的头号问题。表现是块内容里全是“锟斤拷”“烫烫烫”这类字符。前者是UTF-8字节被GBK解码的经典产物后者是未初始化内存的乱码跟编码无关通常意味着文件本身有问题或读取方式出错。我的排查路径是第一步用chardet检测原始字节记得读全文件前64KB而不是开头几KB第二步如果检测结果是Windows-1252或MacCyrillic这类明显不合理的编码直接改判为GBK第三步对检测结果存疑时用小段样本用可能编码各解一次肉眼对比中文是否通顺第四步给每批次导入任务记录一张编码分布表发现编码集中在某几种时提前做人工校验。4.2 Markdown表格被切碎导致检索失效这是一个很隐蔽的坑。你以为Markdown解析器处理了表格但分块策略还是按字符数硬切一个一行三列的表格可能被切到三个块里。你检索“字段存在哪些”时某一块只有表头另一块只有数据召回质量自然稀烂。正确做法在3.6已经说了优先把表格整体转成key-value文本并作为一个不可拆分单元。如果你用的是固定尺寸的分块器也要提前把表格提取出来单独入块。我在项目里验证过同样一批表格文档表格单独入块后针对表格内容的检索准确率提升了接近一倍。4.3 Markdown换行规则导致的语义割裂Markdown有个特点同一段落里如果源码里是单换行渲染后其实不换行除非行尾有两个空格或一个空行。很多从语雀导出的文档换行符用的是\n而非两个空格但内容本身是同一个段落。如果我们在清洗阶段不处理这个特点按空行切段时可能把同一段落拆成多块。解决办法清洗阶段把“单个换行符”统一当成空格处理只有双换行才保留为段落边界。等你清楚Markdown的语法规则后就可以放心做这个合并不会丢语义。4.4 图片路径失效与多模态信息丢失这是Markdown文档导入知识库时特有的大坑。Markdown里的相对路径./images/xx.png在原始环境里能正常显示但导入知识库时如果不把配套图片目录一起搬过来解析出来的就只有一串路径字符串。我的处理习惯是解析前先扫描文档里所有图片引用把相对路径解析为绝对路径无法解析的路径直接丢弃但把文件名提取出来作为纯文本兜底有条件的情况下对成功定位的图片用本地视觉小模型生成一段描述文本文本和图片描述一起入知识库。成本确实有但对架构图、流程图类内容的价值极大。4.5 公式被拦腰切断后的诡异结果公式最大的坑不是公式本体难解析而是分块时把它切碎。尤其行内公式$...$它混在普通段落里固定长度切分很容易把$结束标记切到下一个块导致检索时看到一堆无意义的半个公式。我的方案是加一层“公式保护预处理”扫描全文把块级公式$$...$$先替换为占位符__BLOCK_MATH_1__行内公式替换为__INLINE_MATH_1__在最终分块完成后把占位符恢复成公式原文。这样公式可能还会出现在某个块里但绝对不会被切碎。4.6 问题速查表我把上面提到的问题整理成一张表方便你排查时直接对照现象可能原因处理建议全是乱码编码检测失败换chardet全文件检测按GBK兜底段落与段落粘连清洗阶段没有按空行切段双换行保留为边界单换行合并表格检索不到表格被固定长度切碎表格转key-value文本单独入块局部语义混杂标题层级未参与分块基于标题树分块注入title_hierarchy图片信息缺失路径失效或未处理多模态路径转绝对或提取文件名兜底公式乱码分块切开公式占位符保护恢复时整段还原嵌套列表层级丢失直接剥Markdown标记用token树维护父子关系写在最后的一个小技巧如果你现在手头正好有一批文档要导入RAG别急着写代码。先抽出十分钟用文本编辑器随便打开5份文档观察它们的标题样式、表格形态、换行习惯。你会发现每份文档都有自己的一套“潜规则”——有的表格带合并单元格有的标题编号全是满格数字有的图片路径根式写成/docs/imgs/。这批观察结果就是你的解析规则清单。数据导入和解析这个环节我第一次做的时候也低估了它的难度后来被召回质量反复毒打才意识到这是整个RAG工程里最值得花时间打磨的部分。这篇讲的是txt和Markdown后面PDF和Word的结构化解析坑只会更多等我有空再单独写一篇续集把表格跨页、扫描件OCR、Word样式污染这些实战问题也聊一聊。
返回列表