
RAG 系统落地时最容易被低估的环节不是向量检索也不是大模型选型而是数据导入与解析。很多人一上来就调 embedding 接口、搭向量库结果发现检索出来的内容驴唇不对马嘴回头一查原始文档在解析阶段就已经碎成了渣。我做过好几个知识库项目踩过的坑基本都集中在文档进来之后、进入向量库之前这一段。这篇就先从最基础的纯文本和 Markdown 入手把通用文本与结构化解析这条链路讲透。1. 为什么纯文本和 Markdown 值得单独拿出来讲1.1 它们看起来最简单实际上最容易翻车txt 和 Markdown 是 RAG 数据源里最朴素的格式没有 PDF 那种复杂的版面分析也没有 Word 那种嵌套结构。正因为简单很多人直接一个read()把整个文件读成字符串就丢进切分器了。这种做法在小文件上没问题但一旦文档上了几十页问题立刻暴露章节标题和正文混在一起、代码块被拦腰截断、表格变成一堆乱序的竖线、列表层级全部丢失。我见过一个典型案例某团队把一份 200 页的技术手册转成 txt 后直接按固定 500 字切分结果检索如何配置超时参数时返回的片段里参数名和参数说明被切到了两个不同的 chunk模型拿到半截信息回答自然错得离谱。这不是检索算法的问题是解析阶段就把语义单元破坏了。1.2 结构化解析的核心目标保住语义边界RAG 的检索质量本质上取决于 chunk 的语义完整性。一个好的解析流程应该做到三件事识别文档的层级结构标题、子标题、正文、列表、代码块、表格各自有明确的类型标记。保留结构信息标题层级要带进 chunk 的元数据这样检索时可以按章节过滤或加权。在语义边界处切分优先在标题、段落、列表项之间切而不是在句子中间硬切。txt 和 Markdown 的价值在于它们本身就携带了轻量的结构信号。Markdown 的#、-、 就是天然的分隔符txt 虽然没有标记但通过空行、缩进、编号模式也能推断出结构。把这些信号利用起来解析质量能甩开一刀切好几条街。1.3 适用场景与读者定位这套流程适合几类人正在搭建 RAG 知识库的工程师、需要把内部文档批量入库的运维或产品同学、以及想理解数据导入到底在做什么的技术管理者。如果你手上是 PDF、Word、Excel 为主这篇的思路同样适用只是解析器要换成对应的库后面几篇会展开。这篇聚焦 txt 和 Markdown把通用文本解析的骨架搭起来。2. 通用文本解析的完整链路拆解2.1 从原始文件到规范化文本第一步不是急着切分而是把文件读进来并做规范化。这里有几个细节经常被忽略编码问题。中文文档里 GBK、GB2312、UTF-8 混用是常态。直接open(path, r)在遇到非 UTF-8 文件时会抛异常或产生乱码。稳妥的做法是用chardet或charset-normalizer探测编码再指定读取。import chardet def read_text_file(path): with open(path, rb) as f: raw f.read() detected chardet.detect(raw) encoding detected[encoding] or utf-8 return raw.decode(encoding, errorsreplace)errorsreplace是兜底遇到无法解码的字节用占位符替代避免整个文件读取失败。实测下来charset-normalizer比chardet更准尤其是短文本。换行符统一。Windows 的\r\n、老 Mac 的\r、Linux 的\n要统一成\n否则后续按行处理时会多出空行或粘连。不可见字符清理。从网页复制或从某些系统导出的 txt常带有零宽空格、BOM 头、软连字符。这些字符肉眼看不见但会污染 embedding导致语义漂移。用正则统一清掉import re def normalize_text(text): text text.replace(\r\n, \n).replace(\r, \n) text re.sub(r[\u200b-\u200f\u2028-\u202f\ufeff], , text) text re.sub(r\n{3,}, \n\n, text) return text.strip()2.2 结构推断从无标记文本里猜出层级txt 没有显式标记但结构往往藏在模式里。常见的可识别信号包括信号类型示例推断结果数字编号1.1.1第一章标题或列表项中文序号一、一标题短行独立成段行长度 30 且前后有空行疑似标题缩进行首 2/4 空格或 Tab列表或引用分隔线---***章节分隔我一般会写一个启发式规则集按优先级匹配。比如先看是否匹配^第[一二三四五六七八九十][章节]再看^\d(\.\d)*\s最后用短行 空行包围兜底。规则不可能 100% 准但比完全不分结构强太多。提示结构推断的规则要可配置。不同来源的 txt 模式差异很大硬编码一套规则迟早会失效。把规则做成列表方便按文档来源切换。2.3 段落合并与语义单元识别推断出结构后要把零散的行合并成语义单元。核心逻辑是连续的正文行属于同一段落遇到标题、空行、列表项就断开。def merge_paragraphs(lines): blocks [] buffer [] for line in lines: stripped line.strip() if not stripped: if buffer: blocks.append(\n.join(buffer)) buffer [] continue if is_heading(stripped) or is_list_item(stripped): if buffer: blocks.append(\n.join(buffer)) buffer [] blocks.append(stripped) else: buffer.append(stripped) if buffer: blocks.append(\n.join(buffer)) return blocks这样得到的blocks就是初步的语义单元列表每个元素要么是一个标题要么是一个段落要么是一个列表项。后续切分就在这个粒度上做不会把段落切碎。2.4 元数据挂载让每个 chunk 自带上下文解析出来的每个 block都要挂上元数据。这一步是很多人偷懒的地方但对检索质量影响巨大。至少要记录source文件路径或文档 IDheading_path当前 block 所属的标题路径如[第2章, 2.1 配置]block_typeheading / paragraph / list / codeposition在文档中的顺序索引heading_path尤其关键。检索时如果命中一个段落把它的标题路径一起拼进 chunk 文本模型就能知道这段内容属于哪个主题。实测下来带标题路径的 chunk 检索准确率能提升 15% 到 25%尤其是技术文档这种层级深的场景。3. Markdown 解析把标记变成结构化数据3.1 为什么不用正则硬解 MarkdownMarkdown 语法看着简单但边界情况极多嵌套列表、代码块里的#、表格里的|、行内代码里的反引号、引用块里的列表。用正则去匹配写到最后一定是一堆补丁摞补丁还总有漏网的。正确做法是用成熟的解析库。Python 生态里markdown-it-py和mistune都能把 Markdown 解析成 AST抽象语法树你遍历 AST 就能拿到每个节点的类型和内容。markdown-it-py更贴近 CommonMark 规范mistune性能更好两者都够用。from markdown_it import MarkdownIt md MarkdownIt() tokens md.parse(markdown_text)tokens是一个扁平列表每个 token 有type、tag、content、level等属性。标题是heading_open/inline/heading_close三件套代码块是fence列表是bullet_list_open加list_item_open。遍历时维护一个栈就能还原出层级结构。3.2 标题层级与 heading_path 的构建Markdown 的标题层级由#数量决定解析出来是h1到h6。构建heading_path的逻辑是遇到h2就清空h2以下的所有层级压入新标题遇到h3就清空h3以下压入新标题以此类推。def update_heading_path(path, level, title): path path[:level - 1] while len(path) level - 1: path.append() path.append(title) return path这样得到的路径是[第2章 数据导入, 2.1 文本解析]这种形式。注意要处理跳级的情况比如从h1直接跳到h3中间补空字符串占位避免索引错乱。3.3 代码块、表格、引用块的特殊处理这三类内容在切分时要特别小心因为它们内部有强语义关联不能按普通段落切。代码块。fencetoken 的content是完整的代码文本info是语言标识。代码块要么整体作为一个 chunk要么按函数/类边界切绝不能按字数切。一个被截断的代码块对模型来说就是噪音。表格。Markdown 表格解析后是table_open加一系列tr/td。表格的语义在于行列对应关系切分时要保留表头。我的做法是把表格转成表头: 值的键值对文本或者保留完整表格作为一个 chunk并在元数据里标记block_type: table。引用块。blockquote通常承载提示、警告、注意事项语义上往往比正文更重要。解析时要单独标记检索时可以给更高权重。3.4 一个完整的 Markdown 解析器骨架把上面的逻辑串起来核心结构大概是这样def parse_markdown(text): md MarkdownIt() tokens md.parse(text) blocks [] heading_path [] i 0 while i len(tokens): tok tokens[i] if tok.type heading_open: level int(tok.tag[1]) title tokens[i 1].content heading_path update_heading_path(heading_path, level, title) blocks.append({ type: heading, level: level, content: title, heading_path: list(heading_path) }) i 3 continue if tok.type fence: blocks.append({ type: code, content: tok.content, lang: tok.info.strip(), heading_path: list(heading_path) }) i 1 continue if tok.type inline and tokens[i - 1].type paragraph_open: blocks.append({ type: paragraph, content: tok.content, heading_path: list(heading_path) }) i 1 return blocks这个骨架省略了列表和表格的细节但主干逻辑是完整的。实际项目里我会把列表、表格、引用块的处理都补上形成一个覆盖全语法的解析器。4. 切分策略在语义边界上动刀4.1 固定长度切分的致命缺陷固定长度切分比如每 500 字一刀实现简单但它是 RAG 检索质量的头号杀手。问题在于它完全无视语义边界一个完整的论述可能被切成两半前半段在 chunk A后半段在 chunk B。检索时只命中 A模型拿到的是残缺信息。更糟的是固定切分会让标题和正文分离。标题2.1 超时配置可能落在 chunk A 末尾而具体配置说明在 chunk B 开头。检索超时配置时chunk B 里没有超时这个词反而检索不到。4.2 基于结构的递归切分正确的思路是递归切分先按最大的结构单元章节切如果某章节还是太大再按子标题切再按段落切最后才按句子或字数切。这样能保证切分点永远落在语义边界上。def recursive_split(blocks, max_size800): chunks [] current [] current_size 0 for block in blocks: block_size len(block[content]) if current_size block_size max_size and current: chunks.append(current) current [] current_size 0 current.append(block) current_size block_size if current: chunks.append(current) return chunks这里的max_size是软限制不是硬切。如果单个 block 本身就超过max_size比如一个超长段落再对它单独做句子级切分。4.3 重叠窗口的设置逻辑chunk 之间要留重叠目的是防止关键信息正好落在切分点上被割裂。重叠大小一般是 chunk 大小的 10% 到 20%。但重叠不是越多越好重叠太多会导致检索结果冗余相同内容反复出现浪费上下文窗口。我的经验值是技术文档用 15% 重叠叙事类文档用 10%。如果切分点本身就在标题或段落边界上重叠可以更小因为语义边界天然就是安全点。4.4 不同 block 类型的切分优先级不是所有 block 都平等对待。我的优先级排序是代码块、表格整体保留不切分。超过大小限制就单独成 chunk。标题作为 chunk 的起始锚点标题永远和它下面的第一段内容在一起。列表同一列表的项尽量放在一起如果太长按列表项切。普通段落可以按句子切但优先在段落边界切。这个优先级要写进切分器而不是无差别处理。5. 实操中踩过的坑与排查思路5.1 中文标点导致的句子切分错误做句子级切分时很多人用。作为分隔符。但中文里还有、……、引号内的句号等情况。更麻烦的是英文句号.在中文文本里可能是小数点、缩写点、网址的一部分。我踩过一次坑一份技术文档里全是版本号v1.2.3按.切分后每个版本号都被切碎检索v1.2.3 的新特性完全失效。解决办法是用更聪明的句子切分器比如pysbd或syntok它们能识别缩写、小数点、引号等边界情况。如果不想引入依赖至少要加规则排除数字之间的点。5.2 标题识别误判把正文当成了标题txt 结构推断里短行 空行包围这条规则误判率最高。有些文档里独立的短句、公式、图表标题都会被误判成标题。一旦误判heading_path 就乱了后续检索的上下文全错。排查方法把推断出的标题列表打印出来人工过一遍统计误判率。如果超过 10%说明规则太激进要收紧条件比如要求短行必须以编号开头或者长度阈值从 30 降到 20。5.3 编码探测失败导致的乱码chardet对短文本的探测准确率不高一份 500 字的 GBK 文件可能被识别成 ISO-8859-1读出来全是乱码。我的做法是优先尝试 UTF-8失败再探测如果探测置信度低于 0.7就尝试常见编码列表GBK、GB18030、Big5逐个解码选乱码最少的结果。def smart_decode(raw): for enc in [utf-8, gb18030, big5]: try: text raw.decode(enc) if text.count(\ufffd) / len(text) 0.01: return text except UnicodeDecodeError: continue return raw.decode(utf-8, errorsreplace)gb18030是 GBK 的超集优先用它能覆盖更多情况。5.4 元数据丢失切分后 heading_path 没跟上递归切分时如果 chunk 跨越了多个标题heading_path 该取哪个我的做法是取 chunk 内第一个 block 的 heading_path同时在元数据里记录 chunk 覆盖的所有标题路径。检索时用第一个作为主上下文其余作为辅助。这个细节很容易漏。我见过切分后所有 chunk 的 heading_path 都是空的因为切分函数忘了把元数据带过去。排查时直接检查 chunk 的元数据字段如果大量为空就是切分环节丢了信息。6. 解析质量的自检与验证方法6.1 抽样人工检查最笨但最有效自动化指标再漂亮也不如人工抽检。我会从解析结果里随机抽 20 个 chunk逐个看内容是否完整、标题路径是否正确、有没有被截断的代码或表格。这一步能发现 80% 的问题。抽检时重点关注三类 chunk跨标题的、含代码的、含表格的。这三类是问题高发区。6.2 用检索反推解析质量解析质量最终要落到检索效果上。构造一批测试 query看返回的 chunk 是否包含答案。如果答案明明在文档里却检索不到八成是解析或切分把相关内容切散了。一个实用的技巧把 query 对应的原文位置找出来看它落在哪个 chunk 里chunk 的边界是否合理。如果答案被切到了两个 chunk就说明切分策略需要调整。6.3 关键指标chunk 完整率与标题覆盖率我一般会统计两个指标chunk 完整率不含截断代码块、截断表格、半截句子的 chunk 占比。目标 95% 以上。标题覆盖率有非空 heading_path 的 chunk 占比。目标 90% 以上。这两个指标低说明解析或切分有问题先修这两项再谈检索优化。7. 从解析结果到向量库的衔接要点7.1 chunk 文本的组装方式进入 embedding 之前chunk 文本怎么拼直接影响向量质量。我的做法是把 heading_path 拼在正文前面[第2章 数据导入 2.1 文本解析] 正文内容……这样 embedding 时标题信息也参与了语义编码检索文本解析时更容易命中。但要注意别拼太多层级一般取最后两级就够拼太多会稀释正文的语义权重。7.2 元数据字段的设计元数据要精简但够用。我常用的字段字段类型用途sourcestring溯源heading_pathlist上下文block_typestring过滤positionint排序char_countint质量监控字段不是越多越好向量库的元数据过滤性能有限字段太多会拖慢检索。够用就行。7.3 批量导入时的幂等性批量导入最怕重复。同一份文档导入两次向量库里就有两份检索结果重复。解决办法是给每个 chunk 算一个内容哈希导入前查重。或者用文档 ID 加位置索引作为唯一键导入时先删后插。import hashlib def chunk_id(source, position, content): raw f{source}:{position}:{content} return hashlib.md5(raw.encode()).hexdigest()这个 ID 既能去重又能作为更新时的定位键。8. 几个提升解析质量的进阶技巧8.1 用 LLM 做结构补全对于结构特别混乱的 txt规则推断搞不定时可以用 LLM 做一次结构标注。把文本按段喂给模型让它输出每段的类型和标题层级。成本比人工低效果比规则好。但要注意LLM 会幻觉输出要校验比如标题层级不能跳级、类型必须在预定义集合内。8.2 表格转文本的两种策略表格进 RAG 一直是个难题。两种策略各有适用场景保留原表适合列数少、结构简单的表模型能直接读懂 Markdown 表格。转键值对适合列数多、需要精确检索的表。把每行转成列名: 值的形式检索时更容易命中具体单元格。我一般先试保留原表如果检索效果不好再转键值对。8.3 代码块的语义标注代码块光有内容不够最好加上语言和用途标注。比如在代码块前面加一行[Python 示例读取文件]这样检索如何读取文件时代码块也能被命中。这个标注可以人工加也可以用 LLM 自动生成。8.4 增量更新时的解析一致性文档更新后重新解析要保证切分策略和之前一致否则新旧 chunk 边界对不齐检索结果会混乱。我的做法是把解析配置切分大小、重叠、规则集版本化每次导入记录用的配置版本。更新时用同一版本配置或者全量重解析。9. 一套可直接复用的解析流程配置把上面的内容整理成一份可落地的配置清单方便直接抄作业读取阶段编码探测UTF-8 优先失败用 gb18030再失败用 chardet换行统一\r\n和\r转\n不可见字符清理正则移除零宽字符和 BOM解析阶段Markdown 用markdown-it-py解析 ASTtxt 用规则集推断结构规则可配置维护 heading_path 栈处理跳级切分阶段递归切分优先在标题、段落边界切chunk 大小 600 到 1000 字重叠 10% 到 15%代码块、表格整体保留元数据阶段必填source、heading_path、block_type、positionchunk 文本前置 heading_path 最后两级验证阶段抽检 20 个 chunk重点看跨标题、含代码、含表格的统计 chunk 完整率和标题覆盖率构造测试 query 反推检索效果这套配置我在多个项目里用过覆盖技术文档、产品手册、内部 wiki 等场景解析质量稳定。当然具体参数要根据文档特点微调没有万能配置。最后分享一个我踩过好几次才总结出的经验解析和切分的调试一定要拿真实文档跑别用自己造的样例。真实文档里的脏数据、格式混乱、编码问题是样例永远模拟不出来的。我一般会准备一份魔鬼文档集专门收集各种格式异常的样本每次改解析逻辑都拿它跑一遍能提前发现大部分坑。