尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

RAG数据导入实战:txt与Markdown解析切块避坑指南

RAG数据导入实战:txt与Markdown解析切块避坑指南 RAG 系统里最不起眼、但最容易翻车的环节不是向量检索也不是大模型选型而是数据导入与解析。我做过不下十个知识库项目几乎每一个在 demo 阶段跑得飞起一上真实数据就出问题——PDF 里的表格变成一坨乱码、Markdown 的标题层级全丢、txt 文件里混着各种编码。检索效果差十有八九不是 embedding 模型不行而是喂进去的料本身就是碎的。这篇主要聊 RAG 数据导入链路里最基础的一段纯文本txt和结构化文本Markdown怎么解析、怎么切、怎么保住结构信息。别看这两种格式简单它们恰恰是很多知识库的主力数据源——技术文档、产品手册、会议纪要、小说语料大量都是 txt 或 Markdown。把这两种吃透后面处理 PDF、Word、HTML 就有底子了。适合正在搭 RAG 知识库、被数据清洗折磨过的同学也适合刚接触 RAG、想知道数据到底怎么进库的新手。1. 为什么 txt 和 Markdown 值得单独拎出来讲1.1 它们看着简单坑却最隐蔽很多人觉得 txt 就是纯文本读进来直接切块就完事了。真上手才发现一个 txt 文件可能藏着 GBK、UTF-8、UTF-8 BOM 三种编码甚至同一个文件里中英文段落编码还不一致。你按 UTF-8 硬读中文全变问号切出来的 chunk 全是乱码向量化之后检索命中率直接归零。Markdown 更微妙。它表面是纯文本实际上携带了层级结构——#是一级标题、##是二级、列表项、代码块、表格、引用块。这些结构信息对 RAG 极其宝贵标题能告诉你这段内容属于哪个主题代码块不该被从中间切断表格拆散就失去意义。如果你把 Markdown 当普通 txt 一刀切等于把一份带目录的书撕成等长的纸条检索时根本拼不回上下文。1.2 结构信息是 RAG 检索质量的隐形杠杆我做过一个对比实验同一份技术文档两种处理方式处理方式切块策略检索命中率Top3粗暴切分按 500 字符硬切约 52%结构感知按标题层级切保留标题路径约 81%差距接近 30 个百分点。原因很简单结构感知切分让每个 chunk 自带上下文标签。比如一个 chunk 开头带着产品手册 安装指南 环境要求检索时即使正文没出现环境这个词标题路径也能帮上忙。这就是为什么 Markdown 的解析不能只做文本提取必须把结构一起抽出来。1.3 这一篇在整个导入链路里的位置RAG 数据导入完整链路大致是数据采集 → 格式解析 → 清洗 → 切块 → 元数据标注 → 向量化 → 入库。这篇聚焦格式解析 切块这两步针对 txt 和 Markdown。后面还会涉及 PDF、Word、HTML 等复杂格式但那些格式的解析思路本质上都是从这两种基础格式延伸出去的——PDF 解析完往往也要转成 Markdown 再处理所以先把地基打牢。2. txt 解析编码、清洗与切块的三道关2.1 编码识别别信默认 UTF-8txt 文件最大的坑就是编码。Windows 上很多老文件是 GBK 或 GB2312Mac 和 Linux 默认 UTF-8还有些文件带 BOM 头。你如果无脑用open(file, r)读Python 在部分平台会按系统默认编码走结果就是乱码。我的做法是先探测再读取用chardet或charset-normalizer库自动识别from charset_normalizer import from_path def read_txt_safely(path): result from_path(path).best() if result is None: raise ValueError(f无法识别编码: {path}) text str(result) # 去掉 BOM 头 if text.startswith(\ufeff): text text[1:] return textcharset-normalizer比chardet更新、维护更活跃识别准确率也更高尤其是中英混排的文件。实测下来对 GBK、UTF-8、UTF-16 的识别基本没出过错。注意编码识别不是 100% 可靠尤其是短文件或纯 ASCII 文件。建议在识别后做一次可读性校验——统计一下非 ASCII 字符里有多少是常见汉字如果比例异常低说明可能识别错了需要人工介入或尝试备选编码。2.2 清洗哪些字符必须干掉txt 文件里常见的脏数据包括零宽字符\u200b、不间断空格\xa0、各种控制字符、连续空行、行尾多余空格。这些字符肉眼看不见但会污染 embedding让语义向量偏移。我一般做这几步清洗import re def clean_text(text): # 统一换行符 text text.replace(\r\n, \n).replace(\r, \n) # 去掉零宽字符和 BOM text re.sub(r[\u200b\u200c\u200d\ufeff], , text) # 不间断空格转普通空格 text text.replace(\xa0, ) # 去掉控制字符保留换行和制表 text re.sub(r[\x00-\x08\x0b\x0c\x0e-\x1f], , text) # 连续空行压缩成一个 text re.sub(r\n{3,}, \n\n, text) # 行尾空格去掉 text re.sub(r[ \t]\n, \n, text) return text.strip()这里有个经验不要过度清洗。有些同学喜欢把标点符号也统一了把全角转半角结果把中文的语义节奏破坏了。清洗的目标是去掉机器噪声不是改写内容。标点、语气词、口语化表达都是语义的一部分保留它们反而有助于检索。2.3 切块固定长度 vs 语义边界txt 没有天然结构切块只能靠策略。最常用的是递归字符切分RecursiveCharacterTextSplitter按优先级依次尝试分隔符段落 → 换行 → 句号 → 逗号 → 空格。这样能尽量在语义边界处断开。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[\n\n, \n, 。, , , , ., , ], length_functionlen, ) chunks splitter.split_text(clean_text)参数怎么定chunk_size500是个经验值对应中文大约 250-350 字能容纳一个完整段落或几个短句。chunk_overlap80是为了防止关键信息正好卡在切分点上被切断重叠部分让相邻 chunk 有上下文衔接。但固定长度切分有个硬伤它不理解语义。一段讲安装步骤的文字可能被从中间切开前半段在 chunk A后半段在 chunk B检索时只命中一个答案就不完整。对质量要求高的场景我会用语义切分——先按句子切再用 embedding 计算相邻句子的相似度相似度骤降的地方就是切分点。from langchain_experimental.text_splitter import SemanticChunker from langchain_openai import OpenAIEmbeddings semantic_splitter SemanticChunker( OpenAIEmbeddings(), breakpoint_threshold_typepercentile, breakpoint_threshold_amount95, ) chunks semantic_splitter.split_text(clean_text)语义切分效果好但慢、费 token。我的建议是长文档、结构松散的用语义切分短文档、结构清晰的用递归切分。别一上来就全用语义切分成本扛不住。3. Markdown 解析把标题层级变成检索的导航图3.1 为什么不能把 Markdown 当 txt 处理Markdown 的价值全在结构里。#到######六级标题构成了一棵文档树列表、代码块、表格、引用块是叶子节点。如果你把它当 txt 硬切会丢掉三类关键信息标题路径这段内容属于哪个章节检索时能提供额外语义线索块类型这是代码还是正文代码块不该被切碎表格拆开就废了层级关系子章节和父章节的从属关系决定了 chunk 的归属我见过最典型的翻车案例一份 API 文档每个接口用##标题参数说明用表格。粗暴切分后表格被切成两半检索某接口的参数时命中的 chunk 只有半张表模型只能瞎编。3.2 用 MarkdownHeaderTextSplitter 保住标题路径LangChain 提供了MarkdownHeaderTextSplitter专门按标题层级切分并把标题路径写进每个 chunk 的 metadatafrom langchain.text_splitter import MarkdownHeaderTextSplitter headers_to_split_on [ (#, h1), (##, h2), (###, h3), (####, h4), ] md_splitter MarkdownHeaderTextSplitter( headers_to_split_onheaders_to_split_on, strip_headersFalse, # 保留标题在正文里 ) md_chunks md_splitter.split_text(markdown_content)切出来的每个 chunk 都带 metadata比如{ h1: 产品手册, h2: 安装指南, h3: 环境要求, content: ### 环境要求\n\n- Python 3.9\n- 内存 8GB 以上\n... }这个 metadata 太有用了。检索时你可以把它拼进 chunk 的文本前缀比如[产品手册 安装指南 环境要求] 环境要求Python 3.9...让 embedding 把标题路径也编码进去。实测这个技巧能把检索命中率再拉高 10 个点左右。3.3 代码块和表格必须特殊保护MarkdownHeaderTextSplitter只按标题切不处理代码块和表格。如果某个章节特别长标题切分后还是一个大 chunk就需要二次切分。这时候要小心代码块和表格不能被从中间切断。我的做法是先识别出代码块和表格把它们当作原子块切分时整体保留import re def protect_blocks(text): # 提取代码块用占位符替换 code_blocks [] def replace_code(m): code_blocks.append(m.group(0)) return f__CODE_BLOCK_{len(code_blocks)-1}__ text re.sub(r[\s\S]*?, replace_code, text) # 提取表格连续的 | 开头行 tables [] def replace_table(m): tables.append(m.group(0)) return f__TABLE_{len(tables)-1}__ text re.sub(r(\|.*\|\n), replace_table, text) return text, code_blocks, tables def restore_blocks(text, code_blocks, tables): for i, block in enumerate(code_blocks): text text.replace(f__CODE_BLOCK_{i}__, block) for i, table in enumerate(tables): text text.replace(f__TABLE_{i}__, table) return text切分完再还原。这样代码块和表格始终是完整的不会被切碎。代价是可能出现超长 chunk但比起信息丢失我宁愿 chunk 大一点。提示如果代码块或表格本身超过 chunk_size那就只能单独成块并在 metadata 里标记block_type: code或block_type: table检索时可以针对性处理。3.4 标题路径拼接让每个 chunk 自带上下文前面提到把标题路径拼进 chunk 文本这里展开说下具体做法。切分后每个 chunk 的 metadata 里有 h1、h2、h3 等字段按层级拼成路径def build_context_prefix(metadata): parts [] for key in [h1, h2, h3, h4]: if key in metadata and metadata[key]: parts.append(metadata[key]) return .join(parts) for chunk in md_chunks: prefix build_context_prefix(chunk.metadata) chunk.page_content f[{prefix}]\n{chunk.page_content}这样每个 chunk 开头都带着完整路径。检索时即使正文没出现关键词路径里的词也能帮上忙。比如用户问环境要求是什么chunk 正文可能只写了Python 3.9但前缀[产品手册 安装指南 环境要求]里的环境要求直接命中。4. 从 txt 到 Markdown格式转换的取舍4.1 什么时候该转什么时候不该转很多教程一上来就说把所有格式统一转成 Markdown 再处理。这话对了一半。转 Markdown 的好处是统一了结构表达后续切分逻辑只用写一套。但转换本身会丢信息尤其是从 PDF、Word 转过来的时候表格和排版经常变形。我的判断标准原始格式是否转 Markdown理由txt无结构不转转了也没结构白费功夫txt有伪结构如用分隔转可以转成#标题获得结构HTML转HTML 标签转 Markdown 成熟且信息损失小Word视情况简单文档转复杂排版保留原格式解析PDF谨慎表格和公式转换损失大建议先解析再决定对 txt 来说如果它本身有伪结构——比如用连续等号、短横线做分隔或者用第一章1.1这种编号——可以写规则转成 Markdown 标题这样就能复用 Markdown 的解析逻辑。4.2 伪结构识别用正则把 txt 变成 Markdown常见的 txt 伪结构有几种用或----下划线标记标题用第X章、第X节标记章节用1.、1.1、1.1.1数字编号用全大写行标记标题写一组正则把它们转成 Markdown 标题import re def txt_to_markdown(text): lines text.split(\n) result [] for i, line in enumerate(lines): stripped line.strip() # 下划线标题下一行是 或 --- if i 1 len(lines): next_line lines[i1].strip() if next_line and set(next_line) {} and len(next_line) 3: result.append(f# {stripped}) continue if next_line and set(next_line) {-} and len(next_line) 3: result.append(f## {stripped}) continue # 跳过下划线行本身 if stripped and set(stripped) {, -} and len(stripped) 3: continue # 章节编号 if re.match(r^第[一二三四五六七八九十百][章节], stripped): result.append(f## {stripped}) continue # 数字编号 1.1.1 m re.match(r^(\d(?:\.\d)*)\s(.), stripped) if m: level m.group(1).count(.) 1 level min(level, 6) result.append(f{# * level} {stripped}) continue result.append(line) return \n.join(result)这套规则不是万能的但能覆盖大部分技术文档和书籍 txt。转完之后就能用 Markdown 的解析逻辑处理了。4.3 转换后的校验别让规则误伤正文正则转换最大的风险是误伤。比如正文里出现1. 首先打开设置这其实是个列表项不是标题但会被数字编号规则误判成# 1. 首先打开设置。我的做法是加一层校验转换后统计标题数量如果标题占比超过全文行数的 20%说明规则太激进需要收紧。另外标题一般不会以标点结尾可以加个判断def is_likely_heading(text): if len(text) 80: # 标题不会太长 return False if text.endswith((。, , , , ., ,, ;)): # 标题不以标点结尾 return False return True在转换前用这个函数过滤一遍能挡掉大部分误判。转换完最好人工抽查几个文件确认标题层级合理再批量跑。5. 切块参数的调优没有万能值只有场景值5.1 chunk_size 到底怎么定这是被问得最多的问题。我的答案永远是看你的检索场景和模型上下文窗口。如果检索的是事实型问答XX 的参数是多少chunk 要小200-400 字符保证精准命中如果检索的是总结型问答这段讲了什么chunk 要大800-1500 字符保证信息完整如果模型上下文窗口大如 128k可以适当放大 chunk减少 chunk 数量降低检索开销我一般从 500 起步跑一批测试问题看命中率和答案完整度再上下调整。别迷信某个最佳值不同数据集差异很大。5.2 overlap 的作用与代价overlap 是为了防止信息在切分点丢失。但 overlap 太大会导致 chunk 冗余检索时返回一堆重复内容浪费上下文窗口。我的经验值overlap 取 chunk_size 的 10%-20%。500 的 chunk 配 50-100 的 overlap 比较合适。如果文档结构清晰如 Markdown 按标题切overlap 可以设小甚至为 0因为标题切分本身就是语义边界。5.3 中文的特殊处理中文没有空格分词按字符数切分时要注意别在词语中间切断。比如人工智能被切成人工和智能两个 chunk 都失去了完整语义。解决办法是优先在标点处切分。RecursiveCharacterTextSplitter的 separators 里把中文标点放前面separators[\n\n, \n, 。, , , , , 、, , ]这样切分时会优先在句号、问号处断开实在不行才在逗号处断最后才按字符硬切。实测这个顺序对中文语义完整性帮助很大。6. 元数据标注让 chunk 可追溯、可过滤6.1 必带的元数据字段每个 chunk 除了文本内容还应该带一组元数据方便检索时过滤和展示来源。我一般会带这些字段说明示例source源文件路径docs/install.mdfile_type文件类型markdown / txtchunk_index在文档中的序号12heading_path标题路径产品手册 安装指南block_type块类型text / code / tablechar_count字符数486这些字段在检索时能派上大用场。比如用户问代码示例可以过滤block_typecode用户问某个章节的内容可以按heading_path过滤。6.2 用元数据做检索后过滤向量检索返回 Top-K 后可以用元数据做二次过滤。比如只保留来自特定文档的结果或者排除代码块def filter_results(results, source_filterNone, block_typeNone): filtered [] for r in results: meta r.metadata if source_filter and source_filter not in meta.get(source, ): continue if block_type and meta.get(block_type) ! block_type: continue filtered.append(r) return filtered这个技巧在多知识库场景特别有用。比如一个 RAG 系统同时挂了产品文档和内部 Wiki用户问产品问题时可以按source过滤掉 Wiki 内容避免串味。6.3 元数据别塞太多元数据不是越多越好。每个字段都会增加存储和检索开销而且塞太多无关字段会稀释有效信息。我的原则是只带检索和展示真正用得上的字段。像文件的创建时间、修改时间、作者这些除非业务需要否则不用带。7. 实测中的几个坑与应对7.1 编码识别失败的兜底方案charset-normalizer也有失手的时候尤其是短文件。我的兜底方案是多编码尝试 可读性打分def read_with_fallback(path): encodings [utf-8, gbk, gb18030, utf-16, latin-1] best_text None best_score -1 for enc in encodings: try: with open(path, r, encodingenc) as f: text f.read() score readability_score(text) if score best_score: best_score score best_text text except (UnicodeDecodeError, LookupError): continue return best_text def readability_score(text): # 统计常见汉字和英文字母占比 if not text: return 0 common sum(1 for c in text if \u4e00 c \u9fff or c.isalnum() or c.isspace()) return common / len(text)按可读性打分选最优结果比单纯依赖编码识别库更稳。这个方案我用了两年多基本没再遇到乱码问题。7.2 Markdown 表格被切碎的修复即使做了块保护有时候表格还是会被切碎——比如表格特别长超过 chunk_size。这时候我的处理是把表格转成文本描述而不是保留 Markdown 表格语法。def table_to_text(md_table): lines [l for l in md_table.strip().split(\n) if l.strip()] if len(lines) 2: return md_table headers [c.strip() for c 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)比如一张参数表转成参数A是值1参数B是值2参数A是值3参数B是值4这样的文本。虽然丢了表格形式但语义完整embedding 能理解检索时也能命中。7.3 超长单行文本的处理有些 txt 文件是一整行超长文本没有换行。这时候按换行切分完全失效只能按字符硬切。但硬切会破坏语义。我的做法是先按标点插入换行再走正常切分流程def insert_linebreaks(text, max_len200): # 在句末标点后插入换行如果该行已超长 result [] current [] current_len 0 for char in text: current.append(char) current_len 1 if char in 。 and current_len max_len: result.append(.join(current)) current [] current_len 0 if current: result.append(.join(current)) return \n.join(result)这样超长行会被拆成合理的段落后续切分就正常了。7.4 重复内容去重知识库数据经常有重复——同一份文档多个版本、复制粘贴的段落。重复内容会导致检索时返回一堆相似结果浪费上下文。我一般用SimHash 或 MinHash做近似去重。简单点的话可以对 chunk 做归一化去空格、转小写后算 MD5完全相同的直接去重import hashlib def dedup_chunks(chunks): seen set() result [] for chunk in chunks: normalized re.sub(r\s, , chunk.page_content).lower() h hashlib.md5(normalized.encode()).hexdigest() if h not in seen: seen.add(h) result.append(chunk) return result近似去重更复杂需要算相似度但对质量提升明显。如果数据量大建议上 MinHash LSH效率高。8. 一套可复用的解析流水线把前面的东西串起来我封装了一个通用的解析函数输入 txt 或 Markdown输出带元数据的 chunk 列表def parse_document(path): # 1. 读取带编码兜底 if path.endswith(.md): text read_with_fallback(path) is_markdown True else: text read_with_fallback(path) # 尝试伪结构转 Markdown text txt_to_markdown(text) is_markdown True # 2. 清洗 text clean_text(text) # 3. 保护代码块和表格 text, code_blocks, tables protect_blocks(text) # 4. 按标题切分 md_splitter MarkdownHeaderTextSplitter( headers_to_split_on[(#, h1), (##, h2), (###, h3)], strip_headersFalse, ) chunks md_splitter.split_text(text) # 5. 还原代码块和表格 for chunk in chunks: chunk.page_content restore_blocks(chunk.page_content, code_blocks, tables) # 6. 二次切分超长 chunk final_chunks [] for chunk in chunks: if len(chunk.page_content) 1000: sub_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[\n\n, \n, 。, , , , , , ], ) sub_texts sub_splitter.split_text(chunk.page_content) for i, sub in enumerate(sub_texts): new_chunk Document( page_contentsub, metadata{**chunk.metadata, sub_index: i} ) final_chunks.append(new_chunk) else: final_chunks.append(chunk) # 7. 加标题路径前缀 for chunk in final_chunks: prefix build_context_prefix(chunk.metadata) if prefix: chunk.page_content f[{prefix}]\n{chunk.page_content} chunk.metadata[source] path chunk.metadata[char_count] len(chunk.page_content) # 8. 去重 final_chunks dedup_chunks(final_chunks) return final_chunks这套流水线我用了很久覆盖了 txt 和 Markdown 的绝大多数场景。你可以直接拿去改也可以按自己的需求裁剪。9. 几个容易被忽略的细节9.1 文件读取的编码参数要显式指定Python 的open()如果不指定 encoding会按系统默认编码走。Windows 默认 GBKLinux 默认 UTF-8同一份代码在不同机器上结果不一样。永远显式指定 encoding这是铁律。9.2 Markdown 的 front matter 要单独处理很多 Markdown 文件开头有 YAML front matter---包裹的元数据。这部分不该进正文应该解析出来放进 metadataimport yaml def extract_front_matter(text): if text.startswith(---): parts text.split(---, 2) if len(parts) 3: try: meta yaml.safe_load(parts[1]) return meta, parts[2].strip() except yaml.YAMLError: pass return {}, textfront matter 里的 title、tags、date 都是很好的元数据检索时能用来过滤。9.3 列表项不要单独成块Markdown 的列表项如果被单独切成一个 chunk会失去上下文。比如- 支持 Python 3.9 - 支持 Node 18 - 支持 Java 17如果每个列表项单独成块检索支持哪些语言时可能只命中一个答案不完整。我的做法是把整个列表作为一个块除非列表特别长。9.4 标题本身也要进 chunk有些切分策略会把标题从正文里剥离只留正文。这样 chunk 就失去了这段讲什么的直接线索。我建议strip_headersFalse让标题留在正文里同时 metadata 里也存一份。10. 关于效果验证的一点经验解析和切分做完怎么知道效果好不好别靠感觉要建测试集。我的做法是从文档里挑 20-30 个典型问题人工标注正确答案所在的 chunk。然后跑检索看 Top-K 命中率。如果命中率低于 70%说明切分或解析有问题回去调。另外看 bad case比看整体指标更有用。把没命中的问题拉出来看命中的 chunk 是什么为什么没命中。常见原因有chunk 太大导致语义稀释、chunk 太小导致信息不全、标题路径没拼、编码问题导致乱码。对症下药比盲目调参有效得多。这套流程跑下来txt 和 Markdown 的解析基本就稳了。后面处理 PDF、Word、HTML 时思路是一样的——先保住结构再谈切分。结构是 RAG 检索质量的地基地基打不牢上面堆再多优化都是白搭。
返回列表