☰
RAG数据导入第一步:从txt到Markdown的通用文本解析攻略
2026/10/6 10:16:49 网站建设 项目流程

1. 为什么 RAG 的第一步永远是“把文本喂干净”

做 RAG 的人都有一个共识:检索效果差,八成不是模型不行,而是数据没处理好。我见过太多团队一上来就调 embedding 模型、换向量库、加 rerank,折腾一圈发现召回率还是上不去,回头一看原始数据——PDF 里全是断行、表格错位、页眉页脚混在正文里,这种数据喂给再好的模型也是白搭。

这篇要聊的是 RAG 数据导入与解析的第一环:从最朴素的 txt 文件,到结构清晰的 Markdown。为什么单独把 txt 和 Markdown 拎出来讲?因为它们是整个 RAG 数据管道里最基础、最高频、也最容易被轻视的两种格式。txt 是绝大多数原始语料的默认形态——日志导出、爬虫抓取、数据库字段导出、小说文本、词典文件,最后往往都落到 txt。而 Markdown 是当前 RAG 知识库最友好的中间格式,它用极低的语法成本保留了标题层级、列表、代码块、表格这些结构信息,让后续的切分(chunking)能按语义边界走,而不是傻乎乎地按固定字数硬切。

这篇文章适合谁看?如果你正在搭 RAG 知识库,手头有一堆 txt 不知道怎么清洗;如果你在做文档结构化解析,想让切分更聪明;如果你只是好奇“为什么别人的 RAG 召回那么准”,那这篇从 txt 到 Markdown 的通用文本与结构化解析攻略,能给你一套可以直接抄作业的流程。我会把每一步的为什么这么做讲透,而不是甩一段代码让你自己猜。

先说清楚一个核心判断:RAG 的数据导入不是“读文件”,而是“重建语义结构”。txt 本身几乎没有结构,只有换行和空格;Markdown 的价值在于它把“这段是标题”“这段是列表”“这段是代码”显式标注出来。解析的本质,就是从无结构或弱结构的文本里,把人类能看懂、机器却看不懂的层级关系,重新变成机器能识别的标记。这一步做得好,后面的切分、embedding、检索全都顺;这一步偷懒,后面全是坑。

2. 通用文本解析的整体设计思路

2.1 先搞清楚:txt 到底“通用”在哪

txt 号称通用文本格式,但它的“通用”恰恰是它最大的问题——它通用到没有任何结构约定。同样一个 txt 文件,可能是:

  • 一整篇小说,段落之间用空行分隔;
  • 一份日志,每行一条记录,字段用空格或制表符隔开;
  • 一个词典,每行是“词 释义”的固定格式;
  • 一份从 PDF 复制出来的文档,换行位置完全乱套,一句话被切成三行;
  • 一份从 Excel 导出的数据,字段用逗号或竖线分隔。

所以做 txt 解析,第一步永远不是写代码,而是先看数据长什么样。我习惯先跑三个命令摸清底细:

# 看文件编码,中文乱码十有八九是编码问题 file -i input.txt # 看前 50 行,判断大致结构 head -n 50 input.txt # 统计行数和字符数,估算处理规模 wc -l -m input.txt

这三个命令能帮你快速判断:这是段落型文本、行记录型文本,还是半结构化的混合文本。判断错了,后面全错。

2.2 为什么选 Markdown 作为中间格式

有人会问:为什么不直接解析成 JSON 或者 HTML?JSON 结构最清晰,但可读性差,人工校对成本高;HTML 标签太重,噪声多,清洗麻烦。Markdown 是个折中:

格式结构表达力可读性清洗成本适合 RAG 切分
纯 txt极弱高低但需自建规则差
JSON强差中好但需转换
HTML强中高中
Markdown中强高低好

