做过RAG项目的人应该都有同感:真正拖慢进度的往往不是模型选型,不是向量库调参,而是最不起眼的数据导入与解析环节。尤其是当知识库来源混杂着txt、Markdown、PDF、扫描件时,“垃圾进、垃圾出”这句话会被体现得淋漓尽致。我大概算过一笔账,一个中型RAG项目里,约有40%-60%的联调时间都花在了"文档解析出来结构不对、切块后语义断裂、检索召回一堆噪音"这几件事上。所以想认真聊一聊这个系列的第一篇:从 txt 到 Markdown 的通用文本与结构化解。
这篇内容主要面向刚接触RAG的开发者,以及已经在做知识库但总觉得召回效果不理想、怀疑是分块策略出了问题的朋友。我会从数据导入的底层逻辑讲起,把txt和Markdown这两种文本形态的解析思路、工具选型、切分策略和实测对比完整过一遍。整篇不会只给结论,而是把"为什么这样做"背后的理由也一并拆清楚,这样你换到自己的业务场景时,能直接复用这套思考框架。
1. 数据导入这一环:RAG项目里最容易被低估的拦路虎
在开始动手之前,我想先把数据导入这件事在整个RAG架构里的位置聊透。很多人潜意识里觉得RAG的核心是检索和生成:把用户query丢进去,召回到TopK段落,扔给大模型做答案融合。但坦白讲,检索效果的上限,从你把文档导入系统的那一刻就已经决定了。文本导入、清洗、解析、切块这几步,才是真正决定知识库“理解能力”的地基。
1.1 数据导入在RAG全链路中扮演的角色
RAG的完整链路一般可以拆成五个环节:数据接入、内容解析、切分与结构化、向量化存储、召回与生成。我在多次项目迭代中发现,真正能拉开两个RAG系统效果差距的,往往不在第五环,而在第二环和第三环。因为你喂给切分器的文本质量、结构化程度,直接决定了每个chunk里是否承载了完整语义。
以一份典型的企业运维手册为例。手册里可能有操作步骤、参数表格、故障告警说明、代码示例,它们各自承担不同的语义角色。如果你用“按字数硬切”的方式,可能把一个完整的表格拆成两半,也可能把步骤和告警说明切进同一个chunk。这种碎片化最终会让检索阶段返回一个语义残缺的片段——大模型就算再强,也没法从半张表格里推断出完整结论。
数据导入环节的核心任务可以概括成三个动作:读取、规整、转化。读取解决编码和格式识别,规整负责清理噪声,转化则是把原始文本变成一种可感知结构的中间表示。这套流程做扎实了,后面的检索难度会大幅降低。
1.2 为什么首篇先讲txt和Markdown,而不是PDF或Word
选txt和Markdown作为开篇是有原因的。在各类文档格式中,txt和Markdown代表了两条不同的难度阶梯:txt是“无结构文本”的典型代表,处理好了能建立对编码、清洗和分块底层逻辑的直觉;Markdown则是“轻量结构化文本”的典型代表,它用固定语法标注了标题、表格、代码块、列表,处理它能帮你理解结构化信息如何反哺切分策略。
相比之下,PDF的解析难点在版式还原,Word的难点在样式提取,这两个格式都会引入OCR、字体、布局等额外变量。如果你一上来就啃PDF,很容易陷入“解析库选哪个”的工具泥潭,根本来不及建立对RAG数据工程的整体认识。所以我的建议是:先用txt过一遍编码问题,再用Markdown过一遍结构化思路,有了这两层经验打底,再去碰PDF和Word会轻松很多。
2. txt文件导入:编码、清洗与边界处理的三个实地坑
txt在技术上没有任何门槛,但它的自由度恰恰是坑所在。没有固定的编码声明,没有统一的换行符,没有明确的分段标记,所有责任都压在导入程序身上。这一节我会把三个高频问题的排查思路写清楚,每个都是我在实际项目里踩过的。
2.1 编码识别:永远别信“默认UTF-8”
txt文件最常见的翻车现场就是编码。Windows平台的记事本默认可能是GBK或GB18030,macOS和Linux下大多为UTF-8,还有一部分文件用UTF-16。你按UTF-8打开GBK文件,轻则乱码,重则直接抛UnicodeDecodeError。
很多人图省事直接写open(file_path, encoding="utf-8"),这种代码在自测时没问题,一到真实数据集就各种崩溃。业界通用的做法是先用二进制抽样检测编码:读取文件的前几千字节,交给chardet或charset-normalizer判断,再把判断结果作为open的编码参数。
import chardet def detect_file_encoding(file_path): with open(file_path, "rb") as f: raw = f.read(65536) # 抽样64KB足够判断常见编码 result = chardet.detect(raw) return result.get("encoding")这里有几个注意点:
- 中文场景下,GB18030比GBK覆盖范围更广,兼容生僻字和少数民族字符,检测结果若出现GBK,建议统一按GB18030处理。
- 如果文件开头有
\xef\xbb\xbf这串字节,说明是带BOM的UTF-8。BOM本身不算正文,解析时要么显式跳过,要么用utf-8-sig编码读取,避免第一个字符变成\ufeff污染后续处理。 - chardet并非万能,遇到极短的文件或内码混杂的文本可能判断错误。稳妥做法是拿到检测结果后再做一次“试读取”,如果抛异常就依次尝试常见编码列表(utf-8、gb18030、utf-16、latin-1)。我一般把最后兜底的
latin-1当作逃生舱——它不会抛错,但可能产生字符映射问题,所以必须要有前一步兜底。
2.2 文本清洗:清洗到什么程度算“适度”
编码解决后,txt里还藏着很多不显眼的问题:全角半角混用、连续空行、行首行尾的空白、不可见控制字符、常见的导出残留如著作版权信息、页码脚注等。清洗的目的是降低对后续解析的干扰,但目标不是“把所有特殊符号删光”,而是保留语义、删除噪声。
我习惯按三个层级来做清洗,避免“一刀切”造成信息过度丢失:
- 第一层:删除空行、合并连续空白为单个空格、去掉零宽空格和BOM残留。
- 第二层:针对特定来源的规则清洗,比如从PDF复制来的文本行尾常有多余连字符,需要做规则替换。
- 第三层:只针对明确噪声做的清洗,比如广告块、页眉页脚。这一类需要结合字数过滤和位置模式,比如连续三次出现的相同行,基本可以判定为页眉。
import re def clean_text(text): # 删除BOM if text.startswith("\ufeff"): text = text[1:] # 统一换行符 text = text.replace("\r\n", "\n").replace("\r", "\n") # 去除零宽字符 text = re.sub(r"[\u200b\u200c\u200d\ufeff]", "", text) # 连续多个空格折叠为一个 text = re.sub(r"[ \t\f\v]+", " ", text) # 连续空行折叠为一个 text = re.sub(r"\n{3,}", "\n\n", text) return text.strip()清洗的一个核心心得是写进日志。每次清洗操作都应该记录下原始行数和清洗后行数、删除的规则命中了多少次。这样当你发现某条关键内容在召回里消失时,可以回溯是不是清洗规则杀死了有效信息。我见过有人用激进的规则把包含特殊符号的技术代码全部清掉,结果知识库对代码类问题的召回率直接接近零,这就是“过度清洗”的典型案例。
2.3 大文件与分段读取:别一口气把书啃进去
纯txt的规则文件可能就几百KB,但网文、小说、日志导出的txt动辄几十MB。一次性read()虽然Python能扛住,但后续做清洗、解析、切分时会产生巨大的中间字符串对象,内存和耗时都不可控。更科学的方式是流式分段读取和处理。
def iter_text_chunks(file_path, encoding, chunk_size=8192): with open(file_path, "r", encoding=encoding) as f: while True: chunk = f.read(chunk_size) if not chunk: break yield chunk配合生成器做流水线:读取一个chunk,清理一个chunk,判断是否到达段落边界,攒成逻辑块后再交给后续的解析器。边界的判断标准可以用一个简单规则:遇到连续两个换行符(\n\n)或章节标题模式(如“第X章”)时,强制做一次块切割。这样处理大文件时内存占用能维持在一个稳定水位,而不是随着文档长度线性膨胀。
提示:如果你处理的是几GB级别的超大文本,建议优先考虑用数据库(SQLite或Postgres)存原文,每次只加载分页结果进入记忆体,否则再优雅的解析代码也会被突然飙升的内存打崩。
3. Markdown的结构化解:不要用正则硬刚,试着先把语法树拿下来
Markdown相比txt有了结构:标题、列表、代码块、表格、引用等都有明确的语法标记。这些结构恰恰是RAG切分最需要的“语义边界”。但解析Markdown有一个常见的弯路,就是试图用正则逐条匹配标题符号和列表标记。这个思路在简单场景下能用,一旦遇到嵌套列表、代码块中包含井号、表格行首尾带管道符,正则就会写出充满补丁、难以维护的代码。我的建议是直接上语法树。
3.1 引入Markdown解析器:markdown-it-py的定位与优势
Markdown的规范相当庞杂,从原始语法、GFM到CommonMark各有差异。用解析库而非正则,本质上是把“怎么理解语法”的工作交给维护者,让你把精力集中在“怎么利用结构”上。Python生态里我常用markdown-it-py,它是JS版markdown-it的官方Python移植,对CommonMark的支持十分完善,还内置了GFM的行尾、表格、任务列表支持。
from markdown_it import MarkdownIt md = MarkdownIt("commonmark", {"html": False}).enable("table") tokens = md.parse(document_text)当md.parse()执行后,你会拿到一个扁平的token数组。它包括了heading_open、inline、fence、table_open、list_item_open等类型。每一种token都带tag、map(起止行号)、children等元数据。利用这套token流,你就可以把Markdown还原成一块块语义明确的“结构快照”。
选择markdown-it-py的另一个原因是它支持md.parse输出map信息,可以拿到每个标题作用的真实行号范围,这对下一步做结构感知的切分极其关键。mistune和python-markdown也各有拥趸,但我建议新手就从markdown-it-py入手,它的token语义最直观,文档也相对完整。
3.2 从token流到语义块:标题层级、代码块与表格的结构提取
token流拿到后,最直接的用法就是遍历它,把文档切成若干个“块节点”。我总结了一个快照式解析的思路:
- 遇到
heading_open时,开启一个新的章节块,记录标题文本和级别(h1~h6)。标题的层级可以构成一棵轮廓树,比如h1是根,h2是子节点,后续的正文归到最近的标题节点下。 - 遇到
fence或code_block时,作为独立块保存,并保留info字段里的语言标签。这一步对代码类知识库非常重要,后续可以按语言类型做额外的检索权重调整。 - 遇到
table_open到table_close的token范围时,把整个table合并成一个结构化对象,提取表头行作为列名,表格内容按行组装成二维结构。这比“一行行文本切块”再让模型猜测语义要靠谱得多。 - 遇到
list_item_open时,要考虑嵌套关系。可以用token的level字段判断列表项的嵌套层级,把深层项目归属到最近的父列表项下。
在这之后,你会得到一组Python对象,每个对象至少包含type、content、meta三个字段。type标明它是标题、段落、列表、代码还是表格;content是从token里提取的纯文本;meta则是结构元数据,比如标题层级、语言标签、行列数。
这一步的价值在于:**后续的切分策略不再面对“连续的文本流”,而是面对一组边界清晰的语义块。**你可以自由组合这些块,而不是拿着字符串去数字符。
3.3 图片与链接的取舍:RAG知识库的图像处理边界
Markdown里经常出现和[链接](地址),RAG文本解析时对这两种元素需要区分处理。链接文本本身是有语义的,应该保留显示文本,比如[RAG论文](http://...)可以清洗成“RAG论文”。图片呢,如果是带alt文本的,至少要把alt文本留下来——因为很多情况下alt文本本身就是一段描述性文字。但要注意,纯文本解析并不等于多模态理解。如果你希望RAG系统能回答“这张图里写了什么”,那需要额外引入OCR或视觉模型,这一步通常不会发生在通用文本解析层,而是在上层单独做图像管道。
我的经验是,在第一个版本里先把图像按“占位符+alt文本”的方式暂存,不做真正的内容抽取。跑通端到端链路之后,再决定是否要接OCR。这样能有效控制初版RAG系统的复杂度。
4. 让结构反哺切分:从“语义块”到“聚合分块”的操作路径
好,解析完Markdown,我们手上有了结构化的物料。接下来就是整个数据工程里最需要经验的一步:怎么把它们拼成一个个chunk。这步做得不好,前面的解析全白搭。这一节我会从最底层的切分逻辑讲起,再给出一套可落地的“语义块→聚合分块”操作路径。
4.1 为什么“按字数硬切”在中文场景下特别伤
很多人在初版RAG里会写如下逻辑:读取全文,按固定长度如500字切成若干段,重叠50字。这种方案实现简单,运行速度极快。但它有几个致命问题:
- 打断段落。自然段落可能在200字就结束,而硬切逻辑只在第500字处切断,导致一个chunk的前半段属于上一话题,后半段是下一话题。
- 打断列表和表格。一个5行的表格只要前4行落在chunk A,第5行就会跑去chunk B。检索命中时,模型看到的是一张“不完整的表”。
- 引入语义无关的重叠。重叠设计的初衷是防止内容恰好落在边界被忽略,但它也会让相邻chunk高度相似,向量检索时容易重复召回同一信息,白费token。
中文场景下还会额外遇到一个麻烦:中文句子没有天然空格令牌,硬切边界通常落在句子中间。后续向量化时一个完整的“条件判断”被拆断了,无论用哪个embedding模型都很难复原语义。这也是为什么很多中文知识库的召回效果总感觉“差口气”。
4.2 结构感知的分块规则:以标题为界,以完整块为优先
用Markdown解析得到的语义块,天然给出了分块的优先边界。我的切分规则设定如下:
- 标题优先原则:每次遇到新标题(h1/h2)时,强制结束当前chunk。即使当前chunk的字数还没达到目标长度,也不再跨标题硬拼。这样保证每个chunk隶属于连贯的小节,召回时不会跨越话题边界。
- 块完整原则:表格、代码块、列表项内部不切割。如果某个块本身超过了目标长度,优先做内部二次切分,但要先尝试按行切,比如表格按行、代码按函数或缩进块切。
- 段落聚合原则:分块不只是“切”,更关键的其实是“聚合”。语义块往往很短,一个标题下可能只有两个短段落,一共150字。按“聚合”的思路,可以连续收集同一标题下的多个块,直到达到目标长度下限。
我经常用一个简单的贪心策略跑这个逻辑:维护一个“当前chunk缓冲”,拿一个语义块往缓冲里放;放入后若长度到达目标窗口,就切出去;若下一个块属于新标题,则无论长度如何都强制切出。这个过程实现起来大概100行左右,是整个数据导入管线里性价比最高的一环。
def build_chunks_from_blocks(blocks, max_len=750, min_len=350): chunks, current = [], [] current_len = 0 for block in blocks: # 新标题出现,结束当前chunk if block["type"] == "heading" and current: if current_len >= min_len: chunks.append(("".join(current), current[0]["meta"])) current, current_len = [], 0 current.append(block["content"]) current_len += len(block["content"]) if current_len >= max_len: chunks.append(("".join(current), current[0]["meta"])) current, current_len = [], 0 if current: chunks.append(("".join(current), current[0]["meta"])) return chunks4.3 元数据设计:把标题链、来源与序号写进向量检索的过滤字段
切出来的chunk如果不带元数据,就是“一堆失去身份的文字片段”。向量数据库里的filter、rerank都需要元数据来缩小范围。我设计的元数据结构一般长这样:
{ "source": "path/to/file.md", "title": "故障处理流程", "heading_chain": ["运维手册", "故障处理", "数据库连接失败"], "block_type": "paragraph", "chunk_index": 3 }heading_chain这个数组是结构化解的核心产出,它记录了当前chunk所在的完整标题路径。检索时用户问“数据库连接失败怎么办”,向量召回命中chunk后,大模型能通过heading_chain知道它的上下文归属;如果在多级知识库里做过滤,也可以直接用heading_chain[0]限定一级目录。设计这个字段时有一个重要原则:不要太长。标题链建议截取到二级标题,否则embedding模型会把后半段标题噪声也编码进向量,反而不利于语义聚焦。
5. 实测对比:三种解析切分方案谁更值得用
理论讲再多,不如跑一次实测。我在公司内部的语料库上做过一组对比测试,语料来源是一批技术文档Markdown文件,大小从几KB到几百KB不等,总文档数约300份。这里用了一个简单的召回检验法:人工准备40道问答对,去看每个方案下答案所在chunk的召回率,以及高亮冗余token的占比。
5.1 测试环境与语料构成说明
测试的硬性环境如下:文本处理用Python 3.10,向量模型用text-embedding的通用版本,向量库是基于内存的演示版本,检索方式为TopK=4。语料里包含三类典型内容:操作手册(步骤多、列表密集)、技术FAQ(短问答为主)、API参考(代码块和表格占比高)。这个语料分布刻意覆盖了RAG知识库里最常见的几种内容形态。
5.2 三个候选方案的设计差异
三个方案的控制变量是切分策略,解析环节保持一致。
- 方案A:纯字数硬切。这是基线方案,按500字切分、重叠50字,完全忽略Markdown结构。
- 方案B:正则规则切分。用正则识别标题、列表、代码块,尽量不在标题处切断,但如果语义块过大,继续按字数切割。这个方案不需要依赖解析库,是很多教程里的过渡方案。
- 方案C:结构感知切分。用markdown-it-py解析成语义块,再做聚合分块,窗口定为目标长度750字、下限350字,标题强制边界。
每个方案输出的chunk都走相同的embedding和检索逻辑,保证只有分块这一环节不同。
5.3 评测结果:召回率、冗余token与实现成本
我整理了三个方案的结果对比:
| 方案 | 平均chunk数/文档 | 有效召回率 | 冗余token占比 | 实现成本 |
|---|---|---|---|---|
| 纯字数硬切 | 17.2 | 61% | 约18% | 最低,半天可写完 |
| 正则规则切分 | 14.6 | 72% | 约12% | 中等,需大量规则维护 |
| 结构感知切分 | 11.3 | 86% | 约6% | 较高,前期解析+聚合逻辑更复杂 |
有效召回率代表“正确答案所在chunk被TopK命中的比例”,这是RAG检索最核心的单一指标。结构感知切分把基线从61%拉到了86%,代价是多写了几百行代码,但换来的是更少的chunk数量、更低的冗余token。在真实生产环境中,冗余token不仅增加向量存储成本,还会在最终生成阶段稀释大模型的注意力。
还要提一个对比里体现不出来的细节:问题越具体,结构感知方案优势越大。比如问“某接口的返回值类型”,方案C能精确定位到API参考小节里的代码块段落;方案A则可能返回整页的连续文本切片,命中代码块的概率低很多。
5.4 结合业务场景的选型建议
如果你的知识库只有几十个txt文件,且内容以纯问答为主,那方案A也能凑合跑。只要把重叠设置小一点、切分长度控制在400-600之间,再用后续的rerank环节兜底,效果不会太差。
但如果你的知识库包含标题层级明显、代码块密集、表格丰富的Markdown或网页正文,我建议直接上方案C。前期投入多一些,后期迭代检索策略时能明显省力。尤其是当你准备在知识库里做“按目录过滤”的功能时,没有结构化的heading_chain,这个功能几乎无从下手。正则方案作为过渡没问题,但别长期依赖——每来一种新的Markdown写法,正则就要打一个补丁,维护成本会持续累积。
6. 绕不开的边界情况与我的处理习惯
最后想集中聊一下处理txt和Markdown时经常会出现的几种边界情况,这些在教材和官方文档里很少被提及,但实际项目中几乎一定会遇到。
6.1 非法字符、空文档与极端长行的兜底策略
文本数据里什么都有可能出现:空文档、内容只有几行的“伪文档”、单行有几万个字符的压缩数据。我的兜底策略是自上而下的:
- 解析前先校验基础属性。文件小于10字节或清洗后为空,直接跳过并写入清洗报告,不进入切分管线。
- 对极端长行做“软分割”。不按字符硬断,而是按中文标点(句号、问号、感叹号)、分号、逗号依次寻找次优断点。找不到任何可断点时,才允许在固定字符处切割,并在元数据里标注
cut_by_force。 - 用白名单校验非法字符。有些txt里混入了异常的控制字符,占位且不可打印,这些可以用
unicodedata.category(char)进行过滤,只保留常见类别的字符。
这些兜底逻辑不会增加多少代码量,但能显著减少下游向量化时的“神秘报错”。我遇到过连续两次embedding服务调用失败,排查到最后都指向同一份包含异常字符的txt,就是因为前期的清洗层没有做字符级校验。
6.2 Markdown与txt混合目录的编排策略
如果你同时导入txt和Markdown两种格式,建议在导入时统一注册文件指纹,避免同一个文件被两次解析。文件指纹可以是二进制内容的MD5,也可以是“文件名+大小+修改时间”的组合。目录轮询时发现相同指纹,直接跳过去,并在元数据里记录“已存在”标志。
另外,很多txt文件名本身蕴含分类信息,比如日志导出文件常以日期命名,小说文件常以书名命名。这些文件名可以作为一级分类写入chunk的元数据。通过这种编排,后续即便切换到其他格式,比如Word或PDF,也只是在解析层增加一个适配器,上游的元数据模型和切分策略完全不用动。
6.3 解析日志与溯源机制:出了问题知道去哪查
代码写完不是终点,线上一旦出现“某个问题老召回旧版本的文档片段”,你需要能快速定位是哪份文档、哪个chunk引入了这段内容。我一般会在导入阶段为每份文档生成一行解析日志,记录文件名、处理时间、清洗规则命中次数、切出的chunk数、最终写入向量库的批次号。日志不需要单独搭系统,拍平写进SQLite就有奇效。
溯源机制的核心是“一条链路贯穿到底”:从用户问题开始,查到召回的chunk,从chunk的chunk_index和source倒推回对应文档的原始位置。这个能力在知识库上线初期几乎用不到,但等你开始做知识更新或删除操作时,没有溯源链路就无法做精确的增量刷新。
7. 从txt到Markdown固化下来的通用方法论,和下一步还能做什么
经过这一轮实操,我手里沉淀出一套可以复用到其他格式的骨架流程:读取时统一编码识别,清洗时三层过滤并记录日志,解析时优先使用语法树而非正则,切分时坚持“结构优先、聚合为辅”的思路,最后给每个chunk挂上带层级关系、来源和序号的元数据。这套流程里没有一步是绑定txt和Markdown专用格式的——PDF只需把解析层换成版式提取,Word只需把解析层换成样式还原,后续的清洗、切分、元数据模型依然可以沿用。
下一篇我大概率会继续沿着这个系列往下写,方向有三条候选:一是PDF解析的版式问题,涵盖多栏布局、表格边框提取和扫描件OCR;二是长Markdown表格和复杂嵌套列表的切分细节;三是关于chunk长度到底设为多少更适合不同embedding模型的基准测试。这三条里你想先看哪条,可以在评论区告诉我。
最后分享一个个人体会:做RAG数据工程,真正拉开差距的,往往不是模型或向量库的先进程度,而是你对语料的颗粒度理解有多深。结构化解这件事没有一劳永逸的标准答案,但它为后续所有的检索优化铺平了路。当你把文本块变成了带结构的语义单元,很多看似棘手的调优问题,都会从“黑盒尝试”变成“有迹可循”。