☰
RAG数据导入实战:从txt到Markdown的解析与清洗全攻略
2026/10/6 6:36:24 网站建设 项目流程

1. 为什么RAG的数据导入要从txt和Markdown做起

做RAG(检索增强生成)知识库有一段时间的朋友,应该都有过这种体验:辛辛苦苦搭好了框架、调好了Prompt、选好了向量模型,结果一测问答效果,简直惨不忍睹。问题往往不在模型,不在检索策略,而在一开始的数据导入和解析环节。

数据导入和解析是整个RAG链路的“入口”,这个口子如果处理不好,后面所有的检索、生成都是建立在沙子上。我个人的感受是,RAG项目里至少有六成的问题,根源都能追溯到“脏数据”和“解析不当”这两件事上。而真正想把手上的数据吃透,最基础的功夫恰恰是那些看起来不起眼的txt和Markdown。

你可能会想:txt和Markdown不是最简单的格式吗?随便读一下不就行了?问题恰恰就在这里——正因为大家觉得简单,反而忽视了里面的坑。比如txt的编码问题,GBK、UTF-8、ISO-8859-1,甚至带BOM不带BOM,一不留神就给你来一段乱码;再比如Markdown的标题层级、代码块、表格、列表,如果解析的时候把它们拍平成纯文本,那结构化信息就全丢了,检索的时候自然抓不住重点。

这篇攻略是RAG数据导入系列的第一篇,核心聚焦在“从txt到Markdown”这条线:先讲清楚通用文本如何干净入库,再讲Markdown这种轻量级结构化格式如何被“解构”,让它的结构变成对RAG检索真正有用的东西。适合正在搭建知识库、处理文本类数据的开发者,也适合准备入门RAG、想搞清楚数据链路的朋友。

2. 通用文本的读取与清洗:最不起眼的环节,坑最多

2.1 编码问题:一切乱码的根源

先把txt读取这件事拆开看。txt文件本身的文本内容可能很简单,但它外面套了一层“壳”——编码格式。中文环境里最常见的三种编码是UTF-8、GBK和GB2312,海外文件则往往是UTF-8或ISO-8859-1。不同编码打开同一个文件,结果天差地别。

我刚开始做数据导入的时候,写了一个“看起来没问题”的读取方法,用Python的open()直接按UTF-8读,结果导入一批中文资料时,一半文件全是乱码。后来单独写了编码检测逻辑,才彻底解决。

编码检测不一定要自己造轮子,Python里有个库叫chardet,可以检测文本的编码概率。实操里我会先检测、再读取、再兜底,形成一道完整链路:

import chardet def smart_read_text(file_path): with open(file_path, 'rb') as f: raw = f.read(10240) # 先读前10KB做检测 result = chardet.detect(raw) encoding = result['encoding'] try: with open(file_path, 'r', encoding=encoding, errors='strict') as f: return f.read() except UnicodeDecodeError: # 兜底:如果检测出错,就用 errors='replace' 替换掉无法解码的字符 with open(file_path, 'r', encoding=encoding, errors='replace') as f: return f.read()

这里有几个细节值得注意。

chardet.detect()是拿一段字节流做采样检测,并不是全文件扫描。前10KB足够覆盖绝大多数情况,但如果你处理的文件头尾编码不一致(极少数场景会这样),检测就可能偏差。我的做法是:如果检测结果里UTF-8的概率低于0.8,就再做一次全文件的二次验证。另外,UTF-8带BOM(字节顺序标记)的文件,Python的utf-8编码会把它读成\ufeff这个字符,导入向量库之前要把这个隐藏字符剔除,不然检索的时候会莫名其妙地匹配异常。

至于errors='replace',这是最后的保底策略:宁可拿替换符顶掉极端字符,也不让程序崩溃中断整批导入。但要注意,“能读出来”不等于“读得对”,导入完成后随机抽几段原文人工看一眼,比什么日志都管用。

2.2 清洗细节:空行、空白字符、控制字符

编码解决之后,就是内容层面的清洗。txt虽然叫“纯文本”,但你不知道它是怎么生成的——从网页复制粘贴出来的可能有\xa0(不间断空格)、Windows记事本写的可能有\r\n、从PDF转出来的可能有大量连续空行和首尾空格。

