资讯动态

RAG数据导入实战:从txt编码清洗到Markdown结构化解析

发布时间:2026/10/4 14:29:59 来源:尧图企业网站定制
做RAG项目的朋友应该都有这种经历模型调参调了大半天Prompt换了好几版检索结果还是答非所问最后把文档拉出来一看——切出来的片段里全是乱码、标题连在一起、正文被截断成半句话。数据导入与解析这个环节看着不起眼却是RAG整个链路里最容易翻车也最影响效果的一环。这篇是这个系列的第一篇我会从最基础的txt文本讲起一直讲到如何用Markdown结构化解的思路让普通文本变成一个能被检索器“读懂”的信息单元集合。内容偏实操每一段都有可以直接复制的代码和配置适合正在搭知识库、想做本地RAG、或者已经被数据预处理折磨过的朋友。1. 先想清楚RAG里“数据导入与解析”到底在解决什么1.1 一条数据进RAG的完整链路很多人一提到RAG就想着向量数据库和Embedding模型但整个流程真正跑起来其实是这样一条链路文件上传 → 读取文本 → 内容清洗 → 结构化解 → 文本切片 → 向量化 → 写入向量库这个链路里向量化和写入向量库有成熟框架帮你封装好Embedding模型有现成的可以用最容易被忽略也最需要你亲手干预的是“读取文本”到“文本切片”这一段。这一段做不好后面再好的模型和向量库都是白搭。我见过一个特别典型的例子有人把一本PDF电子书直接丢给LangChain的PyPDFLoader处理切出来的文本包含大量的换行符、页眉页脚、乱码字符向量化的结果自然是一团糟。问LLM的问题稍微拐个弯检索出来的片段就是答非所问。后来我把PDF先转成txt做了清洗和结构化处理同一套检索配置准确率肉眼可见地提升。这里要记住一个核心观点RAG的效果上限取决于你把文档喂给检索器之前文本被处理得有多干净、多结构化。1.2 为什么首选txt和Markdown作为切入点这个系列第一讲选择txt和Markdown不是因为它们功能强大恰恰是因为它们门槛最低、通用性最强。txt是所有文本格式的“母格式”PDF、Word、HTML转出来最终都可以落到txt这一层。先搞懂txt的处理逻辑后面处理PDF和docx就是同一套思路加上对应的解析器。Markdown则是“结构化的起点”。它虽然没有Word那么复杂的排版能力但自带标题层级、列表、引用块、表格这些轻量级结构标记。这些标记可以被解析器直接识别进而成为文本切分的天然边界——比纯文本的“盲切”要优雅得多。更实际的一点现在很多知识库文档本身就是Markdown格式写的GitHub仓库、技术博客、产品手册导出下来就是一整个Markdown目录。把txt和Markdown这两条线走通能覆盖掉RAG知识库建库场景里至少一半的需求。1.3 结构化解析的本质从“排版的文本”到“信息单元”很多人把“数据导入”理解成“把文件内容读出来”其实这是两码事。读出文本只是第一步RAG真正需要的是“信息单元”的集合。什么叫信息单元一段可以独立被检索、被理解、被作为答案依据的语义完整片段。比如一篇技术文档里的“第三章 安装步骤”下的“3.2 Windows环境安装”这一段就是一个信息单元。它有自己的上下文属于哪个章节、清晰的主题边界安装步骤、完整的语义内容。普通txt文本是一坨连续的字符流你需要从里面把信息单元切出来这个动作就是结构化解析。转换成Markdown只是手段识别章节边界、保留层级关系、维护上下文上下文才是目的。我习惯把这一步叫“给文本建立骨架”。有了骨架切片器才知道从哪里下刀有了骨架检索器才能带着章节信息去匹配有了骨架LLM生成答案的时候才能参考到完整的上下文。这就是为什么同样一份文档别人切出来的片段能精准命中而你的总是东一榔头西一棒子。2. 文本读取与编码陷阱txt导入的第一步就翻车2.1 编码问题的实战处理txt处理第一个坑永远是编码。UTF-8、GBK、GB2312、BOM头任何一个处理不到位轻则乱码入库重则整个文件读取报错程序直接崩溃。先看一个最常见的错误写法with open(data.txt, r) as f: content f.read()这段代码在Windows下读取GBK编码的txt文件大概率抛UnicodeDecodeError即使不报错读进来的中文也可能全是乱码。原因是Python默认编码是UTF-8碰到GBK文件自然消化不良。我常用的处理方案是先用chardet库检测编码再按检测结果读取import chardet def read_text_file(path): # 先以二进制方式读取检测编码 with open(path, rb) as f: raw_data f.read() result chardet.detect(raw_data) encoding result.get(encoding, utf-8) # 处理UTF-8-BOM的情况避免开头出现看不见的BOM字符 if encoding and encoding.lower() utf-8-sig: encoding utf-8-sig # 用检测到的编码解码 return raw_data.decode(encoding, errorsreplace)这里两个细节值得注意第一个是errorsreplace参数。检测编码不是百分百准确的遇到无法解码的字节时replace会把异常字节替换成“”保证程序不崩代价是丢失个别字符。对于知识库场景我倾向于接受这个折中总比整个文件挂掉好。第二个是UTF-8-SIG这种编码。很多Windows记事本保存的“UTF-8”文件其实自带BOM头文件开头有几个不可见字节。如果用普通utf-8解码那段字节会变成“\ufeff”字符粘在每段文本的开头后面做匹配做分词都会出问题。2.2 大文件读取与内存控制处理大txt文件是另一个高频问题。一本几百万字的网文txt动辄几十兆甚至上百兆用read()一次性读入内存机器稍微差一点就直接卡死。我处理大文件的标准姿势是生成器加分块读取def read_large_txt_line_by_line(file_path, chunk_size8192): with open(file_path, r, encodingutf-8, errorsreplace) as f: while True: lines f.readlines(chunk_size) if not lines: break for line in lines: yield line调用的时候用for line in read_large_txt_line_by_line(big_novel.txt)每次只处理一部分数据内存占用稳定在一个很小的范围内。不过这里要提一个RAG场景的特殊性向量化入库的最终目的是把文本切片所以你需要的是完整的切片内容而不是单行文本。逐行读取更多是为了清洗和预检真正入库的时候还是需要把片段组织起来。我的建议是预检阶段逐行扫描统计行数、检查编码、抽样预览内容确认没问题之后再一次性读取或者分块读取做清洗。这样既能防住大文件的内存压力又不会因为逐行处理把文本的段落结构搞得支离破碎。2.3 清洗与规范化读进来的原始txt文本几乎一定带着各种“脏东西”。我归纳过最常见的几类零宽字符如\u200b、\u200c、\u200d复制网页内容时常带进来肉眼看不见但会影响分词和文本匹配控制字符如\x00、\x1a某些老软件生成的txt里会出现向量化时可能成为噪声Windows换行符\r\n和Unix系统的\n混在一起影响正则匹配和JSON序列化全角半角混用全角空格、全角逗号对中文分词和检索都有干扰我做清洗的习惯是写一个统一的清理函数import re def clean_text(text): # 去掉零宽字符和控制字符 text re.sub(r[\u200b\u200c\u200d\u2060\ufeff], , text) text re.sub(r[\x00-\x08\x0b\x0c\x0e-\x1f], , text) # 统一换行符 text text.replace(\r\n, \n).replace(\r, \n) # 去掉行尾多余空白 text \n.join([line.rstrip() for line in text.split(\n)]) return text.strip()这一步不要图省事跳过。你喂给Embedding模型的文本越干净向量表示就越准确。尤其是零宽字符这种“隐形杀手”表面上文本看起来完全正常但一旦做精确匹配或者按字符切片它就会导致切片边界错误从而产生你完全意想不到的结果。我之前踩过一次排查了大半天最后用repr打印文本才发现中间嵌了一堆不可见字符。3. 从txt到Markdown通用文本的“结构化解”实战3.1 为什么要把txt转成Markdown清洗干净的txt还是“一坨文本”直接切片的话只能靠固定长度硬切。硬切的问题是语义完整的段落可能被拦腰截断章节标题可能变成孤立片段上下文信息全部丢失。这时候把txt转成Markdown就很有价值了。Markdown的标题标记、列表标记、引用块标记实际上是在给文本标出“结构边界”。有了结构边界切片器才能做到“语义完整优先长度其次”而不是“长度优先语义不管”。举个例子原始txt里的一段内容是第三章 环境配置 3.1 Python安装 首先下载安装包。 然后运行安装程序。转换成Markdown之后就变成## 第三章 环境配置 ### 3.1 Python安装 首先下载安装包。 然后运行安装程序。看到区别了吗原来的txt把章节和正文混在一起只有换行符作为分隔转换成Markdown等于给这段文本贴上了“第三章 环境配置”和“3.1 Python安装”两张标签。切片器遇到标题标记就知道“这是新的章节边界可以把上一段收尾了”。这是一个将“无结构文本”转化为“带层级结构文档”的过程。你不需要给文本增加额外内容只是把本来就存在的隐含结构显式标记出来。3.2 轻量级方案正则识别标题生成Markdown结构最常见的txt文档是小说、技术手册、教程笔记它们通常有比较规律的标题格式比如“第X章”“第X节”“数字编号”。用正则就可以完成大部分标题识别。我常用的标题识别规则是这样import re # 识别 第X章/第X节/数字标题 等多种形式 heading_patterns [ re.compile(r^\s*(第[\u4e00-\u9fa5\d][章节回])), re.compile(r^\s*(\d(?:\.\d){1,3})\s\S), re.compile(r^\s*([一二三四五六七八九十]、)\s*\S), ] def convert_txt_to_md(text): lines text.split(\n) md_lines [] for line in lines: stripped line.strip() for pattern in heading_patterns: if pattern.match(stripped): # 根据标题层级决定加几个# level detect_heading_level(stripped, pattern) md_lines.append(f{# * (level 1)} {stripped}) break else: md_lines.append(line) return \n.join(md_lines)这里的detect_heading_level是判断标题层级的关键。我的经验是“第X章”“第X部”这种最高层级对应Markdown的二级标题##“X.X”“X.X.X”这种数字多级编号根据编号层级对应三级四级标题“一、二、三、”这种中文序号对应二级标题标题层级判断好后Markdown结构基本就成型了。这种方案的好处是零成本、速度极快适合处理几百本批量导入的场景。缺点是只能识别常规模式碰到“作者还有话说”“本章小结”这种不规则标题就无能为力了。如果你是批量处理网文或者结构规整的技术文档这套正则在大多数场景下都能满足要求。遇到识别不了的标题可以把那几个特有词汇手工加到正则里比如补充“楔子”“序章”“番外”等词。3.3 进阶方案用LLM辅助解析非规则文本正则方案搞不定的文本比如内容没有固定格式、标题随意、带大量散文式小标题的文档我会丢给大模型做一次“Markdown格式化”。核心思路是让LLM识别文本的语义结构输出规范的Markdown。我用过一段稳定的Prompt可以参考你是一个文档结构化专员。请把用户提供的纯文本转换为规范的Markdown格式。 规则 1. 识别文本中的章节、小节、段落层级使用正确数量的##标记标题 2. 把列表项转换为Markdown - 列表 3. 把表格排版的内容转换为Markdown表格 4. 保留原文内容不要改写、不要总结、不要翻译 5. 只输出转换后的Markdown文本不要输出额外解释实际操作的时候一次不要喂太长的文本建议每次处理2000字左右。太长了LLM容易丢失细节标题层级会混乱。这个方法比正则方案贵但胜在通用性强。我一般把两条路结合先用正则快速处理规则文本把LLM方案留给那些正则跑完结果不理想的“疑难杂症”。另外LLM输出的Markdown偶尔会有格式幻觉比如多加了不存在的标题所以输出后最好再过一道校验工具比如用markdownlint检查格式合法性。3.4 表格与列表的识别性转换纯文本里还常有两种隐含结构列表和表格。它们在txt里可能是这样的- 苹果 - 香蕉 - 梨也可能是姓名 年龄 城市 张三 25 北京 李四 30 上海第一种情况把行首的“- ”或者“1.”“1、”识别成Markdown列表项就行第二种情况需要用分隔符空格、制表符做列切分。需要注意的是文本里也可能有普通的“姓名 年龄 城市”这种自然语言句子不能见到空格就切。我的处理思路是先用正则匹配“连续多行、以同样分隔符分隔的文本行”判断是否符合表格特征每行列数一致再决定是否转成Markdown表格。对于列表则简单得多凡是行首匹配到-、*、数字.、数字、的连续行统一转成列表标记。这一步识别的是“疑似结构”准确率做不到100%。我的原则是识别出来转成结构化表达是收益识别不出来保留原文是底线。如果转换错误地把正文拆成了表格反而会破坏检索效果。所以判断不了的情况我倾向于不对它做任何改动。4. 落到框架LangChain里txt和Markdown导入的实操配置4.1 TextLoader读文件这一步的正确姿势LangChain本身提供了不少文档加载器txt对应的是TextLoader。基础用法非常简单from langchain_community.document_loaders import TextLoader loader TextLoader(documents/guide.txt, encodingutf-8) documents loader.load()但这里有几个参数细节值得注意。TextLoader接收的encoding参数如果传错了一样会报错。我遇到批量导入不同来源txt文件的情况时会在外面套一层编码检测逻辑先判定编码再创建loaderimport chardet from langchain_community.document_loaders import TextLoader def create_text_loader(file_path): with open(file_path, rb) as f: raw f.read() detected chardet.detect(raw) encoding detected.get(encoding, utf-8) if encoding.lower() in (ascii, utf-8): encoding utf-8 return TextLoader(file_path, encodingencoding, autodetect_encodingTrue)autodetect_encodingTrue是LangChain里的一个实用选项启用状态下它会尝试用多个编码逐一遍历解码虽然速度慢一点但容错性会好很多。建议在批量导入未知来源文件时开启。4.2 RecursiveCharacterTextSplitter切片参数怎么看清洗好文本之后就到了切片的环节。LangChain里最常用的切分器是RecursiveCharacterTextSplitter。它的工作逻辑是先按“章节标题”这种大边界切分再按段落、句子、字符逐级缩小直到每个片段长度落在chunk_size范围内。一个常见的错误配置是随便填个数字就开跑text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, )这样配置的结果经常是正文和标题被分开语义被截断。这里我梳理一下chunk_size和chunk_overlap到底怎么配chunk_size不是“字符数上限”而是“目标切分粒度”。它决定每个片段大致多长。太小了片段凑不够完整语义太大了向量化时信息会被压缩检索精度下降。我一般从400到800字之间起步根据文档类型做调整。代码片段比较多的文档尺寸稍微大一点保证一个代码块能完整装进一个切片里。chunk_overlap是两个相邻切片之间的重叠字符数。它存在的意义是如果一句话正好跨在两个切片边界上重叠部分可以保证这句话在其中一个切片里是完整的。常见的配置是chunk_size的10%到20%。500字的切片重叠50到80字比较合理。还需要注意一个关键点切分器的separators参数。默认的[\n\n, \n, 。, , ]对中文场景其实不太够用。中文的句子边界是句号、问号、感叹号英文的句点是空格所以中英文混排文档要自定义分隔符text_splitter RecursiveCharacterTextSplitter( chunk_size600, chunk_overlap80, separators[\n\n, \n, 。, , , , , ], keep_separatorTrue, )keep_separatorTrue这个参数容易被忽略。它会把分隔符保留在切片内容里避免出现切片以句号开头这种尴尬情况。这种细节对Embedding质量的影响比想象中大。4.3 MarkdownHeaderTextSplitter用标题层级切块的正确用法如果文本已经转成了规范的Markdown我会优先使用MarkdownHeaderTextSplitter而不是RecursiveCharacterTextSplitter。原因很简单它真正按标题层级做切分而不是按字符长度。from langchain.text_splitter import MarkdownHeaderTextSplitter headers_to_split_on [ (##, 章节), (###, 小节), (####, 子节), ] splitter MarkdownHeaderTextSplitter(headers_to_split_onheaders_to_split_on) splits splitter.split_text(markdown_text)这段代码的含义是遇到##标题时把前面已经积累的内容切成一个文档遇到###标题时再切。每个切片会自动带上元数据如“章节第三章 环境配置”这个元数据在后面检索时可以作为过滤条件或者上下文补充。需要留意的是MarkdownHeaderTextSplitter切出来的切片可能长度差异很大。一个章节可能只有50字另一个章节可能有5000字。我的处理方式是先用它按标题切分再对超长的切片用RecursiveCharacterTextSplitter做二次切分两者配合形成一个两级切分策略markdown_splitter MarkdownHeaderTextSplitter(headers_to_split_onheaders_to_split_on) md_docs markdown_splitter.split_text(markdown_text) recursive_splitter RecursiveCharacterTextSplitter( chunk_size800, chunk_overlap80, separators[\n\n, \n, 。, , , , , ], ) final_docs recursive_splitter.split_documents(md_docs)这样既能保留章节层级信息又能控制每个切片的长度是我目前最常用的RAG文档导入配置。4.4 入库前的最后检查切块质量自检向量化入库前建议花一分钟检查切块质量。我用过一个很笨但有效的办法随机抽样打印几个切片人工看一眼内容边界是否合理。for i, doc in enumerate(final_docs[:5]): print(f--- Chunk {i1} ---) print(doc.page_content[:200]) print(Metadata:, doc.metadata)检查的重点有三个一、切片是否以完整句子开头和结尾。如果大量切片以“的”“是”这种虚词结尾说明分隔符配置不合理或者chunk_size设置得太小。二、元数据是否正确携带。检查metadata里有没有“章节”“小节”这些键如果有确认它们的值是否准确对应内容。三、切片之间是否存在大量重复内容。overlap设置过大会导致同一个信息被多个切片重复包含检索时会出现多个高度相似的结果。这一步检查大概花两分钟但能避免把错误数据送进向量库之后“清理困难”的困境。向量库一旦写入脏数据重新清洗和重灌数据的成本远大于你现在多花的两分钟。5. 常见问题与排查技巧实录5.1 检索效果差先查这三个“隐形元凶”落地RAG项目的时候如果检索效果不好别急着换Embedding模型先排查这几个问题第一个是编码问题。我刚做RAG的时候就踩过这个坑导入了一批GBK编码的txtTextLoader直接报错我当时草率地跳过了那些文件。后来用chardet检测后统一转成UTF-8重灌检索效果明显回升。乱码文本和缺失文本都是知识库的“污染源”。第二个是标题没识别出来。有一份文档的章节标题是“1.1 概述”“1.2 背景”这种格式但我的正则把“1.1”识别成了数字列表而不是标题。结果整个文档被当成一坨文本硬切检索时完全无法利用章节信息。解决办法是扩充标题识别规则把“数字.数字空格标题”这种模式单独列出来。第三个是切片粒度过粗过细。我之前配过一个极端参数chunk_size设成3000结果一个切片里包含了好几个小主题检索命中之后LLM需要从3000字里找答案输出质量自然差。后来把3000字切成几段600字左右的切片检索精度明显提高。切片的本质是让“答案所在的区块”和“检索命中的区块”尽量重合。5.2 切块质量自检打印出来看一眼我强烈建议在代码里加一个“数据体检”步骤每次导入新文档都执行一遍。做法很简单就是对切块结果做统计和抽样from collections import Counter def check_chunks(chunks): lengths [len(c.page_content) for c in chunks] print(f总切片数: {len(chunks)}) print(f平均长度: {sum(lengths) / len(lengths):.1f}) print(f最短切片: {min(lengths)} 字) print(f最长切片: {max(lengths)} 字) # 检查元数据覆盖率 meta_keys Counter() for c in chunks: meta_keys.update(c.metadata.keys()) print(元数据字段分布:, dict(meta_keys)) # 随机抽样查看3个切片 import random for idx in random.sample(range(len(chunks)), min(3, len(chunks))): print(f--- 抽样切片 {idx} ---) print(chunks[idx].page_content[:150]) print(metadata:, chunks[idx].metadata)如果发现最长切片比最短切片大出几十倍说明第一级按标题切分不太均衡需要调整二级切分的参数。如果发现元数据字段为空说明结构化解析没生效得回到第一步检查标题识别。这一步最好做成脚本固化到工作流里每次批量导入都跑一遍而不是只在调试期手动执行。5.3 踩坑速查表我把日常工作中遇到的高频问题整理成了一张速查表方便对照排查现象可能原因解决方法读取txt报UnicodeDecodeError编码检测失败或没指定编码用chardet检测后指定encoding开启autodetect_encoding文本开头有“\ufeff”UTF-8 BOM头用utf-8-sig解码或清洗时正则删除\ufeff切片全是半句话chunk_size太小或分隔符缺少中文句号调整chunk_size到600以上补充分隔符“。”章节信息在检索时丢失没做标题识别或没保留metadata用MarkdownHeaderTextSplitter把标题写入metadata同一个内容多个切片重复命中chunk_overlap过大把overlap从20%降到10%左右切片长度极端不均衡一级标题切分不均匀增加二级递归切分统一向量化前的长度分布大文件读取卡死一次性read整个文件改用生成器逐行或分块读取预检后再一次读入脏文本里夹杂控制字符原文件来源不规范清洗阶段用正则统一去除控制字符和零宽字符这张表不是万能的但覆盖了我在实际项目中遇到的大部分数据导入问题。遇到表里没有的情况我的排查思路是“从链路入口往出口走”先确认文件读取成功再确认清洗生效再确认结构识别正确最后确认切片合理。每一步都打印一行日志出问题的时候一眼就能定位到环节。做RAG项目久了你会发现数据导入与解析这个环节最不性感、最容易被忽略但它决定了整个系统的下限。模型选错了可以换向量库不合适可以迁移唯独脏数据进了库后面通常要付出更大代价来修复。这篇把所有关于txt和Markdown的导入解析经验整理成文就是希望准备做知识库的朋友在第一道关卡少走弯路。文本处理这条线到这里告一段落后续PDF、Word、HTML的导入解析坑更多也更隐蔽等我把大致思路理清了再来填坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