ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RAG数据导入必修课:从txt到Markdown的文本解构与清洗策略

RAG数据导入必修课:从txt到Markdown的文本解构与清洗策略 1. RAG 数据导入第一课为什么先卡在文本解析这一关做了几个 RAG 项目之后我最大的感受是RAG 的瓶颈根本不在模型而在数据准备。模型选得再好、向量化方案调得再花哨只要喂进去的文档解析出的是一堆乱码、错位段落、残缺标题后面检索质量必然崩盘。很多人在跑通 Demo 后进入真实业务场景第一反应是优化 embedding 或者换 rerank 模型但真正让线上效果提升一个档次的往往是把数据导入和解析这一层重新做扎实。我见过不少团队花了大几周时间在调 prompt、调召回参数结果最后发现问题是知识库里的文档用 PDF 解析器抽出来全是断行和乱序标题层级丢得一干二净语义检索时 chunk 把完全不相关的段落拼在一起。这个问题的根源就是没有做“结构化解析”只做了“文本抽取”。对于 RAG 项目来说数据导入不是把文件塞进系统就完事了里面的坑密度远超想象。不同来源的文档txt、markdown、PDF、word、HTML各有各的脾气而 txt 和 Markdown 恰恰是最“亲民”但也最容易被轻视的两种格式。本系列文章的第一篇我想把通用文本与结构化文档的处理思路完整地梳理一遍从 txt 到 Markdown从规则到工程化落地结合我实际项目里的踩坑经历给你一套能直接照着做的方案。这篇内容适合谁刚接触 RAG、正在搭建知识库但发现效果不理想的开发者后端工程师要做文档预处理管道的以及任何被“数据导入”搞到头大、想系统了解解析细节的学习者。我会尽可能把原理讲透同时给出能跑的代码和参数建议不讲虚的。2. 从 txt 到 Markdown文本解析的层级与边界2.1 txt 处理并没有你想的那么简单txt 看起来是最简单的文本格式没有样式、没有结构、没有元数据但恰恰因为“什么都没有”解析时反而需要你替它补足一切。我早期做过一个项目知识库里有一大批从旧系统导出的纯文本日志内容包括多级标题、表格用空格和制表符对齐、列表项甚至还有对话记录。直接用split(\n)切行再粗暴地按固定长度切 chunk检索效果只能用“惨”来形容。处理 txt 的第一步是先明确“文档内结构”从哪里来。常见做法有三条路按空行分段、按缩进和符号识别列表、按行首模式识别标题。按空行分段看起来最安全但遇到“标题与正文之间没有空行”的文档就废了。按符号识别需要配合正则处理各级标题第X章、1.1、一这类时要格外小心中文序号带来的匹配优先级问题。按缩进识别适合有一定排版规律的文本比如用四个空格或者 tab 缩进表示层级关系。这里有一个我强烈建议的执行顺序先做“文本清洗”再做“结构识别”最后才做“内容切分”。文本清洗需要处理编码问题GBK、UTF-8、BOM头这些都要能扛住还要处理全角半角不统一、多余空格、行尾回车符。踩过最大的坑是某些 txt 文件内混合了不同编码段落单文件用chardet检测也可能翻车后来我在导入端直接做了编码兜底策略检测置信度低于阈值就按 utf-8 带错误忽略处理并且在清洗阶段抽出原始行号方便后面对照原文。2.2 Markdown 的结构化红利与隐性混乱Markdown 相比 txt 已经有天然的结构信息标题用#标记、列表用-/1.标记、表格用管道符标记、代码块用反引号包裹。这些信息如果被丢掉那用 Markdown 做知识库输入就属于暴殄天物。解析 Markdown 的目标不再是“猜结构”而是“可靠地提取结构”。但 Markdown 也有自己的混乱。最大的问题是“语法不纯”GitHub 扩展语法、Typora 特有写法、各种 callout、脚注、数学公式、任务列表- [ ]这些东西在解析时要么被当普通文本处理掉要么因为正则写得不严谨把标题中的#误判成主题标签把有序列表编号当成多余文本。我在实际项目里用markdown-it和remark都做过解析层发现如果你只需要“标题层级 文本内容 表格内容 链接引用”remark的 AST 结构最清晰适合做结构化抽取如果你还要保留文档在网页端的渲染表现markdown-it更顺手。这里需要强调一个容易被忽略的点Markdown 的标题层级并不总是可靠的。作者可能为了缩小字号跳级使用##和####或者整篇文档只有一个# 标题其余全是##语义层级和视觉层级错位。所以在解析时我会对标题层级做一次“重映射”把跳级问题修掉生成一个连续的、树状嵌套的目录结构后续 chunk 切分直接依赖这棵树而不是依赖原始#数量。2.3 文本解析的通用原子操作不管是 txt 还是 Markdown底层都有一组“原子操作”可以复用。这组操作我用下来非常顺手按固定流水线处理统一换行符、去 BOM、去不可见字符规范化空格全角空格转半角合并连续空白识别段落边界空行分割合并单行短段抽取标题/列表/引用块等结构化元素表格识别按行列切割成结构化记录元信息抽取来源文件名、章节路径、页码或行号区间。这组操作做好之后任何格式的文档进入 RAG 前都能先被“归一化”成一种内部表示我称之为“中间态文档”。这个中间态可以是一份 JSON包含content、metadata、structure三个字段也可以直接落到 SQLite 或 parquet。有了中间态后续无论是做切分策略调整、embedding 重算还是做增量导入都会轻松很多不需要每次重新解析原始文件。3. 结构化解剖为什么 RAG 效果差根因往往在“解构”不彻底3.1 只做纯文本抽取等于给检索埋雷很多人理解“解析 PDF”就是用一个开源工具把 PDF 里的文字抽出来存成一个 txt然后开始切 chunk。这种做法的隐患在于文本流丢失了原有的语义边界。PDF 里一个表格被抽成一行行散落的文本一个跨页的段落被物理换行切成两半标题与正文的关系彻底断裂。这样构建的知识库检索时 query 打过来召回的内容内部缺乏连贯性LLM 再强也只能根据残片瞎编。所以要明确一个核心概念RAG 的知识粒度取决于你如何切分而不是如何 Embedding。切分之前必须先有结构。结构从哪里来从解析器对文档语义的理解来也就是把文档“解构”成树——文档 - 章节 - 小节 - 段落 - 句子。这个树状结构才是后续语义检索的主心骨。root 节点是文档元信息叶子节点才是最小内容单元。3.2 从“线性文本”到“语义树”chunk 切割的正确打开方式做 RAG 时大家都问“chunk_size 设多少合适”但如果你手里有一颗语义树这个问题就不该再是拍脑袋的。我一般采用这种策略先以“标题/段落”为单位划分最小单元再按 token 上限做聚合。最小单元就是树上的叶子节点聚合就是向上回溯把相邻的兄弟节点合并直到接近模型窗口上限。这种做法的好处是切出来的 chunk 天然有内部语义完整性且 chunk 之间不再重叠也能保证衔接自然。举个例子一篇 Markdown 文档有 40 个小节每节约 150 token如果固定按 500 token 切那约等于 5 个小节拼一个 chunk这 5 个小节可能主题不同语义分散。借助语义树就可以按“二级标题 - 下属所有段落”聚合形成一个主题一致的 chunk。我甚至会在 metadata 里存section_path如“安装指南 / Linux 环境配置 / 依赖安装”这样检索命中后能直接告诉用户答案来自文档的哪个章节可追溯性大大增强。3.3 表格与段落混合文档怎么解析才不丢信息业务文档里表格无处不在而很多解析方案对表格无能为力——要么把表格拍平成文本要么完全丢弃。表格里藏着大量结构化事实比如参数配置、对比数据、价格表RAG 检索对这类信息特别敏感。处理表格的正确思路是识别表格边界把每一行转成一个独立的“表行记录”保留表头作为该记录的 metadata。比如一个“服务器参数对照表”解析后每一行变成{header: [型号, CPU, 内存], row: [R240, Xeon 4210, 64G]}再把它序列化成一句话存进向量库如“型号 R240CPU Xeon 4210内存 64G”。这样 query 问“哪款机器是 64G 内存”命中的就是这一行而不是整段表格的模糊切片。同时表标题和上下文描述会被编入父级 chunk保证来源信息不丢失。这个方法在好几个项目里都救了大命尤其是在运维文档、设备台账、产品手册这类场景里。4. 实操搭建一套通用文本导入管道含可直接修改的代码4.1 从零构建“txt Markdown”导入器的完整代码我不喜欢过度封装先给你一套轻量级导入器。它做的事情包括读取文本、清洗编码、识别 Markdown 结构、抽取标题树、按语义树切 chunk。项目代码在 Python 3.9 以上都可以跑。import re import uuid from pathlib import Path from typing import List, Dict, Optional import chardet import markdown from bs4 import BeautifulSoup class TextImporter: 通用文本导入器支持 txt 和 markdown 的解析输出中间态文档。 def __init__(self, encoding_fallback: str utf-8, ignore_errors: bool True): self.encoding_fallback encoding_fallback self.ignore_errors ignore_errors def read_file(self, file_path: str) - str: raw Path(file_path).read_bytes() # 1. 编码检测与兜底 try: encoding chardet.detect(raw)[encoding] text raw.decode(encoding) except (UnicodeDecodeError, TypeError): text raw.decode(self.encoding_fallback, errorsignore if self.ignore_errors else strict) # 2. 统一换行去BOM去零宽字符 text text.replace(\r\n, \n).replace(\r, \n) text text.lstrip(\ufeff) text re.sub(r[\u200b\u200c\u200d], , text) return text def split_paragraphs(self, text: str) - List[str]: # 按空行分割并清理每个段落 blocks re.split(r\n\s*\n, text) paragraphs [re.sub(r\s, , b).strip() for b in blocks] return [p for p in paragraphs if p] def parse_markdown_ast(self, md_text: str) - str: # 先用 markdown 库转 html再用 bs4 抽结构这里保留 html 是为了后续解析 html markdown.markdown(md_text, extensions[tables, fenced_code, sane_lists]) return html def extract_md_heading_tree(self, html: str) - List[Dict]: soup BeautifulSoup(html, html.parser) tree [] stack [] for tag in soup.find_all([h1, h2, h3, h4, h5, h6, p, table, pre]): level int(tag.name[1]) if tag.name.startswith(h) else None if level: node { type: heading, level: level, text: tag.get_text(stripTrue), children: [], } # 将当前标题挂到最近的一棵树上 while stack and stack[-1][level] level: stack.pop() if stack: stack[-1][children].append(node) else: tree.append(node) stack.append(node) else: content tag.get_text( , stripTrue) if tag.name pre: content tag.get_text(stripTrue) if content and stack: stack[-1][children].append({type: content, text: content[:500]}) return tree def to_intermediate(self, file_path: str, source_type: str auto) - Dict: text self.read_file(file_path) ext Path(file_path).suffix.lower() if source_type auto: source_type markdown if ext in (.md, .markdown) else txt structure None if source_type markdown: html self.parse_markdown_ast(text) structure self.extract_md_heading_tree(html) return { file: str(file_path), type: source_type, text: text, structure: structure, } # 使用示例 if __name__ __main__: importer TextImporter() doc importer.to_intermediate(README.md, source_typeauto) print(doc[file], doc[type]) if doc[structure]: print(doc[structure][:3])这段代码不算复杂但已经能覆盖 80% 的“txtMarkdown”导入需求。实际生产中建议把to_intermediate的返回值直接序列化为 JSONL 或者写进对象存储方便后续管道阶段消费。其中split_paragraphs和extract_md_heading_tree是纯函数可以单独做单元测试。4.2 表格数据处理的扩充方案表格在 Markdown 中是很常见的用内建正则可以把表格解析为列表数据。思路是先筛选管道符开头的连续行再拆分成列。需要注意表头行和分隔行要单独识别单元格内可能还有段落或代码片段。下面是一段补充解析代码我常用它来做“表格行记录结构化”。def parse_md_table(md_text: str) - List[Dict]: table_pattern re.compile(r^\s*\|.*\|\s*$) lines md_text.splitlines() tables [] current_table [] for line in lines: if table_pattern.match(line): current_table.append(line.strip()) else: if current_table: tables.append(current_table) current_table [] if current_table: tables.append(current_table) parsed_tables [] for tbl in tables: rows [] for i, line in enumerate(tbl): cells [c.strip() for c in line.strip(|).split(|)] if i 1 and all(re.fullmatch(r:?-{3,}:?, c) for c in cells): continue # 分隔行 rows.append(cells) if rows: headers rows[0] for row in rows[1:]: parsed_tables.append(dict(zip(headers, row))) return parsed_tables这个方案在数据量不大时完全够用。如果表格嵌套复杂比如合并单元格、多行表头就需要接入专业的表格解析工具或者干脆优先把源文件转成 HTML 再按table结构抽取。我的经验是能用 Markdown 表格解决的问题尽量别上复杂工具因为后者一旦引入模型推断反而会在确定性上出幺蛾子。4.3 从文本块到向量“文档”的切分策略文本解析完成后还要有个“检索单元生产器”。实际项目中我会把切分策略做成可配置项针对不同文档类型选择不同策略。下面是两个最常用的策略标题感知切分Heading-Aware从 Markdown AST 里拿到标题树把同一章节下的内容聚合为一个 chunk适合技术文档、使用手册。段落感知切分Paragraph/Table-Aware按段落和表格行生成叶子记录再按上级标题向上聚合适合 FAQ、参数说明书、接口文档。切分成 chunk 时我一直沿用一套固定 metadata 字段{ chunk_id: uuid, source_file: README.md, section_path: 安装指南#Linux环境配置, chunk_type: paragraph, start_line: 120, end_line: 138, text: …… }有了这套 metadata后续调试检索问题时效率会高很多。你直接看source_file和section_path就能定位不需要肉眼去比对原始文档。这在知识库条目数上万时尤为重要否则调试一次要老命。4.4 工具选型解析这些库别乱用各有适用场景市面上的文本解析库很多但每个的侧重点完全不同。我给团队梳理过一个选型表这里也分享出来场景推荐方案理由纯文本 txtPython 内建 正则简单可靠不引入额外依赖Markdown 结构抽取remark / markdown-itAST 解析稳定插件生态丰富Markdown 转 HTML演示python-markdown / pandoc兼容表格、代码块、脚注复杂表格提取PDF/HTMLCamelot / pdfplumber / DeepTable需要位置信息时用 camelot表格完整时用 pdfplumber办公文档Word/Excelpython-docx / openpyxl文档对象模型清晰适合结构化读取PDF 常规文本PyMuPDF / pdfplumber速度快版面还原度适中OCR 场景PaddleOCR / Tesseract扫描件必须走 OCR别用文本抽取硬来这里特别提醒一下遇到扫描版 PDF 千万别拿常规 PDF 解析库硬抽抽出来全是乱码和白字效率浪费严重。正确姿势是先过 OCR 把图像转为文本层再进下面的结构化管道。我踩过这个坑教训很痛。5. 常见问题与排查技巧实录5.1 编码问题中文 txt 导入后全是乱码这种问题一般出现在 Windows 平台生成的 txt编码是 ANSIGBK而 Linux 服务端默认 utf-8。使用chardet.detect()大多数时候能判断出GB2312或GBK但短文本少于几百字节检测准确率很低。经验做法是读取前先根据文件前 4 个字节判断有没有 BOM再结合内容特征兜底比如正则匹配常见的 GBK 中文字符范围和常见标点。系统里配置一个“编码检测失败默认用 GBK 解码”的开关对国内客户数据会好用很多。5.2 Markdown 解析漏掉代码块中的 # 注释解析 Markdown 时如果先按#正则匹配标题代码块里的# include stdio.h或者 Python 的# 注释会被误判成标题。这是一个经典坑。解决思路有两条一是先解析代码块并用占位符替换再做标题识别二是使用 AST 解析器如 remark天然区分代码块和标题。我更推荐第二种因为代码块的边界判定用正则会漏掉 在不同缩进下的情况。类似的问题还有行内代码# 标题误判同样需要 AST 级别处理。5.3 表格解析后行列错位检索结果对不上行错位的主要原因是表格单元格内包含管道符比如|或者代码块中的竖线直接按|分隔就会崩溃。处理技巧是在解析前先做一次“转义保护”把单元格内的管道符替换成特殊占位符等列切分完再还原。更保险的方式是直接用支持 Markdown AST 的解析器因为 AST 里表格是结构化节点不再依赖正切分。如果必须用正则至少在分隔前把单元格内的\|转义处理掉。5.4 chunk 之间信息割裂单条 chunk 理解困难即使做了语义树切分某些长段落仍可能被 token 上限切到两段。这时候我还会给每条 chunk 增加“上下文摘要字段”把父级标题 首段前两句拼成一个 prefix和 chunk 正文一起存储。检索时这个 prefix 可以参与 embedding也可以在 LLM 收到上下文前拼在正文前。这样能有效减少检索命中后答案前言不搭后语的情况实测回答连贯性提升明显。5.5 常见问题速查表症状可能原因处理建议导入的 txt 乱码编码检测失败默认用了错误编码用 chardet 检测 GBK 兜底按 BOM 头判断Markdown 标题全部丢失解析时没有走 AST用正则被代码块干扰切换到 remark/markdown-it AST 解析表格数据检索不中表格被拍平成文本或行列错位按表行转记录表头作为 metadata章节路径全为空没有提取标题树或者标题层级混乱做标题层级重映射生成规范化树同一来源文档多次导入重复项没有做内容 hash 去重对每段正文算 sha256写库前查重导入速度极慢逐条调用 embedding 接口批量 50~100 条一次加本地缓存5.6 增量导入与去重知识库能持续长大的关键知识库不是一次性导入就结束的业务文档会持续更新。增量导入时最容易出现的两个问题就是重复插入和更新残留。我的做法是每一条 chunk 都保存source_filesection_pathcontent_hash三个字段组合的唯一索引。新增文档时先把 source_file 下的旧 chunk 全部标记为不活跃再插入新 chunk。内容没变的行保留内容变了或删掉的行自动过期。这个方案比全量删除重建的效率高很多也保留了历史追溯能力。6. 文档结构化另一面Markdown 数学公式、Callout 与富文本的处理6.1 数学公式与大模型上下文如何保留公式语义RAG 场景中常碰到含 LaTeX 数学公式的文档比如学术论文用 Markdown 写的笔记。公式在解析时有三个选择保留 LaTeX 源码、渲染成图片、转成文本描述。对大模型来说LaTeX 源码其实是更友好的输入只要 embedding 模型在训练时见过类似语法。但如果公式是行内公式夹杂在文字里切分 chunk 时容易把公式切成两半导致上下文破坏。处理这种场景我会在切分时用\(...\)或$...$作为不可分割的边界标识切分器在这一标识处不切断。如果公式较长单独作为一个 chunk 类型在检索到公式时让 LLM 按“公式解释题”处理效果会比混在段落里好很多。6.2 Callout、折叠块、任务列表怎么处理GitHub 风格的 callout如 [!NOTE]在现在的技术文档中出现频率很高。这类 blockquote 不只是引用文字它带有语义类型note、warning、tip。解析时可以将其作为独立结构块提取并在 metadata 中标记block_type: callout和callout_type: note。任务列表- [ ]/- [x]则建议保留状态因为它们往往承载了待办或完成信息后续问答中用户可能会查“哪些还没做”。折叠块details在普通 Markdown 下会被当作 HTML 标签提取时要专门处理否则内部内容会全部丢失这属于很容易被忽略的结构坑。6.3 多级列表的层级处理多级有序/无序列表1. 2. 以及 - 的嵌套在解析时如果只提取 tag 文本层级就扁平化了。我会在列表解析时用缩进级别来恢复层级生成类似root / item2 / subitem2.1的路径并把这个路径拼进 section_path。这样用户问“配置步骤第 2 步下面的关键参数”时检索能准确定位到嵌套列表深处的内容而不是把整个列表当一大片文本检索。实际调试中这个优化对开发文档类知识库效果很显著。7. 实战中的经验总结与进一步优化7.1 结构化管道不需要一步到位很多团队一上来就想要完美方案恨不得把 PDF/CAD/音视频全解析了。我的建议是先把手头最常见的 2~3 种格式吃透大概率是 txt、Markdown、Word/PDF形成一套“解析中间态 chunk 生产 metadata 管理”的基线能力再逐渐扩展格式。因为每种格式的解析都需要单独调试一步到位不现实。7.2 质量评估一定要做建一个“召回对照测验集”没有质量评估的 RAG 是盲人摸象。我每做一个知识库项目都会让业务方出 30~50 对有代表性的 query 和标准答案片段组成一个小的测验集。每调一版解析或者 chunk 策略就在测验集上跑一遍召回算 Hit Ratetop5 是否包含答案。这是唯一能理性评估“解析层改动到底有没有变好”的方法。否则大家凭感觉调参最后全在玄学里打转。7.3 后续系列内容预告这一篇主要覆盖 txt 和 Markdown 的通用文本与结构化解下一篇我会重点讲 PDF 和 Word 这类“伪结构化”格式的解析策略包括版面分析、表格还原、OCR 流程的工程化处理。再后面会抽时间整理 chunk 策略实验笔记不同粒度对于检索和生成质量的影响以及 embedding 模型选型对比。如果你在实际导入过程中遇到“怪文档”也欢迎在评论区把样例内容丢出来我见过足够多奇葩格式能帮你看看解构思路。最后送大家一句话RAG 拼到最后拼的是数据工程不是模型魔法。把数据导入与解析这关做扎实你的知识库就已经赢了大多数人。
返回列表