ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

RAG落地关键:文档解析与文本切片策略全解析

RAG落地关键:文档解析与文本切片策略全解析 先说一个自己身上的翻车案例。去年帮一家客户搭合同问答系统几百份PDF走完解析、清洗、切片、向量化、检索、生成这条完整链路。当时我的主要精力全放在模型选型和 Prompt 调优上对解析和切片的态度就是“能出字就行”。结果上线第一周就被人问住了一份房屋租赁合同里租金条款在一个跨页表格中解析阶段表头全散切片又把表格从中间拦腰切断检索召回的 chunk 里只剩半张乱码表格和几行对不上的数字大模型再厉害也答不出准确金额。那次返工之后我才彻底明白文档解析与切片绝不是“跑通就行”的体力活而是整条检索增强生成链路的地基地基打歪了后面全白搭。这篇是系列第二篇。默认你已经梳理清楚业务问题、知道要解析哪些文档这篇就死磕一件事怎么把解析与切片做到不拖后腿。我会从工具选型、清洗细节、切片策略、参数调优到避坑经验依次展开所有代码都是实际能跑的所有结论都是我拿真实文档测过的。1. 为什么说解析和切片是整条链路的地基1.1 一次让我重写检索逻辑的返工上面提到的合同问答系统后来我复盘了整个链条发现问题全集中在前两步。文档解析阶段某个扫描版合同被 OCR 出来之后表格边框线全没了提取结果变成了“租金8500押金17000”这样一串无结构文本切片阶段我用固定长度按 500 字符硬切结果同一句话被劈成两半检索召回时上下文残缺生成答案自然前言不搭后语。最难受的是这类问题在前期测试时不容易暴露。单个文档、几个测试问题模型答得挺好一到几百份真实文档解析和切片的缺陷就全被放大。检索是“召回再排序”的逻辑前面召回的片段本身就是错的后面排序和生成再怎么优化都是白搭。1.2 解析和切片各自扛什么活把这两件事拆开看职责其实很清晰文档解析把 PDF、Word、扫描件变成干净、连续、保留结构的文本。重点不是“能读到字符”而是读懂版式——标题层级、表格结构、阅读顺序。文本切片把长文本切成适合向量化和检索的基本单元。重点不是“切得整齐”而是每个 chunk 都是一个语义独立、上下文完整的信息块。两者是前后依赖关系。解析做得脏切片就是在垃圾上雕花切片做得烂再好的 embedding 模型也召不回有效信息。所以我把它们放在这一篇里一起讲因为它们本来就是一条流水线上的两道工序。2. 文档解析从“能读到字”到“能读懂版式”2.1 先认清 PDF 的三种真实形态我做解析的第一步从来不是直接上工具而是先摸清楚手头的 PDF 属于哪种形态。在真实业务里PDF 大概分三种形态特征首选方案纯文本型有文本层可以直接选中文字PyMuPDF 或 pdfplumber 直接提取扫描图片型没文本层本质是图片OCR 后再做结构化解析复杂版式型有文本层但包含双栏、表格、浮动图片、公式marker 这类结构化转换工具很多刚接触的人容易犯的错是对所有 PDF 用同一个工具处理。纯文本型 PDF 用pdfplumber.extract_text()就能拿到不错的文本但同一个方法用在扫描件上只能得到空字符串复杂版式用 PyMuPDF 拿到的是按物理位置排列的碎片文本阅读顺序完全不对。先分类再选工具能省掉后面一半的清理时间。2.2 实测过的三档解析工具我自己实测下来文档解析工具可以分三档。第一档是轻量提取类代表是 PyMuPDFfitz。它的特点是快几百页的 PDF 几十秒就能出结果。适合处理结构简单的文本型 PDF。核心代码很简单import fitz doc fitz.open(contract.pdf) for page_no, page in enumerate(doc): blocks page.get_text(blocks, sortTrue) for block in blocks: x0, y0, x1, y1, text, block_no, block_type block if block_type 0: # 文本块 clean_text text.replace(\n, ) print(f[page {page_no1}] {clean_text})注意sortTrue是按块坐标排序但它只能保证“大概顺序”遇到双栏版面时依然会左右穿插这就是后面要讲的顺序还原问题。第二档是表格提取类代表是 pdfplumber。它最有价值的能力是提取表格能把有边框线的表格还原成行列结构import pdfplumber with pdfplumber.open(contract.pdf) as pdf: page pdf.pages[0] tables page.extract_tables() for table in tables: for row in table: print(row)遇到带合并单元格的复杂表格pdfplumber 会把合并的格子拆成 None需要自己写逻辑去填充前值。这不算 bug而是这类工具的理解局限它知道线条在哪但不知道“这个格子横跨了两列”。第三档是结构化转换类代表就是热词里的 marker。它能把 PDF 直接转成 Markdown保留标题层级、表格和图片引用。对复杂版式文档效果明显好于前两档。2.3 marker 能搞定什么搞不定什么marker 的安装和命令行使用比较直接pip install marker-pdf marker_single input.pdf --output_dir output_dir跑完之后输出的就是带 Markdown 结构的文本标题层级、列表、表格都会被还原。我拿一份 30 页的双栏论文试过标题、小节结构、三线表都还原得不错直接省掉了大量手工清洗。但 marker 也不是万能的实测中这几个问题很突出慢。一份几百页的书转换耗时可能是分钟级和 PyMuPDF 的“秒出”完全不是一个量级。批量处理时要做任务队列控制并发。超大表格偶尔翻车。跨页的大宽表有时候列名会对不齐输出的 Markdown 表格少列。公式和手写批注无能为力。LaTeX 公式经常变成杂乱的符号串需要单独配公式识别模型。它对硬件有要求。底层跑深度学习模型CPU 上慢得让人怀疑人生最好有 GPU。所以我的选型习惯是先抽 10 份不同类型的样本用三档工具分别跑一遍肉眼对比输出质量再决定全量策略。大多数项目最终的方案是“PyMuPDF 做主力和兜底,marker 做复杂版式的定向解析”两者配合而不是互相替代。3. 解析后的清洗这层偷懒切片必然遭殃3.1 页眉页脚、页码、连字符的批量处理解析输出通常带着一堆“噪音”最典型的是页眉页脚、页码、水印、以及英文连字符换行。这些如果不清理切片时会被当成正经文本混进 chunk污染向量。页眉页脚的处理我分两层。第一层是模式清洗用正则批量做import re text text.strip() # 去掉独立成行的纯页码 text re.sub(r\n\s*\d{1,4}\s*\n, \n, text) # 去掉页眉页脚常见格式例如 第 3 页、Page 3 of 10 text re.sub(r\n\s*(第\s*\d\s*页|Page\s*\d\s*of\s*\d)\s*\n, \n, text) # 修复英文连字符断行unfortu- naste - unfortunately text re.sub(r(\w)-\n(\w), r\1\2, text)连字符修复这步很容易被忽略但对英文文档非常重要。不去掉的话切片后会留下一个半截单词检索时 embedding 会把它当成一个残缺 token召回质量明显下降。第二层是坐标过滤这个思路更优雅。用 PyMuPDF 拿到每个文本块的边界框 bbox页眉页脚通常集中在固定区域比如每页顶部 10% 和底部 8% 的 y 坐标范围内import fitz doc fitz.open(contract.pdf) for page in doc: height page.rect.height blocks page.get_text(blocks, sortTrue) for x0, y0, x1, y1, text, block_no, block_type in blocks: # 过滤页眉顶部和页脚底部 if y0 height * 0.1 or y1 height * 0.92: continue # 只保留文本块丢弃图片块 if block_type 0: print(text)这个方法比纯正则可控因为它是基于“位置”判断的而不是猜文本内容。唯一要注意的是有些正文真的会顶到页面边缘所以阈值要按实际情况调整。3.2 标题层级和表格结构的保真清洗时最容易犯的错是唯文本论——把 PDF 当成一段连续字符串把表格拆平成“字段值”串把标题降级成普通正文。这会给切片埋雷。先说标题。标题层级是后续做结构化切片的锚点也是检索时给 chunk 补充上下文的关键信息。用 marker 转换时标题通常已经变成#、##这种 Markdown 标记用 PyMuPDF 提取时可以根据字体大小和加粗特征判断标题。我是这么干的# PyMuPDF 获取字体信息部分 PDF 支持 span 级字体 def is_heading(span): return span[size] 14 and span[flags] 16 # 粗体更稳妥的做法是先按 y 坐标和文本内容找标题特征比如“第X章”“X.X”这种编号模式。找到标题后给这一段的后续文本打上“所属章节路径”的元数据切片时每个 chunk 都能知道自己在哪个章节下面。再说表格。表格不能和正文一起整体清洗因为它有自己的行、列、跨页语义。我的习惯是解析阶段就把表格单独抽出来转成二维结构切片阶段再单独处理。表格单独成块不要和正文混切这个策略在第六章的坑里会详细展开。3.3 多栏文档的阅读顺序还原双栏论文是真痛点。直接按坐标 sort 出来的文本左栏读几句跳到右栏再跳回左栏语义完全被打乱。如果你要处理的是学术论文这一步弄不好后面检索基本废掉。我的方案是基于 x 坐标做分栏判断先统计整页文本块的 x0 分布看有没有明显的“两簇”偏向如果左栏块集中在页面左侧 40% 范围右栏块集中在右侧 40%就按栏分开收集最后先输出左栏全部再输出右栏全部。def detect_columns(blocks, page_width): left_blocks [b for b in blocks if b[0] page_width * 0.5] right_blocks [b for b in blocks if b[0] page_width * 0.5] left_blocks.sort(keylambda b: b[1]) # 按 y 排序 right_blocks.sort(keylambda b: b[1]) # 按 y 排序 return left_blocks right_blocks分栏之后文本顺序才真正接近人类阅读顺序。识别“是否双栏”的核心是先看中间地带比如页面横向 45%-55% 区间有没有文本块如果没有大概率是双栏。这个方法对大多数论文都有效但遇到三栏公式密集的页面还需要额外处理建议按页判断而不是全文档一刀切。4. 切片策略chunk_size 和 overlap 不是拍脑袋定的4.1 固定窗口、递归分隔、语义切片怎么选清洗完拿到干净的文本接下来就是切片。我试过三种主流方式也花了不少时间对比它们在不同文档上的效果。固定窗口切片。按字符数硬切加一个重叠区。优点是简单、可控、性能好缺点是切点完全无视语义边界最容易把段落、句子、表格从中间劈开。def fixed_size_chunk(text, chunk_size800, overlap100): if len(text) chunk_size: return [text] chunks [] start 0 while start len(text): end start chunk_size chunks.append(text[start:end]) if end len(text): break start end - overlap return chunks递归字符分隔。这是我现在最常用的方案核心思路是提供一个分隔符优先级列表先按段落切分段落太长再按句子切句子太长再按逗号切尽量把语义断点当作切点。LangChain 里的实现在这里可以直接抄from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size800, chunk_overlap100, separators[\n\n, \n, 。, , , , , , ] ) chunks splitter.split_text(clean_text)注意separators的顺序就是优先级。中文场景把“。”排到英文空格前面效果会比默认配置好很多因为中文的语义断点是句号而不是空格。语义切片。这是更进阶的思路先把文本按段落切好做 embedding计算相邻段的向量相似度相似度低于阈值就切开高于阈值就合并让每个 chunk 内部的话题保持连贯。思路本身很有吸引力但实测下来有两个问题一是计算量明显增大二是阈值很难一次性调好不同领域文本的语义边界密度差异很大。三种方式怎么选我的经验是大多数生产项目用递归字符分隔就够了配合合理的参数能覆盖 80% 的场景对代码文档、法律条款这种结构高度明确的文本优先按结构切对长文章、小说这类强叙事的文本再考虑语义切片。4.2 别把 Python 列表切片和文档切片搞混搜索热词里经常出现“python 数组切片命令”“列表切片”这里我也说说两者关系。语言层面的切片是一个非常基础、也非常有用的操作。列表切片arr [0, 1, 2, 3, 4, 5] arr[1:4] # [1, 2, 3] arr[:3] # [0, 1, 2] arr[::-1] # [5, 4, 3, 2, 1, 0]字符串切片本质是一回事text 文档解析与切片 text[0:3] # 文档解 text[-2:] # 切片numpy 的多维切片更强大比如从矩阵里取子区域import numpy as np m np.arange(12).reshape(3, 4) m[1:3, 0:2] # 第二、三行的一、二列这些语法是“文档切片”的实现工具——比如要按窗口取字符子串就是text[start:end]把一个段落列表切成若干组就是paras[a:b]。但文档切片本质上是一个策略问题切多长、在哪里切、重叠多少、要不要带元数据。这是两件事一个是工具一个是方案别混为一谈。我会在写完整流水线时把两者结合起来先用递归分隔得到段落边界再用语言层切片组装 chunk既保留了语义断点又充分利用了 Python 的切片语义。4.3 参数怎么调chunk_size 和 overlap 的实测感受chunk_size 和 overlap 的调参是我花时间最多、也最容易“好心办坏事”的地方。先说 chunk_size。我试过 200 到 2000 字符之间的多档参数。200-300 太碎一个 chunk 常常装不下一个完整论点检索召回的信息密度不够2000 以上太大虽然上下文完整但向量化之后检索精度下降而且浪费 token最常用、效果最均衡的是 800-1200。中文场景一个字大概等于 1-2 个 token800 字符大约是 1200-1600 token这个量级既装得下一个完整段落群又不会超出大多数 embedding 模型的推荐范围。再说 overlap。它的作用是避免切点把关键信息切断后彻底丢失。我的经验是 10%-20% 比较合适比如 chunk_size 800 就配 overlap 100 左右。overlap 太少切点附近的内容仍然会丢上下文overlap 太多同一段信息在多个 chunk 里重复出现向量库膨胀检索时也容易出现重复召回的冗余片段。最后是混合策略。不要全文一个参数到底。代码、表格、正文的信息密度差别很大我用的是普通正文递归字符分隔chunk_size 800overlap 100表格整表或按行组切chunk_size 可以放大到 1500保证表头和行数据尽量同块代码块按空行和缩进层级切避免函数体被拦腰切断这样做的收益比单纯调一个全局参数明显得多。5. 一条能直接抄的完整流水线5.1 从 PDF 到带来源标注的切片纸上谈兵够多了给一套可以直接跑的完整流程。逻辑是PyMuPDF 按块提取文本正则清洗按段落聚合递归分隔切碎最后带上页码和章节路径元数据输出。import fitz import re from langchain.text_splitter import RecursiveCharacterTextSplitter def clean_text(text: str) - str: text re.sub(r\n\s*\d{1,4}\s*\n, \n, text) text re.sub(r\n\s*第\s*\d\s*页\s*\n, \n, text) text re.sub(r(\w)-\n(\w), r\1\2, text) text re.sub(r[ \t], , text) text re.sub(r\n{3,}, \n\n, text) return text.strip() def parse_pdf(path: str): doc fitz.open(path) page_texts [] for page in doc: blocks page.get_text(blocks, sortTrue) page_parts [] for block in blocks: x0, y0, x1, y1, text, block_no, block_type block if block_type 0: # 过滤页眉页脚区域 if y0 page.rect.height * 0.1 or y1 page.rect.height * 0.92: continue page_parts.append(text) page_text \n.join(page_parts) page_texts.append(clean_text(page_text)) return page_texts def chunk_with_meta(page_texts, chunk_size800, overlap100): splitter RecursiveCharacterTextSplitter( chunk_sizechunk_size, chunk_overlapoverlap, separators[\n\n, \n, 。, , , , , , ] ) all_chunks [] for page_no, text in enumerate(page_texts, start1): chunks splitter.split_text(text) for chunk in chunks: all_chunks.append({ content: chunk, metadata: { source: contract.pdf, page: page_no } }) return all_chunks page_texts parse_pdf(contract.pdf) result chunk_with_meta(page_texts) for item in result[:3]: print(item[metadata], item[content][:50])这一段代码里几个细节值得说。一是按页处理而不是整篇处理这样元数据里的 page 字段自然就有了后续溯源时直接能用。二是清洗放在解析和切片之间每个 page 单独清洗避免跨页把页眉带进去。三是separators里中文断句符优先级排在前面这是中文切片效果差别最大的一个点。5.2 没有标注数据怎么验收切片质量很多项目没有标注数据没法做正规的召回率评估但我有三个土办法成本低效果却很好。第一个是“断点抽查”。随机抽 20 个 chunk观察每个 chunk 的开头和结尾是不是落在语义断点上。理想情况是开头是某个完整参论点或段落的自然开头结尾是句号、问号、感叹号这类终止符。如果 20 个里有超过 5 个是“从句子中间开始”或“到句子中间结束”说明你的切片粒度或分隔符优先级有问题要调。第二个是“按题检索”。找 5-10 个你确定文档里能回答的问题比如“合同里的违约金比例是多少”拿到 embedding 之后 top-5 召回的 chunk人工看这些 chunk 是否包含完整答案以及是否有足够上下文比如答案里的“该比例”能不能找到指代对象。这个测试最贴近生产真实场景。第三个是“重建阅读测试”。把同一章节的 chunk 按顺序拼接起来读一遍看是不是流畅的原文。如果拼接后出现大量重复或跳跃说明 overlap 或切分位置有问题如果拼接后基本能还原原文语义说明切片没有撕裂文本。三招轮流用基本能在一个小时内判断出当前解析和切片的整体质量。这也是我每次调参之后必做的验收步骤。6. 那些让地基开裂的坑6.1 表格被腰斩之后检索结果根本没法看这是我在多个项目里反复踩的坑值得放在最前面。固定窗口切片对表格是毁灭级的一张有 20 行的表格切成 3 块检索到中间那块时既看不到表头也看不到表尾模型根本不知道那列数字是什么含义。解决思路是解析阶段就把表格单独识别出来标记为table类型切片时走单独的表格分支。表格的切片原则是“尽量整表一块整表放不下按行组分块并保留表头”。比如把表头单独提取每 5 行作为一个分组和表头拼接成一个 chunkdef split_table_with_header(table_rows, header, rows_per_chunk5): chunks [] for i in range(0, len(table_rows), rows_per_chunk): group table_rows[i:i rows_per_chunk] chunk_text header \n \n.join(group) chunks.append(chunk_text) return chunks这样每个 chunk 都带着表头检索召回任意一个分组都能知道字段含义。6.2 同一句话被劈成两半引用时前言不搭后语这个坑尤其爱出现在“固定窗口 中文长文本”的组合里。中文没有空格分词固定窗口就是从任意位置下刀一个长句被切开的概率非常大。检索召回后半句时连主语都没有生成模型只能靠瞎编。解决方法是不要用固定窗口切中文正文用递归字符分隔并且确保。在分隔符优先级里排在空格前面。还可以加一个兜底逻辑切完之后检查 chunk 末尾字符如果是逗号、冒号、顿号这类“未完成感”很强的标点就把当前切点往前回退到最近的句末标点。6.3 切片丢了元数据后面溯源全抓瞎早期的版本里我存的 chunk 只有 content 字段没有 source、page、heading 这些元数据。结果用户追问“这个答案出自文档哪一页”的时候我完全答不上来想要按文档过滤检索范围、做权限控制的时候也无从下手。现在我的切片结构统一带三块元数据source来源文件名做溯源和多库隔离page页码回答引用定位heading所属章节路径比如“第三章 费用与支付 3.1 租金”做结构化检索和上下文补充获取 heading 的常见做法是切片时维护一个“当前最近标题”的栈解析到#开头的内容就入栈遇到下一级更短就弹栈这样每个 chunk 都知道自己在哪个层级下面。这个细节看起来小实际做引用标注、做分章节检索、做层级权限控制的时候全靠它。踩过这几轮坑之后我现在对“地基”这个词特别有体会。文档解析和切片改起来非常烦没有模型调优那么光鲜但它们决定了下游所有环节的天花板。每次想在这层省时间的时候我就想起那个被切成两半的合同表格——地基打歪了后面全白搭这句总结不是标题党是拿真金白银的返工换来的教训。所以如果你正要开始搭类似的文档问答系统我建议你在解析和切片上多留一倍的时间预算。前期多花半天把工具选对、把清洗规则写好、把切片策略测一遍后面整个链路都会省心很多。尤其是表格、跨页、双栏这几类难啃的文档提前设计好处理策略比上线后救火强得多。
返回列表