
直接上一线结论RAG 项目十个里有九个最终效果拉胯都是死在数据导入这一步而不是模型选型。你别不信我接手过的几个 rag 知识库项目早期排查问题十次有八次翻车都翻在 txt 编码乱码、Markdown 结构被当纯文本吃掉、分段切得稀碎导致检索召回语义漂移这类“低级”问题上。所以我想把这个系列写透第一篇就聚焦在最基础也最容易被忽略的入口txt 和 Markdown 这两类通用文本格式的导入、清洗与结构化解。这篇攻略适合谁适合刚搭好 rag 框架但不知道怎么喂数据的初学者也适合已经跑通 demo、但召回质量一直上不去的开发者。我会把从拿到一个 txt 或 Markdown 文件开始到最终生成高质量 chunk 的完整链条拆开每个环节都讲清“为什么这么干”和“坑在哪”并且给出一套可以直接复制到项目里的处理思路。不绕弯路直接开始。1. 内容整体设计与思路拆解1.1 为什么文本类文件是 RAG 数据导入的第一道坎但凡你用过 rag 知识库肯定会有这种感觉模型本身的能力早就够用了检索不到、检索不准才是真正的瓶颈。而检索的质量完全取决于你喂给索引的数据长什么样。txt 和 Markdown 是 RAG 最基础的数据来源几乎每个知识库都绕不开但这两个格式恰恰是最容易被“想当然”处理的。很多人拿到 txt 就直接file.read()丢给切分器拿到 Markdown 就当普通文本按长度硬切。这么干短期好像能跑但等到知识库规模一上来或者文档结构复杂一点各种怪问题就会接踵而至。txt 的编码、换行符、不可见字符、无结构的大段文字Markdown 的层级、代码块、表格、链接每一个都是能影响检索效果的细节。我这套方案的核心思路是把“数据导入”分为两条路并行推进。一条是通用文本路线专门处理 txt 这一类纯文本核心动作是“编码归一 清洗 语义分段”另一条是结构化路线专门处理 Markdown 这一类带标记的文本核心动作是“层级解析 结构感知分段 元数据注入”。两条路最终汇合到同一个出口生成带有丰富 metadata 的 text chunk供 embedding 和索引使用。1.2 从“能读”到“读得好”的三个关键转变很多教程讲 RAG 数据导入通篇就是“读文件-切分-embedding”三步好像这活儿特别简单。但实际做下来你会发现真正影响质量的是三个容易被忽略的转变第一个转变是从“乱码容忍”到“编码归一”。txt 文件看起来都一样实际上可能是 UTF-8、GBK、GB18030、BIG5 甚至 UTF-16 编码。如果你不做归一化后面所有环节都会带着乱码的隐患。第二个转变是从“按长度硬切”到“按语义边界切”。固定长度切分是最快的但也是质量最差的它会无情地拆散段落、句子甚至一个完整的概念。第三个转变是从“纯文本处理”到“结构感知处理”。Markdown 的标题、列表、表格、代码块本身就在传递语义边界信息如果这些信息被忽略等于盲人摸象。这篇文章就是围绕这三个转变展开的。我会先用一个章节讲通用文本的导入与清洗再用一个章节讲 Markdown 的结构化解中间穿插大量可以落地的参数配置和代码片段。所有的经验都来自实际项目里的反复调试不是教科书式的理论推导。2. 通用文本txt导入编码、清洗与分段策略2.1 编码识别与归一化乱码问题的唯一解先说最磨人的编码问题。Windows 记事本保存的 txt 默认是 ANSI国内就是 GBKmacOS 和 Linux 下一般是 UTF-8还有一些老系统导出的是 UTF-16。如果你的 RAG 项目从多个渠道收集文档编码混用几乎是必然的。我之前做过一个项目客户丢过来两百多个 txt 文件表面上看全是中文文档结果查出来至少五种编码混合在一起。当时用的方案是charset-normalizer这个库来做编码探测然后统一转为 UTF-8。这里有一个经验之谈用chardet是老牌选择但实测下来charset-normalizer对中文的识别准确率更高尤其在短文本和混合编码场景下优势明显。实际的编码归一化流程是这样设计的from charset_normalizer import from_bytes def normalize_text_file(file_path): # 读取原始字节流而不是直接按文本读 with open(file_path, rb) as f: raw f.read() # 编码探测 result from_bytes(raw).best() encoding result.encoding if result else utf-8 # 解码为文本然后转为 UTF-8 text raw.decode(encoding, errorsreplace) # 统一换行符这一步很重要 text text.replace(\r\n, \n).replace(\r, \n) return text注意一个细节很多教程会直接让你用open(file_path, encodingutf-8)然后报错了再换成gbk。这种试错法在小样本下勉强能用但在批量导入场景下完全不现实。你必须用字节流去探测再解码。另外errorsreplace这个参数很关键它保证即使有识别不准确的地方也不会让整个导入进程崩溃最多是出现这样的占位符。后续清洗环节再处理这些异常字符。还有个容易踩的坑UTF-8 BOM。有些文件开头有\ufeff这个不可见字符不处理的话第一个 chunk 的开头会莫名其妙多一个字符。轻则在预处理时显示为一个空字符重则影响 embedding 结果的精度。归一化时记得text.lstrip(\ufeff)。2.2 数据清洗规则哪些字符必须死哪些字符必须留编码归一化完成之后紧接着是清洗。这里不是让你把文本洗得干干净净变成“标准文章”而是要在“保留语义”和“去除噪声”之间找到平衡。我总结了一套清洗规则的优先级控制字符ASCII 0-31 之间的直接删除它们没有任何语义价值只会干扰计算。零宽空格\u200b、零宽连接符\u200d这类不可见字符必须清除因为它们会导致看似相同的中文分词结果不同。空行压缩多个连续换行合并为最多两个。保留两个换行的原因是它天然是一个段落边界信号。统一全角半角如果你的文档会混入英文标点建议把全角逗号、句号统一为半角或者反过来统一为全角。中文语境下统一为全角更符合习惯但如果你后续还要做英文处理就统一为半角。关键是全项目保持一个标准。特殊标记处理页码、页眉页脚、水印文字这类内容如果有规律可循可以用正则批量剔除。这里特别提醒一下清洗不是越狠越好。有些教程会让你把标点符号全删了这绝对是灾难性的操作。标点是语义边界的重要信号删掉之后切分器会失去大量有效的边界信息。还有的教程建议把所有换行都改成空格这也是错的段落边界没了长文本召回质量会明显下降。清洗完成后建议做一个校验按行统计文本长度分布看看有没有异常的长行或短行。异常长行通常是文件里有未断行的表格或代码需要单独处理异常短行可能是无意义的碎片。这个统计步骤能帮你快速发现导入数据的结构特点为后续分段参数选择提供依据。2.3 分段策略递归字符分段与语义边界的平衡分段是通用文本导入的核心环节。我的建议是直接使用递归字符分隔器RecursiveCharacterTextSplitter而不是固定长度切分。两者的区别在于固定长度切分不管三七二十一每满 N 个字符就咔嚓一刀非常容易把一个完整句子或段落拦腰斩断递归字符分隔器则是按照优先级顺序尝试用不同级别的分隔符来切优先保证语义完整性。实际项目中我常用的优先级顺序是段落边界\n\n- 换行符\n- 句号/问号/叹号 - 分号/逗号 - 空格 - 字符级切割这个顺序背后的逻辑是段落边界是语义最完整的切分点其次是句子再其次是短语。只有当上一级分隔符找不到合适的切分位置时才会降级到下一级。关于 chunk 大小的参数我踩过的坑比较多这里直接分享实测经验。很多教程说 chunk_size 设 500、1000这对英文可能还行但中文的实际语义密度远高于英文同样 500 token 中文能承载的信息量比英文多得多。我之前有个项目一直觉得召回结果碎片化严重后来把 chunk_size 从 500 调到了 800chunk_overlap 从 50 调到了 150效果立刻好了很多。但这不是一个固定值你需要根据自己的 embedding 模型的最大输入长度和文档特点来定。这里给一个具体可参考的起步配置from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( # 按字符计中等偏大的块有利于中文语义完整 chunk_size800, # 重叠部分用于保持跨块的上下文关联 chunk_overlap150, separators[\n\n, \n, 。, , , , , , ], length_functionlen, )注意length_function默认是按字符数计算的。如果你用的是 OpenAI 等 token 计的模型建议换成tiktoken或者对应的 tokenizer 来计算长度避免因为一个 chunk 实际 token 数超限而被 API 拒绝。关于 overlap 这个参数我见过很多人要么设成 0要么乱设。设成 0 会导致上下两个 chunk 之间完全没有语义衔接尤其是当你把一个完整段落切成了两半时后半段的信息会因为缺乏上下文而检索不到。设得太大又会导致大量冗余 embedding浪费算力和存储。我的习惯是先按 chunk_size 的 15%~20% 来设然后通过召回评测不断微调。另外overlap 的位置是固定尾部回退不是动态计算所以选完参数后最好抽样看几个分段的实际效果确认切得是不是自然。3. Markdown 结构化解把标记变成检索的杠杆3.1 为什么 Markdown 不能当纯文本处理现在市面上的 RAG 项目里Markdown 已经是继 txt 之后的第二大文本格式了。尤其你用了一些网页转 Markdown 的工具、将 Notion 或语雀文档导出为 Markdown 之后它的占比会快速上升。但很多人处理 Markdown 时依旧沿用了 txt 的流程这就白白丢掉了大量结构信息。结构和语义是 Markdown 最有价值的资产。一级标题代表着文档的主题分割二级标题代表章节列表代表并列关系表格代表结构化事实代码块代表技术细节。这些是你调试检索召回时最重要的锚点。我把这种处理方式叫做“结构感知解析”。举个例子一个 5000 字的 Markdown 文档如果按纯文本硬切你会得到 5~6 个 chunk每个 chunk 里可能横跨了好几个二级章节。用户在问答时问到第三节的内容检索系统可能命中的是第四、第五节的内容因为它们靠得太近embedding 向量被稀释了。但如果你先按二级标题把文档切成几个大段每个大段再独立切分召回的命中率会直线上升。有人可能觉得这不是相当于人工把文档拆好吗没错RAG 的数据处理阶段就是需要一定程度的“人工规则”。这个规则就是 Markdown 的语法本身。3.2 手写一个轻量级 Markdown 解析器骨架我不太推荐一上来就上重型解析框架因为 Markdown 的变体太多了标准库之外的特性很可能和你项目的实际文件对不上。我自己的习惯是写一个只有十几个函数的小解析器专门处理我们项目里 Markdown 文件的真实特征边跑边补。解析骨架分为三层第一层是按标题切分确定文档的层级树第二层是识别块级元素代码块、表格、引用块这些元素内部不能随意二次切分第三层是行内元素清理链接、加粗、行内代码。按标题切分的逻辑很简单我直接给核心代码思路import re HEADING_PATTERN re.compile(r^(#{1,6})\s(.*)$) def parse_headings(lines): 返回标题层级结构形如 [(level, title, start_line, end_line)] sections [] current { level: 0, title: root, start: 0, content_lines: [] } for idx, line in enumerate(lines): match HEADING_PATTERN.match(line) if match: # 先把当前 section 结算 current[content_lines].append(lines[current[start]:idx]) sections.append(current) # 开启新的 section current { level: len(match.group(1)), title: match.group(2).strip(), start: idx 1, content_lines: [] } # 最后一块也结算 current[content_lines].append(lines[current[start]:]) sections.append(current) return sections[1:] # 去掉 root这个结构的好处是当你把 Markdown 喂给切分器时可以把level和title直接作为 metadata 注入到每个从中切出的 chunk 里。这样检索的时候用户问“第三章讲的是什么”系统甚至可以直接通过 metadata 过滤来定位候选段落而不是纯靠向量相似度。块级元素的识别也需要注意。代码块的显著特征是包裹内部内容完全不能拆开因为一旦跨块切分半个函数体在 chunk A、半个在 chunk Bembedding 结果就会非常诡异。表格的识别稍微复杂一点行与行之间是语义整体我的做法是先将表格按行读取然后拼接成一个带分隔符的描述性文本比如“列名: 值; 列名: 值”这种形式。这样做的原因是纯 Markdown 表格里的管道符|对于 embedding 模型来说是噪声转成描述性文本后语义会更清晰。3.3 结构化元数据设计让 chunk 自带“身份信息”Markdown 结构化处理最大的收益就是你可以给每个 chunk 打上结构化的 metadata。这份元数据相当于 chunk 的“身份证”RAG 检索时既可以用它做过滤又可以用它做溯源。我实际项目中常用的 metadata 字段如下字段说明示例source原始文件路径或 URLdocs/guide/installation.mdheading_h1一级标题安装指南heading_h2二级标题环境要求heading_h3三级标题Python 版本heading_path完整标题路径用连接安装指南 环境要求chunk_index该 chunk 在章节内的序号3total_chunks该章节总 chunk 数7file_type文件类型标记markdown这套 metadata 的威力在于两个方面。第一它让检索阶段可以做“标题路径过滤”用户的问题如果明显指向某个章节可以直接限制候选范围。第二它给你留了回溯的余地当答案拼接完发现有误你可以顺着heading_path快速定位到原始文档的对应章节人工核对。有一个细节我想重点说一下heading_path 的构建。嵌套越深的标题路径越长。我建议path里只保留到二级标题h2就够了因为超过二级标题后路径过长反而会稀释语义。但如果你想做非常细粒度的结构化过滤保留到三级四级也没问题完全取决于你的文档规模和检索方式。4. 实操过程从原始文件到可索引的 chunk 全流程4.1 整体流程与工具链选型前面讲完了思路现在把整个流程串一遍方便你直接照着搭。我这里用一个实际项目的处理管线为例包含文件遍历、格式识别、内容清洗、结构化解、分段、metadata 注入五个环节。工具链方面我建议先不要引入太多重型框架。用pathlib做文件遍历用charset-normalizer做编码探测用markdown-it-py做 Markdown 解析如果想深入研究也可以用mistune这类更轻量的库用langchain-text-splitters或者自己写分段逻辑。如果只是做一个轻量 RAG 服务这些依赖完全够用。等到你确实需要处理 PDF、扫描件等复杂格式再考虑引入unstructured或pypdfium2等重型库。完整的处理流程可以用下面的伪代码串起来from pathlib import Path import json def process_document(file_path: Path): # 1. 格式识别 suffix file_path.suffix.lower() # .txt / .md / .markdown # 2. 读取并归一化编码 raw_text normalize_text_file(file_path) # 3. 清洗 cleaned_text clean_text(raw_text) # 4. 结构化解与分段 if suffix in [.md, .markdown]: chunks parse_markdown_with_structure(cleaned_text) else: chunks split_txt_by_semantics(cleaned_text) # 5. 注入 metadata 并返回 return attach_metadata(chunks, file_path, suffix)4.2 关键步骤一用 markdown-it-py 解析 Markdown 并构造区块树如果你不想完全手写解析器markdown-it-py是一个很顺手的库。它本身是markdown-it一个非常流行的 JS Markdown 解析器的 Python 移植版支持 CommonMark 规范扩展性也不错。它会把 Markdown 解析成 token 流你可以从 token 流里准确识别出标题、段落、代码块、表格、列表等结构。我的实际用法是这样的先把文档 parse 成一个 token 列表然后遍历 token 构建层级树。遇到heading_open就标记当前层级遇到fence代码块就把整块内容单独存储遇到table就提取表格内容并转为描述性文本。from markdown_it import MarkdownIt md MarkdownIt() def markdown_to_blocks(text): tokens md.parse(text) blocks [] current_section {heading: root, level: 0, content: []} for token in tokens: if token.type heading_open: # 根据 token.tag (h1, h2...) 确定层级 level int(token.tag[1]) next_token tokens[tokens.index(token) 1] title next_token.content blocks.append(current_section) current_section {heading: title, level: level, content: []} elif token.type fence: code_content token.content current_section[content].append(f[代码块开始]\n{code_content}\n[代码块结束]) elif token.type inline: current_section[content].append(token.content) blocks.append(current_section) return [b for b in blocks if b[content]]这段代码是为了展示思路实际使用要处理更多 token 类型。但它的优势已经很明显了你能拿到“章节标题 该章节下的文字内容 代码块”这种清晰的结构而不是一坨混杂的 Markdown 源码。另一个关键的细节是链接处理。Markdown 的链接语法[链接文字](url)是结构的一部分但 embedding 模型通常不需要 URL 带来的语义。我的做法是提取链接文字丢弃 URL但如果是兴趣知识库或者技术文档URL 可以存入 metadata 供溯源。除了链接加粗和行内代码的标记字符也需要剥掉只保留纯文本内容。4.3 关键步骤二分块参数与 metadata 注入的搭配现在有了“结构化分块”的输入我们接着说分块参数怎么和 metadata 配合。我是这样设计的对于每个由 Markdown 标题划分出的 section先判断它的字数。如果字数少于chunk_size * 0.7就直接作为一个完整 chunk不切分。如果字数超过阈值就按照句子边界进行二次切分但切分时保留章节标题作为heading_path元数据注入到每个子 chunk 中。为什么这样设计因为有些章节确实很短比如“环境要求”下面只有三行字硬切会破坏完整性。这时候整个章节作为独立 chunk反而能更精准地表达它要传递的核心信息。另外这种策略也避免了大量无意义的切割节省了 embedding 和存储成本。metadata 注入的代码架构def attach_metadata(chunks, file_path: Path, file_type: str): result [] for idx, chunk in enumerate(chunks): result.append({ text: chunk[text], metadata: { source: str(file_path), file_type: file_type, heading_path: chunk.get(heading_path, ), chunk_index: idx, } }) return result这个结构可以很自然地接上向量数据库的写入接口。比如 Chroma 的add_documents或者 FAISS 的add_texts。metadata 直接作为 filter 条件存储后续问答服务就能靠它做过滤。4.4 参数调优chunk_size 该如何用数据说话关于 chunk_size 到底设多少我从不拍脑袋我会在实际语料上做一个统计实验先把所有文档清洗完然后跑一个“句子长度分布”和“段落长度分布”的统计。比如你发现 95% 的段落长度在 300~700 字之间那 chunk_size 设 800 是合理的因为大部分段落可以完整放进一个 chunk。如果你发现大量段落超过 1000 字那就要考虑是否在句子边界上切分。这里我分享一个调优小技巧选 20 个有代表性的文档手工构建 20 个测试问题然后用不同 chunk_size 跑同样的 RAG 流程比较召回准确率。这比单纯调大调小参数要高效得多。我在上一个项目中就是用这种“小样本标注参数扫描”的方法把召回率从 68% 提到了 84%。另外embedding 模型的选择也会直接影响 chunk_size 的上限。如果你用的是 OpenAI 的text-embedding-3-small它支持 8191 token 的输入那你的 chunk 可以适当设大一点。但如果你用的是本地向量模型比如bge-small-zh输入长度往往只有 512 token对应中文大概 350~450 字那 chunk_size 设在 500 字符左右更合适。说白了chunk_size再大不能超过模型的输入上限这是硬约束。5. 常见问题与排查技巧实录5.1 编码与乱码问题速查处理 txt 导入的时候遇到最多的问题就是乱码。我整理了一个排查思路按顺序执行基本都能定位现象可能原因解决思路一打开全是或问号编码识别错误或者原始文件损坏用charset-normalizer重新识别检查原始字节流中文正常但英文后多了奇怪的符号UTF-8 BOM 没有剥掉解码后lstrip(\ufeff)同一文件里一段中文一段英文乱码混合编码文件这种情况最麻烦建议按证据标记人工处理不要自动清换行全都消失了变成一行长文原始文件是 CR 换行或 LF 换行没做统一先\r\n和\r都替换为\n再处理这里特别强调不要在解码阶段用errorsignore这会静默丢弃异常字符导致语义缺失。用errorsreplace至少能让你看到哪些位置有问题。5.2 分段边界切错位置语义被甩到两头的处理分段中最常见的错误是一个完整句子被硬生生切在两个 chunk 之间。比如 chunk A 的结尾停在“根据”“张三”在 chunk B 的开头。这种情况导致检索时系统只能看到前半句或后半句召回质量明显下降。排查的方法是打印每个 chunk 的首尾 30 个字符检查是否有“半句话”现象。我看到过太多人直接看 chunk 数量就完事完全不关心切分质量。解决方案有两种。第一是调大chunk_overlap它有兜底作用。第二是改用更强的边界分隔符比如优先按\n\n切因为段落边界通常对应一个完整语义单元。如果段落很长再降级到\n最后才考虑句号。这里的顺序很重要千万不要一上来就用句号切否则会把一个段落拆得支离破碎。5.3 Markdown 表格和代码块被拆碎导致丢语义Markdown 文档里最脆弱的两个结构就是表格和代码块。表格被拆开后每个 chunk 里只剩一列或一行完全失去了表格的“列名-值”对照关系代码块被拆开后上下文信息全丢检索到一半函数也没意义。解决方案我前面已经提过就是要把这些块级元素作为“不可分割单元”来对待。具体到代码实现上解析 token 流时遇到fence或table就把整个元素吞进去作为一个独立 block 存储。切分器只能在这些 block 之外工作block 内部绝不介入。如果表格非常大超过了 chunk_size 的上限那你只能做“表格摘要化”处理即把表格每一行抽取为一个结构化描述比如“字段: 值; 字段: 值;”。这比直接切碎表格要好得多因为每一行仍然保有完整的字段对应关系。5.4 元数据丢失导致无法溯源到原文最后一个高频问题是到了问答系统上线后用户对某个答案存疑但系统完全无法定位答案来自原始文档的哪个位置。很多人在导入阶段没做 metadata 记录导致后期排查困难。我的习惯是无论用什么向量库在存入 chunk 时一定要带上source和heading_path这两个字段。如果是 Markdownheading_path就是一路的标题路径如果是 txt就用source加上chunk_index来定位。不要嫌 metadata 占空间它对后期调试的价值远超成本的付出。6. 结尾一点实操心得与系列预告这个系列写到这里我个人最大的感受就是RAG 数据导入不是一个“跑通即可”的环节它需要你在“工程效率”和“语义保真”之间反复权衡。我见过不少项目把精力全投在调模型和调 prompt 上最后效果上不去回头一看才发现是数据入口已经烂了。我个人现在做 rag 知识库都会在数据导入阶段沉淀一套标准的检查脚本编码检测、空行统计、段落长度分布、chunk 抽样预览每次都跑一遍再进向量库。这套脚本救了我太多次。最后再分享一个实用小技巧导入完成后可以用一个自问自答的测试集去验证召回效果。准备 10 到 20 个只可能从知识库某个特定位置获取答案的问题逐个跑一遍看系统能不能命中正确的heading_path。这个方法虽然土但比任何指标都直接。下一篇我会重点聊结构化更强的数据源比如 PDF、Office 文档和思维导图也会涉及表格抽取和 OCR 兜底方案。如果你也在做 rag 知识库先把 txt 和 Markdown 这两类通用文本处理好后面的路会顺很多。