
做RAG项目到今天我有个很强烈的感受前端对话框做得多花哨都不如把“数据导入与解析”这一关守住来得实在。很多团队第一步就把txt、docx往向量库里一扔后面检索出来一堆答非所问的东西回头还得排查是哪一步出了问题——最后发现根子全在“解析”上。这个系列我打算从最底层的文件格式讲起第一篇先聊txt到Markdown的通用文本与结构化解。为什么要拿txt开刀因为不管你的文档仓库里有多少PDF、Word、网页它们最终都可以被转出纯文本而txt恰恰是最容易入手、也最容易暴露问题的格式。这篇文章适合正在搭建RAG知识库、以及被“不知道文档该做成什么样才能喂好模型”困扰的同学。1. 为什么第一步是数据导入与解析而不是先想向量化——txt文本里的五个翻车点1.1 编码问题GBK、UTF-8、BOM一行乱码毁掉一个chunk先讲最常见的编码。国内容户的历史文档绝大多数是Windows上存的txt默认可能是ANSI/GBK到Linux服务器上一读就变乱码。Python的open默认utf-8直接读GBK文件屏幕上会冒出一堆乱码字符。更要命的是这类乱码不会被直接报错它被解析后照样切成chunk、照样embedding然后检索时匹配出毫无意义的内容——比报错还难排查。我见过一个真实案例某个知识库里有几百个txt文件看起来title和正文都分得很好但用户问“合同中的违约金比例是多少”模型只返回一段乱码和无意义的字符串就是编码这一步没处理。要根治可以在读文件后用库做编码探测比如charset-normalizer或chardet把内容统一转成UTF-8再进入后续流程。另一个容易被忽略的是BOM头。UTF-8 with BOM在文件开头有三个不可见字节\xef\xbb\xbf如果转换后没去掉第一个chunk的第一个token就会带着看不见的字符影响embedding的准确性甚至导致这个chunk在检索时和用户query完全对不上。清洗时顺手把BOM去掉是不用动脑但必须做的事。1.2 换行符和“假段落”PDF转出来的txt换行根本不是逻辑断行第二个坑藏在换行符里。Windows的txt用\r\nLinux/Mac用\n如果直接按行处理会出现大量“空行错乱”或者“两行文字拼在一起”的情况。更隐蔽的是从PDF导出的txtPDF里每一行都被强制换行复制到txt之后一个完整的自然段可能被拆成四五行。如果不先做行合并后面按段落切分时就会把“合同甲方为乙方提供……”这类完整语义切断。所以解析前先要做“回行合并”判断某一行末尾是不是句号、感叹号、问号、冒号等结束符如果不是下一行又没有缩进或序号特征就把两行合并。这个规则听起来简单却是我用过最省事、最有效的一步。判断结束符时要注意中英文标点都要考虑比如中文的句号、英文的句点、半角问号、全角问号都要纳入。合并时还要防止把真正的两段话粘连在一起——如果上一行末尾没有结束符下一行却以“第”、“一、”这种标题词开头就不要再合并了。1.3 全角半角、特殊空白符检索时匹配不上的隐形杀手第三个翻车点是不可见字符。全角空格U3000、零宽空格、不间断空格nbsp在屏幕上看起来都和普通空格一样但向量模型和分词器把它们当成不同的token。用户问“OAuth 认证”文档里写的是“OAuth 认证”全角空格语义本身可能没变检索时却经常匹配不上。建议清洗时统一全角字母数字转半角特殊空格统一成普通空格。别小看这个检索效果能差出一大截。很多从网页复制过来的文本还会有连续多个空格、制表符和空行混在一起的情况。我处理时会把连续空白符压缩成一个普通空格再把连续两个以上的空行压缩成一个。这样做的目的是减少后续按空行切段时的误判保持段落结构的干净。1.4 清洗实操一个最小可用脚本把上面三个问题用Python串起来就是一个最小的清洗函数。核心思路读文件时用charset-normalizer探测编码读进来后统一换行符把全角字符、特殊空白符转正做一次“回行合并”然后再按空行切分段落。import charset_normalizer def load_txt(path): raw open(path, rb).read() best charset_normalizer.from_bytes(raw).best() text str(best) # 统一换行 text text.replace(\r\n, \n).replace(\r, \n) # 全角空格与其他特殊空白统一 text text.replace(\u3000, ).replace(\xa0, ).replace(\ufeff, ) # 回行合并行尾不是结束符且下一行不以序号/标题开头则合并 lines text.split(\n) merged [] for line in lines: if not line.strip(): merged.append(line) continue if merged and not is_sentence_end(merged[-1]) and not looks_like_heading(line): merged[-1] line else: merged.append(line) return \n.join(merged) def is_sentence_end(s): return s.rstrip().endswith((。, , , !, ?, ., …, , :)) def looks_like_heading(s): import re s s.strip() return bool(re.match(r^(第[一二三四五六七八九十百0-9][章节篇]|[一二三四五六七八九十]、|\d[\.、]), s))判断“句子结束符”和“看起来像标题”的两个小函数可以按自己语料特点去调整。核心不是函数本身是要有“先清洗再解析”的意识。很多RAG项目在解析阶段翻车就是跳过了清洗直接用raw文本切块。这一步不用做得太完美但一定要做。2. 为什么选中 Markdown 作为中间格式——从 txt 直接切块到底差在哪2.1 固定长度切块的三个反例很多人一上来就按固定token数切块比如每512 token一刀。这样做的后果我总结成三个反例标题和正文被拆开问“第三章管什么”时模型只能看到半截标题列表被拦腰截断第2和第3个要点分属两个chunk检索时只能召回一半代码块和公式被拆碎嵌入模型根本没法对“残缺的代码”做有效编码。反例背后是一个很朴素的道理语言模型检索时召回的是“语义片段”而语义片段天然以段落、节、列表为边界不是在固定的字数和token数上切的。固定长度切块还有一个更隐蔽的问题它会让同一个小节的内容被分配到不同chunk而这些chunk在向量空间里的位置可能相距很远。用户问一个跨小节的问题时检索器要么召回不完整的答案要么召回两个互不相关的片段生成阶段就要靠模型自己拼凑。拼对了是运气拼错了就是一本正经的胡说八道。2.2 Markdown在每个RAG管线里的位置和价值那么Markdown为什么适合当中间格式它本身是纯文本不引入二进制依赖结构标记比HTML轻一个量级标题、列表、表格、代码块都有明确的语法对应解析器可以稳定识别而且LLM普遍对Markdown理解得比较好。在我现在的流程里txt/docx/pdf都会先归一化成Markdown再交给下一个解析层。这样每个源头文件的差异被收敛到一个格式里后续切分逻辑只写一套就够了。还有一个很多人没提到的原因Markdown是“写给人和机器共同看的”格式。人可以直接打开看出问题容易排查机器可以解析成AST用结构信息指导切块。相比之下把txt转成HTML再解析标签太多信息密度低直接存JSON又丧失可读性出了问题不好定位。Markdown刚好处在中间位置。2.3 解析原理txt里的“视觉结构”如何变成Markdown的“语法结构”txt看起来没有结构但人眼觉得它有首行缩进表示分段加粗的字号变化表示标题行首的“-”、“1.”表示列表缩进表示代码块。解析的本质是“把视觉线索翻译成语法的显式标记”。比如某一行文字特别短、后面紧跟长段内容就可能是标题行首是中文序号“一、二、三”或阿拉伯数字加顿号的也常被用作标题。这一步没有绝对标准因为txt本身没有元信息连“哪个是标题”都是个概率问题。但RAG恰恰不需要100%精确的文件还原只需要把主要的层级和区块划出来让后面的chunk边界能贴着结构走就行。我在实际项目中把转换准确率目标定在90%左右剩下的10%靠人工抽检和后续检索结果反馈修正。追求100%还原的工作量不值当而且文档本身的排版混乱可能连原作者都说不清结构。3. 从 txt 到 Markdown 的转换实践标题、列表、代码块的提取与还原3.1 标题识别井号、序号、大小写敏感的三个 pattern我在工程里常用三层识别策略。第一层看到行首已经有Markdown风格的#就直接保留第二层行首是中英文序号“一、”、“1.”、“1.1”且该行比较短就把它转成对应层级的#号#第三层全大写短句也能作为标题候选。识别出来后再手动抽检一遍样本因为某些文档里的“1.1”可能是一个编号清单而不是章节名。标题层级的映射也很关键。“一、”这种一级序号对应H1“1.1”对应H2“1.1.1”对应H3以此类推。但要注意有些文档不按层级走比如直接用“一、二、三”列了十几条平级的内容这时候硬把它们分层反而有害。我的处理办法是如果文档里没有明显的多层结构就把所有识别出的标题统一映射到H2正文内容都挂在同一个层级下检索时至少不会因为层级过深导致上下文丢失。3.2 列表与代码块的还原列表识别相对简单行首是*、-、、数字点、或者中文顿号的连续行在Markdown里统一写成-开头。难的是区分“列表”和“代码块的缩进”。如果一段连续行都有4个空格缩进且中间夹杂着等号、冒号优先当作代码块反之优先当作普通列表。这个判断不保证对所有历史文档都适用我已经接受它会有误差因为RAG不要求还原得和原作者排版完全一致。嵌套列表是另一个头疼点。txt里经常出现用不同数量的空格或制表符表示层级的情况转Markdown时要做缩进层级映射。我的做法是统计每行行首的缩进量按缩进量排序后决定是二级还是三级列表。代码块则要求更宽松只要连续行里出现类似配置文件或代码的关键词如import、def、 {就整块包进三个反引号里。3.3 一个可以直接跑的转换脚本示例这里我给一个简化到能理解、但实际可改的版本。它按行扫描先清洗再判断标题、列表、代码块、普通段落分别加上对应的Markdown标记。def txt_to_md(text): lines text.split(\n) out [] in_code False for line in lines: if not line.strip(): if not in_code: out.append() else: out.append(line) continue if looks_like_code_block(line): if not in_code: out.append() in_code True out.append(line) continue if in_code: out.append() in_code False h detect_heading(line) if h: out.append(# * h line) elif looks_like_list(line): out.append(- line.lstrip(*- )) else: out.append(line) if in_code: out.append() return \n.join(out)真实项目里还需要考虑代码块语言标识、嵌套列表缩进不必一开始就做全。先把最核心的“标题结构”拉出来RAG检索的骨架就立住了。转换完成后我建议立刻打印前100行肉眼检查一遍重点看标题层级是否合理、代码块是否闭合、列表是否串到了正文里。4. Markdown 解析与结构化切分怎么让“章节”变成检索单元4.1 解析器选型对比拿到规范化后的Markdown下一步是把它解析成结构化对象。Python里我常用markdown-it-py、mistune需要批量转换文档时用pandoc。三者的差别主要在扩展语法和速度上。markdown-it-py对GitHub风格的表格、任务列表支持好mistune更轻量pandoc能把Markdown转回docx、pdf适合做格式兼容。对RAG场景来说解析器的核心价值不是渲染成网页而是输出“哪些文本属于哪个标题层级”。选型建议先用markdown-it-py理由是其AST输出稳定后续要提取标题路径和父节点索引都很方便。我这里提醒一点不要用正则硬解Markdown结构。Markdown的嵌套规则比看上去复杂行内代码、转义字符、Fenced代码块都可能干扰正则。用解析器拿到AST从AST里按节点类型提取标题和段落出错概率小得多。4.2 按结构切分 vs 按字数切分一个实际例子切分时我的顺序是优先按标题层级切一个二级标题下的内容不超过chunk上限就直接做一个chunk超过就再按三级标题/列表项/自然段逐级切切到最后还超只剩一个长段落再做滑动窗口。这样出来的chunk天然自带“章节上下文”而不是生硬的字符片断。举一个实际例子一份《使用手册.md》结构是# 第一章、## 1.1 安装、## 1.2 配置、### 1.2.1 数据库配置。按结构切分后安装和配置是两个独立chunk库表配置又是更细的chunk。用户问“数据库配置里的连接池超时时间”可以直接命中一个包含完整配置表的chunk。如果按固定500字切“数据库配置”这个小节可能被切到两个chunk里信息就散了。为了让chunk带上上下文锚点我还会在每个chunk的元数据里记录它的“标题路径”比如第一章 1.2 配置 1.2.1 数据库配置。检索命中后这条路径可以作为引用来源展示给用户也能在生成时作为额外的上下文提示词传给LLM让回答更有依据。4.3 chunk size怎么定结构第一字数第二chunk size不是拍脑袋定的。它首先要受嵌入模型输入长度约束比如OpenAI的text-embedding-3-small是8191 token上限本地bge系列常见是512或1024 token。其次受下游检索精度的约束chunk越大语义越混杂检索命中率下降chunk太小上下文不足生成质量下降。我的经验是把“按结构出来的自然块”放到最大然后用字数/Token数硬上限兜底。一句话先让结构定边界再用模型上限做天花板。实际操作时我会先统计语料里自然块的长度分布把90分位的长度作为兜底上限。如果chunk是8000字的长文本块优先尝试按自然段再切一层还是太长就重叠滑动。重叠我习惯用10%-15%既保证边界信息不丢又不至于让向量库体积膨胀太多。5. 表格、图片、链接和公式——Markdown结构化解的进阶细节5.1 表格两种归宿按检索目地取舍Markdown表格是个特殊存在。它既能被当作文本渲染给人看又能被解析成行和列的二维数据。在RAG里我有两种处理方式如果表格很小直接作为chunk正文的一部分如果表格很大就把每一行拆成类似“表头各列的值”的文本描述这样检索“某个字段是什么”时能精确命中。不要指望向量模型能理解行列之间的运算关系但把它们变成“字段:值”的自然语言描述后效果会好很多。还有一种情况表格里的每一行本身就代表一个独立的知识点比如配置参数表。我会把表格的每一行单独作为一个chunk并把表头信息放进元数据。用户问“timeout参数默认值是多少”时检索到的就是那一行而不是整个大表格命中率和可读性都更高。5.2 图片RAG知识库能存图片吗以及纯文本方案下的图片替代做法很多人问RAG知识库能不能存图片。回答要看“存”的定义如果只是把图片文件放进目录那当然可以但纯文本RAG的embedding模型只处理文本图片本身不会参与检索和语义匹配。所以常规做法是把图片的描述文字alt text、caption、周围的段落文字保留下来图片路径写进chunk的元数据这样至少能保证“提到这张图的上下文”可以被检索到。如果业务真的需要按图片内容检索那就要引入多模态模型或者对图片做分块描述这是另一个层级的方案不在txt到Markdown这个范围里。我在处理带图片的文档时会给图片生成一个占位描述比如[图片架构示意图内容请参考正文第X段]再把图片文件路径存进元数据。这样即使后续想升级到多模态方案也能通过路径把图片重新关联回来不用重跑一遍解析。5.3 链接和公式的特殊处理链接建议保留原文别为了“干净”把URL删掉。RAG检索时URL是重要的溯源字段模型回答后能带着来源链接同时链接文本往往就是关键的实体信息。公式则建议用Markdown的$...$或$$...$$包裹。很多解析器如果不认识数学公式会把公式拆进两个chunk导致嵌入结果完全乱掉。我自己遇到过公式被截断后检索“傅里叶变换”永远命不中对应章节的坑后来在切分器里加了一条规则不给公式块切分权重和代码块一样高。链接的元数据我还会额外存一份“链接目标”字段。这样用户问“相关文档在哪”时模型可以直接从元数据里拿到路径而不需要从chunk文本里猜。还有一点如果一份文档里有大量相对链接入库前要统一转成绝对路径否则换服务器或迁移目录后链接全坏。6. 入库前的自检清单用最少的工作量拦住脏数据6.1 回读验证与异常模式数据导入的最后一步不是embedding而是回读验证。我会写一个小脚本把每个文件转换成chunk后输出文件路径、章节层级树、chunk数量、平均字数并正则匹配常见的乱码字符比如、全角空格残留、异常空行。看到章节树里该有的标题都出现了才算过了第一关。验证脚本还可以检查chunk之间是否有重复或覆盖。比如同一个标题下的内容出现在两个chunk多半是切分器的边界条件出了问题。我会额外打印每个chunk的前50个字符扫一眼就能看出大体结构。如果你有几十个文件不必每个都看随机抽10%就行但抽取规则要固定从每类来源里各抽几个。6.2 我踩过的几个“以为没问题”的坑我踩过最典型的坑有三个一是全角空格没处理检索时“OAuth 认证”和“OAuth 认证”匹配不到二是把代码块当普通段落切开了导致问答里出现残缺的配置项三是把一个超长表格整块塞进chunk超出模型上下文检索结果永远召回不了。每个坑都是在“看起来能跑”的状态下埋进去的到了效果不好又回来查时才是最花时间的。后来我养成了一个习惯每个文件入库后随机抽3个chunk人工看一眼虽然原始但比任何监控都管用。第四个坑是编码误判的连锁反应。有一次我用charset-normalizer探测一个GB18030文件结果被误判成了Big5转换后所有中文全变乱码。从那以后我在探测函数里加了置信度阈值低于阈值的文件宁可跳过也不强行转换。还有一次是回行合并的结束符列表漏了英文冒号导致大量英文段落被错误拼接检索时语义完全错乱。这个系列的第一篇就先写到这。txt到Markdown的转换本质上是把“人眼能看出来的结构”翻译成“机器能解析的结构”这一步做好后面PDF、Word、网页转出来的文本都能复用同一套切分和入库逻辑。下一篇我打算讲PDF的解析包括多栏版面、扫描件OCR和表格还原那些才是真正让人头疼的东西。如果你现在也在搭知识库被文档预处理折磨得想放弃我的建议是别急着上复杂工具先把txt这一关走通你会发现后面每一步都顺很多。