做RAG项目的人都知道,搭好框架、选好模型其实只算开了个头,真正让人头大的往往是"数据导入"这一步。尤其是当你面对一堆txt文本,想把它们塞进RAG知识库时,就会发现"解析"两个字能直接决定你最后的检索效果。这个系列我打算从最常用的txt开始,手把手把数据导入和解析的套路拆开讲,第一篇先聚焦一个目标:把杂乱无章的txt文本,变成有结构的Markdown。
为什么非要转换格式?因为RAG这个"搜了再答"的机制,吃的是高质量的结构化文本。txt本身毫无结构,不分段、不标层级的文本直接扔给分块器,切出来的就是一坨没有语义边界的字符串,召回率自然惨不忍睹。而Markdown用符号本身就带上了语义层级——标题、列表、代码块、表格都有了明确标记,这正好是分块、向量化和后续检索都喜欢的食材。下面我把整个处理流程、代码实现和踩坑过程全部摊开讲。
1. RAG数据导入的基础认知:为什么解析是第一步
1.1 RAG知识库的本质与数据全流程
RAG(Retrieval-Augmented Generation)说白了,就是先从一个库里把相关问题的最相关片段"捞"出来,再把捞出来的片段拼进提示词里,让大语言模型基于这些片段作答。这个"库"就是知识库,通常由文档切分出的chunk向量化后构成。所以整个数据全流程是:原始文档 -> 清洗 -> 解析 -> 分块 -> 向量化 -> 存储。很多人搭完LangChain或LlamaIndex,兴致勃勃把txt一读就塞进去,结果问答效果差得离谱,问题多半就出在"解析"前面没做扎实。
拿我自己的项目经历来说,最早我图省事,直接用RecursiveCharacterTextSplitter按字符硬切,500个字符一块。一堆合同文本被拦腰截断,标题搭着八字不合的正文,向量库塞得满满当当,搜索出来的片段却驴唇不对马嘴。后来我开始做数据预处理流水线,才意识到"解析"不是把文件读进来就完事,而是要尽可能把文本里的结构信息还原出来。txt格式看着简单,但它可能是GBK编码、可能带BOM、可能有杂七杂八的缩进乱码,这些细节不处理,后面全崩。
1.2 从txt到Markdown:格式选型的逻辑
为什么是Markdown而不是JSON、HTML?因为Markdown几乎是"人读+机器读"性价比最高的中间格式。第一,它足够轻,纯文本就能搞定;第二,它有标准的语义标记,比如#标题、-列表、```代码块、|表格;第三,主流RAG框架早就为Markdown专门设计了分块器。例如LangChain里的MarkdownHeaderTextSplitter,会根据标题层级自动切块,切完之后chunk自带上下文标题,这对检索排序和生成质量都有实实在在的提升。
对比一下其他格式:纯txt无语义,切片全靠运气;JSON结构强,但人读费劲,而且需要额外生成;HTML标签太嘈杂,嵌入时都是噪声。所以,用Markdown把"无序文本"转成"半结构化文本",是成本最低、收益最高的选择。这个通用链路我整理成一句话:txt原文件 -> 编码归一 -> 文本结构识别 -> Markdown输出 -> 交给后续分块和向量化。这篇文章要解决的就是链路的中间三个环节。
2. 核心细节解析:文本的"骨架"到底怎么识
2.1 解析原理:把字符流扫出语义层级
对于人来说,一眼看去就知道"第一章"是标题,"1. 项目背景"是副标题,"· 第一点"是列表项。但程序拿到的只是一串字符流。所谓解析,本质上是拿一组模式规则去匹配这串字符,把匹配到的区域打上语义标签。放到txt转Markdown的场景里,就是识别四类最基本的骨架:标题、段落、列表、代码块。
我更喜欢用一个手抄报的类比来解释。你看一张手抄报,先看到大标题,再看到小标题,然后是一段段正文,可能还有几个项目符号、一块涂鸦区。txt文本也是一样,有"大标题"(比如章节名)、"小标题"(小节名)、"正文段落"、"列表项"和"代码块"。解析器就是要做那个"眼光毒辣"的编辑,把这些区域一个一个划出来。最朴素的实现就是用正则表达式做模式匹配,简单、快、可控,对txt这种弱格式文本来说已经足够好用。
2.2 工具选型:为什么自己撸正则而不是上Pandoc
工欲善其事,必先利其器。处理编码我用chardet,正则匹配用Python标准库re就够了,后续验证用markdown-it-py把Markdown渲染成HTML检查结构。整体项目我放在Python 3.10环境,用venv隔离依赖。
有人可能会问:直接用pandoc把txt转Markdown难道不行?我说说我的体验。Pandoc强在把一种标准格式转成另一种标准格式,比如Docx转Markdown,那叫专业。但txt最大的问题是"弱结构",没有严谨排版,甚至连编码都千奇百怪。Pandoc不会帮你识别"1.1"到底是列表还是标题,不会清理BOM,也不会处理中文全角符号。它做转换,但做不了"结构化理解"。自己撸正则,成本其实不高,还能针对你的语料做定制,后面我给出的解析器框架可以直接套用、按需改规则。
下面是我的环境准备命令,直接抄作业就能跑:
python -m venv rag-env source rag-env/bin/activate # Windows下用 rag-env\Scripts\activate pip install chardet markdown-it-py解析器的项目结构我很简单,不搞花活:一个parser.py放核心代码,一个samples/放测试txt,一个outputs/放生成的Markdown。等规则成熟后,再考虑工程化封装。
3. 实操过程:一个通用的txt转Markdown解析器
3.1 编码识别与归一化:先过了中文乱码这关
txt最坑的地方,就是编码不统一。从网上爬下来、从同事那儿拷来的txt,可能是UTF-8、GBK、GB2312,还经常带BOM头。我刚开始写解析器时,没管编码,直接用open()默认读,结果控制台一片乱码,后续全白做。解决办法是先用chardet检测,再做转换。
这是我固定用的编码处理函数:
import chardet def read_txt_with_encoding(path): raw = open(path, 'rb').read() # 先检测编码 detected = chardet.detect(raw) encoding = detected.get('encoding', 'utf-8') try: # 统一读取为文本 text = raw.decode(encoding) except UnicodeDecodeError: # 兜底策略:有损解码,或尝试GB18030 text = raw.decode('utf-8', errors='replace') # 清洗BOM和特殊空白 text = text.replace('\ufeff', '').replace('\r\n', '\n').replace('\r', '\n') return text这里有个细节,\ufeff是UTF-8 BOM,Python读取时经常带出来,不删干净后面做正则匹配会莫名其妙多出字符。另外,如果chardet检测结果还是不对,我会手动指定'gb18030'再试一次,因为GB18030基本覆盖了中文所有生僻字。
3.2 标题识别与转写:让层级重新显形
txt里的标题样式五花八门。有"第一章"、有"1.1"、有全大写短行,也有"一、"。我想要的输出是标准的Markdown标题:#、##、###。难就难在怎么判断一个行到底是不是标题,以及它应该是什么级别。
我采用了一套优先级规则:
- 如果行匹配
^第[一二三四五六七八九十百千]+[章节部分],映射为#一级标题; - 如果行匹配
^\d+[\.、]\s*,比如1.1或1、,映射为##或###,根据数字分段数量决定; - 如果整行全大写且长度小于50,也当作标题(英文场景常用)。
这里容易踩一个很经典的坑:目录里的"1.1"会被误判成有序列表。解决思路是先跑标题规则,再跑列表规则,而且标题后面紧跟换行和内容的可能性更大。我写的标题处理函数大致长这样:
import re def convert_heading(line): m = re.match(r'^(第[一二三四五六七八九十百千]+[章节部分])\s+(.*)$', line) if m: return f"# {line.strip()}\n" m = re.match(r'^(\d+(?:\.\d+)*)[、\.]\s*(.*)$', line) if m: level = 2 if len(m.group(1).split('.')) == 1 else 3 return f"{'#' * level} {line.strip()}\n" if len(line) <= 50 and line.isupper(): return f"## {line.strip()}\n" return None注意,这里输出的Markdown标题后面我加了一个空行,是为了避免和下一段合并,保证分块器能正确切分。
3.3 段落、列表与代码块:从"铁板一块"到区块分明
段落识别在txt里相对简单:用空行分割。连续的非空行合并成一个段落。但很多时候txt是用缩进换行而不是空行,这时就需要看行尾是否有标点、缩进是否加深。我的折中方案是:当一行以句号、问号、感叹号等结束,且下一行顶格且不是列表或标题时,认为是一个自然段落结束。
列表又是另一回事。txt里最常见的列表符号是-、*、•和数字点号。转成Markdown时,我统一成-和1.这样的标准形式。嵌套列表按缩进识别,Markdown里嵌套列表只需要父级子项用两个空格缩进。这里我直接给出一个处理函数,能处理一层嵌套:
def convert_list_block(lines): md = [] for line in lines: stripped = line.lstrip() indent = len(line) - len(stripped) m = re.match(r'^([-*•])\s+(.*)', stripped) if m: level = 1 if indent < 4 else 2 prefix = ' ' * (level - 1) + '- ' md.append(prefix + m.group(2)) continue m = re.match(r'^(\d+)[\.\)]\s+(.*)', stripped) if m: level = 1 if indent < 4 else 2 prefix = ' ' * (level - 1) + f'{m.group(1)}. ' md.append(prefix + m.group(2)) continue md.append(line) return '\n'.join(md)至于代码块,txt里一般有两种:用四个空格或Tab缩进,或者用\```包裹。我倾向于统一转成```围栏式代码块,因为四空格缩进在Markdown里容易跟嵌套列表混淆。检测方法就是看一段连续行是否以四空格或Tab开头,且持续一定行数:
def convert_code_blocks(text): lines = text.split('\n') new_lines = [] i = 0 while i < len(lines): if lines[i].startswith(' ') or lines[i].startswith('\t'): # 连续多行都缩进,认为是代码块 code = [] while i < len(lines) and (lines[i].startswith(' ') or lines[i].startswith('\t')): code.append(lines[i].lstrip(' \t')) i += 1 new_lines.append('```') new_lines.extend(code) new_lines.append('```') else: new_lines.append(lines[i]) i += 1 return '\n'.join(new_lines)这版代码够用,但如果你处理的是中文技术文档,要小心" "开头的行可能是正文的缩进首行,而不是代码。我的经验是加上一个条件:缩进段落里如果包含行尾标点比如.、;,则更像正文,因此我还会再跑一层"代码块疑似度"判断。
3.4 表格与图片引用:两处最容易分叉的地方
txt里的表格惨不忍睹,常见两种:一种是制表符分隔的数据,另一种是空格对齐的"伪表格"。制表符分隔还好办,直接转成Markdown管道表格。空格对齐的就麻烦了,因为列数不确定、对齐不齐整。我的做法是只处理TSV样式的数据块,空格对齐的表格宁可保持为纯文本,也不要做傻转,否则生成一堆残缺表格反而污染向量库。
转TSV为Markdown表格的示例:
def tsv_to_markdown(text): lines = [line for line in text.strip().split('\n') if line.strip()] if len(lines) < 2: return text header = lines[0].split('\t') rows = [line.split('\t') for line in lines[1:]] md = ['| ' + ' | '.join(header) + ' |'] md.append('| ' + ' | '.join(['---'] * len(header)) + ' |') for row in rows: md.append('| ' + ' | '.join(row) + ' |') return '\n'.join(md)图片问题也很微妙。经常有人问"RAG知识库能存储图片嘛",我的回答是:纯文本向量模型理解不了图片,但可以存图片的路径或URL,同时保留图片周边文本描述。在txt解析阶段,如果遇到[图片]或img: xx.jpg这样的占位符,我会把它替换为标准Markdown图片语法。如果手头有本地图片,我还会在图片前后抓一两句说明文字,变成一个带上下文的引用块,这样向量化时至少不会丢失图片层面的语义锚点。
3.5 输出验证:怎么确认解析结果没把结构搞坏
解析完不能直接扔进RAG,要先验证。我的验证工具很朴素:用markdown-it-py渲染Markdown成HTML,然后检查是否有未闭合标签、标题层级是否连续、列表嵌套是否正确。再写个小脚本统计标题数量、段落数量、代码块数量,和人工标注对比一下,做到心里有数。
from markdown_it import MarkdownIt def validate_md(md_text): html = MarkdownIt().render(md_text) # 简单检查未闭合 code 标签 if html.count('<code>') != html.count('</code>'): return False # 检查是否有标题节点 for line in md_text.split('\n'): if re.match(r'^#{1,6}\s', line): return True return False这一步不能省。我见过有人辛辛苦苦解析出Markdown,结果每行都被转义成了冷门符号,向量化时全部乱套。验证环节就像你写代码后的pytest,虽然不保证业务正确,但至少能挡住低级错误。
4. 常见问题与排查技巧实录
4.1 编码乱码与BOM问题的处理锦囊
乱码在txt解析里出现频率最高,尤其来自Windows记事本的老文件。症状是中文读出来变"锟斤拷",或者开头多一个\ufeff。排查思路我总结成三步:先用chardet检测,再尝试GB18030或utf-8-sig,最后如果都不行就人工介入看几行。记得在解析管道的最前面就处理编码,否则后面所有正则都会被奇怪的字符干扰。
另外,我强烈建议在管线里加一个"编码日志",把每个文件检测出的编码记下来。这样做的好处是,后续如果某类文件全部乱码,可以快速定位到源头,而不是对着输出猜。
4.2 嵌套列表与多级标题误判的实战修正
我在做一批法律条款txt时,经常遇到"3.1.2"这种行,它到底是第三部分第1节第2条,还是一个三层列表?一开始我的标题规则把3.1.2映射成###,但实际原文里它只是一个句子里的编号。后来我加了上下文判断:如果该行后面跟随的内容是独立成段的,才当作标题;如果后面紧跟的是普通正文,则当作列表项。
修正代码的关键逻辑是:
def classify(line, next_line): # 先尝试标题匹配 heading = convert_heading(line) if heading and next_line and not next_line.startswith(('-', '*', '1.')): return heading # 否则尝试列表匹配 ...这招帮我解决了一大半误判。还有嵌套列表,我干脆用缩进深度来控制层级,同时把四空格缩进天然当作一级嵌套,这样就和代码块的规则避开了冲突。
4.3 "图片到底存不存"的结论和工程化做法
这个问题在RAG社区一直有争议,我的实践经验也有个明确倾向。做纯文本RAG,图片字节塞进向量库没有意义,因为向量模型不吃像素。但你可以存图片的URL或文件路径,并在文本里保留位置信息。比如一段研发文档里有个架构图,我会保留一个,同时把架构图的标题和下面一句话提取成上下文,盖在图片引用前后。这样检索时,模型至少能通过文字知道"这里有张架构图,它描述的是系统的层次关系"。
如果你真的需要让模型"看图",那就得走多模态RAG路线,用CLIP这类模型对图片单独向量化,再和文本向量做融合召回。那是另一个量级的工程了,和本文讨论的解析逻辑不在一个频道。
4.4 解析的边界:什么时候该认怂
解析器不是万能的。遇到手写扫描件、表格复杂到离谱的txt、或者本身就散乱无章的会议记录,硬转Markdown只会产出"貌似有结构、实际一塌糊涂"的垃圾。我的经验是,解析器先跑一遍,然后输出一份"未识别文本比例"的统计。如果超过三成行没法归类,就别死磕正则了,直接采集样本人工标注,再针对性地补规则。
我还会把解析结果分成"高置信度"和"低置信度"两档。高置信度的直接进知识库、低置信度的单独放一个待审核区,由人工快速过一遍。这比全自动一把梭更稳妥,也符合我做数据治理项目的经验——永远给机器留一个"悬置"的出口,而不是强迫它把每行都解释成某个结构。
结尾:一点可以少走弯路的个人体会
解析这件事,我现在越来越觉得,它没有一套"万金油"规则,但把常见模式覆盖住,能省下绝大多数时间。我那套解析器最初只写了五十行正则,应付不了几类txt,于是我先用十来份不重样的txt做样本,逐个跑,逐个调,两周后才敢上批量任务。后来也养成了习惯:每次拿到新的数据源,先跑一遍解析器和验证脚本,再去看输出,而不是直接扔进向量库。最后再分享一个小技巧:把解析后的Markdown和原txt并排放,用文档对比工具快速扫一眼,结构有没有丢、标题有没有错位,几秒钟就能发现。这样的小事在RAG项目里看似不起眼,可它决定着你后续检索效果的下限。