
做 RAG 项目做得越久越会发现一个反直觉的事实决定了整个知识库上限的往往不是模型不是向量数据库也不是召回策略而是最不起眼的上游环节——数据导入与解析。你喂进系统的文档是什么形态系统能回答到什么程度从一开始就基本定下来了。这段时间我把 txt 和 Markdown 这两类最通用文本格式的导入解析过程完整梳理了一遍从编码识别、段落切分到结构化切片踩了不少坑也沉淀出了一套相对稳定的流程这篇先聚焦最基础也最见功底的“通用文本与结构化解”部分纯实战向直接照着落地就行。做 RAG 相关的项目现在最不缺的就是各种框架和模型选型但真正到了知识库交付阶段数据侧的脏活累活才是大头。很多团队号称搭建了 RAG 知识库结果一问数据是怎么进去的答复往往是“丢给解析服务就完事了”。实际上解析环节的质量直接决定切片粒度合不合理、召回命中率高不高、上下文是否被截断得七零八落这些问题不解决后面调什么都白搭。我这次说的“从 txt 到 Markdown 的通用文本与结构化解”本质上是解决 RAG 链路里两个最核心的问题第一如何把普通文本文件干净地读进来不出现乱码、不漏段、不乱切第二如何利用 Markdown 已有的结构化语义在切片时保留文档的逻辑边界而不是暴力按字符数硬切。适合的读者画像很清楚正在搭知识库但发现召回效果稀碎的开发者以及想把存量文档规范化之后接入 RAG 管线的运维或数据工程师。1. 为什么数据导入解析是 RAG 的地基1.1 模型和向量库决定上限解析决定下限先说一个很容易被忽略的结论RAG 系统的回答质量 检索质量 × 生成质量。生成质量由大模型决定可选择的空间大、迭代快检索质量却高度依赖“喂进来的数据被拆成什么样”。如果你把一整本 PDF 的正文按每 500 个字符硬切成碎片很多段落会在中间断掉语义被拦腰截断召回时匹配到的片段往往是半句话LLM 拿到这样的上下文去作答怎么可能不跑偏。所以我把解析环节称为系统的“地基工程”。它的职责不是“把文件变成纯文本”这么简单而是要完成三件事资产化把杂乱的源文件变成规范化的文本资产、语义化识别标题、列表、表格等结构形成语义单元、工程化控制切片的边界和长度让下游检索拿到的是完整可读的片段。1.2 txt 和 Markdown 在 RAG 体系中的独特定位相比 PDF、Word、HTML 这些重格式文档txt 和 Markdown 在 RAG 建设里其实扮演着完全不同的角色。txt 是“下限兜底者”任何文档经过各种转换工具后最终都能落地成 txt它是数据流的最后一道通用语言。Markdown 则是“结构化轻骑兵”一个 Markdown 文件里天然包含了标题层级、列表、表格、代码块、引用这些语义标记让解析器几乎不需要猜结构就能做出高质量的切片。很多团队的存量知识库其实是大量历史遗留的 txt 文件——可能是从老 CMS 导出的日志、备份的笔记、或者手工整理的文档合集。而增量知识库则越来越多地以 Markdown 形态存在毕竟现在笔记软件、AI 辅助写作工具的默认导出格式基本都是 Markdown。把这两条线打通处理是 RAG 数据管线里性价比最高的一步。1.3 理清“通用文本”和“结构化”的分工我处理数据时有一条心得不要把“通用文本解析”和“结构化解析”混为一谈。通用文本解析的核心目标是“忠实还原”即不管文件是 GBK 编码的老古董还是 UTF-8 的新笔记都能准确还原字符内容不引入乱码、不缺行漏字。结构化解析的核心目标则是“语义编目”在忠实文本之上再叠加标题边界、段落关系、表格行列等结构信息为切片提供依据。两者是上下游关系先做通用解析拿到干净文本再做结构化解析提取语义骨架。如果一上来就直接套用 Markdown 解析器去处理 txt或反过来用纯文本方式处理 Markdown都会造成信息损耗。后面第四节我会给出一套把两者串成管道Pipeline的具体做法这是我这段时间实践下来比较稳的方案。2. 通用文本解析的核心逻辑2.1 编码识别乱码问题的一劳永逸解法txt 文件最折磨人的不是内容而是编码。国内历史文档里 GBK、GB18030、UTF-8、UTF-8 BOM、甚至 Latin-1 混杂出现都是家常便饭。我见过有人直接用 UTF-8 读一个 GBK 文件结果全文乱码然后居然在后续流程里做了一堆无用功。这里我建议直接采用一个两段式识别法先看 BOMByte Order Mark有 BOM 就用 BOM 声明编码没有 BOM 就用启发式检测。Python 生态里常用的方案是chardet如果追求速度也可以用charset-normalizer实测在中文文档场景下识别率更高、性能更好。不要迷信单个库建议设置“探测失败就按 GB18030 回退”的兜底策略因为 GB18030 是 GBK 的超集能覆盖绝大多数中文历史文件。有朋友会问为什么不干脆统一转成 UTF-8 再入库。这个思路是对的但要在“探测完成后”做统一转换而不是用 UTF-8 硬读。我踩过的坑是直接从源头用 UTF-8 read遇到生僻字直接抛异常整个批处理任务中断排查起来很费劲。正确做法是探测到原始编码读入内存为 Python 的strUnicode最后写入时统一以 UTF-8 落盘这样下游无论谁接这份数据都不会再遇到编码问题。2.2 段落切分和换行符的讲究拿到“已经解码正确”的文本之后下一个问题是换行符的归一化。txt 文件的换行常见有三种\r\nWindows、\nUnix/Linux/macOS、以及旧 Mac 的\r。在解析时如果不统一会出现大量空行或拼接错误的段落。常规做法是把所有\r\n和\r都替换为\n再进入后续流程。然后是段落识别。txt 没有严格的格式规范我的经验是分段规则从宽松到严格递进不要一上来就用“两个连续换行才算分段”的强规则因为很多 txt 是从网页、PDF 复制出来的空行格式早就丢了。具体而言我常用的段落归一化规则如下连续两个以上换行符视为段落分隔符。单个换行符且行首不是空格/制表符时先合并为同一逻辑行再按句子结束符补切。行首有缩进全角空格、半角空格或制表符的优先当作新段落开始。这样处理后普通叙事文本基本能得到一份干净且分段合理的纯文本。但要注意一个反例诗歌、代码片段、ASCII 图表这类对行敏感的内容就不适合“单换行合并”规则必须在解析阶段就设定“保留原行”的白名单场景避免把代码行错误拼接。2.3 通用文本解析的“回退心态”做通用文本解析必须接受一个现实txt 里没有格式信息任何试图“完全还原原始排版”的做法都是徒劳。我们追求的目标不是排版还原而是语义单元的完整迁移。也就是说解析出来的结果只要能保证语义完整段落、句子、关键词不被破坏就及格了。如果想要更高保真度就要靠 Markdown 这类带标记的格式这也正是本文标题里“从 txt 到 Markdown”这句话的含义——用 txt 保住底用 Markdown 冲上限。3. Markdown 结构化解的核心逻辑3.1 为什么要用 Markdown 结构做切片而不是死记字符数RAG 切片最忌讳的就是“固定 N 字符一刀切”。不管你选 300、500 还是 800只要不关心文本的结构边界切出来的块大概率会在标题之后、表格中间、列表项内部这种奇怪位置断开。而 Markdown 天生自带结构标记比如#、##、-、|、这些标记其实就是文档的“动线图”——每一处标题都是一段上下文的起止点每一个代码块都是一个独立的语义岛。所以 Markdown 结构化解的目标就是想尽办法让“切片的物理边界”尽量贴合“文档的语义边界”。一个由##标题开启的二层小节如果内容量在合理范围内整个小节作为一个切片是信息密度和召回质量很好的平衡。这就是很多 RAG 框架里所谓的 markdown header splitter 的核心思想。3.2 解析器选型别在轮子上浪费时间Markdown 解析器的选择是整个管线的取舍点。大多数项目用 Python社区里成熟的解析器不少Markdown-it-py、Mistune、Python-Markdown 都是常用选择。我的建议是优先用markdown-it-py原因是它支持插件扩展而且把 token 流暴露得很完整非常适合做结构化切片的二次加工。mistune虽然快但 token 形态相对简陋写复杂规则时容易不够用。结构化解的核心步骤是先把 Markdown 文本解析成 token 流再把 token 流递归组织成树状的语义块。以markdown-it-py为例你会拿到heading_open、inline、fence、table_open、list_item_open这些 token 类型我们可以根据它们构建出结构树每一层标题就是树中的一个节点段落、代码块、表格就是挂在节点下的叶子。构建出结构树后切片就变成了树的遍历问题非常直观。3.3 表格、代码块与数学公式的切片特判Markdown 中处理表格是最容易翻车的。表格本身是二维信息如果切成若干碎片每一片都失去列头对应关系问答场景里基本等于废数据。我的建议是表格整体作为一个切片块即使它超过常规长度也不拆开绝不能把一行行拆进不同切片。这样做的代价是单块 token 数可能偏大但换来的是列的语义完整性。代码块的处理则是“保留原样”。Markdown 的围栏代码块fence在 token 流里是独立类型直接用整个代码块作为一个切片块并记住其语言标记后续检索时可以加上 language 过滤条件。数学公式在 Markdown 里通常以$...$或$$...$$包裹如果项目里用到带公式的文档建议切片时做“公式完整保留”设置即单行公式不被切断、独立成块防止公式字符被硬拆后产生乱码式检索。3.4 切片长度的软边界与硬边界即使充分利用了结构边界我们仍然要面对一个工程问题有些标题下的内容太长了一个节点可能有几千行必须二次拆分。我的经验是设两个阈值软边界比如 800 token按需调整和硬边界比如 1500 token。当节点长度低于软边界时整个节点作为一个切片高于软边界但低于硬边界时按二级标题、列表项、空行等语义标记再切超过硬边界时则按段落切且优先在句子边界截断。一句话总结结构边界优先长度边界兜底两条腿走路缺一不可。4. 实操流程一套可直接落地的解析管道4.1 管道总览与目录设计我建议把数据导入解析做成一条清晰的管道从源文件到入库切片每一步都留下中间产物方便排查问题。我常用的目录结构大致如下data/ ├── raw/ # 原始文件txt、md 都放这里 ├── decoded/ # 完成编码识别转换后的 UTF-8 文本 ├── structured/ # 结构化解析后的 JSON / Markdown 标注文件 ├── chunks/ # 最终切片结果一张表一个文件或一个 JSONL └── logs/ # 解析日志与统计报告管道整体分四步读入解码、清洗、文本规范化去 BOM、统一换行、段落归一、结构化解Markdown 解析 结构树构建、切片导出树遍历 边界约束 元数据写回。每步结果都落盘这样任何一个环节出问题都能快速定位是编码问题还是切片规则问题。4.2 关键实现识别、解析、切片的三段式实现接下来给一个浓缩版的 Python 实现骨架基于 mardown-it-py 和 charset-normalizer。先说清楚这不是放之四海皆准的完整产品代码而是一个可以直接改造成管道的最小骨架。import json import re from pathlib import Path from charset_normalizer import from_path from markdown_it import MarkdownIt from markdown_it.token import Token def decode_text_file(filepath): 阶段一编码探测 统一转 UTF-8 best from_path(filepath).best() if best is None: # 回退策略绝大多数情况下 GB18030 能兜底中文旧文档 raw filepath.read_bytes() text raw.decode(gb18030, errorsreplace) else: text str(best) # 去 BOM 和统一换行 text text.lstrip(\ufeff) text text.replace(\r\n, \n).replace(\r, \n) return text def build_md_tree(md: MarkdownIt, text: str): 阶段二解析 Markdown拍平为语义块列表 tokens md.parse(text) blocks [] current_h None current_h_level 0 for i, tok in enumerate(tokens): if tok.type heading_open: current_h_level int(tok.tag[1]) # 找到标题内容 content_tok tokens[i1] current_h content_tok.content.strip() elif tok.type fence: lang tok.info.strip() blocks.append({ type: code, lang: lang, heading_stack: [current_h] if current_h else [], text: tok.content.strip() }) elif tok.type table_open: table_seg extract_table_segment(tokens, i) blocks.append({ type: table, heading_stack: [current_h] if current_h else [], text: table_seg }) elif tok.type paragraph_open: text_seg tokens[i1].content.strip() blocks.append({ type: text, heading_stack: [current_h] if current_h else [], text: text_seg }) return blocks def split_blocks(blocks, soft_limit800, hard_limit1500): 阶段三结构块-切片超长块用软硬边界处理 chunks [] for blk in blocks: text_len len(blk[text]) if text_len soft_limit: chunks.append(blk) elif soft_limit text_len hard_limit: # 按列表项、空行、句子划分 sub_parts re.split(r\n(?[-*]|\s*$), blk[text]) acc for part in sub_parts: if len(acc) len(part) soft_limit and acc: chunks.append({**blk, text: acc.strip()}) acc acc part \n if acc.strip(): chunks.append({**blk, text: acc.strip()}) else: # 超长段落按句子边界切 sentences re.split(r(?[。.!?])\s*, blk[text]) acc for sent in sentences: if len(acc) len(sent) hard_limit and acc: chunks.append({**blk, text: acc.strip()}) acc acc sent if acc.strip(): chunks.append({**blk, text: acc.strip()}) return chunks md MarkdownIt(commonmark, {html: False}).enable(table) def process_file(filepath): text decode_text_file(filepath) blocks build_md_tree(md, text) chunks split_blocks(blocks) return chunks # 使用示例 chunks process_file(data/raw/产品手册.md) out Path(data/chunks/产品手册.jsonl) with out.open(w, encodingutf-8) as f: for c in chunks: f.write(json.dumps(c, ensure_asciiFalse) \n)代码里值得强调的几个点charset_normalizer.from_path()返回的是逐行读取的探测结果对内存友好大文件也不会一次性吃满内存。heading_stack字段保存了当前块所属的标题路径切片后就能知道“这段内容来自哪个章节”对生成嵌套上下文非常有用。split_blocks里用了软硬两条边界避免了单个超长表格被切断表格整体成块软硬边界只对文本块生效。4.3 从解析到入库的元数据设计切片结果不能只留文本还要携带元数据否则下游无法追溯。我常用的元数据字段如下字段含义示例source源文件名产品手册.mdchunk_id切片唯一编号023-14heading_stack标题路径[产品手册, 安装步骤]type语义类型text / table / codetext切片正文...token_count预估 token 数742token_count可以用粗略公式len(text) // 2估算中文场景或者引入对应 tokenizer 精确计算。这个字段能帮我们在回填向量库时判断目标长度是否合适也方便做自适应切片的统计分析。4.4 目录落盘和中间产物检查管道跑完建议花几分钟检查中间产物。我一般会在structured/目录里直接查看某个文档的 JSON 结构树确认标题层级的嵌套关系对不对有没有出现“所有内容都堆在根节点”的情况。这其实是最容易出问题的地方——很多 Markdown 文档标题层级不规范比如直接从####开始写又或者##下面没有内容直接###这些都会导致结构树扭曲。遇到这种情况我会在解析前增加一步“标题层级规整”把跳级的标题按上下文的相对层级重新编号而不是直接报错或忽略。5. 常见问题与排查技巧实录5.1 乱码与异常字符的处理症状是解析后的文本里出现“锟斤拷”“”这类典型替换符。这几乎可以断定是源文件被某个环节用错误的编码重新写过信息已经不可逆地丢失了。此时不要试图在解析层面挽回正确做法是从原始文件重新走一遍探测流程。如果原始文件本身就已经被污染比如曾经用 GBK 读 UTF-8 再另存为 GBK那就只能找源头重新导出。这也是我坚持在管道里把 decoded 结果单独保存的原因——是否被污染一眼就能在中间产物中看出来。5.2 大文件内存占用过高几百 MB 的 txt 是知识库建设里的常客。如果一次性把整个文件读入内存再用正则切分比较容易爆内存。这里有两个实用技巧一是用charset_normalizer的流式接口或者自己用二进制分块读取先用前 64KB 探测编码再按探测结果分块解码二是切片阶段改用生成器逐块处理避免把所有切片都堆积在内存列表中。对超大 Markdown也可以先按顶层标题拆成若干小文件再逐个解析逻辑更清晰。5.3 Markdown 解析后表格内容断裂格式化表格在markdown-it-py里默认需要开启 table 插件如果你用的是commonmark预设且没有显式 enable table表格会被解析成一堆普通段落结构信息直接丢失。这是我在项目里真实踩过的坑表现是切片后表格列全乱。修正方式很简单初始化时显式md.enable(table)并在构建块时识别table_opentoken。另一种更省事的方案是先用markdown-it-py的html插件输出 HTML 表格再解析但那样会绕远路不如直接操作 token。5.4 切片过碎或过长怎么调参切片过碎的症状是召回时每个片段信息量太小模型没有足够上下文推断切片过长的症状是单块 token 数接近上限检索时浪费额度且命中率下降。调整的抓手就是第二节提到的软边界和硬边界。我的经验是如果文档以问答、FAQ 为主切片尽量短400 到 600如果文档以技术规格、长段落为主切片适当加长800 到 1200。这个值不是一个固定常数而是要靠人工查看一批切片结果来定别指望一次调对。5.5 文档格式规范很重要但解析器也要能扛脏数据团队内部最好约定一个“导入文档规范”例如一级标题用#正文段落之间必须空行表格必须有表头等。这能大幅提升解析质量。但现实是历史数据的格式千奇百怪所以解析器还必须具备容错能力。我的具体做法是解析前做两步预处理第一步清理不可见控制字符如\x00到\x1f第二步把断行段落行尾只有一个换行且下一个非空行不以符号开头做拼接。两步之后绝大多数不规范的 Markdown 都能被正常解析剩下的就记录到日志中人工复核。6. 结语之外的几句实在话数据导入解析这件事看似是 RAG 链路里最没有“技术含量”的一环但它其实是投入产出比最高的环节。同一份文档用结构化解和用暴力字符切片最终 RAG 回答的准确率差距可以在二到三成以上。尤其是 Markdown 这类自带结构的格式如果不好好利用标题层级做切片简直是捧着金饭碗要饭。我在实际调过的项目里经常看到团队花大量精力去调整 embedding 模型、换 reranker却忽略了解析质量。如果你现在也遇到召回效果上不去的困境先别动模型把你知识库里随机抽 20 条切片打出来肉眼看看是半句话还是完整语义是顺着标题来的还是从表格中央劈开的基本就能判断病根在哪。最后再分享一个小技巧无论你最终用哪个框架、哪种向量库请务必在切片结果里保存heading_stack字段。这个字段会在你做引用溯源、上下文扩写、以及分步问答时发挥意想不到的作用。一个小字段能救回不少体验分。数据解析的细节远不止我写的这些下一篇我会继续聊 PDF 和 DOCX 这类复杂格式的结构化思路以及多模态文档里图片与表格的联合抽取方案。