这些字符肉眼看上去很“正常”,但在向量化和检索阶段会造成干扰。比如\xa0和普通空格在字符层面是两个东西,分词器处理起来会做不同的归一化,导致同一个词在query里是普通空格、在文档里是不间断空格,检索匹配就断了。

我的清洗管线一般包含这几步:

  • 统一换行符:把\r\n和\r统一成\n。
  • 剔除控制字符:保留\n、\t,其余非打印字符直接过滤。
  • 处理不间断空格:\xa0替换为普通空格,\u3000(全角空格)也一并处理。
  • 合并空行:连续三个以上空行压缩为一个。
  • 去除首尾空白:每个段落边缘的多余空格去掉。
import re def clean_text(raw_text): text = raw_text.replace('\r\n', '\n').replace('\r', '\n') text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text) text = text.replace('\xa0', ' ').replace('\u3000', ' ') text = re.sub(r'\n\s*\n\s*\n+', '\n\n', text) lines = [line.strip() for line in text.split('\n')] return '\n'.join(lines)

这一步看起来简单,但它是“数据质量”的第一道闸门。很多朋友导入数据之后发现问答效果不好,追查半天,结果就是这些看不见的字符在捣乱。我的经验是:凡是文本进入RAG链路之前,一定先过一遍清洗管线,不要觉得麻烦,这一步是整个项目性价比最高的投资。

3. Markdown的结构化解析:把“格式”变成“信息”

3.1 为什么Markdown不能被当成txt拍平处理

解决完txt,进入重头戏——Markdown。网上很多教程处理Markdown的方式其实就是在偷懒:把Markdown源码当成纯文本读取,然后按固定字符长度切块。这样做虽然能跑通流程,但丢掉了Markdown最值钱的东西——结构信息。

