直接上一线结论: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(国内就是 GBK),macOS 和 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, errors='replace') # 统一换行符,这一步很重要 text = text.replace('\r\n', '\n').replace('\r', '\n') return text注意一个细节:很多教程会直接让你用open(file_path, encoding='utf-8'),然后报错了再换成gbk。这种试错法在小样本下勉强能用,但在批量导入场景下完全不现实。你必须用字节流去探测,再解码。另外errors='replace'这个参数很关键,它保证即使有识别不准确的地方,也不会让整个导入进程崩溃,最多是出现�这样的占位符。后续清洗环节再处理这些异常字符。
还有个容易踩的坑: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 调到了 800,chunk_overlap 从 50 调到了 150,效果立刻好了很多。但这不是一个固定值,你需要根据自己的 embedding 模型的最大输入长度和文档特点来定。
这里给一个具体可参考的起步配置:
from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( # 按字符计,中等偏大的块有利于中文语义完整 chunk_size=800, # 重叠部分用于保持跨块的上下文关联 chunk_overlap=150, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], length_function=len, )注意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 B,embedding 结果就会非常诡异。表格的识别稍微复杂一点,行与行之间是语义整体,我的做法是先将表格按行读取,然后拼接成一个带分隔符的描述性文本,比如“列名: 值; 列名: 值”这种形式。这样做的原因是纯 Markdown 表格里的管道符|对于 embedding 模型来说是噪声,转成描述性文本后语义会更清晰。
3.3 结构化元数据设计:让 chunk 自带“身份信息”
Markdown 结构化处理最大的收益,就是你可以给每个 chunk 打上结构化的 metadata。这份元数据相当于 chunk 的“身份证”,RAG 检索时既可以用它做过滤,又可以用它做溯源。
我实际项目中常用的 metadata 字段如下:
| 字段 | 说明 | 示例 |
|---|---|---|
source | 原始文件路径或 URL | docs/guide/installation.md |
heading_h1 | 一级标题 | 安装指南 |
heading_h2 | 二级标题 | 环境要求 |
heading_h3 | 三级标题 | Python 版本 |
heading_path | 完整标题路径,用>连接 | 安装指南 > 环境要求 |
chunk_index | 该 chunk 在章节内的序号 | 3 |
total_chunks | 该章节总 chunk 数 | 7 |
file_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,再处理 |
这里特别强调:不要在解码阶段用errors='ignore',这会静默丢弃异常字符,导致语义缺失。用errors='replace'至少能让你看到哪些位置有问题。
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这两个字段。如果是 Markdown,heading_path就是一路的标题路径;如果是 txt,就用source加上chunk_index来定位。不要嫌 metadata 占空间,它对后期调试的价值远超成本的付出。
6. 结尾:一点实操心得与系列预告
这个系列写到这里,我个人最大的感受就是:RAG 数据导入不是一个“跑通即可”的环节,它需要你在“工程效率”和“语义保真”之间反复权衡。我见过不少项目把精力全投在调模型和调 prompt 上,最后效果上不去,回头一看才发现是数据入口已经烂了。我个人现在做 rag 知识库,都会在数据导入阶段沉淀一套标准的检查脚本:编码检测、空行统计、段落长度分布、chunk 抽样预览,每次都跑一遍再进向量库。这套脚本救了我太多次。
最后再分享一个实用小技巧:导入完成后,可以用一个自问自答的测试集去验证召回效果。准备 10 到 20 个只可能从知识库某个特定位置获取答案的问题,逐个跑一遍,看系统能不能命中正确的heading_path。这个方法虽然土,但比任何指标都直接。
下一篇我会重点聊结构化更强的数据源,比如 PDF、Office 文档和思维导图,也会涉及表格抽取和 OCR 兜底方案。如果你也在做 rag 知识库,先把 txt 和 Markdown 这两类通用文本处理好,后面的路会顺很多。