☰
RAG文本解析实战:从txt与Markdown到高质量切块
2026/10/6 5:23:06 网站建设 项目流程

1. 为什么 RAG 的第一公里永远是文本解析

做 RAG 的人都有一个共识:模型选型、向量库调参、检索策略优化这些事,大家讨论得热火朝天,但真正让一个知识库项目翻车的,往往是最不起眼的那一步——把原始文件变成干净、可切分、可检索的文本。我见过太多团队在 demo 阶段用几个手写的 txt 跑得飞起,一上真实数据就崩了:PDF 里的表格变成一堆乱码、Word 里的多级标题全丢了、扫描件根本读不出字、Markdown 里的代码块被切得七零八落。

这个系列我打算把 RAG 数据导入与解析这条链路完整拆一遍,第一篇先聚焦最基础也最通用的两类:纯文本(txt)和Markdown。别小看这两个格式,它们恰恰是很多结构化解析的"中间态"——PDF 转出来是 txt,网页抓下来转成 Markdown,Word 导出也常常先落成 txt。你把这两类的解析逻辑吃透,后面处理 PDF、HTML、Excel 就是在这套框架上做加法。

这篇文章适合谁?如果你正在搭 RAG 知识库,卡在"数据导进来效果不对"这一步;或者你负责数据治理,需要把一堆杂七杂八的文档统一成可入库的格式;再或者你只是想搞清楚"文档结构化解析"到底在解析什么——那这篇能给你一套可以直接抄的作业。我会从解析目标讲起,把 txt 和 Markdown 的处理逻辑、代码实现、踩坑经验全部摊开,重点解释每一步"为什么这么做",而不是甩一段代码就完事。

先说一个反直觉的结论:txt 并不比 Markdown 好处理。很多人觉得纯文本没有格式,直接读进来切块就行,恰恰是这种"没有结构"让切分变得极其困难——你不知道哪里是段落边界,不知道哪句话是标题,不知道表格数据该怎么还原。而 Markdown 虽然带了一堆符号,但这些符号本身就是天然的结构标记,用好了反而能让切分质量上一个台阶。这个认知差异,是后面所有技术选择的起点。

2. 解析到底在解析什么:从"能读"到"能检索"的四个层次

2.1 字符编码:第一道隐形门槛

任何文本解析的第一步都是编码识别。这件事听起来无聊,但它是最高频的翻车点。中文场景下,txt 文件的编码可能是 UTF-8、GBK、GB2312、GB18030,甚至还有 UTF-8 with BOM 和 UTF-16。你用 UTF-8 去读一个 GBK 文件,得到的是一堆"锟斤拷";反过来读,可能直接抛异常。

我的处理策略是分三步走。第一步,先检测 BOM 头,如果有 BOM 就直接确定编码,这是最可靠的信号。第二步,没有 BOM 的话用chardet或charset-normalizer做统计检测,但要注意这类库对短文本的检测准确率很低,几百字以内的文件经常误判。第三步,对检测结果做一次"试解码 + 合理性校验"——用检测出的编码解码,然后检查解码后的文本里中文字符占比、乱码字符(如 U+FFFD 替换字符)比例,如果乱码比例超过阈值就换一个候选编码重试。

import chardet def detect_encoding(file_path, sample_size=100000): with open(file_path, 'rb') as f: raw = f.read(sample_size) # BOM 优先 if raw.startswith(b'\xef\xbb\xbf'): return 'utf-8-sig' if raw.startswith(b'\xff\xfe') or raw.startswith(b'\xfe\xff'): return 'utf-16' result = chardet.detect(raw) encoding = result['encoding'] confidence = result['confidence'] # 低置信度时,用中文场景常见编码兜底 if confidence < 0.7: for candidate in ['utf-8', 'gb18030', 'gbk']: try: raw.decode(candidate) return candidate except UnicodeDecodeError: continue return encoding or 'utf-8'

这里有个经验:GB18030 是 GBK 和 GB2312 的超集,遇到疑似国标编码时优先用 GB18030 去试,能覆盖绝大部分情况,不用在 GBK 和 GB2312 之间反复纠结。另外,utf-8-sig这个编码名专门用来处理带 BOM 的 UTF-8,用普通utf-8读会多出一个不可见的\ufeff字符,这个字符混进文本里会污染检索结果,务必处理掉。