Markdown的结构信息是什么?标题层级(#到######)、代码块、有序/无序列表、表格、粗体斜体、链接引用。这些结构在RAG场景里至少有三大用途:第一,标题层级能够指导分块切分,让每个块都处在完整的语义上下文里;第二,代码块、表格这类特殊块如果被硬生生切断,切出来的片段会变成无法理解的天书;第三,标题本身可以作为检索元数据,让召回结果直接带上“章节出处”。

我来举一个具体例子。假设你有一个Markdown格式的产品部署手册,里面有一大段是YAML配置文件示例,被包在三个反引号组成的代码块里。如果按固定字符长度分块,这段YAML极可能被拦腰截断,中间那一半既不是完整的配置也不是可读文本。你觉得用户问“数据库连接池超时时间在哪里配置”,系统能召回这段残缺内容吗?答案显而易见。

所以,Markdown的解析必须做“结构化处理”。这里的核心思想是:Markdown不是一个字符串,而是一棵文档树。

3.2 用解析器把Markdown拆成文档树

Markdown解析器的选择上,我实测下来比较推荐Python生态里的markdown-it-py,配合attrs扩展能力,可以做比较细粒度的token级控制。mistune和markdown库也可以用,但前者API灵活、后者更偏渲染场景,适合你手头任务就行,不用纠结选型,关键是理解解析后的数据形态。

用markdown-it-py解析的时候,你会得到一串token流,里面每个token都标注了类型(heading_open、fence、table_open、list_item_open等)。基于这个token流,我可以先拼出整篇文章的目录树,然后按层级去划分块。

下面是我实际项目里用过的一个简化版“结构感知分块器”,核心逻辑是:维护一个标题栈,遇到一级标题就开新块,遇到二级标题就开子块,按层级挂载父子关系。

from markdown_it import MarkdownIt md = MarkdownIt() def split_by_heading(markdown_text): tokens = md.parse(markdown_text) chunks = [] current_chunk = {'level': 1, 'title': '', 'parent': None, 'content': []} stack = [current_chunk] for token in tokens: if token.type == 'heading_open': level = int(token.tag[1:]) # 标题内容在下一个 token title = tokens[tokens.index(token) + 1].content if tokens.index(token) + 1 < len(tokens) else '' while stack and stack[-1]['level'] >= level: stack.pop() parent = stack[-1] if stack else None new_chunk = {'level': level, 'title': title, 'parent': parent, 'content': []} parent['content'].append(new_chunk) stack.append(new_chunk) elif token.type in ('paragraph_open', 'fence', 'table_open', 'list_item_open') and stack: stack[-1]['content'].append(token) return chunks

这段代码的思路是“标题即祖先”,每一个内容token都会被挂到当前最近的一级标题下面。这样切出来的块天然携带了完整上下文:比如一个三级标题下的内容,它的父级是二级标题,二级的父级是一级,把这条链路拼起来就是“章节路径”。

但这里有个非常关键的细节:token流里的inline内容要和heading_open配对取标题文本,不能只拿heading_open本身,否则title字段会变成空字符串。上面代码里我用了一个比较粗暴的tokens.index(token) + 1去取下一个token,这种方法在真实场景中并不健壮,如果你用markdown-it-py,更推荐遍历tokens时用一个序号索引来配对。

3.3 让特殊块保持完整:代码块、表格、列表

分块器做好了,第二步是处理“特殊块不能被切碎”的问题。我的方案是:在分块的时候,把fence(代码块)、table(表格)这类token标记为“不可分割节点”。

实际操作中,我倾向于给特殊块设置一个“完整性优先级”。具体规则是:如果一个块内容包含代码块或表格,并且这个块的字符长度不超过分块尺寸上限(比如1200字),就直接把它作为一个独立块输出,不再往下细分。

BLOCK_TYPES = {'fence', 'table_open'} def build_chunks_with_special_blocks(heading_tree, max_chunk_size=1200): final_chunks = [] for block in heading_tree: if block.get('type') in BLOCK_TYPES: # 特殊块直接作为一个块 final_chunks.append({'path': block['title'], 'content': block['raw_text'], 'type': block['type']}) else: # 普通块递归判断大小 if len(block['text']) <= max_chunk_size: final_chunks.append({'path': block['title'], 'content': block['text'], 'type': 'text'}) else: final_chunks.extend(recursive_split(block, max_chunk_size)) return final_chunks

这一段逻辑可能是我整个数据导入流程里对RAG效果影响最大的代码。做了“特殊块保护”之后,代码片段能被完整检索到,表格数据也不会被切成一半——这两类数据在知识库问答里,往往是用户问“具体参数在哪”的时候最需要的东西。

顺带一提,Markdown里的代码块通常包含编程语言标识,比如python、yaml、javascript,解析的时候可以把语言类型提取出来,作为额外的元数据存进向量库。用户如果问“这个配置的Python实现是什么样的”,语言标签能帮忙做一次过滤,显著提升检索精度。

3.4 Markdown里还有两类特殊结构:数学公式和Callout

除了常规结构,我实际处理知识库时发现,很多Markdown文件里还会混进两类东西:数学公式和GitHub风格的Callout提示块。

数学公式一般用$...$或$$...$$包裹。普通的Markdown解析器会把这当作普通文本处理,如果不加处理,分块器可能把公式和正文混在一起;更糟糕的是,向量模型对公式符号的理解较差,原始LaTeX代码在语义空间里表现得往往莫名其妙。我的处理方式是:把每个公式独立成块,并标注type: formula,这样至少能把标题和上下文信息保留下来,必要时单独用专门的数学相关模型做向量化。

Callout(也就是> [!NOTE]这类语法)是笔记类工具的比较流行格式,里面有NOTE、TIP、WARNING等类型标识。这类块往往是作者想强调的核心信息,从内容质量的角度说,比正文段落更值得被检索到。我一般在解析时保留类型标记,并把它作为检索权重优化的一类信息来源。

4. 实操链路:从原始文件到入库前结构化产物

4.1 整体管线设计与数据流

理论讲了一堆,说点实操层面可落地的。我在一个实际知识库项目里,跑通了一套“txt与Markdown通用导入管线”,你可以直接参考甚至照抄。整体数据流是这样走的:

原始文件——编码检测——内容清洗——格式识别——分块处理——元数据组装——向量化入库

前面两步针对所有文件通用,格式识别这一步把Markdown文件和纯txt文件分流:纯txt走“固定窗口+递归分割”路线,Markdown走“结构感知+特殊块保护”路线。两条路线最后汇聚到同一个元数据规范上,再统一做向量化。

# 建议的目录结构 data_import/ ├── ingest.py # 主入口 ├── encoders.py # 编码检测与处理 ├── cleaners.py # 文本清洗 ├── markdown_parser.py # Markdown 结构化解析 ├── txt_splitter.py # 纯文本分块 ├── metadata.py # 元数据组装 └── output/ # 解析结果输出目录

4.2 纯txt文本的分块策略与参数依据

纯txt没有标题结构,分块策略比较朴素,但朴素不代表不用动脑子。业内常说的是“固定大小+重叠”和“递归字符分割”两种思路。

固定大小就是给定chunk_size和overlap,硬切。这样做最大的风险是断在句子中间。我见过不少项目把chunk_size设为512、overlap设为50,切出来的块经常一句话说一半,后面再接半句,检索时匹配到的内容读起来像得了帕金森。

更稳妥的方案是“优先按段落分割、段落超长再递归切”。实现上不需要多么复杂的库,先按\n\n分段落,段落长度小于chunk_size就单独成块,否则在这个段落内部继续找句子边界(句号、问号、感叹号)切分。

def split_text_recursive(text, chunk_size=1000, overlap=100): paragraphs = text.split('\n\n') chunks = [] buffer = '' for para in paragraphs: if len(para) < chunk_size: if len(buffer) + len(para) <= chunk_size - overlap: buffer += '\n\n' + para else: if buffer: chunks.append(buffer) buffer = para else: # 单段超长,按句子边界继续切 sentences = re.split(r'(?<=[。!?.!?])', para) temp = '' for sent in sentences: if len(temp) + len(sent) <= chunk_size: temp += sent else: chunks.append(temp) temp = sent[len(sent)-overlap:] if len(sent) > overlap else sent buffer = temp if buffer: chunks.append(buffer) return chunks

关于参数,我个人的实践是:chunk_size在800到1200之间比较舒适,overlap设100到150。这个数值不是拍脑袋定的,它和你的向量模型、检索片段长度有关。Embedding模型一般对超过512个token的文本会截断,中文场景下1000个字符大约对应300到500个token,所以chunk_size在1000上下可以保证向量化时信息不丢。叠加上限设100到150,能让相邻块之间的衔接信息保留,又不至于造成过多重复内容影响检索精度的去重。

4.3 元数据组装:让每个块都“自带身份”

文本切块之后,下一步是给每个块装“身份信息”——元数据。这一步很容易被新手忽略,但它在检索阶段的价值极高。

我设计的元数据规范包含以下几项:

字段说明示例
source_file来源文件名product_deploy.md
chunk_id块唯一编号0003
heading_pathMarkdown标题路径部署指南 > 数据库配置 > 连接池
content_type块类型(文本/代码/表格/公式)code
lang代码块语言(如适用)yaml
chunk_size实际字符数845

heading_path这个字段非常重要。当用户问题命中某个块时,把heading_path一起喂给大模型作为上下文,生成回答时会自带“章节感”,同时你还可以让大模型把heading_path整理成引用来源,标注答案出自哪个章节。这个体验差异非常明显:同样是知识库问答,有章节上下文和没有章节上下文,回复的专业度完全是两个档次。

元数据组装完毕之后,整条导入链路就通了。每个块的最终形态是一个JSON对象,长这样:

{ "content": "数据库连接池的超时时间默认设置为30秒,可以通过修改config.yml中的timeout字段调整...", "metadata": { "source_file": "product_deploy.md", "chunk_id": "0003", "heading_path": "部署指南 > 数据库配置 > 连接池", "content_type": "text", "lang": "", "chunk_size": 845 } }

4.4 向量化与入库流程的衔接

结构化产物出来之后,下一步就是向量化入库。这一篇重点讲导入解析,向量化部分只交代一下衔接思路:把上面的JSON对象作为输入,content字段用于调用Embedding接口生成向量,metadata字段作为向量库的附加属性存进去。我常用的向量库是Milvus和pgvector,两者的metadata过滤能力都能支撑这个结构。

入库前的最后一步我强烈建议做“去重检查”。同一批资料可能重复导出多次,字符级完全相同的块在库里出现两三条,检索排序时会互相抢占排名。我在ETL里加了一层MD5去重:计算每个块的Hash,发现重复就丢弃新的。这个机制对清理同源数据特别管用,节省的向量存储空间非常可观。

5. 常见问题与排查技巧实录

5.1 实操中容易踩的坑

这个部分是我真正想跟新手朋友掏心窝子说的。很多问题不是你不会写代码,而是没遇到过、没意识到。

第一个高频坑是“Markdown解析后索引错位”。有些Markdown文件用了setext方式的一级标题(也就是标题文字下面加===),而不是常见的#前缀。很多解析器默认是不开启这种语法支持的,需要单独配置。我和同事对接一个第三方导出的MD文件时,对方说“我明明写了标题,为什么解析出来当成普通文本”,排查半天就是这个问题。

第二个坑是“忘了处理内联代码”。Markdown里的反引号单行代码,比如timeout,解析成token之后是一个inline类型代码,不是fence。如果你在分块时只处理了fence,那“单行代码和正文混在一起”的问题就会一直存在。经验做法是:单行内联代码保持原样,不强拆;但如果连续出现很多行内联代码,可以考虑把它们归并成一个代码块。

第三个坑是“表格被切成上下两半”。Markdown表格的语法边界比代码块更难识别,它靠|和---分割行。有些宽松的解析器会把表格里的每一行当作独立段落,不做table块合并。分块的时候如果不做特殊处理,一个表格会被拆成多条独立记录,语义完全断裂。我处理的办法是在清洗阶段就做表格识别:用正则匹配连续的、包含|的文本行,先整体提取出来,再做后续处理。

第四个坑是关于编码的:chardet在检测中文GBK编码时的准确率其实不算稳定,有些厂商的导出文件会在开头放一段UTF-16编码的日志信息,后面全是GBK,这种混杂文件chardet的前10KB采样可能直接误判。我的兜底方案是:对检测结果做一次“读出来试试”的验证——如果读取后文本里问号比例超过5%,就换编码重试。

5.2 问题速查表

现象可能原因解决方案
大量乱码编码检测误判增大采样字节数,或改用UTF-8-SIG兼容BOM
导入后检索空手而归清洗过度,标点符号被删检查清洗正则,避免误删中文标点
代码片段检索不到代码块被分块器切断开启特殊块保护逻辑
表格问答结果张冠李戴表格行被拆成独立块预处理阶段预先识别并提取表格
标题路径丢失Markdown setext标题未识别开启解析器对应语法支持
重复内容过多同源文件重复导入增加MD5去重机制

5.3 我最后保留的两个小习惯

说到这里,再分享两个我长期保留的小习惯,算是个人经验。

第一个习惯是“每批导入都留样检查”。我不会一股脑把几万个块全部灌进向量库,而是先导一批小样(比如200个块),抽查分块边界、元数据、特殊块保护效果。这一步花不了十分钟,但能避免批量导入后发现设计的解析方案有严重问题,再回头重新处理全量数据的痛苦。

第二个习惯是“保持分块结果可追溯”。我在输出目录里不光存向量化的JSON,还会把每个块对应的原文片段也同步存一份纯文本。索引出问题或者要调试检索结果的时候,可以直接比对原文,不用反复重跑解析流程。这个习惯救过我很多次,尤其是做RAG效果调优、需要对比不同分块方案的时候,可以快速定位到底是分块问题、向量化问题还是检索策略问题。

6. 后续扩展思路:从Markdown到更多复杂格式

txt和Markdown这条路跑通之后,你的数据导入框架就有了一个稳定底座。后续再处理PDF、DOCX、HTML这些复杂格式,思路也是一脉相承的:绕过“格式化读取”和“结构化解析”两关,再进入分块和元数据组装流程。

Markdown的知识在这里之所以重要,是因为它是很多其他格式的“中间形态”——很多在线文档工具支持直接把内容导出为Markdown,有些网页抓取工具也能把HTML转成Markdown。掌握好Markdown的结构化解法,等于拿到了一把万能钥匙。

我个人在实际操作中的体会是,RAG项目的天花板很大程度上由数据解析质量决定,而数据解析质量里,Markdown结构化处理的优先级可以排在前三。你要是能把这篇里的思路吃透,落到自己的代码里,再去啃PDF和DOCX那些硬骨头,会发现底层的思维模型完全相通——无非是识别结构、保护完整性、生成元数据、优化切块。

下一篇系列文章里,我计划重点拆解PDF的文本抽取与版面结构识别,那是一个比txt和Markdown更让人头疼的领域,但也是真实业务里被问得最多的场景。先把这篇基础打牢,我们下篇接着聊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询