资讯动态

RAG数据导入与解析:从txt清洗到Markdown结构化

发布时间:2026/10/8 10:48:12 来源:尧图企业网站定制
1. RAG 数据导入为什么第一个瓶颈不在模型而在喂进去的东西做 RAG 知识库的人大概都经历过这样一个阶段模型换了好几个Embedding 模型也挑了又挑向量库从 FAISS 迁到 Milvus重排序也上了结果一问到具体业务问题答案还是东拼西凑、前后矛盾。这时候很少有人会怀疑到数据头上——毕竟数据就在那儿txt 文件、Markdown 文档、PDF 表格看起来都读得出来。但实际排查下来80% 的检索质量问题出在数据导入和解析环节。我之前接过好几个RAG 效果差的咨询最后都是数据侧出了问题标题层级丢了、段落被切成碎片、列表项被拦腰斩断、表格变成了一堆无意义的字符串。用户问第三季度的营收是多少系统召回的是第三季度营收这几个词散落在不同 chunk 里的片段而不是那句完整的第三季度营收为 12.3 亿。这篇是 RAG 数据导入与解析系列的第一篇聚焦最基础也最容易被低估的一条线从 txt 这类纯文本出发做通用文本清洗、段落还原、Markdown 结构化最终让 RAG 的检索单元具备清晰的层级关系。为什么先讲 txt因为它最裸——没有样式表、没有内嵌元数据、没有任何可供依赖的格式信息能把 txt 啃干净再回头处理 Word、PDF、网页转存文本基本就是降维打击。2. 拿到 txt 之后的第一件正事编码、清洗、分段2.1 编码识别90% 的 txt 解析事故都出在这很多人在做文本解析时第一步就写open(file, encodingutf-8)这是典型的理想主义。现实中的 txt 文件来源五花八门Windows 记事本默认还是 GBK或 GB18030Mac 导出的可能带 UTF-8 BOM老旧系统导出的是 UTF-16LE还有一些内网系统直接给你 ANSI 编码。编码判断错后面全是乱码乱码进了向量库检索时匹配到的就是一堆锟斤拷烫烫烫。我一般用一个两层的识别策略先看 BOMEF BB BF是 UTF-8FF FE是 UTF-16LEFE FF是 UTF-16BE。有 BOM 就直接按对应编码读基本不会错。没 BOM 就用chardet或charset_normalizer做概率推断。实测下来charset_normalizer对中文语料的识别更稳一些尤其是 GBK 和 UTF-8 混合的场景。from charset_normalizer import from_bytes def detect_encoding(data: bytes) - str: if data.startswith(b\xef\xbb\xbf): return utf-8-sig if data.startswith(b\xff\xfe): return utf-16-le if data.startswith(b\xfe\xff): return utf-16-be result from_bytes(data).best() return result.encoding if result else utf-8注意一个小坑utf-8-sig和utf-8在读取时会差一个 BOM 字符。Python 里utf-8-sig会自动去掉 BOM而用utf-8读会把\ufeff当成内容带进来。这个不可见字符要是混进了文本后续做关键词匹配或者清洗时特别容易出怪问题。2.2 文本清洗清单不是所有字符都该进知识库编码搞定之后下面这些脏数据几乎每次都会遇到全角与半角混用中文引号、括号、逗号混进了英文标点或者反过来。这部分如果不统一检索时你好世界和你好,世界会被当成两个完全不同的句子。控制字符\x00、\x1a这类隐藏字符常出现在从旧系统导出的文本里。多余的空白与空行连续三四个换行、行尾多余空格、每段开头的全角空格老中文排版习惯会干扰后续基于空行的段落切分。乱码残片锟斤拷、烫烫烫、遇到这种直接标记该文件为疑似编码错误放到人工复核队列。页眉页脚残留从 PDF 或网页复制来的文本经常带着第 1 页 / 共 20 页目录上一篇|下一篇这类噪声。我推荐把清洗逻辑做成一个可配置的管道按顺序处理。常用的一段清洗代码大致长这样import re def clean_text(text: str) - str: # 去掉 BOM text text.lstrip(\ufeff) # 统一换行 text text.replace(\r\n, \n).replace(\r, \n) # 控制字符保留换行和制表符 text .join(ch for ch in text if ch \x20 or ch in \n\t) # 全角空格 / 不间断空格转半角 text text.replace(\u3000, ).replace(\xa0, ) # 连续空行压缩为最多一个 text re.sub(r\n{3,}, \n\n, text) # 去掉行尾空白 text \n.join(line.rstrip() for line in text.split(\n)) return text.strip()这里最容易被忽略的是顺序先统一换行符再做控制字符过滤再压缩空行。如果顺序反了\r\n里的\n可能被当成控制字符删掉或者空行压缩逻辑被破坏。这种细节平时不痛不痒一旦文本量上了几万篇累积的影响就很明显。2.3 基于空行的段落切分先有段落才谈结构化清洗完的文本第一件事是切成段落数组。最简单的规则就是按连续两个换行分。但我做了这么多年解析发现只靠这个还不够还要结合几种常见情况做兜底一行的长度超过某个阈值比如 200 字且没有标点结尾大概率是合并了多个段落需要在句号、感叹号、问号后补切。标题下紧跟正文、中间没有空行的情况很多旧文档格式不讲究需要等结构化阶段通过标题特征来区分。诗歌、代码、表格这类特殊格式空行切分会把它们打散要在切分前先保护起来。切完之后我会给每个段落打上基础标签段落索引、字符数、是否以句号结尾、是否包含数字/英文、首行是否有缩进。这些标签不需要很复杂但在后面的标题识别和分块策略里能省掉大量启发式规则的重复判断。def split_paragraphs(text: str) - list[dict]: raw_paras [p.strip() for p in re.split(r\n\s*\n, text) if p.strip()] paras [] for idx, para in enumerate(raw_paras): paras.append({ index: idx, text: para, length: len(para), starts_with_indent: para.startswith( ) or para.startswith(\t), ends_with_terminal: para.endswith((。, , , ., !, ?)), }) return paras2.4 元数据补全让 txt 文件也带上身份证纯文本文件最吃亏的地方是没有自带元数据。PDF 还有标题、作者、创建时间Word 还能读 document propertiestxt 就真是一张白纸。但这不代表我们只能将就。在导入阶段我会做三层元数据补全来源信息文件名、文件路径、导入批次、源文件哈希值MD5/SHA256。这些字段后续在做增量更新和数据去重时是硬需求。内容指纹对清洗后的文本内容做 SimHash 或其他 minhash用于检测同一份内容在不同路径下的重复。RAG 知识库里重复文档的危害比想象中大同一段内容如果以 90% 的相似度进了三个 chunk检索结果里就会有三条互相矛盾的回答。结构信息这是我们后面用 Markdown 结构化的产出——标题路径、段落类型、文档大纲。结构信息要单独存不要拼进文本内容里否则会增加向量存储的噪声。元数据的存储我建议用 JSON Lines 格式每行一条记录方便后续拆进各种向量库的 payload 字段。下面这条记录基本上是我所有导入任务的标配{file: docs/2024/产品说明书.txt, content: …清洗后的正文…, meta: {title: 产品说明书, source: file, batch_id: 20250115, md5: abc123, heading_path: [产品说明书, 第二章, 安装步骤]}}3. 从 txt 到 Markdown通用结构化解析的实战打法3.1 为什么中间格式选 Markdown而不是直接上 Word/PDF 解析拿到清洗后的段落数组接下来要做的不是直接分块而是先结构化。结构化之后的载体我几乎总是选 Markdown。原因很直接Markdown 的语法子集足够小标题、列表、表格、引用、代码块这几种元素已经覆盖了绝大多数文档的结构表达需求。它不像 HTML 那样有一堆 div/span/class 要处理也不像 Word 的 docx 需要解析 XML。Markdown 天然可分割转成 Markdown 之后每个结构元素都有明确的边界后面做 heading-based 分块极其顺手。可读、可审计解析结果是人眼能直接复查的。出了问题打开文件一眼就能看到是标题层级错了还是表格散架了调试成本低。当然不是所有场景都适合 Markdown 中转。如果你要解析的是复杂的 PDF 排版多栏、浮动图、页眉页脚交错Markdown 会丢失大量布局信息那应该用 layout-aware 的解析器比如基于目标检测模型的文档解析框架。但那是另一个话题今天这条链路解决的是通用文本——即纯文本、无复杂排版、以段落与标题为主要结构的内容。3.2 标题识别启发式规则的组合拳txt 没有样式表标题和正文在视觉上可能只是多了一个换行、或者行首有几个空格。我们要靠规则恢复标题层级。我的做法是把多级规则按优先级组合数字序号模式第[一二三四五六七八九十百]章、^\d(\.\d)*[、.]、^\d\.\d\s\S。这种最典型基本可以断定是标题。行长度与结尾特征一行不超过 30 字且不是以句号、逗号结尾即不是完整句子同时它后面一两行内是空行或另一标题判定为标题候选。连续标题特征真实的标题往往成组出现比如第一章 xxx第二章 xxx。当你发现一个文档里超过 3 个类似模式的行这一整类都可以升级为标题。与已知标题风格一致性前面识别出1.1 xxx后后面出现1.2 xxx哪怕长度略长也优先纳入标题。识别出标题行之后我们需要给它分配层级。这里有个细节txt 里没有级别信息第一章和1.1哪个是一级标题需要上下文推断。我的习惯是第X章、第X部分、连续数字序号的第一级1.视为一级标题1.1、1.1.1依点号数量递增层级第一章 第一节这种中文层级再单独映射。最终统一转成标准 Markdown 的#到######六级。import re HEADING_PATTERNS [ (re.compile(r^第[一二三四五六七八九十百零]章\s*(.*)$), 1), (re.compile(r^第[一二三四五六七八九十百零]节\s*(.*)$), 2), (re.compile(r^(\d)\.(\d)\.(\d)(\.\d)*\s*(.*)$), 3), (re.compile(r^(\d)\.(\d)\s*(.*)$), 2), (re.compile(r^(\d)[、.]\s*(.*)$), 1), ] def detect_heading(text: str): for pattern, level in HEADING_PATTERNS: m pattern.match(text.strip()) if m: return level, .join(m.groups()) return None注意这里匹配到1.作为一级标题时要小心那些以1.开头的正常列表。区分方法是看后续行如果1.后面紧跟的段落比较长超过 50 字且后面还有2.那它大概率是列表项而非标题如果1.后面是1.1或2.且各自很短那它们是同级标题。这几个规则不完美但在通用文本上准确率已经做到了 90% 左右剩下的可以靠下一节的 LLM 兜底。3.3 列表、引用与代码块的识别标题之外的三种结构元素我按下面这套逻辑处理列表行首以-、*、、•、·、数字加点1.开头且后面有空格。连续多行同类前缀视为同一个列表块。转换成 Markdown 时无序列表统一转-有序列表保留原始数字层级通过缩进2 空格判断。引用行首以开头的在转 Markdown 时保留。还有一种情况——段落缩进非常深超过 8 个空格在原文里可能是引用或注释我会把它转成引用块而不是正文避免它和正文混在一起污染分块。代码块一致缩进的连续行通常是 4 空格或 Tab 开头且在上下文里像代码含等号、括号密度高、有编程关键字转成 fenced code block。这一步很关键因为代码块的语义是整块一体如果被切开后续分块策略会非常难看。识别出代码块之后我会先把它从段落流里抽出来单独存等 Markdown 组装时再放回去。列表识别有个常见翻车点项目符号-同时是 Markdown 的无序列表语法。如果原文用全角或中文破折号开头就未必是列表。我做了一层简单的 whitelist——只有 ASCII 的-/*/后跟空格才算列表项全角符号一律还原成普通文本避免误识别。3.4 表格的宽松识别与规范化txt 里的表格通常有两种形态。第一种是 Markdown 风格的管道分隔行| 项目 | 数量 | 备注 | |------|------|------| | A | 10 | 好 |这种直接保留就行。第二种更常见也更麻烦用空格或 Tab 对齐的伪表格比如项目 数量 备注 A 10 好 B 20 也好对第二种我的策略是走 tab 分割优先空格对齐次之。规则是如果一行里包含两个以上的连续多个空格或 Tab作为分隔且相邻行的分隔位置大致一致就尝试解析为表格。更稳妥一点的做法是借助文本表格解析工具比如texttable或pdfplumber的 table 策略但通用文本里我一般就用正则加对齐判断。表格在 RAG 里是个特殊存在。把整张表塞进一个 chunk长度可能超限切成多行又丢失了表头上下文。我的建议是把表格转成 Markdown 表格后表头单独保留每行数据前拼接上表头的 key 列表形成列名值的键值对文本再做分块。这样既压缩了 token又保留了语义。def table_to_keyvalue(md_table: str) - str: lines [l.strip() for l in md_table.strip().split(\n) if l.strip()] if len(lines) 2: return md_table headers [c.strip() for c in lines[0].strip(|).split(|)] rows [] for line in lines[2:]: cells [c.strip() for c in line.strip(|).split(|)] row_kv .join(f{h}{c} for h, c in zip(headers, cells) if c) rows.append(row_kv) return \n.join(rows)3.5 LLM 结构化兜底该用的时候别省别用的时候别滥用规则解析有天花板比如第一章和1.1混排、标题没有序号只有粗体字样但 txt 里没有粗体、或者内容里大量是对话体、散文体。这时候我会引入 LLM 做二次结构化。实践经验是不要试图用 LLM 处理全部文档成本高且不稳定只让它处理规则引擎标记为不确定的行。具体做法是规则解析后输出一份带置信度的结构化草稿。置信度低的行比如标题候选但特征不足单独汇总成一批连同上下文一起发给 LLM让它判断这一行是不是标题、属于什么层级、以及前后段落该归到哪个标题下。这一步通常用便宜的模型就够了因为输入输出量都很小。# 伪代码示例实际调用按你选的模型调整 prompt f以下是一段纯文本的行请你判断该行是否为标题。 - 如果是标题输出 level 1-6 和规范化后的标题文本 - 如果不是标题输出 NOT_HEADING 行内容{line} 上下文前一行{prev_line} 上下文后一行{next_line}LLM 兜底要控制使用比例。如果一个批次的规则置信度低于 30%同时文档量巨大那很可能这个文档根本不是通用文本结构比如是日志文件、数据 dump、诗歌集这时候应该直接走纯分块策略而不是跟结构化死磕。后面第五节我会讲哪些数据不适合走 Markdown 路线。4. Markdown 结构在 RAG 检索链路里怎么发挥作用4.1 按标题层级切块的三种模式与代码示范Markdown 结构化的意义在分块阶段才真正体现。固定字符数切块fixed-size chunking不关心语义边界是最省事也最伤检索效果的做法。有了标题树我一般采用三种切块模式按一级标题切块粗粒度每个一级标题下的整段内容作为一个超长块。适用于文档本身较短的场景或者作为后续细切的基础。按二级标题切块中粒度大多数知识库问答场景的首选。一个二级标题对应一个主题块内语义相对内聚长度通常在 300-800 token。混合粒度如果某个二级标题下的内容特别长超过 1500 token再在其内部按三级标题细切如果某个三级标题下内容很短不足 200 token就连同它的上下文归入上级块。在 LangChain 的MarkdownHeaderTextSplitter里可以通过headers_to_split_on指定参与切分的标题层级。但如果你不想被框架绑死自己写也不难——本质上就两步解析 Markdown 的标题行和内容片段然后维护一个当前标题栈。def split_markdown_by_heading(md_text: str, max_tokens: int 800): lines md_text.split(\n) heading_stack [] # [(level, text)] chunks [] current {path: [], text: []} for line in lines: m re.match(r^(#{1,6})\s(.*)$, line) if m: level len(m.group(1)) heading_text m.group(2) # 弹出更深层级 while heading_stack and heading_stack[-1][0] level: heading_stack.pop() heading_stack.append((level, heading_text)) # 检查当前块长度决定是否归档 chunk_text \n.join(current[text]) if chunk_text.strip(): chunks.append({path: list(current[path]), text: chunk_text}) current {path: [h[1] for h in heading_stack], text: []} else: current[text].append(line) # 收尾 if current[text]: chunks.append({path: current[path], text: \n.join(current[text])}) return chunks这里有个细节值得说明heading_stack弹出逻辑用的是而不是。也就是说当遇到同级标题时前一标题块要立即归档内容不能累积到下一个同级标题下面。这个小点很多人容易写错结果就是一个标题块包含了后续好几个兄弟标题的内容检索时每个 chunk 的中心主题被稀释召回精度下降。4.2 元数据注入让每个检索单元自带出身分块完成后每块都带着heading_path这一步要做的就是把路径信息揉进最终存储的文档对象里。为什么重要因为单看安装步骤这个块模型不知道它是哪个产品的安装步骤但你给它一条元数据heading_path: [产品说明书, 第二章, 安装步骤]再拼进 prompt检索答案的指向性会清晰得多。我常用的做法是把 heading_path 序列化为两种形式嵌套数组[产品说明书, 第二章, 安装步骤]存入向量库的 payload 或 metadata 字段便于结构化过滤。拼接字符串产品说明书 第二章 安装步骤拼进实际 embedding 的文本前面让向量本身也携带层级语义。这招实践效果不错代价是 embedding 输入变长一些成本可接受。record { id: f{file_md5}_{chunk_index}, text: chunk[text], embedding_input: .join(chunk[path]) \n chunk[text], meta: { source_file: file_name, heading_path: chunk[path], chunk_index: chunk_index, token_count: estimate_tokens(chunk[text]) } }4.3 结构化知识库与纯文本/向量库的配合策略RAG 落地的时候不只是 Markdown 结构化这一种路线。很多团队最后会用混合检索向量召回为主关键词检索BM25 或全文索引为辅再配合重排序。当你手里已经有一份 Markdown 结构化数据还能多做两件事标题路径过滤如果用户问题里提到第二章或安装步骤可以用元数据里的 heading_path 做一次预过滤把检索范围直接缩小到某个子树。这本质上是一种轻量级的规则路由能显著降低召回噪音。父子块检索从 Markdown 结构里天然得到了父子关系——二级标题块是父三级标题块是子。检索命中子块后可以向上补充父块摘要这样喂给 LLM 的上下文既有细节又有概览。实现上就是在切块时多维护一个parent_id字段。结构化知识库和纯文本向量库不是二选一。我现在做的项目里通常是同一份文档同时存两份一份是 Markdown 结构化后的父子块带 heading_path一份是清洗后的原始全文。前者走向量检索 摘要增强后者走全文关键词兜底。两者的召回结果再合并、去重、重排序。5. 实测对比与踩坑复盘5.1 同一批语料结构化前后召回效果对比我拿一批真实的中文技术文档约 200 篇共 300 多万字内容涵盖产品手册、接口文档、故障排查指南做了对比测试。测试方式是 100 条人工标注的 QA 问题看命中率回答完全正确或部分正确的比例处理方式命中率观察到的典型失败模式直接按 512 字符固定切块61%跨标题内容粘连、答案被切到两个块清洗 按段落切块74%段落级语义完整但缺少段落归属清洗 Markdown 结构化 标题路径可检索89%失败集中在标题识别错误的文档提升主要来自两方面一是语义边界对齐一个块内只讲一个二级标题下的事情Embedding 表征更集中二是标题路径的额外召回信号很多问题是靠产品说明书 第二章 安装步骤这条路径直接定位的。5.2 解析过程中最常见的五类问题与完整排查链路第一类全篇乱码但开头几行是正常的。这种情况通常是文件头部用了 UTF-8中间插入了一段 GBK 内容常见于多系统拼接生成的文本。单纯调一个全局编码解决不了。我的排查链路是按行读取对每一行做编码有效性检测把解码失败的行单独摘出来尝试用另一种编码重解最后把两种编码的段落拼接成统一的 UTF-8 文本。若重解后仍失败才放弃该文件。第二类标题识别准确但标题块长度失衡。比如目录识别成了标题后面跟着几百个条目或者致谢标题下只有一句话。遇到这个问题我会加一条规则标题下内容小于某个阈值比如 100 字时把它并入父块标题下内容超过 3000 字时强制按段落二次切分。第三类Markdown 表格被分块切断。这是分块阶段最容易出现的尴尬事切块逻辑按标题走标题下有一张 50 行的表表被从中间切断后半张表接在下一个块里。解决方式是在切块工具里加入表格保护逻辑——遇到|开头的连续行先整体提取为一个块再按行数拆分成多个表头行的迷你块。第四类列表项前面的序号被当成标题。前文提过如果一行是1. 安装步骤后面跟的正文超过 100 字它是个标题但如果1.后面是 5 个字的句子且前面有其他更明显的标题模式那它多半是列表。我的兜底规则是一旦一个候选标题行的前后行里存在两个以上同类模式且它们的行长度分布差异很大就把这些行降级为列表项。第五类编码检测与清洗把有效内容误删了。有一次我把一段包含 emoji 的文本当控制字符删了导致整段内容丢失。后来把清洗规则改成白名单制明确保留字母、数字、常见中文与标点不确定的字符先不删只做标记。清理不等于过度清理宁可留下一点噪声也不能把有效信息误杀。5.3 哪些文本不适合走txt 转 Markdown这条路不是所有文本都适合统一走 Markdown 结构化。以下三类我的经验是绕开对话流/聊天记录没有标题结构整篇是换行接换行。硬套标题识别规则经常误判效果反而不如老老实实固定窗口切块 轮次合并。超大文件单文件几十 MB正则清洗和段落切分还好但标题树会非常庞大分块后动辄几万个 chunk管理成本极高。我的做法是先按文件大小或章节数量做预切割切割完再走同一套流程。代码仓库源码如果知识库目标是代码问答直接把源文件按 Markdown 处理是灾难。源码有自己天然的语法树应该用针对代码的分割策略按函数、类、模块边界切而不是拿无意义的 Markdown 标题硬套。最后说一句个人体会。我见过不少团队在 RAG 搭建初期把精力全放在选型上——今天换个 Embedding 模型明天换个向量数据库后天上一套 RAGFlow 或者 LlamaIndex。这些当然重要但真正决定问答质量下限的往往是导入环节那些不起眼的细节编码能不能读对、标题层级能不能还原、表格会不会被切碎。先把喂进去的东西弄干净后面的模型、索引、重排序才有发挥空间。这一篇讲的是通用文本路线下一篇我打算写 PDF 和 Word 的结构化解析以及它们和 Markdown 结构化之间的衔接问题。

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

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

免费获取报价 →
↑