1. RAG数据导入的难点与解析思路
1.1 纯文本看似简单,但RAG真正卡在结构上
把txt文件丢给RAG知识库,是很多人起步时干过的事。我自己第一次做的时候也觉得,txt嘛,直接读进来切一切、向量化,完事了。但实际跑起来才发现,问题全出在“切一切”上。
同样是txt,小说、合同范文、技术手册、爬虫抓下来的网页存档,它们的内部结构完全不同。小说按章节推进,合同按条款组织,技术手册则充满了层级标题、表格说明和代码块。如果不做解析,直接按固定字数切块,结果就是:一个语义完整的条款被腰斩成两半,标题和正文分到两个chunk里去了,检索阶段召回的内容往往前言不搭后语,生成阶段拿到残缺上下文自然也说不出人话。
所以RAG工程里,数据导入这一步的核心,不是“把文件读进来”,而是“把无结构的文本恢复成有语义边界的内容”。这个恢复过程,就是解析。
1.2 RAG管线的通用链路与解析定位
一个标准的RAG管线大概是这样的:加载文件 -> 解析内容 -> 文本分块 -> 向量化入库 -> 检索召回 -> 生成回复。很多人会花大力气调向量模型、调提示词,却对中间的“解析”环节一笔带过。但我的实测感受是,解析做不好,后面的向量化再强也救不回来。
解析在整个管线里的定位非常特殊:它承上启下。承上,是把各种乱七八糟格式的文件转换成统一的结构化中间表示;启下,是为分块提供清晰的语义边界。如果中间表示就是纯文本加换行符,那分块策略只能靠字符数硬切;如果中间表示是带标题层级、段落结构、列表结构的Markdown,那分块就能做到按语义单元切分,检索质量完全不一样。
1.3 我先说结论:解析目标不是“转换格式”
很多时候我们聊解析,容易陷入一个误区,觉得把txt变成Markdown就是胜利。但实际上,格式转换只是表象,真正的目标有两个:
第一,恢复层级关系。原本txt里可能靠缩进、空行、或无规律的编号来暗示结构,解析后要变成明确的标题层级。
第二,屏蔽无关信息。txt里经常混着广告、导航、版权声明、无意义的重复内容,这些东西在RAG里全是噪声,解析时要尽可能剔除。
所以整篇博文的核心思路,就是围绕这两条展开。所有解析规则、代码逻辑、踩坑记录,都是为这两个目标服务的。
2. 为何选Markdown作为结构化落点
2.1 不该把所有txt都当成纯字符串
早期我设计解析方案的时候,纠结过一个问题:解析完之后,中间格式到底用什么?当时摆在面前的选择有三个:直接切分、转HTML、转Markdown。
直接切分最省事,把txt读进来按“\n\n”分段,再按字符数切块,代码不超过20行。但它丢失了所有潜在结构,标题不特殊、列表不特殊、引用不特殊,一切全靠语义模型硬扛。在小规模测试集上看着还行,数据量一大,召回质量就明显降下来。
转HTML是个稳的方向,结构表达能力强,标题、列表、表格都有语义标签,后端的解析器也成熟。但HTML过于冗余,一段简单的文本会被包裹成一大片尖括号,而且向量化的时候,如果模板没处理好,容易把标签本身也带进chunk。
转Markdown是我最终的选择。它提供的不是“标签”,而是轻量标记符号,结构化能力比纯文本强一大截,又没有HTML那么大的噪音。
2.2 Markdown的三个关键优势
先说轻量。Markdown的语法标记都是可读字符,就算不经过渲染也能看懂。在数字化文本时,噪声很小,向量化后不会干扰语义。
再说语义明确。标题用#表示,列表用-或1.表示,代码块用三个反引号包起来,引用用>开头,表格用竖线分隔。这些标记虽然不是严格的语义标签,但它们在文本聚类和块与块之间的边界判定上足够好用。比如分块时遇到“## ”就知道这是一个新章节的开始,遇到“|”开头的连续多行就知道这是一张表格。
最后是对模型友好。现在主流的大模型在预训练阶段都看过海量Markdown语料,它们在理解“# 标题”和“列表项”的含义上天然有优势。你在prompt里塞一段Markdown,和塞一段纯文本,模型的解析效率差别很明显。
2.3 不是所有txt都适合直接转Markdown
这里必须泼一盆冷水。txt是一个高度不稳定的格式,它只有“字节流和换行符”这个底线。不同来源的txt,可能面临完全不同的地狱:
- 编码谜题。有的文件是UTF-8,有的是GBK,有的是GB18030,还有的是UTF-16LE。解码错了,满屏乱码,后面所有步骤都白搭。
- 全角半角混乱。很多从PDF或网页复制下来的文本,冒号、括号、空格全是全角形态,看起来一样,机器比对就完全不同。
- 超长行。有些网页转存txt时没有正确断行,整个段落几千字挤在一行,直接破坏了“空行分段”的基本假设。
- 隐藏字符。零宽空格、BOM头、制表符缩进,这些看不见的字符,会让文本清洗环节防不胜防。
所以我做解析器的第一原则是:对输入永远保持怀疑。默认每份txt都是脏的,先清洗,再结构化。
另外补充一点,不是所有文件都需要先转Markdown再分块。如果是需要OCR的扫描件,或者排版极其复杂的PDF,那根本不在txt解析这个讨论范围内,那是另一条完全不同的技术路线。
3. 从txt到Markdown的完整解析流程
3.1 第一步:编码检测与文本清洗
我在项目里遇到的第一批问题,几乎全是编码问题。有的文件是UTF-8,有的是GBK,有的还有BOM。如果不先检测编码,后面做正则匹配再细致也白搭,因为解码后的字符串本身就已经错乱了。
我常用的方案是先用chardet做一个快速推测,再抽样验证。实际操作中不要完全信任检测结果,最佳实践是小范围试切:读前1000字节,检测编码,尝试解码,如果出现异常或乱码率过高,再换编码重试。
import chardet with open("source.txt", "rb") as f: raw = f.read(4096) detected = chardet.detect(raw) print(detected) # {'encoding': 'GB2312', 'confidence': 0.99} with open("source.txt", "r", encoding=detected["encoding"], errors="replace") as f: text = f.read()清洗阶段我做了这几件事:
- 统一换行符:把
\r\n和\r全部换成\n。 - 删除BOM和零宽字符:
\ufeff、\u200b这类字符在文本里不可见,但会影响后续匹配。 - 合并多余空行:连续超过两个
\n的,统一变成两个。 - 全角转半角:英文字母、数字、常见标点从全角转成半角,中文标点保留全角。
- 剔除异常的孤立符号:比如文件中残留的
━、│这类表格边框字符,如果不成簇出现,直接移除。
清洗的关键是“宁滥勿缺”。规则宁可多写几个,也不要在第一步就漏掉噪声,因为后面的所有解析逻辑都是在清洗后的文本上跑的。
3.2 第二步:标题层级的自动识别
清洗完之后,下一步是识别标题。这一步决定了Markdown里#的数量,也决定了后续分块的层级边界。
我采取的方案是“正则规则 + 启发式打分”的组合。
先通过正则匹配常见的章节标题模式:
import re chapter_patterns = [ r"^第[一二三四五六七八九十百千万零〇]+[章节卷篇部].*$", # 第一章 引言 r"^Chapter\s+\d+.*$", # Chapter 1 r"^\d+(\.\d+)*\s+.+$", # 1.1 背景 r"^[一二三四五六七八九十]+、.+$", # 一、背景 ]但仅靠这些正则还不够。很多txt文件的“标题”并没有编号,只有一个短句,比如“产品需求背景”“API接口说明”。这种情况下,我用了两个启发式特征去兜底:
- 行长度较短,一般少于30个中文字符;
- 该行之后紧跟着一个空行,或者该行前后都有空行。
如果一个短行同时满足“独立成段”和“长度较短”这两个条件,我就给它一个“疑似标题”的置信度。再用规则把它和正文短句区分开:例如正文短句往往以句号结尾,标题通常没有句末标点;正文短句在上下文语境中前后有主谓语结构,标题则常以名词短语为主。
实际做的时候,我把这些特征打分,总分超过阈值就认定是标题。有时候还要人工处理一批样本去校准阈值。
识别出标题后,根据层级关系分配#的数量。一级标题用#,二级标题用##,以此类推。如果识别出来的编号版本是“第X章”这种,统一放到#级别;如果是“1.1”“1.1.1”这种,就根据编号层级匹配##、###等。
3.3 第三步:段落与列表结构的还原
标题识别完之后,正文的段落结构就好处理了。核心逻辑是空行分段,把连续非空的行合并为一个逻辑段。
但这个逻辑不能做得太死板。有些txt段落之间没有空行,只有两个换行符;有些段落内部又会因为换行而粗暴断行。我在实际处理时会引入一个“换行宽度”的概念:
- 如果某个换行符后面紧跟着的是带缩进的文本,或者行尾是逗号、冒号等未完结标点,就认为这是段落内的软换行,合并到当前段落;
- 如果换行符前后是完整的句子,且后面出现了新主题,就认为是段落边界。
def merge_soft_lines(lines): paragraphs = [] current = [] for line in lines: stripped = line.strip() if not stripped: if current: paragraphs.append("".join(current)) current = [] continue if current and (current[-1].endswith(",") or current[-1].endswith(",") or current[-1].endswith(":")): current[-1] = current[-1] + stripped else: current.append(stripped) if current: paragraphs.append("".join(current)) return paragraphs列表的识别相对直白。行首出现-、*、•、·的,转成Markdown的无序列表;出现1.、(1)、1)这类,转成有序列表。但这里有一个隐藏的坑:如果一行里以*开头,但后面的文本只有零散几个字,且整个文件里就这一处,那大概率不是列表,而是装饰符号,直接剔除。
引用块的处理则看行首的>或“”包裹的短句,常见于文档中的注意事项、提示语。被识别出来后就转成>格式,这对RAG的语义召回非常有帮助,因为提示类文本和正文文本的语义权重完全不同。
3.4 第四步:表格、公式与图片引用的处理
txt里面真正规整的表格其实很少见,更多的是一种“伪表格”:多行文本用制表符或连续空格对齐,语法上看起来像表,但拆开单元格后内容又乱又碎。我在解析时用过保守策略:连续三行以上都包含同一个分隔符(比如两个以上连续空格或制表符),且每一行拆分后的字段数基本一致,才判定为表格。
判定为表格后,还需要清洗单元格内部的多余空格,把制表符当作列分隔符,然后转成Markdown表格:
| 字段1 | 字段2 | 字段3 | | --- | --- | --- | | 值A | 值B | 值C |这个环节我踩过的坑主要是最后一行的空壳子。很多txt表格末尾会多出一个空行或者分隔横线,如果不处理,会形成一张多出空行的畸形表格,影响后续解析。
公式方面,txt中如果有$...$或$$...$$包裹的LaTeX片段,直接保留。如果是纯文本里手写的数学表达式,比如“x^2 + y^2 = r^2”,那就得靠规则判断。我的经验是,这类内容在RAG场景里的召回价值通常不高,因为向量化对公式的语义表达能力极弱,与其费力还原成Markdown公式,不如先保留原文,留待后续专用处理链路来接管。
图片引用相对少见,但如果txt是从网页转存的,可能会有一堆![]()或者本地图片路径的线索。我用正则把它们统一成Markdown图片语法,保留alt描述文本。在RAG场景里,图片本身进不了向量库,但alt文字是有价值的,它往往概括了图片内容,值得放入chunk。
image_pattern = re.compile(r"(?:图\s*示?[::]?\s*)?(\S+\.(?:png|jpe?g|gif))", re.I) text = image_pattern.sub(r"", text)3.5 第五步:导出Markdown并校验
解析过程的最后一步,是把结构化结果写回Markdown文件。我习惯保留一个“源文件名.md”的产物,方便人工抽查。
导出之前必须做一次完整性校验,否则问题会一直潜伏到下游。我的校验手段主要有三个:
- 用VS Code预览Markdown,肉眼检查标题层级、列表缩进、表格是否渲染正常。这一步最快,也最直观。
- 用
markdown库把Markdown转成HTML,查看嵌套的h1/h2/h3数量和原有标题数量是否一致。 - 写一段脚本统计异常:比如存在连续两个一级标题没有正文间隔、有段落以孤立的列表项结尾、有未闭合的代码块。这些往往就是解析规则出纰漏的信号。
import markdown html = markdown.markdown(output_md) h1_count = html.count("<h1>") h2_count = html.count("<h2>")校验通过后,这份Markdown才算真正可以喂给分块模块。
这一套流程看起来很基础,但基础往往最重要。很多新手做RAG项目时,精力全扑在向量库和模型调用上,等到效果不佳才回头补解析,那时排错成本就高了。先花一两个小时把txt解析链路搭扎实,后面的调试能轻松很多。
4. 结构化解析的进阶细节与工具选型
4.1 从“格式修复”到“语义分块”
Markdown生成后,很多人直接交给分块器,按固定长度切开。这又绕回了最初的问题:固定长度分块会让一个标题和它的正文被拆散。
更好的做法是把“解析”和“分块”结合起来。我的思路是用Markdown的标题层级作为天然边界,生成“标题-正文块”的结构化单元。
具体来说,遍历Markdown的AST,遇到#或##标题时,新建一个chunk候选;后续的普通段落、列表、表格,都追加到当前chunk中;遇到下一个同级或更高级别的标题,再另起一个新chunk。
这样生成的chunk自带上下文标题。比如一个chunk以“### 3.2 参数说明”开头,那这个chunk的语义边界就非常清晰,检索时用户查“参数”相关的内容,召回的自然而然就是这个带标题的块。
def chunk_by_heading(md_text): lines = md_text.split("\n") chunks = [] current_heading = "" current_body = [] for line in lines: if line.startswith("#"): if current_heading or current_body: chunks.append({"heading": current_heading, "body": "\n".join(current_body)}) current_heading = line.lstrip("# ").strip() current_body = [] else: current_body.append(line) if current_heading or current_body: chunks.append({"heading": current_heading, "body": "\n".join(current_body)}) return chunks这里有个取舍值得讲一下:到底按几级标题切?切得太细,每个chunk都太短,语义不完整;切得太粗,一个大章节几百行,照样会被二次切碎。我实际项目中,先用二级标题切分,再对每个大块做二次校验,如果块超过阈值(比如1500字),就利用三级标题再细分。这种“动态粒度”方案比固定长度分块稳健得多。
4.2 通用解析器的架构设计
我一开始写解析脚本,是面对一份文本写一份逻辑,后来发现完全不可维护。因为tx t的来源实在太杂:爬虫抓的、导出工具生成的、手工整理的,每个都有独特的问题。
后来我重构成了“Reader -> Cleaner -> Parser -> Structurer -> Exporter”五段式管道:
- Reader只负责按编码读入字节流,产出原始字符串。
- Cleaner做通用清洗,跟具体业务无关。
- Parser负责识别结构,输出一个中间结构体,包含标题、段落、列表、引用、表格等节点。
- Structurer做语义层面的组织,比如楼层归属、标题拼接、噪声剔除。
- Exporter负责把结构体导出成Markdown,或者将来导成JSON、HTML,都不会影响前面几个阶段。
分层的最大好处是每一阶段都能单独测试和替换。比如后来我发现某个来源的txt有特殊噪声,只需要在Cleaner里加一条规则,不需要动后面任何代码。
# 管道示例 python clean_text.py -i raw/ -o cleaned/ python parse_structure.py -i cleaned/ -o structured/ python export_markdown.py -i structured/ -o output/4.3 不同来源的txt差异
谈工具选型前,有必要把“来源差异”说透。同样是txt,小说网站导出的txt和开源项目里的README.txt,解析规则完全不同。
小说类txt,结构特征最明显:章标题规则单一,正文全是长段落,几乎没有列表和表格。处理这类文件的关键是“识别章标题”和“合并散乱正文段”,其他的都可以忽略。
技术文档类txt,结构复杂得多。有层级标题、嵌套列表、代码块、表格、甚至广告脚注。处理这类文件,标题层级和代码块的识别优先级要拉到最高,因为代码块内部的行往往以空格开头,如果不先隔离,后面的列表识别会误伤。
日志类txt则完全是另一个物种。每行都是时间戳加消息,几乎无段落概念。这种文件的RAG价值在于查询特定时间段的事件,解析时应该考虑按时间戳或按行切条,而不是硬套标题体系。
我的经验是:先确认你对文件来源的预期,再选解析规则组合。不要试图用一个通用的“智能解析”通吃所有txt,那只会得到平庸的结果。
5. 常见问题与排查技巧实录
5.1 乱码和编码识别失败
项目中我碰到最多的问题是乱码。大多数情况下chardet能猜对编码,但有三个场景它容易翻车:
- 文件是UTF-16编码,且带BOM。
chardet有时候会把这种文件误判为ASCII或UTF-8。 - 文件是混合编码,前面是GBK,后面又有UTF-8片段。这种情况大概率是之前有人拼接文件时出了问题。
- 文件字节数太少,比如只有几十个字符,采样统计的置信度太低,导致误判。
我的排查方法分两步。第一步,不用chardet直接硬猜,而是先看字节流里有没有BOM标记:\xef\xbb\xbf是UTF-8,\xff\xfe是UTF-16 LE。第二步,如果实在不确定,就同时用两种常见编码各解码一遍,对比哪边的乱码率更低。
def decode_robust(raw_bytes): encodings = ["utf-8", "gb18030", "gbk", "utf-16-le"] best_candidate = None best_errors = float("inf") for enc in encodings: try: decoded = raw_bytes.decode(enc) except UnicodeDecodeError: continue errors = decoded.count("\ufffd") if errors < best_errors: best_errors = errors best_candidate = decoded best_encoding = enc return best_candidate, best_encoding5.2 标题识别失败
标题识别的问题主要体现在两类:一类是编号格式太野,比如“§1.1”“1.1.1.1”“(一)1.”全混在一起;另一类是没有编号的短行标题,启发式打分经常把它和正文短句混到一起。
我最终用的方案是把“候选标题”和“正文首句”放在一起做对比:候选标题前的段落如果是完整结尾,候选标题后又有较长的正文段落,那它基本可以确认是标题;如果候选标题前后的段落语义高度连续,那它大概率只是正文的换行。这个规则比单纯看行长度可靠不少。
还有一个加分技巧:对标题做“标题聚合”。把识别出来的所有标题放到一起看,如果整个文档里出现“第X章”和“X.X”两套体系混着用,就统一换算成一套层级,避免后续分块时出现层级断裂。
5.3 分块过碎或过大
即使解析做对了,分块还是可能不理想。最常见的表现是:表头单独成了一个chunk,表格内容在另一个chunk里;或者列表项每个条目都成了孤零零的迷你块。
原因在于,只按段落换行切分,忽略了“表格整体”和“列表整体”的边界。
解决办法是:在分块前,先把表格、列表这类“逻辑单元”合并为一个整体再参与分块。我写了一个预处理函数,遇到连续三个以上的|开头行为或连续三个以上的-开头行,就把它们包成一个block,后续分块时不被拆散。
def pack_structural_blocks(md_text): lines = md_text.split("\n") packed = [] i = 0 while i < len(lines): line = lines[i].strip() if line.startswith("|"): table_lines = [line] j = i + 1 while j < len(lines) and lines[j].strip().startswith("|"): table_lines.append(lines[j].strip()) j += 1 packed.append("|".join(table_lines)) i = j continue if line.startswith("-") or line.startswith("*"): list_lines = [line] j = i + 1 while j < len(lines) and lines[j].strip().startswith(("-", "*", "+")): list_lines.append(lines[j].strip()) j += 1 packed.append("\n".join(list_lines)) i = j continue packed.append(lines[i]) i += 1 return "\n".join(packed)5.4 Markdown特殊符号解析错乱
最后一个高频坑,是Markdown输出后,特殊符号导致下游解析错乱。
最典型的问题是表格里的竖线。txt原文本里,如果单元格内容本身含|,比如“参数|说明”,直接转Markdown表格后,列数就被拆错了。我处理的原则是:单元格里的|统一转义成\|。
另一个问题是代码块里的#。如果一段示例代码里本身有#注释,而解析器在处理标题时没先把代码块隔离出来,它就会误判为一个新标题。所以我在整个解析流程里,把代码块识别放在最前面,先用三个反引号把代码块区域保护起来,后续结构解析都不进代码块内部。
```python # 注意:这里的 # 是注释,不是标题 print("hello")还有一个细节,Markdown里常见的空行问题。有些转换器会在段落末尾多打几个空行,虽然人眼看不出来,但后续拼接chunk时往往出现莫名多出来的空块。统一用`strip()`清理每段首尾后再拼接,能省掉不少调试时间。 ## 6. 实操心得与后续计划 回归最初的话题,RAG数据导入这一步,看起来像杂活,实际上是最值得花时间的部分。我个人的看法是,解析器的投资回报率远高于调模型参数。因为用户问的问题千奇百怪,但底子都是数据。数据结构不清晰,后面的召回就像是拿着错的地图找路,模型再强也白搭。 在这个系列里,这篇我只讲了txt到Markdown的通路。但实际项目里,txt只是众多数据格式的一种。PDF、Word、HTML、扫描件,这些格式各有各的解析难题,它们不能简单套用同一套规则。后续我会写第二篇,专门聊PDF的版面分析和表格抽取,第三篇聊HTML转Markdown时如何处理网页噪声。每一篇都是独立可用的方案,但组合起来才是一个完整的RAG数据导入库。 最后分享一个小技巧:解析器写完后,不要只拿一两个样例测,去网上下载几个来源不同的大型txt语料,包括小说、技术文档、合同模板、网页转存版,一次跑通,把所有异常行导出到一个日志文件里,再一个个处理。这个过程虽然枯燥,但做完之后,你的解析器才真正有资格进入RAG生产链路。