
做 RAG 项目做多了我越来越觉得有个环节被严重低估数据导入与解析。很多人一上来就调 embedding 模型、换 rerank结果文档喂进去检索质量还是稀碎。问题往往不在模型而在源头——你的 txt、Markdown、PDF 到底有没有被干净、有结构地解析出来。这个系列我打算按文件类型一条条讲第一篇先把最基础也最容易被轻视的通用文本处理讲透从 txt 到 Markdown到底该怎么导入、清洗、结构化以及这些动作和 RAG 检索效果之间藏着哪些坑。这篇内容适合正在搭 RAG 知识库、做文档问答系统的开发者也适合刚接触知识库工程、还没把“解析”当回事的初学者。读完你能拿到一套可直接落地的处理思路和代码骨架更重要的是搞清楚每个步骤背后的“为什么”。1. 解析这件事才是 RAG 的上游命门1.1 一次完整的数据导入长什么样先把 RAG 的数据链路摆出来。一个典型流程是文档加载 - 解析与清洗 - 结构化 - 分块 - 向量化 - 入库 - 检索 - 生成。大多数人把注意力放在向量化和生成上但真正决定效果上限的其实是前面的“解析与结构化”环节。为什么这么说因为检索质量取决于两件事向量库里存的文本是不是“干净且语义完整”的块用户查询时能不能召回这些块。如果原始文档里满是页眉页脚、乱码、断裂的段落、错误的换行不管 embedding 模型多强向量空间里也被这些噪音撑得乱七八糟。我见过一个项目喂进去一堆从网页上直接复制的 txt结果正文里混着“查看更多”“点击下载”“上一篇下一篇”之类的导航文字。检索“产品保修条款”的时候召回的块里一半是垃圾导航信息。这不是模型问题是上游没做解析。数据导入不是“把文件读进来”就完了它决定了下游所有环节的地基。1.2 txt 看着简单为什么反而最考验解析功底txt 可能是所有人觉得最简单的文件格式但实际处理起来它比 PDF 和 Word 更容易翻车。PDF 至少还有页面结构可以提取Word 有样式和段落对象txt 只有一个字符流什么都没有。你拿到的 txt 可能是 UTF-8可能是 GBK可能是从老系统导出的 ANSI甚至可能一个文件里混着两种编码。没有样式、没有边界、没有隐藏的 DOM 节点解析全靠你自己定义规则。更麻烦的是很多 txt 是从网页、PDF、聊天记录里复制出来的半成品有手动加的标题编号有空格对齐的“伪表格”有被打断的段落有全角半角混排。这些不规整的地方恰恰是 embedding 模型最不擅长处理的东西。模型把一个 800 token 的块编码进向量空间如果其中一半是乱格式语义重心就被稀释了。所以我把 txt 比作“素颜文本”——没有美颜、没有修饰你的解析规则就是它的化妆师。规则定得好后续所有环节轻松规则定得差后面每一步都在还债。1.3 Markdown通用文本与结构化之间的“桥”既然 txt 太“裸”那为什么标题里特别强调要从 txt 转到 Markdown因为 Markdown 是一种“半结构化”格式它介于纯文本和完整结构化数据之间对人和模型都非常友好。Markdown 用 # 表示标题层级用 - 或数字表示列表用 | 表示表格用 表示代码块。这些标记天然定义了文本的边界和层级。对 RAG 来说这意味着两件事一是分块时可以顺着标题层级切而不是盲人摸象般按字符数硬切二是向量化时这些标记能帮模型理解文本结构减少语义噪音。我在实际项目里会把 Markdown 当作“中间表达层”从 txt 清洗后生成规范的 Markdown后续要转 JSON、转知识图谱、做父子块切分都是从 Markdown 出发。相当于先把原材料加工成半成品之后炒菜怎么炒都顺手。如果你跳过这一步直接拿原始 txt 切块入库等于把带泥的菜直接下锅。2. txt 读取真正决定后续解析成败的细节2.1 编码检测与 BOM 处理读取 txt 第一个大坑就是编码。Windows 记事本另存的 txt 可能是 ANSIGBK从 Linux 服务器上下载的可能是 UTF-8macOS 上某些老软件可能生成 UTF-16LE。如果你直接用open(path, r, encodingutf-8)去读一个 GBK 文件轻则乱码重则直接抛UnicodeDecodeError。处理方案是先检测再读取。Python 里最常用的库是chardet它会对字节内容做统计推断。但它不是万能的短文本推断经常出错连中文短句都可能被误判成 KOI8-R 或 ISO-8859-1 之类的奇怪编码。所以我一般会先抽样文件头部和尾部的若干字节做检测再把检测结果和常见编码列表做一次匹配命中优先。还有一个细节是 BOM。UTF-8 文件开头可能带\ufeff字节序标记读取后第一行最前面会多出这个不可见字符。一眼看不出来但它会让“第一行内容”在切块、匹配时出诡异问题。解决方式很简单用utf-8-sig编码读取Python 会自动剥掉 BOM或者读取后手动s.lstrip(\ufeff)。我建议在项目入口写一个统一的read_text_file函数内部处理检测、BOM、异常回退而不是到处撒open()。这样后面所有解析流程都从同一个干净入口取数据出问题时也只需要改一个地方。2.2 换行符、大文件与隐藏字符换行符是另一个隐形杀手。Windows 用\r\nLinux 和 macOS 用\n。Python 的open()在文本模式下会做通用换行转换默认把\r\n转成\n所以大部分情况下你感知不到差异。但一旦你用二进制模式读取或者用split(\n)手工切分行尾就会残留\r这个字符会跟着文本一起进入 embedding。更隐蔽的是各种“看起来像空格但并不是空格”的字符全角空格\u3000、不间断空格\xa0、零宽空格\u200b、各种 emoji 和控制字符。这些字符在屏幕上可能看不见但会对文本语义产生干扰还会让渲染和切块结果异常。建议在清洗阶段就把它们归一化或移除正则表达式[\u200b-\u200d\ufeff]之类的范围可以覆盖常见干扰。大文件也要单独处理。一次性read()一个几百 MB 的 txt 会吃光内存而且分块处理时也容易卡死。按行迭代是最稳的with open(path, encoding...) as f: for line in f:这样每次只在内存里保留一行。清洗规则写到循环里再逐步累积到当前块块满了就 yield 出去。2.3 一段可复用的 txt 读取代码我直接给一段我在项目里反复用的读取函数包含了编码检测、BOM 清理和基础字符归一化可以直接抄进你的工具库。import re import chardet from pathlib import Path def detect_encoding(path: Path, sample_size: int 4096) - str: 检测文本编码抽样文件头尾综合判断。 with open(path, rb) as f: head f.read(sample_size) f.seek(max(0, f.getbuffer().nbytes - sample_size)) tail f.read(sample_size) sample head tail # 优先判断 UTF-8 BOM if sample.startswith(b\xef\xbb\xbf): return utf-8-sig if sample.startswith(b\xff\xfe) or sample.startswith(b\xfe\xff): return utf-16 result chardet.detect(sample) encoding result.get(encoding, utf-8) or utf-8 # 中文场景下常见编码修正 if encoding.lower() in (gbk, gb2312, gb18030, big5): encoding gb18030 # gb18030 是 GBK 超集兼容性最好 return encoding def clean_special_chars(text: str) - str: 清理不可见/易混字符保留结构和正文内容。 # 去掉 BOM 和零宽字符 text text.replace(\ufeff, ).replace(\u200b, ).replace(\u200d, ) # 去掉 Windows 换行符中的 \r text text.replace(\r\n, \n).replace(\r, \n) # 全角空格、不间断空格归一化为半角空格 text text.replace(\u3000, ).replace(\xa0, ) return text def read_text_file(path: str) - str: 读取 txt 文件的统一入口。 p Path(path) encoding detect_encoding(p) try: with open(p, r, encodingencoding) as f: return clean_special_chars(f.read()) except (UnicodeDecodeError, LookupError): # 检测失败时退化成逐字节替换的宽松解码 with open(p, rb) as f: raw f.read() return clean_special_chars(raw.decode(utf-8, errorsignore))这段代码的核心思路是先猜编码、再读、再清理。注意gb18030的选择——它是 GBK 的超集客家字、生僻字都能解出来比单独指定gbk稳得多。errorsignore是最后一道防线宁可丢一两个字符也不能让整个导入流程崩溃。3. 从 txt 到 Markdown把“文本”升级成“半结构化”3.1 转换前先问自己这份文本里有什么拿到一份 txt 后不要急着写正则先抽样读几行问自己三个问题它的标题长什么样它的列表用什么符号它有没有伪表格、代码块、网址比如一份操作手册标题可能是“第一章 系统概述”“1.2 环境准备”列表可能是“1. 安装依赖 2. 启动服务”伪表格可能是用空格对齐的参数说明。而一份合同文本可能只有自然段标题层级藏在“第X条”里。不同文本你的转换规则权重完全不同。我习惯先写一个“结构画像”函数统计标题候选行、列表候选行、空行比例、平均行长度。跑一遍之后你对这份 txt 的“脾气”就有数了。这个画像直接决定你后续的转换规则用激进还是保守模式。比如空行比例特别高的说明原文分段清晰切块时可以放心依赖空行平均行长度特别大的可能是从 PDF 复制出来的连续文本就要注意段落重排。3.2 标题、列表、段落的启发式识别Markdown 转换的核心是把文本中“隐含的结构”显式化。第一个要识别的是标题。常见的中文 txt 标题模式有第X章、第X节、X.Y.Z、一、二、三、【标题】、数字编号 空格。我用的策略是正则先行加规则兜底先匹配显著模式再对未命中的短行做长度判断——如果一行少于 30 字、以数字开头、不以句号结尾就可以候选为标题。但这里有个经典歧义“1. 打开配置文件”是标题还是列表项我的处理方式是如果这种模式连续出现多行按列表处理如果出现后接大段正文且后面没有再连续编号则按标题处理。实际项目中可以做一个白名单和黑名单把“目录、前言、附录”这类通用词纳入标题识别把“如、例、注”等引导词排除掉。列表识别相对简单行首匹配[-*•]或\d[.、)]即可但要注意全角符号。转换时把•和-统一为 Markdown 的-把1.保留为有序列表。段落转换的核心是空行分割两个空行之间的内容视为一个段落段落内的单个换行在中文场景下往往应该合并为空格但英文或代码场景下换行有时有意义需要按场景配置。3.3 表格、链接、代码块的保守处理很多人看到 txt 里有竖线或者空格对齐的“表格”就忍不住要转成 Markdown 表格。我劝你克制。纯文本里的表格往往是残缺的字段对不齐、分隔线缺失、单元格里还有换行。硬转出来的表格在向量化阶段可能是一堆碎片反而伤害语义。我建议保守策略如果文本里存在明显的|分隔且每行列数一致再转成标准 Markdown 表格如果只是空格对齐的伪表格直接转成列表描述比如“参数名值”这种键值对格式。别小看键值对它对 embedding 的友好程度非常高后面做字段级检索也方便。链接和邮箱则可以用正则捕捉。https?://\S、[a-zA-Z0-9._%-][a-zA-Z0-9.-]\.[A-Za-z]{2,}把这些替换为[原文](原文)形式。这样 LLM 看到链接时知道它是一个实体而不是一串乱码 token。代码块的识别依赖缩进。如果某一段连续的行都以 4 个空格或 Tab 开头且语言特征明显比如包含def、function、{}就包进 代码块。这一步对技术文档至关重要因为代码一旦被分块切断检索命中率会直线下降。3.4 一个轻量转换器的实现思路与代码我写过一个不到两百行的轻量转换器专门处理“让它足够好”而不是“让它处理所有情况”。核心逻辑分四层先做字符清洗再做结构识别标题、列表、段落、表格、代码、链接然后按识别结果逐行输出 Markdown最后做一次整体校验。import re def guess_title(line: str) - bool: 启发式判断当前行是否为标题。 line line.strip() if not line: return False if re.match(r^第[一二三四五六七八九十百千0-9][章节篇], line): return True if re.match(r^\d(\.\d)*\s\S, line) and len(line) 40 and not line.endswith((。, , )): return True if re.match(r^[一二三四五六七八九十]、, line): return True if re.match(r^【., line): return True return False def txt_to_markdown(text: str, merge_line_in_para: bool True) - str: lines text.split(\n) result [] current_para [] in_code_block False for raw_line in lines: line raw_line.rstrip() # 代码块检测缩进或围栏 if line.startswith() or line.startswith( ) or line.startswith(\t): if not in_code_block and current_para: result.append( .join(current_para).strip()) current_para [] if line.startswith(): in_code_block not in_code_block result.append(line) else: if not in_code_block: result.append() in_code_block True result.append(line) continue if in_code_block: result.append(line) continue # 空行结束当前段落 if not line.strip(): if current_para: result.append( .join(current_para).strip()) current_para [] result.append() continue # 标题 if guess_title(line): if current_para: result.append( .join(current_para).strip()) current_para [] result.append(f## {line.strip()}) continue # 列表 if re.match(r^\s*[-*•]\s, line): if current_para: result.append( .join(current_para).strip()) current_para [] result.append(line.replace(•, -, 1).strip()) continue if re.match(r^\s*\d[.、)]\s, line): if current_para: result.append( .join(current_para).strip()) current_para [] result.append(line.strip()) continue # 普通行累积到当前段落 if merge_line_in_para: current_para.append(line.strip()) else: result.append(line.strip()) if current_para: result.append( .join(current_para).strip()) return \n.join(result)注意我并没有在这个版本里做表格和链接转换因为那需要更多上下文判断更适合做成插件模式。核心是把标题、列表、段落、代码块这四件事先做好。这四点已经能覆盖绝大多数从网页/PDF复制来的 txt 文档。你可以在入口处先跑txt_to_markdown然后人工抽查前 50 行确认标题识别率和段落合并是否符合预期再决定是否调整阈值。4. 分块策略Markdown 结构化之后的临门一脚4.1 分块不是“数着字数切”而是顺着语义切解析完成后下一个动作就是分块。很多教程告诉你 chunk size 设 500、overlap 设 50跟着抄就行。但实测下来盲目按字符数切块是 RAG 效果差的头号原因。把一段完整论述从中间拦腰截断前半段和后半段分别变成两个语义残废的块检索时不是漏掉信息就是召回到半截话。打个比方分块就像切西瓜顺着纹路切每块都带着完整的果肉乱刀剁下去籽和皮混在一起吃着硌牙。Markdown 的标题就是西瓜的纹路。你既然辛辛苦苦把 txt 转成了带结构的 Markdown分块时就必须利用这些结构而不是回到“无脑按长度硬切”的老路。具体操作是按标题层级递归切割。先按一级标题切出大章节再按二级标题切出子节每个子节内部再按段落切。如果一个子节超过 chunk_size 上限再用 overlap 滑窗补充切分。这样切出来的每块都有明确的主题边界语义完整度远高于纯长度切块。4.2 标题感知的层级切分与父子块设计我这里重点推荐一种工程上非常好用的策略父子块Parent-Child Chunking。逻辑是用子块小粒度比如 200-300 token做向量检索用父块大粒度比如 1500-3000 token作为 LLM 的上下文。先让子块精准命中用户问题再把子块所属的父块整体喂给模型做生成。Markdown 的标题层级天然适合做父子映射每个标题下的整节是父块节内的每个段落是子块子块的 metadata 里记录父块 ID 和标题路径。当用户问“第三章节的安装步骤”时子块命中“安装步骤”而父块提供了章节上下文LLM 不会答得没头没尾。实现上我会在解析后的 Markdown 上做一个简单的树形扫描def hierarchical_chunks(markdown_text: str, max_chunk_tokens800, min_chunk_tokens100): 按标题层级切分 Markdown输出父子块结构。 from dataclasses import dataclass, field from typing import Optional dataclass class Block: title_path: list level: int text: str children: list field(default_factorylist) parent: Optional[Block] None扫描逻辑大概是按行遍历 Markdown遇到标题就入栈遇到正文就加入当前标题对应的块当块累积 token 数超过上限时把块内段落继续拆成子块。整个过程不强求一步到位你可以先用markdown库把文档解析成 AST再基于 AST 做切分这样标题层级不会认错。4.3 chunk_size 与 overlap 的实际选择参数到底怎么定我的经验是不要迷信固定值先按 token 数而不是字符数来算。中文一个 token 大约对应 0.6-1 个汉字英文一个 token 大约 4 个字符。一个 500 字符的英文块可能只有 125 token而一个 500 字符的中文块可能有 300 token差距非常大。我的起步建议chunk_size 设为 500-800 tokenoverlap 设为 chunk_size 的 10%-20%。对技术文档、合同、学术论文这类上下文强依赖的文本overlap 可以上调到 20%-25%对新闻、FAQ 这类独立性强的文本overlap 10% 就够。如果检索时发现同一个问题命中了多个几乎相同的块说明 overlap 太大如果某段关键内容总是差半句接不上说明 overlap 太小。还有一个容易忽略的点分块后要过滤过短的碎片。解析出来的 Markdown 里经常有空标题、单行孤句、无意义的重复符号这些碎块如果进向量库检索时会产生大量无意义匹配。我通常设一个min_chunk_tokens 50的阈值低于阈值的块直接丢弃除非它包含代码块或表格结构。5. 常见问题与排查技巧实录5.1 问题速查表我把实际项目中反复踩过的问题整理成一张表方便你定位和快速处理。问题现象常见原因处理方式读取 txt 后大片乱码编码检测失败文件实际是 GBK 但检测为其他编码抽样检测改为头尾合并检测必要时人工指定编码参数第一行总多一个隐形字符UTF-8 BOM 未清除统一用utf-8-sig读取或刷新后lstrip(\ufeff)文本里有大量“上一页/下一页/广告”从网页复制导航噪音未清理建立噪音行黑名单正则过滤标题识别过多正文也被切成标题阈值太低短句和数字开头的句子被误判加“后接正文长度”判断标题后至少应有一段非空内容代码块被拦腰切断检索不到完整代码分块时没有识别代码块边界把代码块作为不可分单元超长则整体放入更大父块表格内容检索后答非所问伪表格被硬转成不规整的 Markdown 表格对不规整表格改为键值对列表或整块保留不拆分分块后很多空块/异常短块连续空行、标题重复、特殊符号残留增加min_chunk_tokens过滤清洗阶段合并连续空行中文检索命中率低分块语义不全chunk_size 没按 token 计算或者 overlap 过小按 token 估算重新计算设置 20% overlap 重跑5.2 我的操作习惯小步快跑地验证解析效果最后分享一个我踩过几次坑之后养成的习惯每次改完解析规则不要直接全量导入向量库而是先拿 5 个代表性文件走一遍流程把解析后的 Markdown 导出人工看一遍再抽样几个 query 测试检索效果。这个“解析-抽检-再调整”的循环花的时间不超过半小时却能避免后面返工重跑向量化的几小时。我通常还会在解析阶段就把 metadata 留好文件来源、标题路径、chunk 序号、SHA256 哈希。这样当年后检索结果不对时能顺着 metadata 一路追查到原始文本、原始清洗规则、原始分块参数。没有 metadata排查问题就只能靠猜效率极低。具体做法是每条 chunk 数据都附带一条类似{source: path/to/file.txt, title_path: [第一章, 1.2, 安装步骤], chunk_index: 3, sha256: ...}的记录入库时一并写入向量库的 payload。关于解析这件事我个人现在的态度是不要追求一个万能解析器覆盖所有文件类型而是针对你的文本特征组合出一套“够用且可维护”的规则集。聪明地保守比激进的“全自动”更可靠。这套 txt 到 Markdown 的流程已经在我自己的知识库项目里稳定跑了很久后续有机会再聊聊 Word 和 PDF 的提取思路以及知识图谱和 RAG 的衔接。