
1. RAG 数据导入与解析全攻略从 txt 到 Markdown 的通用文本与结构化解析做 RAG 应用最让人头疼的环节往往不是模型选型也不是向量库调参而是数据导入与解析这一步。我见过太多团队在 demo 阶段跑得挺欢一上真实文档就翻车——PDF 里的表格变成乱码、Markdown 的层级结构全丢、txt 文件里混着各种编码格式。说白了RAG 的上限很大程度上取决于你喂进去的数据质量而数据导入与解析就是这条流水线的第一道关口。这篇内容主要面向正在搭建 RAG 知识库的开发者、数据工程师以及需要批量处理文档的技术人员。我会把从 txt 到 Markdown 这类通用文本格式的解析思路、实操步骤、踩坑经验完整拆一遍重点讲清楚为什么这样设计、参数怎么定、遇到问题怎么排查。读完你至少能搭出一套稳定可复用的文本解析管线而不是每次换一批文档就重新调一遍。2. 整体设计思路为什么解析层要独立出来2.1 解析层与索引层解耦的核心逻辑很多人一开始会把解析和向量化写在一个脚本里读一个文件、切一段、embedding 一次、写一次库。小批量看着没问题一旦文档量上到几千份问题就暴露了解析失败的文件没法单独重试换一种切分策略要全量重跑调试的时候根本定位不到是哪一步出的错。我的做法是把整条链路拆成三层解析层负责把任意格式转成统一的中间表示切分层负责按语义单元切块索引层负责向量化和入库。三层之间用文件系统或消息队列传递每层都可以独立重跑。这样做的好处很直接——解析层输出的中间格式一旦统一后面换 embedding 模型、换向量库前面的工作完全不用动。中间表示我选的是Markdown而不是纯文本或 JSON。原因有几个Markdown 天然带层级结构标题、列表、代码块能保留文档的语义骨架它是纯文本diff 和版本管理都方便几乎所有大模型对 Markdown 的理解都很好直接喂给 LLM 做摘要或问答效果比纯文本强不少。所以整个解析层的目标可以一句话概括把各种格式的文档尽可能无损地转成结构清晰的 Markdown。2.2 通用文本与结构化文本的分野标题里提到通用文本与结构化解析这两类要分开对待。通用文本指的是 txt、log、纯文本笔记这类没有显式标记的内容它们的难点在于如何从无结构中推断出结构——比如一段小说文本你得靠空行、缩进、章节关键词来判断哪里是标题、哪里是正文。结构化文本指的是 Markdown、HTML、XML、JSON 这类本身带标记的内容难点在于如何正确解析标记并映射到统一的中间表示同时不丢失原有的语义信息。我一般会先做一个格式嗅探根据文件扩展名和内容特征判断属于哪一类再走对应的解析分支。这个判断逻辑看似简单但实际项目里经常遇到扩展名骗人的情况——比如一个.txt文件打开一看是 HTML 源码或者.md文件里混着大段未转义的 HTML 标签。所以嗅探不能只看扩展名还要看前几 KB 的内容特征。2.3 为什么选择 Markdown 作为中间格式再展开说一下选 Markdown 的理由因为这是整个设计的关键决策。纯文本丢失了所有结构信息切分的时候只能按固定长度硬切很容易把一句话切成两半。JSON 虽然结构完整但可读性差人工检查解析结果的时候很痛苦而且不同来源的文档映射到同一套 JSON schema 需要大量适配工作。Markdown 处在中间位置它比纯文本多了标题、列表、代码块、引用这些结构标记又比 JSON 轻量、可读。更重要的是Markdown 的标题层级天然对应文档的语义层级切分的时候可以按标题切、按段落切切出来的块自带上下文比如知道这个块属于哪个二级标题下检索时的相关性会明显提升。实测下来同样一批技术文档按 Markdown 标题切分的检索命中率比固定长度切分高出不少。3. 核心细节解析txt 与 Markdown 的解析要点3.1 txt 文件的编码识别与清洗txt 文件最大的坑就是编码。国内环境里常见的就有 UTF-8、GBK、GB2312、GB18030还有带 BOM 的 UTF-8。如果直接用默认编码读遇到不匹配的就会抛异常或者读出乱码。我的处理流程是先用chardet或charset-normalizer做编码探测拿到置信度最高的候选再用候选编码试读读出来的内容做一次乱码检测比如统计替换字符\ufffd的比例超过阈值就换下一个候选。import chardet def detect_and_read(file_path): with open(file_path, rb) as f: raw f.read() result chardet.detect(raw) encoding result[encoding] confidence result[confidence] # 置信度低时尝试常见中文编码 candidates [encoding, utf-8, gb18030, gbk] for enc in candidates: if not enc: continue try: text raw.decode(enc) bad_ratio text.count(\ufffd) / max(len(text), 1) if bad_ratio 0.001: return text, enc except (UnicodeDecodeError, LookupError): continue return raw.decode(utf-8, errorsreplace), utf-8清洗环节要处理的东西也不少多余的空行、行尾空格、全角半角混用、不可见字符比如零宽空格\u200b。这些字符肉眼看不见但会污染 embedding 结果。我一般会做一轮正则清洗把连续三个以上的换行压成两个去掉行尾空白把零宽字符全部剔除。注意清洗不要过度。有些看似多余的空行其实是段落分隔的语义信号全删了反而破坏结构。我的原则是只清理明确的噪声保留所有可能承载语义的空白。3.2 从无结构文本推断标题层级txt 转 Markdown 最核心的一步是识别标题。没有标记的情况下只能靠启发式规则。我总结了几条在实践中比较靠谱的信号行首有第X章、第X节、一、、1.、1.1这类编号模式整行较短比如少于 30 字且前后都是空行行首有 Markdown 风格的#或全角字体信息如果有的话纯 txt 没有但解析 docx 时有这些规则单独用都不够准我会给每条规则打分综合得分超过阈值才判定为标题。比如第X章这种强模式直接给高分短行加空行给中等分。判定为标题后还要推断它的层级——第X章是一级第X节是二级1.1这种数字编号按点数判断层级。import re CHAPTER_PATTERNS [ (re.compile(r^第[一二三四五六七八九十百零\d]章), 1), (re.compile(r^第[一二三四五六七八九十百零\d]节), 2), (re.compile(r^\d\.\d\s), 2), (re.compile(r^\d\.\s), 1), (re.compile(r^[一二三四五六七八九十]、), 1), ] def infer_heading(line): stripped line.strip() if not stripped or len(stripped) 40: return None for pattern, level in CHAPTER_PATTERNS: if pattern.match(stripped): return level return None这里有个经验不要试图做到 100% 准确。启发式规则能覆盖 80% 的常见情况就够了剩下的靠人工抽检修正。追求完美规则会让代码越来越复杂维护成本远超收益。3.3 Markdown 解析中的结构保留Markdown 解析看起来简单其实细节很多。我用的是markdown-it-py或者mistune这类解析器把 Markdown 转成 AST抽象语法树然后遍历 AST 提取结构。为什么不直接用正则因为 Markdown 的嵌套结构列表里套代码块、引用里套列表用正则处理会非常痛苦AST 才是正道。解析时要特别注意几个点代码块要完整保留包括语言标记因为代码块里的内容往往是最有价值的检索目标表格要转成结构化数据Markdown 表格在 AST 里是独立节点可以提取成二维数组链接和图片要保留 alt 文本和 URL图片虽然 RAG 知识库不一定能直接存但 alt 文本是重要的语义信息。提示如果你的 RAG 知识库需要处理图片Markdown 里的图片引用可以先提取出来单独走 OCR 或多模态 embedding正文里保留一个占位符和 alt 文本检索时用 alt 文本做召回。3.4 元数据提取与附加解析出来的 Markdown 只是内容还需要附加元数据才能支撑后续的检索和过滤。我一般会提取这几类元数据来源信息文件路径、文件名、修改时间、结构信息标题层级树、章节数量、字数、内容特征是否含代码、是否含表格、语言。这些元数据在切分和入库时都会用到比如按来源过滤、按章节定位、按内容类型加权。元数据的存储我倾向于用 YAML front matter 放在 Markdown 文件头部这样单个文件就是自包含的迁移和备份都方便。格式大概是这样--- source: /data/docs/guide.md title: 使用指南 created: 2024-01-15 sections: 12 has_code: true language: zh ---4. 实操过程搭建完整的解析管线4.1 环境准备与依赖选型先把环境搭起来。Python 3.9 以上核心依赖就几个chardet或charset-normalizer做编码探测markdown-it-py做 Markdown 解析beautifulsoup4和lxml处理 HTMLpyyaml读写 front matter。如果还要处理 docx、pdf再加python-docx和pymupdf。pip install chardet markdown-it-py beautifulsoup4 lxml pyyaml python-docx pymupdf选型上我踩过的坑markdown这个库注意不是markdown-it-py扩展性一般处理复杂嵌套时容易出问题mistune速度快但 AST 结构不如markdown-it-py清晰。综合下来markdown-it-py是平衡最好的插件生态也丰富需要支持数学公式、callout 这些扩展时直接加插件就行。4.2 目录扫描与格式嗅探管线入口是一个目录扫描器递归遍历目标目录对每个文件做格式嗅探。嗅探逻辑分两步先看扩展名扩展名明确且可信的直接走对应分支扩展名缺失或可疑的读前 4KB 内容做特征匹配。import os from pathlib import Path EXT_MAP { .txt: text, .md: markdown, .markdown: markdown, .html: html, .htm: html, .json: json, .xml: xml, } def sniff_format(file_path): ext Path(file_path).suffix.lower() if ext in EXT_MAP: return EXT_MAP[ext] # 扩展名未知读内容判断 with open(file_path, rb) as f: head f.read(4096) try: text head.decode(utf-8, errorsignore) except Exception: return binary if text.lstrip().startswith(!DOCTYPE) or html in text[:500].lower(): return html if text.lstrip().startswith({) or text.lstrip().startswith([): return json if text.lstrip().startswith(?xml): return xml return text扫描的时候要跳过隐藏文件、临时文件.tmp、~结尾、以及超过大小阈值的文件比如超过 50MB 的单独处理避免内存爆掉。这些边界情况不处理批量跑的时候一定会出问题。4.3 txt 到 Markdown 的转换实现txt 转换的核心流程是读文件 → 编码识别 → 清洗 → 逐行扫描推断结构 → 生成 Markdown。逐行扫描的时候维护一个状态机记录当前是否在代码块内、当前标题层级是多少遇到疑似标题就根据推断的层级插入对应数量的#。def txt_to_markdown(text): lines text.split(\n) output [] in_code False for line in lines: stripped line.strip() # 代码块边界检测三个反引号或四个空格缩进 if stripped.startswith(): in_code not in_code output.append(line) continue if in_code: output.append(line) continue level infer_heading(line) if level: output.append(# * level stripped) else: output.append(line) return \n.join(output)这里有个细节代码块检测要在标题检测之前。因为代码块里的内容可能长得像标题比如注释里的# 这是注释如果先判标题就会误伤。状态机的顺序很重要先判代码块边界再判标题。4.4 Markdown 结构解析与标准化Markdown 输入的处理分两种情况如果本身就是规范的 Markdown直接解析 AST 提取结构即可如果是伪 Markdown比如从网页复制粘贴的、层级混乱的需要先做一轮标准化——统一标题层级确保不跳级、规范列表标记、修正未闭合的代码块。from markdown_it import MarkdownIt md MarkdownIt(commonmark, {html: False}) def parse_markdown(text): tokens md.parse(text) structure [] current_heading None for token in tokens: if token.type heading_open: level int(token.tag[1]) current_heading {level: level, text: , content: []} structure.append(current_heading) elif token.type inline and current_heading is not None: current_heading[text] token.content elif token.type paragraph_open and current_heading is not None: pass return structure标准化的时候要特别小心标题层级跳级的问题。比如一级标题下面直接跟三级标题这种在渲染时可能没问题但在按标题切分时会出乱子。我的做法是维护一个层级栈遇到跳级就自动补一个中间层级的空标题或者把当前标题降级到合法层级。4.5 统一输出与元数据写入所有格式解析完统一输出成带 front matter 的 Markdown 文件。输出目录结构我一般保持和输入一致方便溯源。文件名做一次 sanitize去掉特殊字符避免不同系统下的兼容问题。import yaml from datetime import datetime def write_markdown(content, metadata, output_path): os.makedirs(os.path.dirname(output_path), exist_okTrue) front_matter yaml.dump(metadata, allow_unicodeTrue, sort_keysFalse) with open(output_path, w, encodingutf-8) as f: f.write(---\n) f.write(front_matter) f.write(---\n\n) f.write(content)元数据里我必带的字段source原始路径、format原始格式、parsed_at解析时间、char_count字符数、heading_count标题数。这些字段在后续排查问题时特别有用比如发现某批文档检索效果差一看heading_count是 0就知道是解析时没识别出结构。5. 常见问题与排查技巧实录5.1 编码乱码问题速查编码问题是最常见的表现是读出来的文本里混着锟斤拷、烫烫烫这类乱码。排查思路是先确认原始文件的真实编码用file -i命令或十六进制查看器看 BOM再检查代码里的编码探测逻辑是否被绕过。现象可能原因解决方法中文全是乱码用 UTF-8 读了 GBK 文件用 chardet 探测后按结果解码部分字符乱码混合编码或文件损坏逐行解码失败行单独处理开头有\ufeffUTF-8 BOM解码时用utf-8-sig零宽字符污染从网页复制带入正则剔除[\u200b-\u200f\ufeff]注意chardet对短文本的探测准确率不高小于 1KB 的文件建议直接尝试常见编码列表哪个能无错解码用哪个。5.2 标题识别误判与漏判标题识别的问题分两类误判把正文当标题和漏判标题没识别出来。误判通常是因为短行加空行的规则太激进把一些独立的短句也当成了标题。漏判则是因为标题格式太特殊不在预设模式里。我的调优方法是先跑一批样本把识别结果导出来人工看统计误判和漏判的案例针对性调整规则权重。比如发现注意这种开头的短行被误判为标题就加一条规则排除以冒号结尾的短行。发现某种编号格式没覆盖就补一条正则。这个过程迭代两三轮基本就能收敛。5.3 大文件处理的内存与性能单个文件超过 10MB 的时候一次性读进内存再处理可能会爆。我的做法是流式处理按行读维护一个滑动窗口做标题推断处理完的行直接写输出不在内存里攒。这样内存占用基本恒定跟文件大小无关。def stream_process(input_path, output_path): with open(input_path, r, encodingutf-8, errorsreplace) as fin, \ open(output_path, w, encodingutf-8) as fout: buffer [] for line in fin: buffer.append(line) if len(buffer) 3: process_line(buffer.pop(0), fout) for line in buffer: process_line(line, fout)性能上纯 Python 的逐行处理对几万份文档来说够用了瓶颈通常在 IO 而不是 CPU。如果确实需要提速可以把解析逻辑用多进程并行每个进程处理一批文件注意输出文件不要冲突。5.4 解析结果质量抽检方法批量解析完一定要抽检不能直接进下一环节。我的抽检方法是随机抽 5% 的文件检查几个指标标题数量是否合理跟原文对比、代码块是否完整、表格是否保留、front matter 字段是否齐全。发现系统性问题的回退重跑。抽检的时候我会写一个简单的对比脚本把原始文件和解析后的 Markdown 并排输出人工扫一眼就能看出问题。这个投入很值得因为解析层的错误会一路传导到检索效果越晚发现修复成本越高。6. 一些实操心得与后续扩展方向聊几个我在实际项目里踩过的坑。第一个是不要迷信自动化启发式规则再完善也有边界重要文档建议保留人工校对环节尤其是法律、医疗这类对准确性要求高的领域。第二个是解析配置要版本化规则改了之后之前解析的结果和之后的不一致排查问题时很容易懵所以每次改规则都要记录版本号写进元数据里。第三个心得是关于增量解析。文档库是持续增长的每次都全量重跑太浪费。我的做法是用文件修改时间加内容哈希做去重只解析新增和变更的文件删除的文件同步清理输出。这样日常维护的成本就降下来了。后续可以扩展的方向接入更多格式docx、pdf、pptx 的解析逻辑类似只是提取层不同、增加 OCR 处理扫描件、对接多模态模型处理图片内容。但核心思路不变——先把文本类格式的解析做扎实再逐步扩展。文本解析是所有格式的基础这一步稳了后面加什么格式都是套同一个框架。解析层做完下一步就是切分和索引了那是另一个话题涉及切分策略、embedding 模型选型、向量库调优坑同样不少。但只要你手上的数据是干净、结构清晰的 Markdown后面的工作会顺畅很多。