1. RAG 数据导入第一课:为什么先卡在文本解析这一关
做了几个 RAG 项目之后,我最大的感受是:RAG 的瓶颈根本不在模型,而在数据准备。模型选得再好、向量化方案调得再花哨,只要喂进去的文档解析出的是一堆乱码、错位段落、残缺标题,后面检索质量必然崩盘。很多人在跑通 Demo 后进入真实业务场景,第一反应是优化 embedding 或者换 rerank 模型,但真正让线上效果提升一个档次的,往往是把数据导入和解析这一层重新做扎实。
我见过不少团队,花了大几周时间在调 prompt、调召回参数,结果最后发现问题是知识库里的文档用 PDF 解析器抽出来全是断行和乱序,标题层级丢得一干二净,语义检索时 chunk 把完全不相关的段落拼在一起。这个问题的根源就是没有做“结构化解析”,只做了“文本抽取”。
对于 RAG 项目来说,数据导入不是把文件塞进系统就完事了,里面的坑密度远超想象。不同来源的文档(txt、markdown、PDF、word、HTML)各有各的脾气,而 txt 和 Markdown 恰恰是最“亲民”但也最容易被轻视的两种格式。本系列文章的第一篇,我想把通用文本与结构化文档的处理思路完整地梳理一遍,从 txt 到 Markdown,从规则到工程化落地,结合我实际项目里的踩坑经历,给你一套能直接照着做的方案。
这篇内容适合谁?刚接触 RAG、正在搭建知识库但发现效果不理想的开发者;后端工程师要做文档预处理管道的;以及任何被“数据导入”搞到头大、想系统了解解析细节的学习者。我会尽可能把原理讲透,同时给出能跑的代码和参数建议,不讲虚的。
2. 从 txt 到 Markdown:文本解析的层级与边界
2.1 txt 处理并没有你想的那么简单
txt 看起来是最简单的文本格式,没有样式、没有结构、没有元数据,但恰恰因为“什么都没有”,解析时反而需要你替它补足一切。我早期做过一个项目,知识库里有一大批从旧系统导出的纯文本日志,内容包括多级标题、表格(用空格和制表符对齐)、列表项,甚至还有对话记录。直接用split("\n")切行,再粗暴地按固定长度切 chunk,检索效果只能用“惨”来形容。
处理 txt 的第一步,是先明确“文档内结构”从哪里来。常见做法有三条路:按空行分段、按缩进和符号识别列表、按行首模式识别标题。按空行分段看起来最安全,但遇到“标题与正文之间没有空行”的文档就废了。按符号识别需要配合正则,处理各级标题(第X章、1.1、(一)这类)时要格外小心中文序号带来的匹配优先级问题。按缩进识别适合有一定排版规律的文本,比如用四个空格或者 tab 缩进表示层级关系。
这里有一个我强烈建议的执行顺序:先做“文本清洗”,再做“结构识别”,最后才做“内容切分”。文本清洗需要处理编码问题,GBK、UTF-8、BOM头这些都要能扛住;还要处理全角半角不统一、多余空格、行尾回车符。踩过最大的坑是某些 txt 文件内混合了不同编码段落,单文件用chardet检测也可能翻车,后来我在导入端直接做了编码兜底策略,检测置信度低于阈值就按 utf-8 带错误忽略处理,并且在清洗阶段抽出原始行号,方便后面对照原文。
2.2 Markdown 的结构化红利与隐性混乱
Markdown 相比 txt 已经有天然的结构信息:标题用#标记、列表用-/1.标记、表格用管道符标记、代码块用反引号包裹。这些信息如果被丢掉,那用 Markdown 做知识库输入就属于暴殄天物。解析 Markdown 的目标不再是“猜结构”,而是“可靠地提取结构”。
但 Markdown 也有自己的混乱。最大的问题是“语法不纯”:GitHub 扩展语法、Typora 特有写法、各种 callout、脚注、数学公式、任务列表(- [ ]),这些东西在解析时要么被当普通文本处理掉,要么因为正则写得不严谨,把标题中的#误判成主题标签,把有序列表编号当成多余文本。我在实际项目里用markdown-it和remark都做过解析层,发现如果你只需要“标题层级 + 文本内容 + 表格内容 + 链接引用”,remark的 AST 结构最清晰,适合做结构化抽取;如果你还要保留文档在网页端的渲染表现,markdown-it更顺手。
这里需要强调一个容易被忽略的点:Markdown 的标题层级并不总是可靠的。作者可能为了缩小字号跳级使用##和####,或者整篇文档只有一个# 标题,其余全是##,语义层级和视觉层级错位。所以在解析时,我会对标题层级做一次“重映射”,把跳级问题修掉,生成一个连续的、树状嵌套的目录结构,后续 chunk 切分直接依赖这棵树,而不是依赖原始#数量。
2.3 文本解析的通用原子操作
不管是 txt 还是 Markdown,底层都有一组“原子操作”可以复用。这组操作我用下来非常顺手,按固定流水线处理:
- 统一换行符、去 BOM、去不可见字符;
- 规范化空格:全角空格转半角,合并连续空白;
- 识别段落边界:空行分割,合并单行短段;
- 抽取标题/列表/引用块等结构化元素;
- 表格识别:按行列切割成结构化记录;
- 元信息抽取:来源文件名、章节路径、页码或行号区间。
这组操作做好之后,任何格式的文档进入 RAG 前都能先被“归一化”成一种内部表示,我称之为“中间态文档”。这个中间态可以是一份 JSON,包含content、metadata、structure三个字段,也可以直接落到 SQLite 或 parquet。有了中间态,后续无论是做切分策略调整、embedding 重算,还是做增量导入,都会轻松很多,不需要每次重新解析原始文件。
3. 结构化解剖:为什么 RAG 效果差,根因往往在“解构”不彻底
3.1 只做纯文本抽取,等于给检索埋雷
很多人理解“解析 PDF”,就是用一个开源工具把 PDF 里的文字抽出来,存成一个 txt,然后开始切 chunk。这种做法的隐患在于:文本流丢失了原有的语义边界。PDF 里一个表格被抽成一行行散落的文本,一个跨页的段落被物理换行切成两半,标题与正文的关系彻底断裂。这样构建的知识库,检索时 query 打过来,召回的内容内部缺乏连贯性,LLM 再强也只能根据残片瞎编。
所以要明确一个核心概念:RAG 的知识粒度,取决于你如何切分,而不是如何 Embedding。切分之前,必须先有结构。结构从哪里来?从解析器对文档语义的理解来,也就是把文档“解构”成树——文档 -> 章节 -> 小节 -> 段落 -> 句子。这个树状结构才是后续语义检索的主心骨。root 节点是文档元信息,叶子节点才是最小内容单元。
3.2 从“线性文本”到“语义树”:chunk 切割的正确打开方式
做 RAG 时大家都问“chunk_size 设多少合适”,但如果你手里有一颗语义树,这个问题就不该再是拍脑袋的。我一般采用这种策略:先以“标题/段落”为单位划分最小单元,再按 token 上限做聚合。最小单元就是树上的叶子节点,聚合就是向上回溯,把相邻的兄弟节点合并,直到接近模型窗口上限。这种做法的好处是,切出来的 chunk 天然有内部语义完整性,且 chunk 之间不再重叠也能保证衔接自然。
举个例子,一篇 Markdown 文档有 40 个小节,每节约 150 token,如果固定按 500 token 切,那约等于 5 个小节拼一个 chunk,这 5 个小节可能主题不同,语义分散。借助语义树,就可以按“二级标题 -> 下属所有段落”聚合,形成一个主题一致的 chunk。我甚至会在 metadata 里存section_path(如“安装指南 / Linux 环境配置 / 依赖安装”),这样检索命中后能直接告诉用户答案来自文档的哪个章节,可追溯性大大增强。
3.3 表格与段落混合文档,怎么解析才不丢信息
业务文档里表格无处不在,而很多解析方案对表格无能为力——要么把表格拍平成文本,要么完全丢弃。表格里藏着大量结构化事实,比如参数配置、对比数据、价格表,RAG 检索对这类信息特别敏感。
处理表格的正确思路是:识别表格边界,把每一行转成一个独立的“表行记录”,保留表头作为该记录的 metadata。比如一个“服务器参数对照表”,解析后每一行变成:
{"header": ["型号", "CPU", "内存"], "row": ["R240", "Xeon 4210", "64G"]}再把它序列化成一句话存进向量库,如“型号 R240,CPU Xeon 4210,内存 64G”。这样 query 问“哪款机器是 64G 内存”,命中的就是这一行,而不是整段表格的模糊切片。同时,表标题和上下文描述会被编入父级 chunk,保证来源信息不丢失。这个方法在好几个项目里都救了大命,尤其是在运维文档、设备台账、产品手册这类场景里。
4. 实操:搭建一套通用文本导入管道(含可直接修改的代码)
4.1 从零构建“txt + Markdown”导入器的完整代码
我不喜欢过度封装,先给你一套轻量级导入器。它做的事情包括:读取文本、清洗编码、识别 Markdown 结构、抽取标题树、按语义树切 chunk。项目代码在 Python 3.9 以上都可以跑。
import re import uuid from pathlib import Path from typing import List, Dict, Optional import chardet import markdown from bs4 import BeautifulSoup class TextImporter: """ 通用文本导入器:支持 txt 和 markdown 的解析,输出中间态文档。 """ def __init__(self, encoding_fallback: str = "utf-8", ignore_errors: bool = True): self.encoding_fallback = encoding_fallback self.ignore_errors = ignore_errors def read_file(self, file_path: str) -> str: raw = Path(file_path).read_bytes() # 1. 编码检测与兜底 try: encoding = chardet.detect(raw)["encoding"] text = raw.decode(encoding) except (UnicodeDecodeError, TypeError): text = raw.decode(self.encoding_fallback, errors="ignore" if self.ignore_errors else "strict") # 2. 统一换行,去BOM,去零宽字符 text = text.replace("\r\n", "\n").replace("\r", "\n") text = text.lstrip("\ufeff") text = re.sub(r"[\u200b\u200c\u200d]", "", text) return text def split_paragraphs(self, text: str) -> List[str]: # 按空行分割,并清理每个段落 blocks = re.split(r"\n\s*\n", text) paragraphs = [re.sub(r"\s+", " ", b).strip() for b in blocks] return [p for p in paragraphs if p] def parse_markdown_ast(self, md_text: str) -> str: # 先用 markdown 库转 html,再用 bs4 抽结构;这里保留 html 是为了后续解析 html = markdown.markdown(md_text, extensions=["tables", "fenced_code", "sane_lists"]) return html def extract_md_heading_tree(self, html: str) -> List[Dict]: soup = BeautifulSoup(html, "html.parser") tree = [] stack = [] for tag in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6", "p", "table", "pre"]): level = int(tag.name[1]) if tag.name.startswith("h") else None if level: node = { "type": "heading", "level": level, "text": tag.get_text(strip=True), "children": [], } # 将当前标题挂到最近的一棵树上 while stack and stack[-1]["level"] >= level: stack.pop() if stack: stack[-1]["children"].append(node) else: tree.append(node) stack.append(node) else: content = tag.get_text(" ", strip=True) if tag.name == "pre": content = tag.get_text(strip=True) if content and stack: stack[-1]["children"].append({"type": "content", "text": content[:500]}) return tree def to_intermediate(self, file_path: str, source_type: str = "auto") -> Dict: text = self.read_file(file_path) ext = Path(file_path).suffix.lower() if source_type == "auto": source_type = "markdown" if ext in (".md", ".markdown") else "txt" structure = None if source_type == "markdown": html = self.parse_markdown_ast(text) structure = self.extract_md_heading_tree(html) return { "file": str(file_path), "type": source_type, "text": text, "structure": structure, } # 使用示例 if __name__ == "__main__": importer = TextImporter() doc = importer.to_intermediate("README.md", source_type="auto") print(doc["file"], doc["type"]) if doc["structure"]: print(doc["structure"][:3])这段代码不算复杂,但已经能覆盖 80% 的“txt+Markdown”导入需求。实际生产中,建议把to_intermediate的返回值直接序列化为 JSONL 或者写进对象存储,方便后续管道阶段消费。其中split_paragraphs和extract_md_heading_tree是纯函数,可以单独做单元测试。
4.2 表格数据处理的扩充方案
表格在 Markdown 中是很常见的,用内建正则可以把表格解析为列表数据。思路是先筛选管道符开头的连续行,再拆分成列。需要注意:表头行和分隔行要单独识别,单元格内可能还有段落或代码片段。下面是一段补充解析代码,我常用它来做“表格行记录结构化”。
def parse_md_table(md_text: str) -> List[Dict]: table_pattern = re.compile(r"^\s*\|.*\|\s*$") lines = md_text.splitlines() tables = [] current_table = [] for line in lines: if table_pattern.match(line): current_table.append(line.strip()) else: if current_table: tables.append(current_table) current_table = [] if current_table: tables.append(current_table) parsed_tables = [] for tbl in tables: rows = [] for i, line in enumerate(tbl): cells = [c.strip() for c in line.strip("|").split("|")] if i == 1 and all(re.fullmatch(r":?-{3,}:?", c) for c in cells): continue # 分隔行 rows.append(cells) if rows: headers = rows[0] for row in rows[1:]: parsed_tables.append(dict(zip(headers, row))) return parsed_tables这个方案在数据量不大时完全够用。如果表格嵌套复杂(比如合并单元格、多行表头),就需要接入专业的表格解析工具,或者干脆优先把源文件转成 HTML 再按<table>结构抽取。我的经验是,能用 Markdown 表格解决的问题,尽量别上复杂工具,因为后者一旦引入模型推断,反而会在确定性上出幺蛾子。
4.3 从文本块到向量“文档”的切分策略
文本解析完成后,还要有个“检索单元生产器”。实际项目中,我会把切分策略做成可配置项,针对不同文档类型选择不同策略。下面是两个最常用的策略:
- 标题感知切分(Heading-Aware):从 Markdown AST 里拿到标题树,把同一章节下的内容聚合为一个 chunk,适合技术文档、使用手册。
- 段落感知切分(Paragraph/Table-Aware):按段落和表格行生成叶子记录,再按上级标题向上聚合,适合 FAQ、参数说明书、接口文档。
切分成 chunk 时,我一直沿用一套固定 metadata 字段:
{ "chunk_id": "uuid", "source_file": "README.md", "section_path": "安装指南#Linux环境配置", "chunk_type": "paragraph", "start_line": 120, "end_line": 138, "text": "……" }有了这套 metadata,后续调试检索问题时效率会高很多。你直接看source_file和section_path就能定位,不需要肉眼去比对原始文档。这在知识库条目数上万时尤为重要,否则调试一次要老命。
4.4 工具选型解析:这些库别乱用,各有适用场景
市面上的文本解析库很多,但每个的侧重点完全不同。我给团队梳理过一个选型表,这里也分享出来:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 纯文本 txt | Python 内建 + 正则 | 简单可靠,不引入额外依赖 |
| Markdown 结构抽取 | remark / markdown-it | AST 解析稳定,插件生态丰富 |
| Markdown 转 HTML演示 | python-markdown / pandoc | 兼容表格、代码块、脚注 |
| 复杂表格提取(PDF/HTML) | Camelot / pdfplumber / DeepTable | 需要位置信息时用 camelot,表格完整时用 pdfplumber |
| 办公文档(Word/Excel) | python-docx / openpyxl | 文档对象模型清晰,适合结构化读取 |
| PDF 常规文本 | PyMuPDF / pdfplumber | 速度快,版面还原度适中 |
| OCR 场景 | PaddleOCR / Tesseract | 扫描件必须走 OCR,别用文本抽取硬来 |
这里特别提醒一下,遇到扫描版 PDF 千万别拿常规 PDF 解析库硬抽,抽出来全是乱码和白字,效率浪费严重。正确姿势是先过 OCR 把图像转为文本层,再进下面的结构化管道。我踩过这个坑,教训很痛。
5. 常见问题与排查技巧实录
5.1 编码问题:中文 txt 导入后全是乱码
这种问题一般出现在 Windows 平台生成的 txt,编码是 ANSI(GBK),而 Linux 服务端默认 utf-8。使用chardet.detect()大多数时候能判断出GB2312或GBK,但短文本(少于几百字节)检测准确率很低。经验做法是:读取前先根据文件前 4 个字节判断有没有 BOM,再结合内容特征兜底,比如正则匹配常见的 GBK 中文字符范围和常见标点。系统里配置一个“编码检测失败默认用 GBK 解码”的开关,对国内客户数据会好用很多。
5.2 Markdown 解析漏掉代码块中的 # 注释
解析 Markdown 时,如果先按#正则匹配标题,代码块里的# include <stdio.h>或者 Python 的# 注释会被误判成标题。这是一个经典坑。解决思路有两条:一是先解析代码块并用占位符替换,再做标题识别;二是使用 AST 解析器(如 remark)天然区分代码块和标题。我更推荐第二种,因为代码块的边界判定用正则会漏掉 ``` 在不同缩进下的情况。类似的问题还有行内代码# 标题误判,同样需要 AST 级别处理。
5.3 表格解析后行列错位,检索结果对不上
行错位的主要原因,是表格单元格内包含管道符(比如|或者代码块中的竖线),直接按|分隔就会崩溃。处理技巧是:在解析前先做一次“转义保护”,把单元格内的管道符替换成特殊占位符,等列切分完再还原。更保险的方式是直接用支持 Markdown AST 的解析器,因为 AST 里表格是结构化节点,不再依赖正切分。如果必须用正则,至少在分隔前把单元格内的\|转义处理掉。
5.4 chunk 之间信息割裂,单条 chunk 理解困难
即使做了语义树切分,某些长段落仍可能被 token 上限切到两段。这时候我还会给每条 chunk 增加“上下文摘要字段”:把父级标题 + 首段前两句拼成一个 prefix,和 chunk 正文一起存储。检索时,这个 prefix 可以参与 embedding,也可以在 LLM 收到上下文前拼在正文前。这样能有效减少检索命中后答案前言不搭后语的情况,实测回答连贯性提升明显。
5.5 常见问题速查表
| 症状 | 可能原因 | 处理建议 |
|---|---|---|
| 导入的 txt 乱码 | 编码检测失败,默认用了错误编码 | 用 chardet 检测 + GBK 兜底,按 BOM 头判断 |
| Markdown 标题全部丢失 | 解析时没有走 AST,用正则被代码块干扰 | 切换到 remark/markdown-it AST 解析 |
| 表格数据检索不中 | 表格被拍平成文本或行列错位 | 按表行转记录,表头作为 metadata |
| 章节路径全为空 | 没有提取标题树,或者标题层级混乱 | 做标题层级重映射,生成规范化树 |
| 同一来源文档多次导入重复项 | 没有做内容 hash 去重 | 对每段正文算 sha256,写库前查重 |
| 导入速度极慢 | 逐条调用 embedding 接口 | 批量 50~100 条一次,加本地缓存 |
5.6 增量导入与去重:知识库能持续长大的关键
知识库不是一次性导入就结束的,业务文档会持续更新。增量导入时最容易出现的两个问题就是重复插入和更新残留。我的做法是:每一条 chunk 都保存source_file+section_path+content_hash三个字段组合的唯一索引。新增文档时先把 source_file 下的旧 chunk 全部标记为不活跃,再插入新 chunk。内容没变的行保留,内容变了或删掉的行自动过期。这个方案比全量删除重建的效率高很多,也保留了历史追溯能力。
6. 文档结构化另一面:Markdown 数学公式、Callout 与富文本的处理
6.1 数学公式与大模型上下文:如何保留公式语义
RAG 场景中常碰到含 LaTeX 数学公式的文档,比如学术论文用 Markdown 写的笔记。公式在解析时有三个选择:保留 LaTeX 源码、渲染成图片、转成文本描述。对大模型来说,LaTeX 源码其实是更友好的输入,只要 embedding 模型在训练时见过类似语法。但如果公式是行内公式,夹杂在文字里,切分 chunk 时容易把公式切成两半,导致上下文破坏。处理这种场景,我会在切分时用\(...\)或$...$作为不可分割的边界标识,切分器在这一标识处不切断。如果公式较长,单独作为一个 chunk 类型,在检索到公式时,让 LLM 按“公式解释题”处理,效果会比混在段落里好很多。
6.2 Callout、折叠块、任务列表怎么处理
GitHub 风格的 callout(如> [!NOTE])在现在的技术文档中出现频率很高。这类 blockquote 不只是引用文字,它带有语义类型(note、warning、tip)。解析时可以将其作为独立结构块提取,并在 metadata 中标记block_type: callout和callout_type: note。任务列表(- [ ]/- [x])则建议保留状态,因为它们往往承载了待办或完成信息,后续问答中用户可能会查“哪些还没做”。折叠块(<details>)在普通 Markdown 下会被当作 HTML 标签,提取时要专门处理,否则内部内容会全部丢失,这属于很容易被忽略的结构坑。
6.3 多级列表的层级处理
多级有序/无序列表(1. 2. 以及 - 的嵌套)在解析时,如果只提取 tag 文本,层级就扁平化了。我会在列表解析时用缩进级别来恢复层级,生成类似root / item2 / subitem2.1的路径,并把这个路径拼进 section_path。这样用户问“配置步骤第 2 步下面的关键参数”时,检索能准确定位到嵌套列表深处的内容,而不是把整个列表当一大片文本检索。实际调试中这个优化对开发文档类知识库效果很显著。
7. 实战中的经验总结与进一步优化
7.1 结构化管道不需要一步到位
很多团队一上来就想要完美方案,恨不得把 PDF/CAD/音视频全解析了。我的建议是,先把手头最常见的 2~3 种格式吃透(大概率是 txt、Markdown、Word/PDF),形成一套“解析中间态 + chunk 生产 + metadata 管理”的基线能力,再逐渐扩展格式。因为每种格式的解析都需要单独调试,一步到位不现实。
7.2 质量评估一定要做:建一个“召回对照测验集”
没有质量评估的 RAG 是盲人摸象。我每做一个知识库项目,都会让业务方出 30~50 对有代表性的 query 和标准答案片段,组成一个小的测验集。每调一版解析或者 chunk 策略,就在测验集上跑一遍召回,算 Hit Rate(top5 是否包含答案)。这是唯一能理性评估“解析层改动到底有没有变好”的方法。否则,大家凭感觉调参,最后全在玄学里打转。
7.3 后续系列内容预告
这一篇主要覆盖 txt 和 Markdown 的通用文本与结构化解,下一篇我会重点讲 PDF 和 Word 这类“伪结构化”格式的解析策略,包括版面分析、表格还原、OCR 流程的工程化处理。再后面会抽时间整理 chunk 策略实验笔记(不同粒度对于检索和生成质量的影响)以及 embedding 模型选型对比。如果你在实际导入过程中遇到“怪文档”,也欢迎在评论区把样例内容丢出来,我见过足够多奇葩格式,能帮你看看解构思路。
最后送大家一句话:RAG 拼到最后拼的是数据工程,不是模型魔法。把数据导入与解析这关做扎实,你的知识库就已经赢了大多数人。