Markdown 的标题(#)、列表(-、1.)、代码块(```)、表格(|)这些标记,天然对应 RAG 切分时想要的语义边界。一个##标题就是天然的 chunk 分界点,一个代码块就应该整体保留不被切断。这就是为什么主流 RAG 框架(比如 LangChain 的 MarkdownHeaderTextSplitter)都优先支持 Markdown 切分。

2.3 整体流程拆解

我把从 txt 到 Markdown 的解析流程拆成五步,后面每一节都会展开:

  1. 编码与清洗:统一编码为 UTF-8,去掉不可见字符、页眉页脚、乱码。
  2. 结构识别:判断文本类型(段落型/行记录型/混合型),识别标题、列表、表格等结构。
  3. 结构重建:把识别出的结构转成 Markdown 标记。
  4. 质量校验:检查转换后的 Markdown 是否语义完整、层级正确。
  5. 切分准备:按 Markdown 结构做初步切分,为后续 embedding 做准备。

这五步里,结构识别是最难的一步,也是决定成败的一步。因为 txt 没有显式结构,你得靠启发式规则去猜。猜得准不准,直接决定最终质量。

3. 核心细节解析与实操要点

3.1 编码处理:中文乱码的根源与解法

中文 txt 最常见的坑就是编码。Windows 下默认 GBK/GB2312,Linux 和 Mac 默认 UTF-8,一个文件在两边打开可能就是一堆问号。更麻烦的是,有些文件是 GBK 和 UTF-8 混着来的,或者带 BOM 头。

处理原则很简单:统一转成 UTF-8 无 BOM。但转换前要先探测编码。我常用chardet(Python)来探测:

import chardet def detect_encoding(file_path): with open(file_path, 'rb') as f: raw = f.read(100000) # 读前 100KB 足够判断 result = chardet.detect(raw) return result['encoding'], result['confidence'] # 实测:confidence 低于 0.8 时要人工确认,别盲目相信

注意:chardet对短文本判断经常出错,尤其是纯中文短句。如果 confidence 低于 0.8,建议手动用编辑器打开确认,或者用iconv试转几个编码看哪个不乱码。

转换时用iconv最稳:

# GBK 转 UTF-8 iconv -f GBK -t UTF-8 input.txt -o output.txt # 如果报错,加 //IGNORE 跳过无法转换的字符 iconv -f GBK -t UTF-8//IGNORE input.txt -o output.txt

Python 里转换要注意errors参数:

with open('input.txt', 'r', encoding='gbk', errors='ignore') as f: content = f.read() with open('output.txt', 'w', encoding='utf-8') as f: f.write(content)

errors='ignore'会丢掉无法解码的字符,可能丢信息;errors='replace'会替换成�,保留位置但内容没了。我的经验是:先 ignore 跑一遍,统计丢了多少字符,如果丢得少就接受,丢得多说明编码判断错了,得重新探测。

3.2 不可见字符清洗:那些看不见的“脏东西”

txt 里藏着一堆看不见的字符,它们不影响阅读,但会污染 embedding。常见的:

  • \x00(空字符):从某些系统导出时会有;
  • \xa0(不换行空格):从网页复制常见;
  • \u200b(零宽空格):从某些编辑器复制会有;
  • \r\n和\n混用:跨平台文件常见;
  • 全角空格\u3000:中文排版常见。

清洗用正则一把梭:

import re def clean_invisible(text): # 统一换行符 text = text.replace('\r\n', '\n').replace('\r', '\n') # 去掉零宽字符和空字符 text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\u200b-\u200f\ufeff]', '', text) # 不换行空格转普通空格 text = text.replace('\xa0', ' ').replace('\u3000', ' ') # 多个连续空格压成一个(但保留行首缩进要谨慎) text = re.sub(r'[ \t]{2,}', ' ', text) return text

注意:压缩连续空格时,如果原文有靠空格对齐的表格或代码,会被破坏。所以这一步要在结构识别之后、针对正文段落做,别一上来就全局压。

3.3 结构识别:从“猜”到“有依据地猜”

结构识别是核心难点。我把它分成三类文本分别处理:

段落型文本:特征是段落之间有空行,段落内部换行是排版换行而非语义换行。典型的是小说、文章。处理重点是合并被硬换行切断的句子。判断规则:如果一行结尾不是句号、问号、感叹号、冒号、分号,且下一行开头不是特殊标记,就合并。

def merge_paragraphs(text): lines = text.split('\n') merged = [] buffer = '' for line in lines: stripped = line.strip() if not stripped: if buffer: merged.append(buffer) buffer = '' merged.append('') # 保留空行作为段落分隔 continue if buffer and not re.search(r'[。!?:;.!?:;”"))]$', buffer): buffer += stripped else: if buffer: merged.append(buffer) buffer = stripped if buffer: merged.append(buffer) return '\n'.join(merged)

行记录型文本:每行一条独立记录,比如日志、词典、CSV。处理重点是识别字段分隔符,转成 Markdown 表格或列表。先统计每行分隔符出现次数,如果次数稳定,说明是结构化行记录。

from collections import Counter def detect_delimiter(lines, sample=100): candidates = [',', '\t', '|', ';', ' '] scores = {} for delim in candidates: counts = [line.count(delim) for line in lines[:sample] if line.strip()] if not counts: continue counter = Counter(counts) most_common_count, freq = counter.most_common(1)[0] # 分隔符出现次数稳定且大于 0,得分高 if most_common_count > 0: scores[delim] = freq / len(counts) return max(scores, key=scores.get) if scores else None

混合型文本:既有段落又有列表、表格。这种最难,需要先按空行分块,再对每块单独判断类型。我的策略是先粗分块,再逐块识别,而不是全局套一个规则。

3.4 标题识别:没有#怎么知道它是标题

txt 里标题没有任何标记,但通常有这些特征:

  • 单独成行,前后有空行;
  • 长度较短(一般不超过 30 字);
  • 不含句号等句末标点;
  • 可能带编号(如“第一章”“1.1”“一、”);
  • 字体在原始文档里可能加粗或放大(但 txt 丢失了这些信息)。

我用的启发式规则组合:

def is_heading(line, prev_line, next_line): stripped = line.strip() if not stripped or len(stripped) > 40: return False # 前后都是空行 if prev_line.strip() or next_line.strip(): return False # 带编号模式 if re.match(r'^(第[一二三四五六七八九十百]+[章节篇部分]|[0-9]+(\.[0-9]+)*[、..]|[一二三四五六七八九十]+[、..])', stripped): return True # 短行且无句末标点 if len(stripped) <= 20 and not re.search(r'[。!?,、;:]$', stripped): return True return False

注意:这套规则误判率不低,尤其是短句成段的诗歌、对话。所以识别完一定要人工抽检,别全自动跑完就入库。我的做法是随机抽 20 个识别为标题的行,人工确认准确率,低于 90% 就调规则。

识别出标题后,还要判断层级。带“第X章”的是 H1,“1.1”这种数字层级按点数决定 H2/H3。没有编号的短标题,默认 H2,因为 H1 通常留给文档标题。

4. 实操过程与核心环节实现

4.1 完整流程:一个真实 txt 的解析实录

拿一个真实场景举例:我手头有一份从某系统导出的产品手册 txt,大约 3 万字,结构混乱——标题没标记,列表用空格缩进,表格用竖线分隔但列数不齐。目标是把

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

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

立即咨询