本文是 RAG 实战系列第一篇,介绍 RAG 流水线的第一个环节:如何把原始 PDF 文档解析成可检索的文本片段。很多人忽视这一步,但实际上文档解析和切块的质量直接决定了整个 RAG 系统的上限。
本系列基于开源项目 IlyaRice/RAG-Challenge-2 的工程实践总结。
目录
整体流程概览
第一步:PDF 解析 —— 选对工具是成功的一半
补充:Docling 为什么比其他库解析得更好?
第二步:元信息提取与保存
第三步:文档结构化 —— 不只是提取文字
第四步:表格的专项处理
第五步:文本清洗 —— 容易被忽视的关键环节
第六步:文本切块策略
第七步:切块后的元信息挂载
常见错误汇总与避坑指南
完整数据结构参考
1. 整体流程概览 很多新手拿到一堆 PDF 文件后,第一反应是直接用 PyPDF2 提取文本,然后按固定字符数切块,最后灌进向量数据库。这种做法在 Demo 阶段勉强能跑通,但在生产环境中会带来一系列问题:检索质量差、表格信息丢失、上下文断裂、元信息缺失 。
一个经过打磨的 RAG 文档处理流程应该是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 原始 PDF │ ▼ [步骤1] PDF 解析(Layout分析 + OCR + 表格识别) │ → 输出:结构化 JSON(文本块、表格、图片,含坐标和页码) │ ▼ [步骤2] 表格专项序列化(用 LLM 将表格转为自包含的文本块) │ → 输出:每个表格行/列的独立语义描述 │ ▼ [步骤3] 文本清洗与格式化(修复 OCR 噪声、统一 Markdown 格式) │ → 输出:按页面组织的干净文本(含 Markdown 结构标记) │ ▼ [步骤4] 文本切块(语义感知切块,按 Token 数控制大小) │ → 输出:带元信息的 Chunk 列表 │ ▼ [步骤5] 向量化 + 索引(向量数据库 + BM25 双路索引) │ ▼ 可检索的知识库
下面我们逐步拆解每个环节。
2. 第一步:PDF 解析 —— 选对工具是成功的一半 2.1 为什么不能直接用 PyPDF2? PyPDF2、pdfplumber 等基础工具只能做简单的文本提取,它们有一个致命缺陷:不理解文档版式(Layout) 。对于包含多列排版、嵌套表格、页眉页脚的商业文档(如年报、合同),这类工具提取的文本顺序往往是错乱的。
典型问题场景:
1 2 3 4 5 6 7 8 9 10 11 原始 PDF 双列布局: ┌──────────────┬──────────────┐ │ 第一列第1段 │ 第二列第1段 │ │ 第一列第2段 │ 第二列第2段 │ └──────────────┴──────────────┘ PyPDF2 提取结果(顺序混乱): "第一列第1段 第二列第1段 第一列第2段 第二列第2段" 正确的提取结果应该是: "第一列第1段 第一列第2段" 和 "第二列第1段 第二列第2段"(独立段落)
2.2 推荐工具:Docling 对于企业级文档解析,推荐使用 Docling 。它基于深度学习模型,能够:
理解文档版式,正确区分多列、标题、正文、页眉页脚
识别表格结构(行列关系)
处理扫描件(OCR)
输出结构化的文档模型
基础使用示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 from docling.document_converter import DocumentConverter, FormatOptionfrom docling.datamodel.pipeline_options import PdfPipelineOptions, TableFormerMode, EasyOcrOptionsfrom docling.datamodel.base_models import InputFormatfrom docling.pipeline.standard_pdf_pipeline import StandardPdfPipelinefrom docling.backend.docling_parse_v2_backend import DoclingParseV2DocumentBackendpipeline_options = PdfPipelineOptions() pipeline_options.do_ocr = True pipeline_options.ocr_options = EasyOcrOptions( lang=['en' ], force_full_page_ocr=False ) pipeline_options.do_table_structure = True pipeline_options.table_structure_options.do_cell_matching = True pipeline_options.table_structure_options.mode = TableFormerMode.ACCURATE format_options = { InputFormat.PDF: FormatOption( pipeline_cls=StandardPdfPipeline, pipeline_options=pipeline_options, backend=DoclingParseV2DocumentBackend ) } converter = DocumentConverter(format_options=format_options) result = converter.convert("your_document.pdf" )
坑1 :force_full_page_ocr=False 是合理的默认值。如果对非扫描件开启全页OCR,处理速度会慢 5-10 倍,且结果不一定更好。只有当你确认文档是扫描件时才开启。
坑2 :TableFormerMode.ACCURATE 比 FAST 模式慢,但对复杂表格(合并单元格、多级表头)的识别准确率显著更高。如果你的文档包含财务报表等复杂表格,建议用 ACCURATE。
2.3 解析结果的数据结构 Docling 解析后,通过 export_to_dict() 可以得到一个结构化字典,包含:
1 2 3 4 5 6 7 8 9 10 { "origin" : {"filename" : "report.pdf" }, "pages" : [...], "texts" : [...], "tables" : [...], "pictures" : [...], "body" : { "children" : [...] } }
每个文本块(texts 中的元素)都包含非常有价值的信息:
1 2 3 4 5 6 7 8 9 10 { "text" : "Revenue increased by 15% year-over-year" , "label" : "text" , "prov" : [{ "page_no" : 3 , "bbox" : { "l" : 72.0 , "t" : 150.0 , "r" : 540.0 , "b" : 165.0 } }] }
这些信息非常重要,后续处理都依赖它们,一定要在处理过程中保留。
3. 补充:Docling 为什么比其他库解析得更好? 这一节专门讲 Docling 的内部工作原理,帮助你理解它和传统工具在本质上的差异,而不只是知道”用 Docling 效果更好”。
3.1 传统工具的工作方式:字符流提取 PyPDF2、pdfminer、pdfplumber 等传统工具的工作原理本质上都是从 PDF 的底层字节流中读取字符和坐标 。
PDF 文件格式在内部存储的并不是”段落”或”表格”,而是一条条绘图指令,类似:
1 2 3 4 5 6 7 8 9 10 # PDF 内部指令(简化示意) BT % 开始文本块 /F1 12 Tf % 设置字体 72 720 Td % 移动到坐标 (72, 720) (Revenue grew by) Tj % 绘制文字 150 720 Td % 移动到坐标 (150, 720) (15%) Tj % 绘制文字 72 700 Td % 移动到下一行坐标 (in fiscal year 2023.) Tj % 继续绘制 ET % 结束文本块
传统工具做的事情是:把这些字符按坐标排序,然后拼成字符串 。它们并不知道哪些字符属于”标题”,哪些属于”表格的第二列第三行”,哪些是”页眉”。
这就是双列布局会出错的根本原因:两列的 Y 坐标交错,按坐标排序后左右列的内容就混在一起了。
1 2 3 4 5 6 7 坐标示意(Y值越大越靠上): (72, 720): "左列第一行" (320, 720): "右列第一行" ← 同一Y高度 (72, 700): "左列第二行" (320, 700): "右列第二行" ← 同一Y高度 按Y排序后:左列第一行 右列第一行 左列第二行 右列第二行 ← 顺序混乱
3.2 Docling 的工作方式:模型驱动的版式理解 Docling 采用完全不同的思路,它的处理流水线分为多个阶段,每个阶段都使用专门的模型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 PDF 原始数据 │ ▼ [阶段1] PDF 后端解析(DoclingParseV2) │ 提取原始字符、字体、坐标、图片等底层元素 │ → 输出:原始元素列表(字符、图形、图片区域) │ ▼ [阶段2] 版式分析模型(Layout Model) │ 对每一页截图,用目标检测模型识别版式区域 │ → 输出:带标签的区域列表(标题/段落/表格/图片/页眉/页脚...) │ 每个区域有精确的边界框(bounding box) │ ▼ [阶段3] 内容分配(Cell Matching) │ 将阶段1的原始字符,分配到阶段2识别出的各个区域中 │ → 输出:每个区域内的有序文字内容 │ ▼ [阶段4] 表格结构识别(TableFormer 模型) │ 针对被识别为"表格"的区域,专门识别行列结构 │ → 输出:结构化的表格(知道哪个单元格属于第几行第几列) │ ▼ [阶段5] OCR(可选,EasyOCR / Tesseract) │ 对无文字的图片区域(扫描件)运行 OCR │ → 输出:扫描内容的文字 │ ▼ [阶段6] 文档模型组装 将上述结果组合为层级化的文档模型(DoclingDocument) → 输出:带语义标签的结构化文档
关键差异在阶段2 :Docling 会对每一页生成一张截图,然后用类似目标检测(Object Detection)的深度学习模型来识别版式区域。这个模型是在大量学术论文和商业文档上训练过的,它”看到”的是整张页面的视觉布局,而不是一个个孤立的字符。
3.3 各类文档元素的识别能力对比
文档元素
PyPDF2/pdfplumber
Docling
单列正文文本
✅ 基本正确
✅ 正确
双列/多列布局
❌ 顺序混乱
✅ 正确分列
章节标题识别
❌ 无法区分(仅字体大小)
✅ 模型识别,输出 section_header 标签
页眉页脚识别
❌ 混入正文
✅ 识别为 page_header/page_footer 并可单独过滤
表格结构(行列)
❌ 表格变成乱序文本
✅ TableFormer 识别完整行列结构
合并单元格(rowspan/colspan)
❌ 完全丢失
✅ 保留并输出 HTML
多级表头
❌ 完全丢失
✅ 保留层级关系
脚注识别
❌ 混入正文
✅ 识别为 footnote 标签
列表项识别
❌ 无法区分
✅ 识别为 list_item 标签
图片区域识别
❌ 无法识别
✅ 识别区域和边界框
扫描件 OCR
❌ 不支持
✅ 集成 EasyOCR/Tesseract
公式识别
❌ 不支持
✅ 部分支持,输出 equation 标签
3.4 label 字段:Docling 最值得利用的输出 Docling 给每个文本块打上语义标签(label 字段),这是它相比传统工具最大的优势之一。可用的标签包括:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 "section_header" "page_header" "page_footer" "text" "paragraph" "list_item" "footnote" "caption" "formula" "checkbox_selected" "checkbox_unselected"
这些标签让后处理变得极为精确。例如,过滤页眉页脚只需一行代码:
1 2 ignored_types = {"page_footer" , "picture" } filtered_blocks = [b for b in blocks if b.get("type" ) not in ignored_types]
而用 PyPDF2,你要通过坐标位置猜测哪些是页眉(”Y 坐标大于页面高度的 90%?也许是页眉?”),极不可靠。
Docling 的表格识别使用了 TableFormer 模型(IBM Research 开发),这是专门针对文档表格设计的 Transformer 架构。
它解决的核心问题是:PDF 里的表格在底层只是一堆坐标随机排列的文字,没有任何”行”和”列”的概念。TableFormer 通过理解视觉上的对齐关系来推断表格结构:
1 2 3 4 5 6 7 8 9 PDF 底层存储的表格(只有坐标和文字): (100, 500): "Metric" (200, 500): "Q1" (300, 500): "Q2" (100, 480): "Revenue" (200, 480): "4521" (300, 480): "4890" (100, 460): "Net Inc" (200, 460): "412" (300, 460): "456" TableFormer 推断出的结构: 行0(表头): ["Metric", "Q1", "Q2"] 行1: ["Revenue", "4521", "4890"] 行2: ["Net Inc", "412", "456"]
更重要的是,它能处理合并单元格 ,这是传统工具完全无法做到的:
1 2 3 4 5 6 7 8 9 10 带合并单元格的表头: ┌─────────┬────────────────────────┐ │ │ Financial Results │ ← 这个单元格跨越2列(colspan=2) │ Metric ├────────────┬───────────┤ │ │ Q1 │ Q2 │ ├─────────┼────────────┼───────────┤ │ Revenue │ 4,521 │ 4,890 │ PyPDF2 提取结果:混乱的字符串,完全失去表头层级 TableFormer 输出:正确的 rowspan/colspan HTML,语义完整
do_cell_matching=True 这个配置项的作用是:让 TableFormer 推断出的行列结构与底层的字符坐标进行精确匹配,确保每个单元格内的文字内容正确对应到正确的格子里,而不是靠坐标猜测。
3.6 两种 PDF 后端的差异
后端
说明
适用场景
DoclingParseV2DocumentBackend(推荐)
Docling 自研的高精度解析器,能正确处理复杂字体编码和特殊字符
大多数商业 PDF,尤其含特殊字符/字体时
DoclingParseDocumentBackend(v1)
老版本,精度略低
兼容性回退
PyPdfiumDocumentBackend
基于 pdfium 库,速度快但精度一般
对速度要求高且 PDF 格式简单时
为什么 DoclingParseV2 更好 :它在处理字体映射时更精确,这直接影响特殊字符(如货币符号 €、数学符号 ±)和非英文字符的提取质量。用 PyPdfium 后端时,这类字符可能被替换为乱码或 ?,而 DoclingParseV2 能正确解析字体编码表并还原原始字符。
3.7 性能取舍:什么时候可以降级? Docling 的完整流水线比 PyPDF2 慢得多(主要消耗在版式模型推理上),大概慢 10-30 倍。如果处理量很大,有几个可调节的旋钮:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 pipeline_options.table_structure_options.mode = TableFormerMode.FAST pipeline_options.do_ocr = False pipeline_options.do_table_structure = False parser.parse_and_export_parallel( input_doc_paths=pdf_paths, optimal_workers=4 , chunk_size=5 )
决策建议 :
文档含财务表格、对表格精度要求高 → 用完整配置(ACCURATE 模式)
文档以叙述性文字为主、表格不重要 → 可关闭 do_table_structure,速度提升明显
文档是扫描件 → OCR 必须开启,不可妥协
原生数字 PDF → 关闭 OCR,速度提升显著
4. 第二步:元信息提取与保存 4.1 什么是元信息,为什么重要? 元信息(Metadata)是关于文档本身的描述性信息,例如:
文档来源(公司名称、文档类型)
文档统计(总页数、表格数量、图片数量)
每个 Chunk 所在的页码
元信息的核心作用:
过滤检索范围 :用户问”A公司2023年的营收”时,可以先通过元信息过滤只在A公司的文档里检索
溯源 :检索到的内容能追溯到原文的具体页码,方便人工验证
调试 :出现问题时快速定位是哪个文档哪一页的数据
4.2 文档级元信息的设计 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 def assemble_metainfo (self, data ): metainfo = {} sha1_name = data['origin' ]['filename' ].rsplit('.' , 1 )[0 ] metainfo['sha1_name' ] = sha1_name metainfo['pages_amount' ] = len (data.get('pages' , [])) metainfo['text_blocks_amount' ] = len (data.get('texts' , [])) metainfo['tables_amount' ] = len (data.get('tables' , [])) metainfo['pictures_amount' ] = len (data.get('pictures' , [])) metainfo['equations_amount' ] = len (data.get('equations' , [])) metainfo['footnotes_amount' ] = len ( [t for t in data.get('texts' , []) if t.get('label' ) == 'footnote' ] ) if sha1_name in self .metadata_lookup: metainfo['company_name' ] = self .metadata_lookup[sha1_name]['company_name' ] return metainfo
坑3 :不要用文件名作为文档唯一标识。在批量处理时,不同来源的文档可能有相同文件名。用文件内容的 SHA1 哈希作为标识符,既能去重,又能稳定索引。
4.3 外部元信息的关联 实际项目中,文档的业务信息(如所属公司、年份、分类)往往不在 PDF 里,而在一个外部 CSV 或数据库中。常见做法是用文档的哈希值作为 key 关联:
1 2 3 4 5 6 7 8 9 10 11 12 13 @staticmethod def _parse_csv_metadata (csv_path: Path ) -> dict : """从CSV加载元信息,以sha1为key建立查找表""" import csv metadata_lookup = {} with open (csv_path, 'r' , encoding='utf-8' ) as csvfile: reader = csv.DictReader(csvfile) for row in reader: company_name = row.get('company_name' , row.get('name' , '' )).strip('"' ) metadata_lookup[row['sha1' ]] = { 'company_name' : company_name } return metadata_lookup
5. 第三步:文档结构化 —— 不只是提取文字 5.1 按页面组织内容 Docling 的输出是一个逻辑文档树(body.children),不是按页面组织的。我们需要将其转换为按页面组织的结构,这对后续的切块和检索非常有用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 { "content" : [ { "page" : 1 , "content" : [ {"type" : "page_header" , "text" : "Annual Report 2023" }, {"type" : "section_header" , "text" : "Financial Highlights" }, {"type" : "text" , "text" : "Revenue grew by 15%..." }, {"type" : "table" , "table_id" : 0 }, {"type" : "text" , "text" : "Note: All figures in USD millions" } ], "page_dimensions" : {"l" : 0 , "t" : 842 , "r" : 595 , "b" : 0 } }, // ...更多页面 ] }
5.2 处理页面序号缺口 这是一个非常容易踩到的坑 。Docling 在某些情况下会跳过没有文本内容的页面(如全图页面),导致页面序号不连续(1, 2, 4, 5…)。如果后续按索引访问页面,会产生越界或错位问题。
正确做法是填充空页面,保持序号连续:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def _normalize_page_sequence (self, data: dict ) -> dict : """填充缺失页面,确保页码序列连续""" existing_pages = {page['page' ] for page in data['content' ]} max_page = max (existing_pages) new_content = [] for page_num in range (1 , max_page + 1 ): page_content = next ( (page for page in data['content' ] if page['page' ] == page_num), {"page" : page_num, "content" : [], "page_dimensions" : {}} ) new_content.append(page_content) data['content' ] = new_content return data
5.3 处理文档逻辑树中的 Group 引用 Docling 的 body.children 中有时会出现 $ref 引用(指向 groups、texts、tables 等),需要递归展开。初学者容易忽略 groups 层级的处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 def expand_groups (self, body_children, groups ): """展开body中的group引用,将group的子项直接平铺到父级""" expanded_children = [] for item in body_children: if isinstance (item, dict ) and '$ref' in item: ref = item['$ref' ] ref_type, ref_num = ref.split('/' )[-2 :] ref_num = int (ref_num) if ref_type == 'groups' : group = groups[ref_num] for child in group['children' ]: child_copy = child.copy() child_copy['group_id' ] = ref_num child_copy['group_name' ] = group.get('name' , '' ) child_copy['group_label' ] = group.get('label' , '' ) expanded_children.append(child_copy) else : expanded_children.append(item) else : expanded_children.append(item) return expanded_children
6. 第四步:表格的专项处理 6.1 表格是 RAG 的噩梦 表格是 RAG 中最难处理的内容类型。直接把 Markdown 表格灌进向量库,检索时有两个问题:
语义稀疏 :向量模型不擅长理解表格的行列关系,”Q3 Revenue: $5.2B” 和 “Revenue in the third quarter was 5.2 billion dollars” 语义相似度可能很低
上下文缺失 :一行数据脱离表头就失去了意义,比如 “5,234” 单独看毫无意义,需要知道”这是营收数据,单位是百万美元,Q3 2023”
6.2 表格序列化:用 LLM 转化为自包含文本块 解决方案是用 LLM 将每一行(或逻辑相关的几行)转化为一段自包含的自然语言描述:
原始 Markdown 表格:
1 2 3 4 | Metric | Q1 2023 | Q2 2023 | Q3 2023 | |--------|---------|---------|---------| | Revenue (USD M) | 4,521 | 4,890 | 5,234 | | Net Income (USD M) | 412 | 456 | 501 |
序列化后的文本块(每行一个):
1 2 3 4 5 6 7 8 9 10 11 Revenue for the company: - Q1 2023: USD 4,521 million - Q2 2023: USD 4,890 million - Q3 2023: USD 5,234 million (Source: Quarterly Financial Summary table, amounts in USD millions) Net Income for the company: - Q1 2023: USD 412 million - Q2 2023: USD 456 million - Q3 2023: USD 501 million (Source: Quarterly Financial Summary table, amounts in USD millions)
6.3 关键:利用表格上下文 表格旁边的文字(标题、注释、脚注)对理解表格至关重要。在调用 LLM 序列化时,要把这些上下文一并传入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 def _get_table_context (self, json_report, target_table_index ): """提取目标表格在页面中的上下文文字""" table_info = next (t for t in json_report["tables" ] if t["table_id" ] == target_table_index) page_num = table_info["page" ] page_content = next ( (page["content" ] for page in json_report["content" ] if page["page" ] == page_num), [] ) current_pos = next ( i for i, block in enumerate (page_content) if block["type" ] == "table" and block.get("table_id" ) == target_table_index ) prev_table_pos = next ( (i for i in range (current_pos-1 , -1 , -1 ) if page_content[i]["type" ] == "table" ), -1 ) next_table_pos = next ( (i for i in range (current_pos+1 , len (page_content)) if page_content[i]["type" ] == "table" ), -1 ) start = prev_table_pos + 1 if prev_table_pos != -1 else 0 context_before = "\n" .join( b.get("text" , "" ) for b in page_content[start:current_pos] if "text" in b ) end = min (current_pos + 4 , next_table_pos) if next_table_pos != -1 else current_pos + 4 context_after = "\n" .join( b.get("text" , "" ) for b in page_content[current_pos+1 :end] if "text" in b ) return context_before, context_after
LLM Prompt 设计要点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 system_prompt = """你是一个表格序列化代理。 你的任务是基于提供的表格和周围文字,创建一组上下文无关的信息块。 这些信息块必须完全独立,因为它们将作为独立的 Chunk 存入数据库。 每个 Chunk 必须包含: 1. 行头信息(核心主体) 2. 所有列头信息(维度说明) 3. 单位、货币等说明 4. 表格名称或来源说明 跳过任何有价值的信息将受到严重惩罚!""" user_prompt = f""" 表格前的上下文: "{context_before} " HTML 格式的表格: "{table_html} " 表格后的上下文: "{context_after} " """
坑4 :不要只传 Markdown 格式的表格给 LLM。HTML 格式 保留了合并单元格信息(rowspan/colspan),对于理解复杂的多级表头非常关键。Markdown 格式在表示合并单元格时会信息丢失。
6.4 序列化结果的存储策略 序列化后的表格信息有两种使用方式:
方式一(推荐):Markdown + 序列化并行存储
1 2 3 4 5 6 7 8 9 10 11 for page in file_content['content' ]['pages' ]: page_chunks = split_page(page) chunks.extend(page_chunks) if page['page' ] in tables_by_page: for table_chunk in tables_by_page[page['page' ]]: table_chunk['type' ] = 'serialized_table' chunks.append(table_chunk)
方式二:替换 Markdown 表格
直接用序列化文本替换原始 Markdown 表格,更简洁,但丢失了原始表格格式。
7. 第五步:文本清洗 —— 容易被忽视的关键环节 7.1 OCR 噪声问题 对于扫描 PDF,OCR 会引入各种噪声。一个常见但极其隐蔽的问题是 PDF 字体命令残留 。某些 PDF 文件在提取时会保留字体排版指令,例如:
1 Revenue was /five/period/two/three billion dollars in Q3
实际应该是:
1 Revenue was 5.23 billion dollars in Q3
必须用正则清洗这类噪声:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 command_mapping = { 'zero' : '0' , 'one' : '1' , 'two' : '2' , 'three' : '3' , 'four' : '4' , 'five' : '5' , 'six' : '6' , 'seven' : '7' , 'eight' : '8' , 'nine' : '9' , 'period' : '.' , 'comma' : ',' , 'colon' : ':' , 'hyphen' : '-' , 'percent' : '%' , 'dollar' : '$' , 'slash' : '/' , 'space' : ' ' , 'plus' : '+' , 'minus' : '-' , 'asterisk' : '*' , 'lparen' : '(' , 'rparen' : ')' , 'parenright' : ')' , 'parenleft' : '(' , } recognized_commands = "|" .join(command_mapping.keys()) slash_command_pattern = rf"/({recognized_commands} )(\.pl\.tnum|\.tnum\.pl|\.pl|\.tnum|\.case|\.sups)?" text = re.sub(slash_command_pattern, lambda m: command_mapping.get(m.group(1 ), m.group(0 )), text) text = re.sub(r'glyph<[^>]*>' , '' , text) text = re.sub(r'/([A-Z])\.cap' , r'\1' , text)
7.2 用 Markdown 结构化文本,提升切块质量 将文档的视觉结构映射到 Markdown 语义,可以显著提升后续切块的效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 def _apply_formatting_rules (self, blocks ): """将结构化块转为 Markdown 格式的文本""" final_blocks = [] for i, block in enumerate (blocks): block_type = block.get("type" ) text = block.get("text" , "" ).strip() if block_type in {"page_footer" , "picture" }: continue if block_type == "page_header" : prefix = "\n# " if i < 3 else "\n## " final_blocks.append(f"{prefix} {text} \n" ) elif block_type == "section_header" : final_blocks.append(f"\n## {text} \n" ) elif block_type == "paragraph" : final_blocks.append(f"\n### {text} \n" ) elif block_type == "list_item" : final_blocks.append(f"- {text} \n" ) elif block_type == "table" : table_md = self ._get_table_markdown(block["table_id" ]) final_blocks.append(f"\n{table_md} \n" ) elif block_type in {"text" , "footnote" , "caption" }: if text: final_blocks.append(f"{text} \n" ) return final_blocks
为什么要转成 Markdown?
RecursiveCharacterTextSplitter 等工具会优先在 Markdown 分隔符(#、\n\n 等)处切割,避免在句子中间切断。章节标题 ## Revenue Overview 会成为天然的切割点,确保每个 Chunk 以完整的语义单元为边界。
7.3 表格与脚注的关联处理 这是一个非常重要但常被忽视的细节 。表格下方的脚注(如”* 金额以百万美元为单位”)往往对理解表格数据至关重要。要将表格及其相关脚注作为一个整体处理,不能被切断:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 if block_type == "table" : group_blocks = [] if prev_block_ends_with_colon: group_blocks.append(prev_block) group_blocks.append(table_block) while next_block.type == "footnote" : group_blocks.append(next_block) advance() group_text = render_table_group(group_blocks)
8. 第六步:文本切块策略 8.1 切块的核心矛盾 切块大小是一个权衡:
太小 (< 100 tokens):单个 Chunk 信息不完整,缺乏上下文,检索到了也没用
太大 (> 1000 tokens):向量语义被稀释,精确信息被淹没,检索精度下降
实践经验 :对于企业文档(年报、合同、技术文档),200-400 tokens 的 Chunk 大小是较好的平衡点,重叠(overlap)设为 Chunk 大小的 15%-20%。
8.2 使用 Token 计数而非字符数 不要用字符数控制切块大小。 中文、英文、代码的字符密度差异巨大,1000 个字符可能是 500 tokens(英文),也可能是 300 tokens(中文)。应该直接按 Token 计数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import tiktokenfrom langchain.text_splitter import RecursiveCharacterTextSplitterdef count_tokens (text: str , encoding_name="o200k_base" ) -> int : """精确计算 Token 数(使用与目标模型一致的分词器)""" encoding = tiktoken.get_encoding(encoding_name) return len (encoding.encode(text)) text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder( model_name="gpt-4o" , chunk_size=300 , chunk_overlap=50 )
坑5 :编码器的选择要与你使用的 Embedding 模型保持一致。OpenAI 的 text-embedding-3-large 使用 cl100k_base 分词器,gpt-4o 使用 o200k_base。两者对同一段文本的 Token 计数可能有差异。
8.3 RecursiveCharacterTextSplitter 的切割优先级 这是 LangChain 中最常用的切块工具,它按以下优先级寻找切割点(从高到低):
1 2 3 4 5 1. \n\n (段落间空行 —— 对应 Markdown 块间距) 2. \n (换行) 3. 。/. /!/? (句子结束标点) 4. 空格 5. 字符(最后手段)
这就是为什么我们要把文档格式化为 Markdown —— Markdown 的结构恰好与这些切割优先级对齐,能最大概率在”好的位置”切割。
8.4 按页面切块 vs 全文切块 强烈推荐按页面切块 ,而不是将整个文档拼接后再切块。原因:
页码元信息 :每个 Chunk 可以知道自己来自第几页,便于溯源
避免跨页混淆 :两个相邻页面的内容可能没有任何逻辑关联,强行重叠会产生语义噪声
性能 :更小的处理单元,并行更容易
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 def split_all_pages (file_content: dict , chunk_size: int = 300 , chunk_overlap: int = 50 ): """按页面切块,每个Chunk携带页码信息""" all_chunks = [] chunk_id = 0 for page in file_content['content' ]['pages' ]: text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder( model_name="gpt-4o" , chunk_size=chunk_size, chunk_overlap=chunk_overlap ) chunks = text_splitter.split_text(page['text' ]) for chunk_text in chunks: all_chunks.append({ "id" : chunk_id, "page" : page['page' ], "text" : chunk_text, "length_tokens" : count_tokens(chunk_text), "type" : "content" }) chunk_id += 1 return all_chunks
9. 第七步:切块后的元信息挂载 9.1 每个 Chunk 应携带的信息 一个完整的 Chunk 对象应该包含以下字段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "id" : 42 , "text" : "Revenue grew by 15%..." , "page" : 3 , "type" : "content" , "length_tokens" : 287 , }
9.2 文档级元信息 vs Chunk 级元信息的存储策略 有两种方案:
方案A:扁平化(每个Chunk都存文档元信息)
1 2 3 4 5 6 7 8 9 10 chunk = { "id" : 42 , "text" : "..." , "page" : 3 , "sha1_name" : "abc123" , "company_name" : "Example Corp" , "document_pages" : 48 , ... }
优点:检索后无需额外查询;缺点:存储冗余。
方案B:分层存储(推荐)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 document = { "metainfo" : { "sha1_name" : "abc123" , "company_name" : "Example Corp" , "pages_amount" : 48 , ... }, "content" : { "chunks" : [ {"id" : 42 , "page" : 3 , "text" : "..." , "type" : "content" }, ... ] } }
检索时,通过 sha1_name 关联文档的 metainfo。存储更紧凑,且文档元信息修改不需要更新所有 Chunk。
10. 常见错误汇总与避坑指南 错误清单
#
错误
后果
正确做法
1
用 PyPDF2 处理多列/复杂PDF
文本顺序混乱,检索内容错误
使用 Docling 等支持 Layout 分析的工具
2
不处理 OCR 噪声
数字和特殊字符显示为乱码
用正则清洗字体命令残留
3
直接将 Markdown 表格作为 Chunk
表格行脱离上下文无意义
用 LLM 序列化表格为自包含文本块
4
按字符数切块
中英文切块大小不一致
用 tiktoken 按 Token 数切块
5
全文拼接后再切块
丢失页码信息,跨页内容混淆
按页面独立切块
6
不保留页码信息
无法溯源,用户无法验证
每个 Chunk 必须携带页码
7
不处理页码缺口
页面数组访问越界
用空页面填充确保序号连续
8
不关联表格的脚注
表格数据单位/货币信息丢失
将表格+脚注作为整体处理
9
用文件名作为文档唯一标识
文件名冲突导致数据错乱
用文件内容的 SHA1 哈希
10
切块大小太小(< 100 tokens)
单个 Chunk 信息不完整
200-400 tokens 是合理范围
11
切块大小太大(> 800 tokens)
向量语义稀释,检索精度下降
根据实际文档类型调整
12
不保存表格的 HTML 格式
合并单元格信息丢失,LLM 无法正确序列化
同时保存 Markdown、HTML、JSON 三种格式
调试技巧
质量检查 :统计各类型 Chunk 的 token 数分布,发现异常大或异常小的 Chunk
1 2 3 4 5 6 7 8 import numpy as nptoken_lengths = [chunk['length_tokens' ] for chunk in chunks] print (f"平均: {np.mean(token_lengths):.1 f} " )print (f"中位数: {np.median(token_lengths):.1 f} " )print (f"最大: {np.max (token_lengths)} " )print (f"最小: {np.min (token_lengths)} " )print (f"超过500 tokens的Chunk比例: {sum (1 for t in token_lengths if t > 500 ) / len (token_lengths):.1 %} " )
可视化检查 :将处理后的文档导出为 Markdown 文件,人工抽查几篇看格式是否正确
OCR 质量检查 :统计噪声修正次数,修正量过大说明 PDF 质量差,需要检查
11. 完整数据结构参考 处理后的 JSON 结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 { "metainfo" : { "sha1_name" : "a1b2c3d4e5" , "company_name" : "Example Corporation" , "pages_amount" : 48 , "text_blocks_amount" : 312 , "tables_amount" : 15 , "pictures_amount" : 8 , "equations_amount" : 0 , "footnotes_amount" : 23 } , "content" : { "chunks" : [ { "id" : 0 , "page" : 1 , "text" : "# Annual Report 2023\n\nExample Corporation delivered strong results..." , "length_tokens" : 287 , "type" : "content" } , { "id" : 1 , "page" : 2 , "text" : "Revenue for the company in Q3 2023: USD 5,234 million..." , "length_tokens" : 145 , "type" : "serialized_table" , "table_id" : 3 } ] , "pages" : [ { "page" : 1 , "text" : "# Annual Report 2023\n\n## Financial Highlights\n\nExample Corporation delivered strong results..." } ] } }
流水线代码骨架 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 from pathlib import Pathfrom pdf_parsing import PDFParserfrom tables_serialization import TableSerializerfrom parsed_reports_merging import PageTextPreparationfrom text_splitter import TextSplitterRAW_PDF_DIR = Path("./data/raw_pdfs" ) PARSED_DIR = Path("./data/parsed" ) SERIALIZED_DIR = Path("./data/serialized_tables" ) MERGED_DIR = Path("./data/merged" ) CHUNKED_DIR = Path("./data/chunked" ) parser = PDFParser( output_dir=PARSED_DIR, csv_metadata_path=Path("./metadata.csv" ) ) parser.parse_and_export(doc_dir=RAW_PDF_DIR) serializer = TableSerializer() serializer.process_directory_parallel(PARSED_DIR, max_workers=5 ) preparation = PageTextPreparation( use_serialized_tables=True , serialized_tables_instead_of_markdown=False ) preparation.process_reports(reports_dir=PARSED_DIR, output_dir=MERGED_DIR) splitter = TextSplitter() splitter.split_all_reports( all_report_dir=MERGED_DIR, output_dir=CHUNKED_DIR ) print ("文档处理完成!" )
总结 文档解析和切块是 RAG 系统的地基。地基不稳,上层再精妙的检索策略和 Prompt 工程也无法挽救。
核心原则回顾:
用合适的工具解析 PDF :Docling > pdfplumber > PyPDF2,选择能理解版式的工具
元信息是一等公民 :页码、文档来源、文档标识符,从一开始就要设计好并贯穿整个处理流程
表格需要特殊对待 :序列化为自包含文本块,结合上下文(标题、脚注)处理
清洗不能省 :OCR 噪声、字体命令残留,这些”看不见的垃圾”会悄悄污染检索结果
按 Token 切块,按页面划分 :控制精度,保留溯源能力
用 Markdown 结构化中间表示 :让切块工具能够”理解”文档结构,在合理位置切割
做好这些,你的 RAG 系统就有了一个坚实的起点。
本系列下一篇:RAG实战(二)-向量化与索引构建 ,介绍如何把切好的 Chunk 变成可被快速检索的向量索引。
本文基于 RAG-Challenge-2 项目的工程实践总结,参考代码见 src/pdf_parsing.py、src/tables_serialization.py、src/parsed_reports_merging.py、src/text_splitter.py。