ARTICLE DETAIL

资讯详情

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

RAG数据导入实战:txt与Markdown解析清洗及切分策略

RAG数据导入实战:txt与Markdown解析清洗及切分策略 1. 为什么 RAG 的第一步永远是“把数据喂干净”做 RAG 的人都有一个共识检索效果差八成不是模型不行而是数据没处理好。我见过太多人一上来就折腾向量库选型、Embedding 模型对比、重排序策略结果召回的内容驴唇不对马嘴回头一查原始数据——PDF 里全是断行、表格错位、页眉页脚混进正文这种料喂给再强的模型也白搭。这一篇只聊 RAG 数据导入与解析里最基础、也最容易被跳过的一环通用文本txt和结构化文档Markdown的解析与清洗。为什么先讲这两种因为它们构成了知识库的“地基格式”。txt 是最原始的纯文本载体几乎所有格式最终都能降级成它Markdown 则是带轻量结构标记的文本既能保留标题层级、列表、代码块这些语义信息又不像 HTML/PDF 那样解析起来一堆坑。把这两种吃透后面处理 PDF、Word、Excel、网页就有了统一的参照系。这篇文章适合谁看如果你正在搭自己的 RAG 知识库卡在“文档丢进去检索不出来”这一步或者你打算写一个通用的文档导入管道但不确定每种格式该怎么切、怎么洗再或者你只是好奇“为什么我导入的 txt 检索效果这么差”——那这篇就是给你写的。我会把解析思路、切分策略、清洗规则、踩过的坑全部摊开讲代码能直接抄。先说一个核心判断RAG 的数据导入不是“读文件”而是“把非结构化信息重构成机器可检索的语义单元”。读文件谁都会open().read()一行搞定但那只是拿到了字符串。真正决定检索质量的是这段字符串怎么切、切完保留什么元数据、脏数据怎么过滤、结构信息怎么不丢。下面按这个逻辑一层层拆。2. 通用文本解析txt 看着简单坑全在编码和切分上2.1 txt 解析的第一个拦路虎编码识别很多人觉得 txt 最好处理直接读就行。我一开始也这么想直到有一次导入一批中文小说 txt检索出来全是乱码排查半天发现是 GBK 编码被当成 UTF-8 读了。txt 没有自描述编码信息这是它最大的坑。处理方案是先探测再解码。Python 里我常用charset-normalizerchardet的继任者维护更活跃它能给出编码置信度from charset_normalizer import from_path def detect_encoding(file_path): result from_path(file_path).best() if result is None: return utf-8 # 兜底 return result.encoding def read_txt(file_path): encoding detect_encoding(file_path) with open(file_path, r, encodingencoding, errorsreplace) as f: return f.read()注意errorsreplace这个参数。探测不可能 100% 准遇到个别无法解码的字节用 replace 替换成占位符比直接抛异常中断整个导入流程要好。但这里有个经验如果替换字符\ufffd占比超过 1%说明编码探测大概率错了应该报警而不是静默吞掉。我一般会加一个统计def read_txt_safe(file_path): encoding detect_encoding(file_path) with open(file_path, r, encodingencoding, errorsreplace) as f: text f.read() bad_ratio text.count(\ufffd) / max(len(text), 1) if bad_ratio 0.01: raise ValueError(f编码探测可能失败: {file_path}, 异常字符占比 {bad_ratio:.2%}) return text提示中文场景下最常见的三种编码是 UTF-8、GBK、GB18030。GB18030 是 GBK 的超集遇到疑似 GBK 的文件直接用 GB18030 解码兼容性更好。2.2 换行符统一别让\r\n毁掉你的切分Windows 出来的 txt 是\r\nLinux/Mac 是\n老 Mac 是\r。如果你按\n切段落\r\n会在每行末尾留一个\r检索时匹配不上。统一处理text text.replace(\r\n, \n).replace(\r, \n)这一步看着不起眼但我在实际项目里遇到过因为\r残留导致 BM25 分词结果异常的情况排查了两小时。导入管道里所有文本进切分器之前必须先做换行归一化这是铁律。2.3 切分策略固定长度 vs 语义切分txt 没有结构标记切分只能靠启发式规则。常见三种策略做法优点缺点固定字符切分每 N 字符一刀实现简单、块大小均匀容易切断句子、语义不完整递归字符切分按段落→句子→字符逐级降级尽量保持语义边界块大小不均语义切分用 Embedding 判断句子相似度断点语义最完整慢、成本高我的建议是默认用递归字符切分LangChain 的RecursiveCharacterTextSplitter就是这个思路分隔符优先级设为[\n\n, \n, 。, , , , , , ]。中文场景一定要把中文标点加进去默认的英文分隔符对中文几乎无效。关于块大小很多人纠结 chunk_size 设多少。我的经验值中文 300~500 字英文 500~800 字符。为什么因为主流 Embedding 模型如 bge、m3e的上下文窗口通常在 512 token 左右中文一个字约 1~1.5 token500 字差不多到上限。设太大超出部分被截断信息丢失设太小一个完整语义被切碎检索出来是残句。overlap 我一般设 chunk_size 的 10%~15%也就是 50 字左右。overlap 的作用是防止关键信息正好落在切分点上被割裂但设太大又会导致大量重复内容进库浪费存储还拉低检索精度。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size400, chunk_overlap50, separators[\n\n, \n, 。, , , , , , ], length_functionlen, ) chunks splitter.split_text(text)2.4 元数据切完不记来源检索出来就是孤儿这是新手最容易忽略的一点。切完的 chunk 如果不带元数据检索出来你根本不知道它来自哪个文件、哪一段。元数据至少要包含source文件路径或文件名chunk_index在原文中的序号char_start/char_end字符偏移量方便回溯原文file_typetxt / md / pdf 等chunks_with_meta [] offset 0 for i, chunk in enumerate(chunks): start text.find(chunk, offset) chunks_with_meta.append({ text: chunk, metadata: { source: file_path, chunk_index: i, char_start: start, char_end: start len(chunk), file_type: txt, } }) offset start len(chunk)注意text.find在 chunk 有重复内容时可能定位不准更稳妥的做法是用 splitter 返回的create_documents接口它内部会维护偏移量。但如果你自己手写切分记得处理这个边界。3. Markdown 解析结构信息是宝藏别当纯文本读3.1 为什么 Markdown 不能按 txt 处理Markdown 的价值在于它用极低的成本携带了结构语义#是标题层级-是列表是代码块|是表格。如果你把它当纯文本切这些标记要么被当成噪声要么被切得七零八落标题和它下面的正文分到不同 chunk检索时上下文就断了。正确做法是先解析成 AST抽象语法树再按结构切分。Python 里我用markdown-it-py或mistune它们能把 Markdown 解析成 token 流每个 token 带类型和层级信息。from markdown_it import MarkdownIt md MarkdownIt() tokens md.parse(markdown_text) for token in tokens: print(token.type, token.tag, token.level, token.content[:50])输出会类似heading_open h1 0、inline None 1 标题内容、paragraph_open p 0这样。有了这个你就能知道每个内容块属于哪个标题下。3.2 按标题层级切分让每个 chunk 自带“面包屑”我的做法是以标题为切分锚点把标题路径作为元数据附加到 chunk 上。比如一个 chunk 来自“## 3. Markdown 解析 ### 3.2 按标题层级切分”那它的元数据里就记heading_path: 3. Markdown 解析 3.2 按标题层级切分。检索时把这个路径拼到 chunk 前面能显著提升召回准确率因为标题本身就是高度浓缩的语义。def split_markdown_by_heading(md_text): tokens MarkdownIt().parse(md_text) chunks [] heading_stack [] # 维护当前标题路径 current_content [] for token in tokens: if token.type heading_open: # 遇到新标题先把之前累积的内容存起来 if current_content: chunks.append({ text: \n.join(current_content), heading_path: .join(heading_stack), }) current_content [] level int(token.tag[1]) # h1 - 1 # 更新标题栈 heading_stack heading_stack[:level-1] # 下一个 inline token 是标题文本 elif token.type inline and heading_stack is not None: if token.level 1 and not current_content: heading_stack.append(token.content) elif token.type in (paragraph_open, fence, table_open): current_content.append(token.content if token.content else ) if current_content: chunks.append({ text: \n.join(current_content), heading_path: .join(heading_stack), }) return chunks上面是简化版逻辑实际用的时候建议直接用langchain的MarkdownHeaderTextSplitter它已经把这套逻辑封装好了from langchain.text_splitter import MarkdownHeaderTextSplitter headers_to_split_on [ (#, h1), (##, h2), (###, h3), ] splitter MarkdownHeaderTextSplitter(headers_to_split_onheaders_to_split_on) docs splitter.split_text(md_text) # 每个 doc 的 metadata 里会带 h1/h2/h3 字段3.3 代码块和表格特殊内容特殊对待Markdown 里的代码块和表格|如果被普通切分器处理很容易被切断。我的处理原则是代码块整体保留不切分。一个代码块通常是一个完整逻辑单元切断了就没法用。如果代码块超过 chunk_size宁可单独成块也不切。表格转成文本描述。表格直接进向量库检索效果很差因为列名和单元格是分离的。我一般把表格转成“列名: 值”的键值对文本或者用 LLM 生成一句表格摘要。def table_to_text(table_token): # 简化示例把 markdown 表格转成自然语言描述 lines table_token.content.strip().split(\n) headers [h.strip() for h in lines[0].strip(|).split(|)] rows [] for line in lines[2:]: cells [c.strip() for c in line.strip(|).split(|)] row_desc .join(f{h}为{c} for h, c in zip(headers, cells)) rows.append(row_desc) return .join(rows)3.4 数学公式别让$符号干扰解析Markdown 里的数学公式$...$行内、$$...$$块级在解析时容易被当成普通文本$和\这些符号还会干扰后续处理。我的做法是在解析前先把公式提取出来用占位符替换解析完再还原。这样公式内容不会被切分器破坏检索时也能作为独立单元。import re def extract_math(text): math_blocks [] def replacer(match): math_blocks.append(match.group(0)) return f__MATH_{len(math_blocks)-1}__ # 先匹配块级公式再匹配行内公式 text re.sub(r\$\$(.?)\$\$, replacer, text, flagsre.DOTALL) text re.sub(r\$(.?)\$, replacer, text) return text, math_blocks def restore_math(text, math_blocks): for i, block in enumerate(math_blocks): text text.replace(f__MATH_{i}__, block) return text提示如果你的知识库涉及大量公式比如技术文档、论文建议把公式单独存一份检索时用专门的公式检索方案不要和普通文本混在一起。4. 从解析到入库完整管道怎么串4.1 统一入口按文件类型分发解析器一个健壮的导入管道应该有一个统一入口根据文件扩展名分发到不同解析器输出统一的数据结构。我定义的结构是dataclass class ParsedChunk: text: str metadata: dict # metadata 至少包含 source, chunk_index, file_type分发逻辑PARSERS { .txt: parse_txt, .md: parse_markdown, .markdown: parse_markdown, } def parse_file(file_path): ext os.path.splitext(file_path)[1].lower() parser PARSERS.get(ext) if parser is None: raise ValueError(f不支持的文件类型: {ext}) return parser(file_path)这样后面加 PDF、Word 解析器只要往PARSERS里注册就行主流程不用动。4.2 清洗规则哪些内容该丢哪些该留解析出来的文本不能直接入库得先清洗。我总结了一份清洗清单按优先级排列清洗项处理方式是否默认开启多余空白连续空格/换行压缩成一个是页眉页脚按行频统计高频重复行删除是PDF 场景控制字符删除\x00-\x08等不可见字符是超短块少于 20 字的 chunk 合并到相邻块是纯符号块只有标点/数字的块删除是重复内容相似度 0.95 的块去重可选超短块合并这个特别重要。我见过很多知识库检索出来一个 chunk 就俩字“如下”完全没用。合并逻辑def merge_short_chunks(chunks, min_len20): merged [] buffer for chunk in chunks: if len(chunk[text]) min_len: buffer chunk[text] else: if buffer: chunk[text] buffer chunk[text] buffer merged.append(chunk) if buffer and merged: merged[-1][text] buffer return merged4.3 去重别让同一段内容进库十遍重复内容对 RAG 是灾难。同一段文本进库多次检索时全被它占满其他相关内容反而排不上。去重我分两层精确去重用文本的 hash如 MD5做 key完全相同的直接丢。近似去重用 SimHash 或 MinHash相似度超过阈值的保留一个。import hashlib def exact_dedup(chunks): seen set() result [] for chunk in chunks: h hashlib.md5(chunk[text].encode(utf-8)).hexdigest() if h not in seen: seen.add(h) result.append(chunk) return result近似去重成本高我一般只在数据源本身有大量重复比如多个版本的同一文档时才开。4.4 入库前的最后一道关质量抽检管道跑完别急着全量入库先抽 20~30 个 chunk 人工看一眼。重点检查有没有乱码有没有被切断的句子元数据是否完整标题路径是否正确我踩过的坑有一次 Markdown 解析器把代码块里的#当成标题导致标题栈错乱所有 chunk 的 heading_path 全错了。这种问题不抽检根本发现不了等检索效果差再回头查成本高十倍。5. 常见问题与排查技巧实录5.1 检索效果差怎么定位是不是解析的锅排查顺序我一般这样走看召回内容本身检索出来的 chunk 是不是完整句子有没有乱码如果 chunk 本身就是残句那问题在切分。看元数据source 对不对heading_path 有没有如果元数据缺失问题在解析阶段。看重复率随机抽 100 个 chunk统计有多少是重复或高度相似的。超过 10% 说明去重没做好。看块大小分布如果大量 chunk 长度远小于 chunk_size说明切分器没生效或者分隔符设置有问题。5.2 常见问题速查表现象可能原因解决方向检索结果全是乱码编码探测错误检查 charset-normalizer 结果手动指定编码chunk 被从句子中间切断分隔符列表缺中文标点加入。标题和正文分到不同 chunk按固定长度切分改用 MarkdownHeaderTextSplitter代码块被切碎普通切分器不识别代码块代码块整体保留不切表格检索不出来表格被当普通文本切表格转文本描述或单独处理同一内容检索出多条去重没做加 MD5 精确去重短 chunk 太多切分过细合并超短块或调大 chunk_size公式符号干扰解析$被当普通字符解析前提取公式占位符替换5.3 几个我踩过的坑坑一errorsignore比errorsreplace更危险。ignore 会直接丢掉无法解码的字节导致文本内容缺失而且你完全不知道丢了什么。replace 至少留个占位符能统计出来。坑二Markdown 的---分隔线被当成标题。有些解析器会把---识别成 setext 标题的下划线导致后面一行被误判为标题。处理办法是在解析前把独立的---行替换成空行。坑三chunk_overlap 设太大导致检索结果重复。overlap 是为了防止边界信息丢失但如果设成 chunk_size 的 50%相邻 chunk 有一半内容重复检索时这两条会同时被召回浪费上下文窗口。10%~15% 足够。坑四忘了处理 BOM。UTF-8 with BOM 的文件开头会有\ufeff这个字符会混进第一个 chunk影响检索。读取时用encodingutf-8-sig可以自动去掉。坑五元数据里的路径用了绝对路径。换台机器或者迁移知识库时绝对路径全失效。统一用相对路径或者只存文件名。6. 一些实操心得和后续扩展方向关于 chunk_size 的选择我再补充一个实测经验不要迷信固定值要按文档类型调。技术文档、API 文档这种信息密度高的chunk_size 可以小一点300 字因为每句话都可能是独立知识点小说、散文这种叙事性的chunk_size 要大一点500~600 字因为上下文依赖强切太碎反而丢语义。还有一个容易被忽略的点解析和切分是两个独立阶段不要耦合在一起。我见过有人把解析和切分写在一个函数里结果想换切分策略时得重写整个解析逻辑。正确的做法是解析器只负责“文件 → 纯文本 结构信息”切分器只负责“纯文本 结构信息 → chunks”两者通过中间数据结构解耦。这样你换切分器、换 chunk_size都不用动解析代码。这个系列后面还会讲 PDF、Word、Excel、HTML 的解析那些格式的坑比 txt 和 Markdown 多得多——PDF 的版面分析、Word 的样式继承、Excel 的多 sheet 处理每一个都能单独写一篇。但不管处理什么格式最终都要归到这篇讲的这套逻辑上解析出文本和结构按语义切分带上元数据清洗去重抽检入库。把 txt 和 Markdown 这两个基础格式跑通后面的格式只是解析器不同管道骨架是一样的。最后分享一个我一直在用的小技巧给每个 chunk 的文本前面拼上它的 heading_path。比如一个 chunk 原文是“chunk_size 建议设 300~500 字”拼上路径后变成“RAG 数据导入 切分策略 chunk_size 建议设 300~500 字”。这样即使 chunk 本身没提“切分”检索“切分策略”时也能命中它。实测下来这个小改动能让召回率提升 10% 以上成本几乎为零。
返回列表