2.2 段落与换行:切块的物理边界

编码解决之后,下一个问题是:这段文本里,哪里是一个"语义单元"的边界?RAG 的切块(chunking)质量直接取决于这个判断。

txt 文件的换行符有三种:\n(Unix)、\r\n(Windows)、\r(老 Mac)。统一成\n是基本操作。但更麻烦的是"软换行"和"硬换行"的区分——很多从 PDF 或网页复制出来的 txt,一个自然段被硬生生拆成好几行,每行末尾都有\n。如果你按\n切块,一个完整段落就被切碎了。

我的做法是:先按连续空行(\n\s*\n)切出"块",块内部再把单个换行替换成空格或直接拼接。判断依据是行尾是否有标点——如果一行以逗号、顿号结尾,下一行大概率是同一段的延续;如果以句号、问号、感叹号结尾,则可能是段落边界。这个启发式规则不完美,但在中文文本上准确率相当可观。

import re def normalize_paragraphs(text): # 统一换行符 text = text.replace('\r\n', '\n').replace('\r', '\n') # 按空行切块 blocks = re.split(r'\n\s*\n', text) normalized = [] for block in blocks: lines = [l.strip() for l in block.split('\n') if l.strip()] if not lines: continue merged = lines[0] for line in lines[1:]: # 上一行以中文标点结尾,视为段落边界 if re.search(r'[。!?;:""'')】]$', merged): normalized.append(merged) merged = line else: merged += line normalized.append(merged) return [p for p in normalized if p]

2.3 结构信息:标题、列表、表格的识别

纯文本最要命的地方在于结构信息全靠"约定"。一份 txt 文档里,"第一章 概述"这行可能是标题,也可能只是正文里的一句话。怎么判断?靠模式匹配加位置特征。

常见的标题模式有几类:编号型("1."、"1.1"、"第一章"、"第一节")、符号型("==="、"---" 包裹)、关键词型("摘要"、"前言"、"结论")。位置特征则是:标题通常独占一行、前后有空行、长度较短(一般不超过 50 字)、不以句号结尾。把这些特征加权打分,超过阈值就判定为标题。

列表的识别相对简单,看行首是否有-、*、•、1.、(1)这类标记。表格在纯文本里最难还原,常见的是用空格或制表符对齐的"伪表格",以及用|分隔的管道表格。前者需要检测列对齐位置,后者直接按|切分即可。

2.4 元数据:给每个块贴上"身份证"

最后一个层次是元数据抽取。一个 chunk 光有文本内容是不够的,检索时需要知道它来自哪个文件、属于哪个章节、在原文的什么位置。这些信息在重排序(rerank)和引用溯源时至关重要。

从 txt 和 Markdown 里能抽的元数据包括:文件名、文件路径、章节标题层级、块在文档中的序号、字符偏移量。Markdown 还能额外抽链接、图片引用、代码块语言。这些元数据建议以 JSON 形式跟 chunk 一起存储,检索时可以作为过滤条件,比如"只在某个章节内检索"。

3. Markdown 的结构化优势:把符号变成切分信号

3.1 标题层级就是天然的切分树

Markdown 最大的价值在于,它的#、##、###直接对应文档的层级结构。这意味着你可以用标题做"父块",把标题下的内容做"子块",形成一棵树。检索时先定位到相关章节,再在章节内做细粒度匹配,这种"两级检索"策略对长文档效果提升非常明显。

具体实现上,我通常用递归下降的方式解析:遇到#开一个一级节点,遇到##开二级节点,以此类推。每个节点记录自己的标题文本、层级、起止行号。切块时,如果某个章节内容太长,就在章节内部按段落再切;如果太短,就把相邻的小节合并。这样切出来的块,每个都带着完整的"章节路径",比如"第三章 > 3.2 数据清洗 > 3.2.1 缺失值处理",检索命中后用户一眼就知道内容出处。

