ARTICLE DETAIL

资讯详情

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

RAG文本解析实战:从txt与Markdown到高质量切块

RAG文本解析实战:从txt与Markdown到高质量切块 1. 为什么 RAG 的第一公里永远是文本解析做 RAG 的人都有一个共识模型选型、向量库调参、检索策略优化这些事大家讨论得热火朝天但真正让一个知识库项目翻车的往往是最不起眼的那一步——把原始文件变成干净、可切分、可检索的文本。我见过太多团队在 demo 阶段用几个手写的 txt 跑得飞起一上真实数据就崩了PDF 里的表格变成一堆乱码、Word 里的多级标题全丢了、扫描件根本读不出字、Markdown 里的代码块被切得七零八落。这个系列我打算把 RAG 数据导入与解析这条链路完整拆一遍第一篇先聚焦最基础也最通用的两类纯文本txt和Markdown。别小看这两个格式它们恰恰是很多结构化解析的中间态——PDF 转出来是 txt网页抓下来转成 MarkdownWord 导出也常常先落成 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做统计检测但要注意这类库对短文本的检测准确率很低几百字以内的文件经常误判。第三步对检测结果做一次试解码 合理性校验——用检测出的编码解码然后检查解码后的文本里中文字符占比、乱码字符如 UFFFD 替换字符比例如果乱码比例超过阈值就换一个候选编码重试。import chardet def detect_encoding(file_path, sample_size100000): 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 文件的换行符有三种\nUnix、\r\nWindows、\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 sections3.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_chars500, overlap_paragraphs1): 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 chunks4.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 全角半角与不可见字符中文文本里混着全角标点和半角标点是常态但这对检索有实际影响。用户搜索数据清洗用的是半角文档里写的是全角数据清洗如果没做归一化可能就匹配不上。我的处理是在清洗阶段统一把全角字母数字转半角标点则保留原样因为中文标点转半角会破坏语义。不可见字符更隐蔽。零宽空格U200B、零宽连接符U200D、软连字符U00AD这些字符肉眼看不见但会污染文本导致关键词匹配失败。清洗时用正则[\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 的版面分析、表格还原、扫描件 OCRHTML 的正文提取、导航栏过滤每一个都能单独写一篇。如果你现在正在处理 txt 和 Markdown先把这篇里的编码归一、结构识别、语义切块这三件事做扎实后面接更复杂的格式时你会轻松很多。
返回列表