做 RAG 项目一年半,我踩过最大的坑不在模型调用,不在向量化参数,而在最不起眼的数据导入环节。这个系列我打算写透一件事:怎么把一个乱七八糟的 txt 文件,变成一份 RAG 能吃、而且吃得舒服的 Markdown 文档。先说明白,这不是简单的“格式转换”,而是把纯文本中的隐含结构——标题层级、列表、表格、段落边界——在进向量库之前挖出来,为后面的召回质量打底。
我知道不少人拿到 RAG 项目的第一个反应是选框架、调 embedding,但真正上线后会发现,召回效果的上限在数据解析阶段就已经决定了。这篇是系列第一篇,聚焦“从 txt 到 Markdown 的通用文本与结构化解”,先把数据入口这条路打通。后续再聊 PDF、DOCX、HTML 的专用解析。
1. 先想清楚:RAG 的数据入口到底卡在哪
1.1 RAG 丢分点往往藏在源头
我们先复盘一个典型场景:你接手一个内部知识库项目,几十个 txt 文件堆在一起,有的是产品文档导出,有的是会议纪要转存,还有的是爬下来的网页文本。大多数人会直接让 LLM(大语言模型)去读、去切块,结果检索出来的片段要么上下文缺失,要么把目录和正文混在一起,要么明明有两章讲同一件事,向量相似度却天差地别。
这个现象的根因在于:向量检索的本质是“语义相似”,而不是“逻辑相邻”。模型看不懂文档结构,它看到的是一串 token。如果我们在喂给它之前,连“标题在哪、段落在哪、列表在哪”这件事都没做,那它只能瞎猜。而 Markdown 恰恰是一种低成本、高表达力的中间结构,能把这些语义边界显式地标出来。
其实这个道理和做饭很像。同样的食材,你洗好切好再下锅,和连泥带土直接扔进去,出锅质量完全不一样。RAG 里的“洗菜切菜”就是导入解析,这一步做得越细,后面的“烹饪”(召回、生成)越稳。
1.2 txt 文件并不“简单”:三种常见的反直觉现实
很多人觉得 txt 是最简单的格式,打开就能读,错了。实际项目里我见过三类反直觉情况:
第一种是编码混乱。Windows 下大量老文档是 GBK/GB18030,Unix 系生成的是 UTF-8,还有带 BOM 的 UTF-8。按 UTF-8 直接读,GBK 文件会变成一串乱码或直接报 UnicodeDecodeError。你以为数据进来了,其实是废的。
第二种是伪 txt 结构。用 txt 后缀装 HTML 的、装 JSON 的、装 markdown 的都有。比如从某个网站上直接另存为的网页,后缀是 .txt,内容却是带<html>标签的源码。如果不管不顾地切块入库,检索到的片段里全是标签碎片。
第三种是页面残留噪声。PDF 导出的 txt 常见页眉页脚、页码、断行,爬虫抓的文本里可能有广告行、来源链接、乱序段落。这些不去掉,向量空间里就会多出一堆垃圾特征,拉低检索精度,而且很难通过后期调参补救。
所以我把 txt 解析的第一步定位为“体检”,而不是“转换”。先摸清文件里到底是什么,再决定怎么处理。
1.3 这一篇要解决的边界:解析 ≠ 检索
我见过很多教程把“解析”和“检索”混在一起写,这是误导新手。解析负责的是:把原始字节变成干净的、带结构的 Markdown 文本。检索负责的是:在解析好的文本上做切块、向量化、召回。两者完全可以、也应该解耦。
为什么强调边界?因为一旦耦合,你去调 embedding 参数时会发现永远是数据问题,去改解析规则时又发现召回效果没变化,最后变成互相甩锅。先把解析做到位,你才有资格谈检索优化。
顺带提一句,网上搜“RAG 知识库怎么搭”会看到大量框架教程,但很少有人讲清楚“文档结构要怎么保留”。我写这一篇,就是想把这块空白补上。
2. 从 txt 到 Markdown:解析链路的整体设计
2.1 为什么中间态选 Markdown,而不是 JSON、纯文本
做解析方案时,技术选型几乎是一边倒的——中间态用 Markdown。为什么?
纯文本的问题在上一节说过了,它丢失了结构。JSON 虽然结构清晰,但对 LLM 和人不友好,你把一个 JSON 片段喂给模型,模型得先理解 JSON 语法再理解内容,相当于多了一道翻译。而且 JSON 一旦字段设计不合理,后续扩展、迁移都是大麻烦。
Markdown 的优势有两个。一是人类可读、模型也熟,GPT 等模型在预训练阶段见过海量 Markdown 语料,你对它说“这是二级标题”“这是一个表格”,它马上能理解语义。二是结构化天然轻量,不需要额外定义 schema,#、-、|这些符号就是结构本身。
实际项目里,还有一个更现实的理由:现在很多 RAG 框架(LangChain 的 MarkdownHeaderTextSplitter、LlamaIndex 的 MarkdownNodeParser)原生支持按 Markdown 标题层级来切块。你把数据事先整理成标准 Markdown,后面所有环节都顺了。
2.2 三步走的解析总策略
我总结了一套三段式流水线,至今在项目里复用得很顺:
第一步,清洗(Clean):处理编码、去 BOM、修畸变字符、去页眉页脚、去空行和只有符号的行。
第二步,结构识别(Structure):识别标题、列表、表格、代码块、引用、段落边界。这一步是核心,复杂度全部集中在这里。
第三步,Markdown 输出(Serialize):把识别出的结构按规范写进.md文件,同时输出一份结构化元数据(章节位置、标题路径等),方便后面切块时使用。
三步不要混在一起写。我见过有人在一个循环里又清又拆又写,代码是短了,但出了问题根本没法排查。分段式的好处是每一段输出可以肉眼检查,定位问题快得多。
2.3 工具选型:正则还是 LLM?脚本语言选谁?
这是新手问得最多的问题:“我要不要用大模型来做结构化?”我的建议分场景:
- 如果文档有规律(比如都是产品文档、都有固定模板),优先用规则+正则,快、稳、便宜。
- 如果文档千奇百怪(比如混合了技术文档、公告、邮件、聊天记录),规则打底 + LLM 二次精修。
不要一上来就全交给 LLM。LLM 解析慢、贵、有幻觉,而且你永远不知道它这次会把哪个段落合并。正确姿势是“抓大放小”:用正则把 80% 的确定性结构稳住,剩下 20% 的疑难杂症再交给 LLM 抽离语义。
语言方面我推荐 Python,生态太全了:chardet解决编码识别,pathlib解决路径,re解决规则,markdownify可以兜底 HTML 转日 Markdown。如果你所在团队是 Java 体系也没问题,核心逻辑不复杂,Java 也能实现,只是生态工具少一些。
3. 实操:实现通用文本清洗与标准化
3.1 文件读取时先把编码问题解决掉
这是整个链路里最先要处理的,也是最容易踩坑的。写一段健壮的读取逻辑,顺序是:
from pathlib import Path import chardet def read_text_file(path: Path) -> str: raw = path.read_bytes() # 先去 BOM,BOM 是隐藏的麻烦制造者 if raw.startswith(b'\xef\xbb\xbf'): encoding = 'utf-8-sig' elif raw.startswith(b'\xff\xfe') or raw.startswith(b'\xfe\xff'): encoding = 'utf-16' else: detected = chardet.detect(raw[:5000]) encoding = detected.get('encoding') or 'utf-8' try: return raw.decode(encoding) except UnicodeDecodeError: # 兜底:用 replace 模式,避免单字节错误导致全文件失败 return raw.decode(encoding, errors='replace')几个细节值得说一下:
chardet只检测前几千字节就够,全文件检测既慢又没必要。- 解码失败时用
errors='replace',把无法识别的字节替换为占位符,而不是直接抛异常。宁可留几个�让后续清洗规则处理,也不能让整个文件导入失败。 - BOM 的处理顺序要在 chardet 之前。因为 BOM 文件自带编码声明,detect 的结果往往不可靠。
我见过一个项目,上线一周后突然检索质量下降,查了半天才发现是运维把一份 UTF-8 文件保存成了 GBK 编码,导入脚本没识别出来。加了 chardet 后这类问题基本绝迹。
3.2 清洗规则:重点不是“删什么”,而是“保留什么”
很多人做清洗时容易陷入“什么都想删”的怪圈。我的经验是:先定义保留规则,再定义删除规则。保留规则决定了文档的语义骨架,删除规则只处理噪声。
常用的清洗规则,我按优先级排个序:
- 去掉全角空格、PageBreak 分页符、零宽字符等不可见控制符;
- 去掉连续空行(超过两个换行压缩为一个);
- 去掉只有空格、只有符号、只有半个括号的“假段落”;
- 去掉页眉页脚(通常用正则匹配页码模式,如
第 1 页、- 1 -); - 去掉URL链接、邮箱、来源声明等爬虫噪声(仅针对爬取文本,本地文档别乱删)。
注意:第 5 条要谨慎。如果文档内容本身就是网页链接列表或推广文案,那这些 URL 也是正文的一部分,不能无脑删。我的做法是:统计 URL 占全文的比例,超过 30% 按噪声处理,低于 10% 保留原样。
清洗阶段我不建议做“统一换行符”“去除多余空格”之外的重度操作。因为过度清洗会丢失有效信息,比如段落的缩进可能在转换表格时还有用。
3.3 切分段落:Markdown 的段落语义不能靠猜
纯文本里,一个空行通常代表新段落;但在老式文档里,段落之间可能只有一个换行。清洗之后我们面临的第一个结构化任务就是把连续的文本行组成段落。
这里我有一个笨但有效的策略:先按空行分组,再检查组内行是否过长。
如果一个组只有一行且长度超过 80 个字符,可能是清洗时没去掉的手动换行,可以用行尾字符特征判断(比如该行是否以句号、逗号、冒号结尾)。如果以“,”“、”“:”结尾,说明句子没完,应该和下一行拼接。
def group_paragraphs(lines): paragraphs = [] current = [] for line in lines: stripped = line.strip() if not stripped: if current: paragraphs.append(''.join(current)) current = [] continue current.append(stripped) if current: paragraphs.append(''.join(current)) return paragraphs这里没有任何魔法,核心是一个原则:段落是语义单元,不能简单按行切。因为 txt 导入时行和段的关系千奇百怪,把行直接当段落会导致切块结果碎片化,检索时的 context 质量很差。
3.4 转 Markdown:先立规范,再写转换函数
转换之前,先想清楚你输出的 Markdown 要长什么样。我给项目定了一套规范,写出来供你参考:
#一级标题:文档名或章节大标题(一个文件只允许一个一级标题)##二级标题:章###三级标题:节- 段落:直接写文本,不加额外符号
- 列表:
-无序,1.有序 - 表格:
| col | col |+ 分隔行 - 代码块:用
```包裹,语言标注可选 - 引用:
>开头
这个规范的意义不是好看,而是统一后续切块器的行为。LangChain 的 MarkdownHeaderTextSplitter 默认就是按#层级切块的,你的规范越一致,切出来的块就越符合逻辑边界。
4. 结构识别与 Markdown 后处理:核心难点拆解
4.1 标题识别:比想象中更麻烦
标题识别是结构化的第一块硬骨头。txt 文件里,标题通常长这样:
第1章 绪论、第一章 概述、1.1 背景- 纯文本大写字:
INTRODUCTION、BACKGROUND - 前后空行 + 居中 + 字号幻觉(老式文档导出后会有大量空格前缀)
我的正则方案分三层:
第一层,识别显式编号标题,如第[一二三四五六七八九十百0-9]+[章节部分篇]和\d+(\.\d+)*\s+[\u4e00-\u9fa5A-Za-z]。
第二层,识别全大写短行。如果一行全是英文大写字母且长度小于 30,大概率是标题。要和上下文配合判断:它的前后距离有没有空行?它下面有没有正文?如果下一行不是标题格式,说明这个标题是有效的。
第三层,识别居中文本。老式文档导出后,居中行往往以大量空格开头,数量超过行长的 1/4。这种行优先按标题处理,但要在生成 md 前人工抽查确认。
标题识别错了,后面全错——所以我的建议是:标题识别的结果不追求 100% 准确,但必须可追溯。我习惯把识别结果先输出到一个 JSON 文件里,人工扫一眼,改错再批量生成 md。
4.2 表格无法避免:用启发式规则救回来
很多 txt 是从 PDF 转出来的,表格会变成一堆空格对齐的文本块。这里有一个非常实用的策略:
先判断某段文本是否“像表格”——特征是有多行都包含|、+、-或大规模空格分隔。如果是|风格,直接转成 Markdown 表格:
| 字段 | 类型 | 说明 | |------|------|------|如果是空格对齐风格,就比较难办。我的经验是用“列边界对齐”法:统计每一行非空字符出现的列位置,找到出现频率高的列边界,再据此分割单元格。这个方案不能覆盖所有情况,但能覆盖 60% 的常见表格,剩下的交给人工或 LLM 修。
提醒:表格转换不是必须一步到位。如果你的 RAG 项目检索的主要是文字段落,表格转换的价值在于“把它们从视觉结构变成语义结构”,让向量化阶段能把表格整体当作一个语义块,而不是散落到各个切块里。这样召回更准确。
4.3 列表与代码块:识别要趁早
列表识别相对简单:行首是-、*、+、1.等符号,且连续出现两行以上。但有一个细节:无序列表的*和加粗**冲突,需要看上下文,不能单独看一行。我的规则是:若行首是*且后面还有*(成对出现),优先按加粗处理,否则按列表处理。
代码块的识别在 txt 里更依赖启发式:连续 N 行有相同缩进(通常 4 个空格或一个 Tab)且包含括号、等号、分号等符号。识别后统一用三个反引号包裹,这会让代码在 Markdown 渲染和向量切块都更好用。
为什么不把代码块转换成纯文本?因为代码块的语义和普通文本不同,它不适合去和自然语言做向量比较。但保留代码块的 Markdown 标记,切块器可以把整个代码块作为一个整体切出,检索到相关问题时直接给你一段完整代码,体验比零碎代码行好太多。
4.4 数学公式、图片路径:别被 Markdown 插件绑架
现在很多人问数学公式怎么处理,因为热词里有一堆“markdown 数学公式插件”。我的观点是:解析阶段不用依赖插件,只需要把公式在 Markdown 里用规范包裹即可。
- 行内公式:
$公式$ - 块级公式:
$$公式$$
本模型的损失函数为 $L = -\sum_{i=1}^n y_i \log(p_i)$, 其中…公式在向量化时可能表现不佳,因为它占用的 token 多但语义有限。我的方案是:导出的 md 里保留公式原样,同时给公式前后加上一句自然语言描述。这样在检索时,即使问题不提公式本身,也能通过描述命中相关内容。
图片路径也是类似逻辑。txt 里不可能有图片,但可能有图片路径或图片说明,比如[图片:架构图.png]。我建议保留这个占位文本,并在 md 中改成形式。图片路径本身就携带语义,会参与向量化。之前有热词“markdown 图片路径”,其实就是指这类路径处理问题——图片在本地库中,RAG 检索到路径后,前端渲染可以展示图片,但图片本身不会被向量化。
4.5 元数据:每个 md 文件都应该带上“身份证”
最后一步:在导出的 Markdown 文件头部,加一段 YAML 或 HTML 注释形式的元数据。
<!--- title: 产品操作手册 source: /data/raw/op-manual.txt created: 2024-05-01 category: 产品文档 --->为什么需要这个?因为纯文本本身是“哑”的,它不知道自己来自哪、属于哪个类别。而检索时有一个很重要的 trick:用元数据过滤替代一部分向量检索。比如用户问“操作手册里的权限设置”,如果你索引里存了category=产品文档,就可以先按元数据过滤到该类别再检索,比全局向量检索精准一个量级。
这个元数据不一定非要在 md 文件内,也可以在向量数据库里单独维护。但放在文件头部有个好处:文件自解释,团队协作时光看文件就能知道它的背景。
5. 常见问题与排查技巧实录
5.1 编码问题排查:避开“看着是好的,跑起来就坏”
有次我的导入脚本突然失败,报错UnicodeDecodeError: 'gbk' codec can't decode byte 0x8b。原因是我用记事本另存了一个 UTF-8 文件,Windows 记事本自动写出了 BOM,导致前一模块的编码探测顺序乱了。后来我把“先看 BOM,再看 chardet,最后 fallback”的顺序固定下来,就再没出过这类问题。
另一个低频问题:文件里混用 GBK 和 UTF-8 两种编码。这种情况无解,只能用errors='replace'先读完,再靠清洗规则处理占位符。不是每次都能救回来,但至少不会让批量导入中断。
5.2 性能坑:几 GB 的 txt 怎么处理
单个 txt 十几个 MB 以上,Python 的read_bytes()和逐行处理就会变慢。我试过一个 800MB 的日志导出文件,用read().splitlines()跑了三分钟内存暴涨。后来改成分块读取,每次只读 1MB,逐行 yield:
def read_chunks(file_path, chunk_size=1024*1024): with open(file_path, 'rb') as f: while True: chunk = f.read(chunk_size) if not chunk: break yield chunk注意:分块读取时编码判断与文件头、文件尾交错,可能出现跨块的行被切断。我采用折中方案——分块读取后按\n拆分,保留末尾半个行到下一块再拼,这样既省内存又不丢内容。
5.3 结构化失败的典型症状与对策
我把这几年遇过的典型症状列个表,方便你对照:
| 症状 | 可能原因 | 对策 |
|---|---|---|
| 所有内容变成一段,没有标题 | 原始 txt 全用换行,空行被清洗掉了 | 检查清洗规则中空行压缩逻辑,保留“双换行” |
| 标题散落、没有层级 | 原始文档本身没有编号规范 | 用 LLM 辅助识别标题,人工修正后固化规则 |
| 表格内容乱成一锅粥 | 空格对齐表格匹配失败 | 手动指定该文件走 LLM 表格抽取流程 |
| Markdown 渲染后多出很多空行 | 原文本有大量控制符 | 增加控制符清洗步骤,重点处理\x00和\r |
| 公式变成了一堆符号 | 数学文本未识别为公式 | 用启发式规则包裹$,检索时加自然语言描述 |
5.4 大绝招:导出的盲区检查
我每次做完批量解析后不会立刻入库,先做一个“盲区检查”:随机抽取 5 个文件,手动打开 md 渲染预览,快速扫标题、表格、代码块和段落边界。这个习惯救了我很多次。
另一个绝招是导出“折叠文本”视图:把所有 Markdown 符号去掉,只留纯文本,专门检查清洗阶段有没有误删。因为人眼看渲染效果会忽略符号层面的错误,看纯文本更容易发现问题。
6. RAG 知识库和结构知识库:刚入门最容易混淆的两个方向
6.1 两类知识库到底解决什么问题
这个热词在最近的项目交流里被反复问到:“RAG 知识库和结构知识库到底怎么选?”先说结论:它们不是替代关系,是互补关系。
RAG 知识库(也可以叫文本向量知识库)的核心是把文档切块、向量化、用语义相似召回。它适合“非结构化文本”的问答,比如产品文档、帮助中心、内部 wiki、政策文件。它的优势是覆盖广、零门槛,你把文档一丢就能跑出个能用的效果;劣势是精确性差,对知识密集型问题容易答非所问。
结构知识库(知识图谱 KG、结构化数据库)是把实体、关系、属性抽出来,存成三元组或表结构,用图查询或 SQL 精确检索。它适合“强关联、需要精确推理”的场景,比如人员关系、产品参数对照、故障排查逻辑链。它的优势是精确、可解释、支持多跳推理,劣势是构建成本高,需要大量人工或高质量 NLP 抽取。
6.2 一个实际项目的选型逻辑
我之前接过一个设备维护项目,说要做知识问答。一开始客户以为只要把说明书导入 RAG 就行,结果上线后客户问“发动机型号 A100 应该配什么型号机油”,RAG 老是答不准确,因为这段信息分布在不同章节和表格里,向量相似度排不到最前面。
后来我改成混合方案:说明书全文走 RAG,另加一个小的知识图谱存“设备型号—适配零件—适配参数”三元组。用户提问时先做实体识别(提取“A100”“机油”),命中图谱就走图查询,拿精确答案;没命中图谱再退回 RAG 全文检索。
这才是正确姿势:RAG 解决“我不知道文档在哪”,知识图谱解决“我知道而且必须知道精确答案”。
6.3 进阶路线:Ontology + RAG 怎么衔接
最近“ontology rag”热词也起来了,这算是 RAG 加知识图谱的进阶版。我不展开细讲,但给你一个可落地的衔接思路:
第一步,用规则或 LLM 把文档按章节拆成候选实体段落;第二步,定义轻量本体(Ontology),比如“设备”“零件”“参数”是三个类,“适配”“属于”是关系;第三步,从段落里抽实体和关系,写入图谱。这样 RAG 检索到某段内容时,可以联动图谱把精确关联信息带出来。
提醒:这套方案的成本比纯文本 RAG 高一个数量级,适合那些真需要“精确+多跳推理”的场景。如果你的客户只是想要一个“AI 问答客服”,纯文本 RAG 加好的解析已经能解决 80% 的问题,别为了技术先进而堆复杂度。
6.4 再回到本系列的核心:结构是两者共用的地基
我写这一节的目的是帮大家理清方向,但有一点必须强调:不管是走 RAG 还是走知识图谱,第一步都是从文档里把结构挖出来。你连标题、段落、表格都识别不准,图谱抽取的候选段落质量就差;连 Markdown 规范化都没做,向量切块的边界就是随机的。
所以“从 txt 到 Markdown 的通用文本与结构化解”不是一个小工具,它是通往文本 RAG 和结构化知识库的公共地基。这个地基打牢了,后续你选择怎么走都很舒服。
写到这儿,这第一篇的主体内容差不多了。我这套解析流程在几个项目里反复打磨过,每次改动清洗规则或结构识别策略,都要重新跑一遍盲区检查。最大的体会是:解析这件事急不得,但永远值得做在前头。
最后分享一个小技巧:在团队协作时,把“清洗规则定义”和“清洗规则代码”分开管理。规则用一份文档记录,写明“为什么保留某些结构”“为什么删除某些噪声”,这样后面接手的人不会因为看不懂正则而乱改规则。
下一篇我会专门拆 PDF 和 DOCX 的解析方案,重点讲表格抽取和版式还原。如果你在数据解析上遇到过奇葩文件,欢迎在评论区贴出来,常见坑我会直接在后续内容里补充。