import re def parse_markdown_structure(text): lines = text.split('\n') headers = [] for i, line in enumerate(lines): m = re.match(r'^(#{1,6})\s+(.+)$', line) if m: headers.append({ 'level': len(m.group(1)), 'title': m.group(2).strip(), 'line': i }) # 为每个标题计算内容范围 sections = [] for idx, h in enumerate(headers): end_line = headers[idx + 1]['line'] if idx + 1 < len(headers) else len(lines) content = '\n'.join(lines[h['line'] + 1:end_line]).strip() sections.append({**h, 'content': content, 'end_line': end_line}) return sections

3.2 代码块和表格不能被切碎

Markdown 里的代码块(``` 包裹)和表格是"原子结构",切块时必须整体保留。我踩过一个坑:早期切块逻辑按固定字符数硬切,结果一个 200 行的代码块被从中间截断,检索出来的代码根本没法用。后来改成"保护性切分"——先扫描出所有代码块和表格的起止位置,切块时避开这些区间,如果代码块本身超过块大小上限,就单独成块,绝不拆分。

表格同理。Markdown 表格的|分隔结构一旦被破坏,表头和表体的对应关系就丢了。我的处理是:检测到连续的|开头行,就把整段表格作为一个块,并在块前面补上表头,确保每个表格块都是自解释的。

3.3 链接、图片、公式的处理策略

Markdown 里的链接[文本](url)和图片![alt](url)需要区别对待。链接的文本部分通常有语义价值,URL 部分在检索时基本是噪音,我一般保留文本、丢弃 URL,或者把 URL 放到元数据里。图片的 alt 文本要保留,因为它是图片内容的唯一文字描述;如果 alt 为空,就标记一个占位符,提醒后续可能需要 OCR 补充。

数学公式是另一个坑。行内公式$...$和块级公式$$...$$在切块时容易被标点切分逻辑误伤。我的做法是在预处理阶段把公式替换成占位符(如[[FORMULA_001]]),切完块再还原,这样公式内部的符号不会干扰边界判断。热词里提到的"markdown 数学公式插件"其实也印证了这一点——公式处理是 Markdown 解析里公认的难点。

3.4 从 Markdown 反推文档意图

Markdown 的符号不仅标记结构,还隐含了作者的意图。比如引用块>通常表示强调或引用他人观点,在 RAG 里这类内容往往权重更高;加粗**...**和斜体*...*标记的是关键术语,可以在切块时把这些词提取出来作为块的关键词标签,提升检索召回。

我做过一个实验:对同一批 Markdown 文档,一组只存纯文本,一组额外存了"标题路径 + 加粗词 + 引用标记"这些结构特征。在检索评测里,后者的 Top-5 命中率比前者高了将近 15 个百分点。这说明结构信息不是锦上添花,而是实打实地影响检索质量。

4. 一套可复用的解析流水线怎么搭

4.1 流水线的五个阶段

把前面讲的东西串起来,一条完整的解析流水线应该包含五个阶段:读取与编码归一→结构识别→清洗与规范化→切块→元数据封装。每个阶段的输出都是下一个阶段的输入,阶段之间保持松耦合,方便单独替换和调试。

读取阶段负责把各种编码的文件统一成 Python 的 str;结构识别阶段抽取出标题、列表、表格、代码块的位置信息;清洗阶段去掉页眉页脚、多余空白、控制字符;切块阶段根据结构信息做语义切分;封装阶段给每个块打上元数据标签,输出成统一的 JSON 结构。

这个流水线的好处是,当你后面要接入 PDF 或 HTML 时,只需要替换"读取"和"结构识别"两个阶段,后面的清洗、切块、封装逻辑完全可以复用。这就是为什么我说 txt 和 Markdown 是"中间态"——它们是整个解析体系的公共基础。

4.2 切块大小的确定:没有万能数字

切块大小(chunk size)是 RAG 里被问得最多的问题之一,但答案从来不是某个固定数字。它取决于三个因素:嵌入模型的最大输入长度、检索粒度需求、文本本身的语义密度。

嵌入模型方面,主流模型的支持长度从 512 到 8192 token 不等,你的块大小不能超过模型上限,否则会被截断。检索粒度方面,块太大则检索不精准(一个块里混了多个主题),块太小则上下文不足(检索出来一句话没法回答问题)。语义密度方面,技术文档信息密度高,块可以小一些(300-500 字);叙述性文本密度低,块可以大一些(800-1200 字)。

我的经验值是:中文技术文档用 400-600 字,带 10%-20% 的重叠(overlap)。重叠的作用是防止关键信息正好落在切分边界上被割裂。重叠部分不需要太大,一到两句话即可,太大反而会造成检索结果重复。

4.3 重叠窗口的正确用法

重叠窗口不是简单地把上一块的末尾复制到下一块开头。如果无脑复制,会造成两个问题:一是存储冗余,二是检索时同一内容被多次命中,排序结果里全是重复项。

正确的做法是"语义重叠"——在切分点附近找到最近的段落边界,把边界前的最后一个完整段落作为重叠内容。这样既保证了上下文连续,又不会把一句话切成两半。实现上,可以在切块函数里维护一个"上一块末尾段落"的引用,新块生成时把它拼到开头。

def chunk_by_paragraphs(paragraphs, max_chars=500, overlap_paragraphs=1): chunks = [] current = [] current_len = 0 for para in paragraphs: if current_len + len(para) > max_chars and current: chunks.append('\n'.join(current)) # 保留末尾若干段作为重叠 current = current[-overlap_paragraphs:] if overlap_paragraphs else [] current_len = sum(len(p) for p in current) current.append(para) current_len += len(para) if current: chunks.append('\n'.join(current)) return chunks

4.4 输出格式的统一约定

不管输入是 txt 还是 Markdown,最终输出的 chunk 结构应该统一。我习惯用这样的 JSON schema:

{ "chunk_id": "doc_001_sec_3_2_chunk_005", "content": "切块后的文本内容", "metadata": { "source_file": "技术手册.md", "file_type": "markdown", "section_path": "第三章 > 3.2 数据清洗", "chunk_index": 5, "char_offset": 10240, "keywords": ["缺失值", "填充策略"], "has_code": false, "has_table": true } }

这个结构里,section_path用于展示和过滤,char_offset用于溯源定位,keywords用于辅助检索,has_code和has_table用于特殊处理。字段不用多,但每个都要有明确用途,避免存一堆用不上的信息。

5. 那些只有踩过才知道的坑

5.1 全角半角与不可见字符

中文文本里混着全角标点和半角标点是常态,但这对检索有实际影响。用户搜索"数据清洗"用的是半角,文档里写的是全角"数据清洗",如果没做归一化,可能就匹配不上。我的处理是在清洗阶段统一把全角字母数字转半角,标点则保留原样(因为中文标点转半角会破坏语义)。

不可见字符更隐蔽。零宽空格(U+200B)、零宽连接符(U+200D)、软连字符(U+00AD)这些字符肉眼看不见,但会污染文本,导致关键词匹配失败。清洗时用正则[\u200b-\u200f\u2028-\u202f\ufeff]一次性清掉。

5.2 表格跨页与合并单元格

从 PDF 或 Word 转出来的 txt,表格经常跨页断裂,或者合并单元格被展开成重复内容。纯文本里没有"合并单元格"的概念,你只能靠内容重复模式去推断。比如连续几行的第一列内容相同,很可能原本是一个合并单元格。这种推断不可能 100% 准确,我的策略是:宁可保留冗余,也不要做有损的合并推断,因为冗余只是浪费一点存储,错误合并会直接导致信息丢失。

5.3 编码检测的边界情况

前面提过chardet对短文本检测不准,还有一个更坑的情况:纯 ASCII 文本。一个只有英文和数字的文件,chardet会返回ascii,但实际它可能是 UTF-8 的子集,也可能后面藏着中文。我的做法是:如果检测结果是 ascii,一律按 utf-8 处理,因为 ascii 是 utf-8 的子集,这样不会出错。

另一个边界是空文件和只有 BOM 的文件。这类文件要在读取阶段就过滤掉,不要让它进入后续流程,否则会在切块时产生空块,污染向量库。

5.4 切块边界的"信息孤岛"问题

有时候一个关键信息被切成了两半,前半块在检索时被命中,但答案需要的后半块没被召回,导致模型答非所问。这个问题在问答类场景里特别常见。

缓解办法有两个。一是前面说的语义重叠,让边界附近的内容在两个块里都出现。二是在检索阶段做"邻块扩展"——命中某个块后,把它前后相邻的块也一起取出来送给模型。这个策略在实现上很简单,只要在元数据里记录块的序号,检索后按序号取邻居即可。实测下来,邻块扩展能把答案完整率提升不少,代价是送入模型的上下文变长,需要在效果和成本之间权衡。

5.5 元数据缺失导致的溯源失败

RAG 系统上线后,用户经常会问"这个答案是从哪来的"。如果 chunk 的元数据里没有记录源文件和位置,你就没法给出引用。我见过有团队为了省事,只存文本不存元数据,结果上线后被用户质疑"胡编乱造",因为拿不出出处。

所以元数据一定要在解析阶段就打好,不要等到入库时再补。source_file、section_path、char_offset这三个字段是底线,缺一不可。如果源文件本身有版本号或更新时间,也一并记上,方便后续做增量更新。

6. 从解析到入库:数据质量的最后一道关

6.1 解析后的质量校验清单

解析完不等于可以入库了,中间要过一道质量校验。我通常会检查这几项:空块比例(超过 5% 说明切块逻辑有问题)、超长块比例(超过模型上限的块要截断或重切)、重复块比例(完全相同的块要去重)、乱码字符比例(超过阈值说明编码处理有漏网之鱼)、元数据完整率(关键字段不能为空)。

这些检查可以写成一个校验函数,每次解析完自动跑一遍,输出一份质量报告。发现问题就回到对应阶段修,而不是带着脏数据入库。

6.2 增量更新时的去重策略

知识库不是一次性的,源文件会更新。增量更新时,怎么判断一个块是新的、修改过的还是删除的?最可靠的方式是用内容哈希。每个块算一个 SHA-256,入库时以哈希为唯一键,新块哈希不存在就插入,存在就跳过,源文件里消失的哈希就标记删除。

但内容哈希有个问题:改一个标点,整个块的哈希就变了,会被当成新块。更精细的做法是结合"块位置 + 内容相似度"来判断,位置相同且相似度高就视为修改,位置相同但相似度低视为重写,位置新增视为插入。这套逻辑复杂一些,但对高频更新的知识库很有必要。

6.3 解析日志:出问题时能查

最后强调一点:解析过程一定要打日志。哪个文件、用了什么编码、识别出多少章节、切了多少块、有没有异常——这些信息在出问题时是唯一的排查线索。我习惯把日志按文件维度记录,格式用 JSON Lines,方便后续用脚本分析。

日志不用记太细,但关键节点必须有。比如编码检测的结果和置信度、结构识别的标题数量、切块前后的块数对比。有了这些,当用户反馈"某个文档检索不到"时,你能快速定位是解析阶段就丢了,还是检索阶段的问题。

7. 写在最后的一点个人体会

这套 txt 和 Markdown 的解析逻辑,我从最早的"能读就行"版本迭代到现在,前后改了不下十版。最大的体会是:解析阶段多花一小时,检索阶段能省十小时。很多团队急着把数据灌进去看效果,结果检索质量差,回头排查发现是解析阶段埋的雷,返工成本极高。

另一个体会是,不要追求一步到位的完美解析。真实数据永远比你想的脏,与其设计一个能处理所有情况的复杂逻辑,不如先搭一条能跑通的基础流水线,然后针对实际遇到的数据问题逐个打补丁。我现在的解析器里有一大半代码是处理各种"奇葩数据"的补丁,这些补丁不是设计出来的,是踩坑踩出来的。

下一篇我会接着讲 PDF 和 HTML 的解析,那才是真正的硬骨头——PDF 的版面分析、表格还原、扫描件 OCR,HTML 的正文提取、导航栏过滤,每一个都能单独写一篇。如果你现在正在处理 txt 和 Markdown,先把这篇里的编码归一、结构识别、语义切块这三件事做扎实,后面接更复杂的格式时你会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询