RAG 系统落地时,十有八九的人会把八成精力砸在向量库选型、Embedding 模型对比、检索策略调优上,结果真正跑起来才发现,效果拉胯的根因往往出在最不起眼的第一步——数据导入与解析。我见过太多团队拿着一堆格式各异的原始文件,直接一把梭丢进分块器,切出来的东西要么语义断裂,要么把表格拆得七零八落,检索时召回的全是噪声。这篇就从最基础的 txt 和 Markdown 入手,把通用文本与结构化数据的导入解析链路彻底讲透,后面再逐步展开 PDF、Word、HTML 等复杂格式的处理。
1. 为什么数据导入解析是 RAG 的第一道生死线
1.1 解析质量决定了 RAG 效果的天花板
很多人对 RAG 的理解停留在"把文档切块、向量化、存库、检索"这个流水线上,觉得解析只是走个过场。但实际情况是,解析阶段丢失的结构信息,在后续任何环节都无法被恢复。你切块切得再科学,Embedding 模型再强,检索算法再精妙,如果输入本身就是一坨没有层次、没有边界的纯文本,那最终召回的内容质量必然堪忧。
举个很典型的例子。一份产品需求文档,原文里"功能需求"是一级标题,"用户登录"是二级标题,下面跟着一段描述和一个参数表格。如果你用最简单的按行读取方式处理,标题和正文混在一起,表格被拆成一行行孤立的文字,那么当用户问"用户登录功能有哪些参数"时,检索系统根本不知道哪些内容属于"用户登录"这个语义单元,很可能召回的是隔壁"用户注册"的参数,或者把表格里的表头和数据行拆散到不同的块里,导致模型拿到的是残缺信息。
这就是为什么我一直强调:RAG 的数据导入解析不是简单的文件读取,而是一次语义结构的重建过程。你要做的,是在解析阶段尽可能保留原文的层级关系、段落边界、表格结构、代码块边界,让后续的分块器有足够的"锚点"来判断哪里该切、哪里不该切。
1.2 通用文本与结构化数据的边界在哪里
在动手之前,得先搞清楚我们面对的数据到底分几类。从 RAG 的视角看,导入的数据大致可以分成两大阵营:
- 通用文本:纯自然语言为主,结构松散,比如 txt 笔记、Markdown 文档、纯文本日志、聊天记录导出等。这类数据的核心挑战是"如何识别语义边界"。
- 结构化数据:有明确的层级、字段、表格、代码等结构,比如 Markdown 里的表格和代码块、CSV、JSON、XML、YAML 等。这类数据的核心挑战是"如何把结构信息无损地传递给下游"。
txt 和 Markdown 恰好是这两类的典型代表,而且它们之间还有交集——Markdown 本身就是一种轻量级结构化文本。所以拿这两个格式开刀,既能覆盖最基础的纯文本场景,又能把结构化解析的核心思路讲清楚,性价比最高。
1.3 一个常见的认知误区:解析越"干净"越好
新手常犯的一个错误是,追求把文档解析成"最干净"的纯文本,把所有格式标记、多余空格、特殊符号全部清掉。这个思路在传统全文检索时代或许没问题,但在 RAG 场景下是有害的。
原因很简单:格式标记本身就是语义信号。Markdown 里的#表示标题层级,**表示强调,|表示表格边界,``` 表示代码块范围。这些符号在人类阅读时是视觉提示,在机器解析时是结构线索。你把它们全删了,等于把地图上的路标全拔了,后续分块器只能盲切。
正确的做法是:解析时保留结构,分块时利用结构,入库时按需剥离。也就是说,结构信息要在解析阶段完整提取出来,至于最终存进向量库的文本要不要带格式符号,那是分块和入库阶段根据检索策略来决定的,不该在解析阶段就一刀切掉。
2. txt 文件导入:看似最简单,坑却最多
2.1 编码问题:第一个拦路虎
txt 文件最大的特点就是"没有格式规范",这既是优点也是缺点。优点是你不用处理复杂的标记语言,缺点是你连它用什么编码存的都不知道。
我处理过的 txt 文件里,GBK、GB2312、UTF-8、UTF-8 with BOM、甚至 UTF-16 都遇到过。如果编码判断错了,读出来就是一堆乱码,后面全白搭。所以 txt 导入的第一步,永远是编码检测。
Python 里常用的方案是chardet或charset-normalizer库。实测下来,charset-normalizer对中文文本的检测准确率更高一些,尤其是短文本场景。基本用法是这样:
from charset_normalizer import from_path def detect_encoding(file_path): results = from_path(file_path) best = results.best() if best is None: return 'utf-8' return best.encoding def read_txt(file_path): encoding = detect_encoding(file_path) with open(file_path, 'r', encoding=encoding, errors='replace') as f: return f.read()这里有个细节要注意:errors='replace'是兜底策略,遇到无法解码的字节用替换字符代替,避免整个文件读取失败。但如果你对数据质量要求高,可以先用errors='strict'试读,失败后再降级到replace,同时记录日志,方便后续人工排查。
提示:BOM 头是个容易被忽略的坑。UTF-8 with BOM 的文件开头会有
\ufeff字符,如果不处理,它会混进你的第一个文本块里,影响 Embedding 质量。读取后用content.lstrip('\ufeff')清一下。
2.2 换行符与段落边界的识别
txt 文件里的换行符也是五花八门:Windows 用\r\n,Unix 用\n,老 Mac 用\r。Python 的open()在文本模式下会自动做通用换行处理,把\r\n和\r都转成\n,这一点倒是省心。但真正麻烦的是段落边界的判断。
纯 txt 里,段落之间可能用空行分隔,也可能用多个空格,还可能直接连着写。你不能简单地按\n切分,因为那样会把一句话拆成好几行。我的经验是分两步走:
- 先把连续多个换行(两个及以上)视为段落分隔符,合并单个换行。
- 对于没有空行分隔的文本,用标点符号(句号、问号、感叹号)加换行作为辅助判断。
import re def normalize_paragraphs(text): # 统一换行符 text = text.replace('\r\n', '\n').replace('\r', '\n') # 多个连续换行视为段落分隔 text = re.sub(r'\n{2,}', '\n\n', text) # 单个换行如果前后不是标点,视为同一段落内的折行,合并 text = re.sub(r'(?<![。!?.!?])\n(?![\n])', '', text) return text这个正则的逻辑是:如果换行前面不是句末标点,且后面不是另一个换行,那就把这个换行去掉,把两行合并。这样能有效处理那些"一段话被硬折成多行"的情况。
2.3 大文件的分批读取策略
txt 文件动辄几十上百 MB,直接read()全部读进内存,在小机器上容易爆。这时候要用流式读取,按块处理。但注意,流式读取不能简单地按固定字节数切,否则会把一个多字节字符从中间截断,导致乱码。
正确的做法是按行迭代,或者按固定字符数读取并保留边界:
def stream_read_txt(file_path, encoding, chunk_size=8192): with open(file_path, 'r', encoding=encoding, errors='replace') as f: while True: chunk = f.read(chunk_size) if not chunk: break yield chunk按字符数读取的好处是,Python 的文本模式读取是以字符为单位的,不会出现多字节字符被截断的问题。如果你用二进制模式读取再手动解码,那就得自己处理边界了,麻烦得多。
2.4 txt 解析后的元数据补全
纯 txt 文件几乎没有元数据,但 RAG 检索时,元数据往往是提升召回精度的关键。比如你想按来源文件过滤,或者按时间排序,没有元数据就做不到。所以解析 txt 时,我习惯手动补全一批基础元数据:
| 元数据字段 | 来源 | 用途 |
|---|---|---|
| source_file | 文件路径 | 溯源、按文件过滤 |
| file_name | 文件名 | 展示、分组 |
| file_size | 文件大小 | 质量评估 |
| encoding | 检测结果 | 排查乱码问题 |
| char_count | 字符数 | 分块参数参考 |
| import_time | 导入时间戳 | 增量更新判断 |
这些字段看起来不起眼,但在实际调优时非常有用。比如你发现某个文件的召回效果特别差,一看 char_count 只有几十,那大概率是个空文件或者解析失败,直接就能定位问题。
3. Markdown 解析:把结构信息榨干
3.1 Markdown 的语法树到底长什么样
Markdown 解析的核心思路,是把它转成一棵抽象语法树(AST),然后遍历这棵树提取内容。市面上主流的解析库有 Python 的markdown-it-py、mistune,JavaScript 的remark、marked,各有优劣。
我个人的偏好是markdown-it-py,原因是它严格遵循 CommonMark 规范,对边界情况的处理比较稳,而且插件生态丰富,扩展性强。基本用法:
from markdown_it import MarkdownIt md = MarkdownIt("commonmark") tokens = md.parse(markdown_text) for token in tokens: print(token.type, token.tag, token.content)解析出来的 token 流里,每个 token 有type(如heading_open、paragraph_open、fence)、tag(如h1、p、code)、content(文本内容)等属性。你要做的,就是遍历这个 token 流,根据 type 判断当前是什么结构,然后决定怎么处理。
3.2 标题层级:分块的第一锚点
Markdown 的标题(#到######)是天然的分块锚点。一个合理的策略是:以标题为边界切分章节,把每个标题及其下属内容作为一个语义单元。
但这里有个细节:标题是有层级的。一级标题下面可能有好几个二级标题,二级标题下面又有三级标题。你不能简单地按任意级别标题切,否则要么切得太碎(按三级标题切),要么切得太粗(只按一级标题切)。
我的做法是维护一个标题栈,记录当前路径。比如遇到二级标题时,如果栈里已经有一级标题,就把一级标题作为父级路径的一部分。这样每个内容块都能带上完整的标题路径,检索时语义更完整。
def extract_with_heading_path(tokens): heading_stack = [] blocks = [] current_content = [] for token in tokens: if token.type == 'heading_open': # 保存上一个块 if current_content: blocks.append({ 'path': list(heading_stack), 'content': ''.join(current_content) }) current_content = [] level = int(token.tag[1]) # 维护标题栈 heading_stack = heading_stack[:level-1] elif token.type == 'inline' and heading_stack: # 判断是否是标题内容 pass elif token.type in ('paragraph_open', 'fence', 'table_open'): pass # ... 其他处理 return blocks这段代码是简化版,实际实现要处理更多 token 类型。核心思想就是:标题路径作为元数据附加到每个内容块上,这样检索时即使块本身内容不完整,标题路径也能提供上下文。
3.3 表格解析:别让结构化数据变成乱码
Markdown 表格是结构化数据的重灾区。一个典型的表格长这样:
| 参数名 | 类型 | 必填 | 说明 | |--------|------|------|------| | userId | string | 是 | 用户唯一标识 | | age | int | 否 | 用户年龄 |如果你直接按行读取,会得到一堆带|的文本行,语义完全丢失。正确的做法是解析成结构化的二维数据,然后在入库时选择合适的形式。
markdown-it-py会把表格解析成table_open、thead_open、tr_open、th_open、td_open等一系列 token。你需要遍历这些 token,还原出表格的行列结构:
def parse_table(tokens, start_idx): rows = [] current_row = [] current_cell = [] in_header = False i = start_idx while i < len(tokens): token = tokens[i] if token.type == 'thead_open': in_header = True elif token.type == 'tr_open': current_row = [] elif token.type in ('th_open', 'td_open'): current_cell = [] elif token.type == 'inline': current_cell.append(token.content) elif token.type in ('th_close', 'td_close'): current_row.append(''.join(current_cell)) elif token.type == 'tr_close': rows.append(current_row) elif token.type == 'table_close': break i += 1 return rows, i解析出二维结构后,入库时有两种选择:一是转成自然语言描述("userId 是 string 类型,必填,表示用户唯一标识"),二是保留表格结构用特殊标记包裹。前者对 Embedding 友好,后者对精确检索友好。我的建议是两者都存,用不同的字段,检索时根据查询类型选择。
3.4 代码块:边界识别与语言标记
代码块在 Markdown 里用三个反引号包裹,可以带语言标记:
```python def hello(): print("hello") ```代码块的处理有两个要点:一是边界要完整保留,二是语言标记要提取出来。代码块被切开是 RAG 里非常常见的问题,因为代码里往往有很多换行和特殊符号,分块器容易误判边界。
我的做法是,解析时给代码块打上特殊标记,比如用<CODE lang="python">...</CODE>包裹,这样分块器看到这个标记就知道不能从中间切。同时,语言标记作为元数据存下来,检索时可以按语言过滤。
注意:代码块里的内容不要做任何"清洗",包括去除多余空格、合并空行等。代码的缩进和空行都是有意义的,动了就变味了。
3.5 列表、引用、链接的处理细节
除了标题、表格、代码块,Markdown 还有列表、引用、链接、图片等元素,每个都有自己的处理要点:
- 有序列表:保留序号,因为序号本身携带顺序信息。无序列表的
-或*可以转成统一的符号,但不要删。 - 嵌套列表:缩进层级要保留,可以用空格或特殊标记表示层级,方便后续还原。
- 引用块:用
>标记的内容,通常表示强调或补充说明,建议单独成块或加上特殊标记。 - 链接:
[文本](URL)形式,解析时把文本和 URL 都提取出来。文本用于 Embedding,URL 作为元数据存下来,检索时可以返回给用户。 - 图片:
形式,alt 文本用于 Embedding,URL 存元数据。如果 RAG 系统支持多模态,图片本身也要单独处理。
这些元素看起来琐碎,但正是它们构成了文档的完整语义。处理得好,检索质量能上一个台阶;处理得糙,就会在各种边界情况上翻车。
4. 从解析结果到可入库数据:中间层设计
4.1 统一中间表示:让下游不挑食
txt 和 Markdown 解析出来的原始结构差异很大,如果直接对接分块器,分块器就得写一堆 if-else 来判断数据来源。更好的做法是设计一个统一的中间表示(Intermediate Representation),把不同格式的解析结果归一化成同一种结构。
我常用的中间表示是一个 JSON 结构,包含以下字段:
{ "content": "文本内容", "content_type": "text|table|code|list", "heading_path": ["一级标题", "二级标题"], "metadata": { "source_file": "xxx.md", "language": "python", "table_data": [[...]], "links": [{"text": "...", "url": "..."}] }, "position": { "start": 0, "end": 100 } }这个结构的好处是,分块器只需要面对一种数据格式,不用关心原始文件是 txt 还是 Markdown。同时,content_type字段让分块器可以针对不同类型采用不同的切分策略——文本按语义切,表格整体保留,代码块不切。
4.2 结构信息的保留与剥离策略
前面说过,结构信息在解析阶段要完整保留,但入库时要不要带格式符号,得看检索策略。这里给一个实用的判断标准:
- 如果检索主要靠语义相似度:入库文本可以剥离大部分格式符号,只保留标题路径和必要的结构提示。因为 Embedding 模型对格式符号不敏感,带太多反而增加噪声。
- 如果检索需要精确匹配:比如用户会搜具体的参数名、代码函数名,那格式符号要保留,甚至要额外建立关键词索引。
- 混合策略:存两份,一份"干净版"用于向量检索,一份"原始版"用于展示和精确匹配。
我自己的项目里用的是混合策略,实测下来召回率和用户满意度都比单一策略好。代价是存储翻倍,但现在的存储成本,这点开销完全可以接受。
4.3 元数据的标准化字段设计
元数据这块,最忌讳的是每个解析器各搞一套。我建议在项目初期就定一套标准字段,所有解析器都往这套字段上靠。下面是我常用的一套:
| 字段名 | 类型 | 说明 | 是否必填 |
|---|---|---|---|
| doc_id | string | 文档唯一标识 | 是 |
| chunk_id | string | 块唯一标识 | 是 |
| source_type | string | 来源类型(txt/md/pdf...) | 是 |
| source_path | string | 原始文件路径 | 是 |
| heading_path | list | 标题路径 | 否 |
| content_type | string | 内容类型 | 是 |
| char_count | int | 字符数 | 是 |
| token_count | int | Token 数 | 否 |
| created_at | datetime | 创建时间 | 是 |
| updated_at | datetime | 更新时间 | 否 |
| custom | dict | 自定义扩展字段 | 否 |
这套字段覆盖了溯源、过滤、统计、扩展四类需求,基本够用。custom字段留给特殊场景,比如 Markdown 的代码语言、表格的列数等。
4.4 解析失败的兜底与日志
再完善的解析器也会遇到搞不定的文件。这时候兜底策略和日志记录就很重要。我的做法是:
- 解析失败时,降级到最基础的纯文本读取,至少保证内容不丢。
- 记录失败原因、文件路径、失败位置,写入日志。
- 失败的块打上
parse_status: failed标记,检索时可以过滤掉或降权。
def safe_parse(file_path): try: return parse_markdown(file_path) except Exception as e: logger.warning(f"Markdown parse failed: {file_path}, {e}") try: return parse_as_plain_text(file_path) except Exception as e2: logger.error(f"Plain text fallback failed: {file_path}, {e2}") return None这套机制看起来简单,但在处理大批量异构文件时能救命。你永远不知道用户会丢进来什么奇怪的文件,有个兜底总比整个流程崩掉强。
5. 实操中踩过的坑与经验总结
5.1 中文标点与英文标点混用导致的切分错误
中文文档里经常中英文标点混用,比如"这是一个句子.这是另一个句子."。如果你按英文句号切分,会把中文语境下的句子切错。我的处理方式是同时识别中英文标点,并且在切分时优先考虑中文标点,因为中文文档里中文标点更可靠。
SENTENCE_ENDINGS = r'[。!?.!?;;]'但要注意,英文句号在数字、缩写、网址里也会出现,比如"3.14"、"U.S.A"、"example.com"。所以切分前要先做一轮保护,把这些情况用占位符替换掉,切完再还原。
5.2 Markdown 表格跨行合并的解析陷阱
标准 Markdown 表格不支持跨行合并(rowspan),但有些扩展语法支持,比如 GitHub 的表格扩展。如果你用的解析库不支持这些扩展,遇到跨行合并的表格就会解析错乱。
我的建议是:先检测表格是否用了扩展语法,如果用了,要么换支持扩展的解析库,要么在解析前做预处理,把扩展语法转成标准语法。预处理虽然麻烦,但比解析错乱后再修数据要省事得多。
5.3 大段落与超长代码块的切分边界
有些文档里会有超长段落(几千字不分段)或超长代码块(几百行)。这些内容如果整体作为一个块,会超出 Embedding 模型的输入限制;如果硬切,又会破坏语义。
我的处理策略是:
- 超长段落:按句子边界切,保留重叠窗口(overlap),确保上下文连续。
- 超长代码块:按函数或类边界切,如果找不到明确边界,按固定行数切,但要在每块开头加上"这是 XXX 代码的第 N 部分"的说明。
重叠窗口的大小,我一般设为块大小的 10% 到 20%。太小了上下文不够,太大了存储浪费。实测 15% 是个比较平衡的值。
5.4 解析性能优化:批量处理与并行
当文件数量上千时,单线程解析会慢得让人抓狂。这时候要上并行。Python 里可以用concurrent.futures的ProcessPoolExecutor,因为解析是 CPU 密集型任务,多进程比多线程有效。
from concurrent.futures import ProcessPoolExecutor def batch_parse(file_paths, max_workers=4): with ProcessPoolExecutor(max_workers=max_workers) as executor: results = list(executor.map(safe_parse, file_paths)) return resultsmax_workers一般设为 CPU 核心数,不要设太大,否则进程切换开销反而拖慢速度。另外,解析结果要及时落盘,不要全攒在内存里,避免大文件批量处理时内存爆掉。
5.5 一个容易被忽略的细节:文件读取的权限与路径
最后说个不起眼但很烦人的问题:文件权限和路径编码。在 Linux 环境下,如果文件权限不对,读取会直接抛异常;如果路径里有中文或特殊字符,某些库处理不好会报错。
我的做法是:
- 读取前先用
os.access(path, os.R_OK)检查权限。 - 路径统一用
pathlib.Path处理,它会自动处理不同系统的路径分隔符。 - 路径里的中文用
os.fsencode和os.fsdecode做转换,避免编码问题。
这些细节看起来琐碎,但在实际项目里,往往是这些小问题让整个流程跑不通。提前处理好,能省下大量排查时间。
数据导入与解析这条链路,说到底就是"把原始文件里的语义结构,尽可能无损地传递到下游"。txt 和 Markdown 只是起点,后面还有 PDF 的版面分析、Word 的样式提取、HTML 的 DOM 遍历、Excel 的多 sheet 处理等等,每一个都有自己的门道。但只要你把"保留结构、统一表示、兜底容错"这三个原则吃透,换什么格式都是同一套思路的延伸。下一篇我会接着讲 PDF 和 Word 这类复杂格式的解析,那才是真正考验功力的地方。