ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用Kreuzberg搞定PDF文档结构提取:打造高质量RAG知识库的实践

2026/9/7 21:07:57 拓冰建站 浏览量
用Kreuzberg搞定PDF文档结构提取:打造高质量RAG知识库的实践 上个月我在做一批历史技术手册的知识库预处理遇到了一个很典型的问题PDF数量不小而且绝大部分是扫描件或带复杂排版的电子版直接把整页文字整块扔给大模型做切片结果要么是一段毫无语义的纯文字堆要么是标题、正文、表格全混在一起检索时一问一个错。后来我把方案换成了Kreuzberg来做文档结构提取才算是把这个链条真正理顺了。Kreuzberg这个工具简单说就是专门从PDF、图片这类文档里把“结构”拉出来的解析库。它和传统OCR最大的区别在于OCR给你的是文字而它给你的是“有组织、有位置、有层级”的文字——哪些是标题、哪些是正文段落、哪些是表格、哪些是页眉页脚、阅读顺序如何。对于做RAG知识库、文档二次编辑、合规审查、数据迁移这类场景这一层结构信息往往比文字本身更值钱。这篇文章我会从思路、实践、踩坑三个层面把整个提取流程完整讲一遍适合正在搭建文档处理管线的开发者也适合好奇PDF解析背后原理的读者。1. 为什么文档结构提取不能只靠OCR1.1 纯文本识别结果的致命缺陷我们团队最开始处理文档时走的也是大多数人的常规路线Tesseract或云厂商的OCR接口识别出文字后直接按页存文本再丢给下游做切片和向量化。前期测试看着还行一旦放到真实数据集上就暴露问题了。比如一份技术手册页面左上角是章节标题中间是正文右下角是图片注释OCR会把这一页所有文字按坐标从上到下排成一段字符串标题和正文之间没有任何边界标记。你以为你喂给大模型的是“第3章 系统架构”实际上它看到的是“3. 系统架构…系统由以下模块组成…注图3-1…”。这个问题的本质是“识别”和“理解”之间的鸿沟。OCR负责的是把像素变成字符它天然不关心哪些字符组成一个标题、哪些字符属于同一段落、表格的单元格边界在哪里。而文档结构提取要做的恰恰是后面这件事把字符重新组织成语义单元再给这些语义单元标注角色。如果你的下游是全文检索那么多几行杂讯可能还能容忍如果你的下游是结构化入库或者大模型问答结构混乱就意味着召回质量和回答准确率双双下降。1.2 结构信息在下游场景中的真实价值我举一个我们实际遇到过的案例。有一批产品说明书需要做合规比对核心诉求是“找到每份文档中关于安全警告的章节并对比不同版本间的措辞差异”。如果只用纯文本你需要写一堆正则去猜“警告”“注意”“重要”这些词出现的位置而且很容易被正文里的同义词干扰。但有了结构提取结果之后问题就变成了一条SQL级别的查询找role“heading”且level“1”的块读取其后续兄弟块的文本然后对比。这就是结构信息的价值——它把原本需要靠启发式规则硬猜的问题变成了一个相对确定的数据访问问题。在RAG场景下结构信息的价值更直接。传统切分策略是按固定字符数或token数切块切到一半会把标题和正文切断。而基于结构提取的结果你可以“按标题切块”——每个二级标题下的所有内容作为一个切片单元这样每个切片天然自带语义边界和层级上下文向量检索时可以同时匹配标题向量和内容向量准确率提升是很明显的。这也是我为什么坚持在管线里引入文档结构提取而不是继续用OCR的原因。1.3 Kreuzberg在文档解析工具链里的定位现在市面上做文档解析的工具不少比如PyMuPDF能拿到PDF的文本块坐标Tesseract擅长纯图像文字识别LlamaParse等商业服务则把版面分析打包成了云API。Kreuzberg的特点在于它把“文字识别”和“结构解析”这两件事整合在了一起并且让布局分析和阅读顺序重建的过程对使用者透明化。你给它一个文件路径它返回的是带角色标注、带边界框、带层级关系的结构化块列表而不是一串裸文本。当然这不是说Kreuzberg是万能的。它更适合“常规版式的印刷类文档”像论文、手册、合同、报告、表单。对于一些版式极度复杂的文档比如带大量浮动文本框的PPT导出PDF仍需要人工校对。但在我测试过的十几类文档里它的综合表现已经很能打了尤其是对栏目标题和表格结构的识别明显比传统OCR加后处理的方式稳得多。2. 核心特性拆解与提取出的结构长什么样2.1 布局分析从矩形框到语义角色Kreuzberg的底层大概由两个阶段构成。第一阶段是版面分析它会用训练好的模型在页面上找出一块块视觉上独立的区域每个区域对应一个候选块第二阶段是角色分类模型根据块的位置、大小、文字密度、相对关系把这些候选块标记为“标题”“正文”“表格”“图表标题”“页眉页脚”等角色。这两阶段合在一起相当于把“页面”这个视觉对象翻译成了一份“语义地图”。我最初上手时以为调用过程会非常复杂后来发现其实极其直白。核心接口大概就几个函数传入文件拿回Document对象文档对象再包含Page和Block。每个Block上都有type字段表示角色有bbox表示坐标还有text表示内容。如果你只需要标准输出那个extract_text相关的方法就足够用了。2.2 输出的结构模型和坐标体系直接看代码更直观我摘一段我们项目里的解析脚本from kreuzberg import KreuzbergDocument # 示意导入具体以实际版本为准 client KreuzbergDocument() doc client.extract(manual_v3.pdf) for page in doc.pages: print(fPage {page.number}) for block in page.blocks: print( f role{block.type:12s} fpos({block.bbox.x0:.0f},{block.bbox.y0:.0f}) fsize({block.bbox.width:.0f}x{block.bbox.height:.0f}) ftext{block.text[:50]!r} )这段代码的输出大致长这样Page 1 roleheader pos(48, 28) size(504x18) text技术白皮书 V3.0 roleheading pos(48, 72) size(504x28) text1. 系统概述 roleparagraph pos(48, 108) size(504x96) text本章主要介绍系统的设计目标、适用范围以及…… roletable pos(48, 224) size(504x142) text| 模块名称 | 版本 | 说明 |… roleparagraph pos(48, 384) size(504x88) text各模块之间的依赖关系如下图所示。 rolefooter pos(48, 742) size(504x18) text第 1 页共 20 页这套输出里两个信息对我们后续处理特别关键。一个是role字段它直接给出了语义角色下游代码不需要再做正则猜测另一个是bbox坐标它能支撑你做区域筛选。比如某些文档页脚会包含日期、页码之类变化频繁的噪音内容在切分前直接过滤从根源上减少了脏数据进入知识库的概率。2.3 表格结构的特殊处理表格是文档解析里公认的深水区。很多PDF的表格并不是真正的线条表格而是用Tab键或空格对齐的“伪表格”。传统OCR会把这种表格识别成一段混着空格和文字的乱序字符串根本没法用。Kreuzberg的表格处理思路是把表格先当作版面块识别出来然后对内部区域做进一步的单元格切分和行列重建。提取结果会尽量对齐成结构化的行列形式。我在测试中拿到过一个管理制度的PDF里面有一张宽达8列的审批权限表解析后的结果虽然需要少量手调但整体可用度已经超过了我预期的90%。还需要提一下阅读顺序。多栏文档是PDF解析的经典难题双栏排版的论文如果不做阅读顺序重建解析结果会左右两栏交错阅读语义完全混乱。Kreuzberg在这一块做得比较细腻它根据块与块的几何位置关系和文本语序重建了一个可读的线性顺序我在几篇双栏IEEE论文上测试顺序基本都能和人类阅读顺序对齐。3. 环境准备与快速上手实操3.1 安装和依赖说明Kreuzberg这类工具通常基于Python生态安装方式一般就是pip但在正式安装前有两个前置依赖值得注意一是底层依赖的系统库比如libcairo、libmagic等不同系统稍有区别二是如果你打算处理扫描版PDF需要确保图像处理相关的依赖完整。我的建议是准备一个干净的虚拟环境在虚拟环境里安装避免污染系统级Python。python -m venv .venv source .venv/bin/activate pip install kreuzberg如果你的环境里没有编译工具链安装时出现greenlet或cffi相关报错通常先补一下系统的build-essential包就能解决。我在Ubuntu 22.04上装过基本上一条命令装完没有遇到额外问题。3.2 第一个完整示例提取多页文档并输出JSON为了说明实际用法我写一个更完整的示例它读取一个多页PDF把所有块按页输出成JSON结构方便下游程序读取import json from kreuzberg import load_document doc load_document(manual_v3.pdf) result [] for page in doc.pages: page_entry { page: page.number, width: page.width, height: page.height, blocks: [ { role: b.type, bbox: [ round(b.bbox.x0, 1), round(b.bbox.y0, 1), round(b.bbox.x1, 1), round(b.bbox.y1, 1), ], text: b.text, confidence: b.confidence, } for b in page.blocks ], } result.append(page_entry) with open(manual_structure.json, w, encodingutf-8) as f: json.dump(result, f, ensure_asciiFalse, indent2) print(fparsed {len(result)} pages, total blocks: {sum(len(p[blocks]) for p in result)})这个脚本基本上就是一个“结构提取最小闭环”高频使用场景中无非就是导入工具、读取文档、遍历block、序列化输出。我把结果存成JSON后下游无论是写Python处理还是导进其他语言都非常方便。这里有一个小建议bbox尽量保留至少一位小数不要全转成int因为后续如果要做区域筛选精度损失会直接影响阈值判断。3.3 不同文件类型和参数的效果差异实际工作中不可能只有一种PDF。我测试下来Kreuzberg应对不同类型文档的表现差异还是挺明显的这里把经验整理成一个表格供参考文档类型识别难度实测效果建议参数或做法电子版单栏PDF低标题、正文、表格区分准确几乎不用后处理默认参数即可扫描版PDF中高依赖图像清晰度300dpi以上效果稳定预处理时做倾斜校正、去噪点双栏论文PDF中阅读顺序重建准确偶尔误判跨栏标题必要时按列裁剪后分别提取复杂表格PDF高常见表格没问题极端合并单元格会丢边界提取后做行列表格结构校验带水印或印章PDF高水印可能被识别成独立块或噪声用bbox区域过滤去除底部水印如果你是第一次在自己的数据集上试我建议不要急着调参数先用默认设置跑一遍看输出再根据输出中具体哪一类块的识别错误最多反推应该调整哪个环节。这样比盲目调参高效得多。4. 从提取结果到业务落地的完整管线4.1 基于结构块的高质量文本切分有了结构块之后第一步就是过滤噪音。页眉页脚这两类块对于大多数下游任务都是干扰项做知识库时我通常在切分阶段直接丢弃。DISCARD_ROLES {header, footer, page_number} def clean_blocks(blocks): return [b for b in blocks if b.type not in DISCARD_ROLES]随后按标题层级做切片遇到heading块判断level是1还是2遇到level 2就开启一个新的切片单元后续的paragraph、table、list都归属于当前切片直到遇到下一个同级标题为止。def structure_to_chunks(page_blocks): chunks [] current_chunk None for b in page_blocks: if b.type heading and getattr(b, level, 1) 2: if current_chunk: chunks.append(current_chunk) current_chunk {title: b.text, contents: []} elif current_chunk is not None: if b.type table: current_chunk[contents].append(b.as_markdown()) else: current_chunk[contents].append(b.text) if current_chunk: chunks.append(current_chunk) return chunks这样切出来的每个chunk天然就有一个明确的标题和一段聚焦的内容。和固定长度切分相比这种“语义切片”在RAG里的优势非常直观检索时你匹配到的不是某句话所在的500个token窗口而是它所属的完整章节上下文信息完整得多。4.2 表格转Markdown/HTML的落地方式表格块是结构提取结果里最复杂的一类Kreuzberg通常提供了把表格转成纯文本、Markdown或HTML的方法。我在知识库项目里优先用Markdown格式因为大多数大模型对Markdown表格的理解好于用竖线和空格拼出来的原始文本。注意一点对于包含复杂单元格合并的表格转出来的Markdown有时候会丢失合并信息这种情况下建议用HTML保留更完整。我在实际项目中写过一个简单的导出函数把每页所有表格转成Markdown后拼进chunk内容里。def table_block_to_markdown(table_block): try: return table_block.to_markdown() except Exception as e: print(ftable convert failed: {e}) return 另外表格块往往伴随一个表标题块比如“表2-1 参数说明”。这种标题在视觉上可能和表格本身有一定距离模型有时会把它们分开标记成两个不同的块。如果你需要把表标题和表格连线可以基于坐标做一次简单的“最近的表格块”匹配这条规则虽然朴素但很有效。4.3 面向海量文档的批处理方案单文档处理没问题后批量处理就要考虑效率和稳定性。我建议用Path.glob收集所有PDF路径然后用concurrent.futures.ThreadPoolExecutor做并发提取。这里有个经验IO密集型的PDF解析线程池比进程池效率高很多但如果你同时在做图像预处理这类CPU密集型操作就需要改用进程池或异步任务队列。from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed def handle_one(pdf_path): doc load_document(str(pdf_path)) chunks [] for page in doc.pages: chunks.extend(structure_to_chunks(page.blocks)) out_path pdf_path.with_suffix(.json) out_path.write_text( json.dumps({source: pdf_path.name, chunks: chunks}, ensure_asciiFalse), encodingutf-8, ) return pdf_path.name pdf_dir Path(docs) all_pdfs list(pdf_dir.glob(*.pdf)) with ThreadPoolExecutor(max_workers8) as pool: futures {pool.submit(handle_one, p): p for p in all_pdfs} for fut in as_completed(futures): name fut.result() print(fdone: {name})这个方案在处理500份PDF时的稳定性还不错耗时大约取决于平均页数和扫描质量页数少的文档基本能在秒级完成。跑批时注意在每份文档处理失败时补一条异常捕获丢掉个别文件比让整个任务因异常中断要好得多。4.4 结果缓存与增量更新在知识库场景里文档内容的更新往往不是全量替换而是增量更新。我的做法是给每次解析结果算一个hash比如用源文件的MD5加上最后修改时间作为缓存键如果这次扫描发现同一个文件没有变化就直接读上次的JSON结构不再重新解析。这个优化能把重复构建知识库的时间成本降到一个极低水平。import hashlib import json from pathlib import Path def get_cache_key(pdf_path: Path): h hashlib.md5() h.update(pdf_path.read_bytes()) h.update(str(pdf_path.stat().st_mtime).encode()) return h.hexdigest() def cached_structure(pdf_path: Path, cache_dir: Path): key get_cache_key(pdf_path) cache_file cache_dir / f{pdf_path.stem}_{key}.json if cache_file.exists(): return json.loads(cache_file.read_text(encodingutf-8)) doc load_document(str(pdf_path)) data {...} # 按需要序列化 cache_file.write_text(json.dumps(data, ensure_asciiFalse, indent2), encodingutf-8) return data缓存机制加上之后知识库重建从过去的“全量解析几十分钟”降到“只需处理新增文档”实际运维成本省了一大截。5. 常见问题与排查技巧实录5.1 表格识别乱行、错列这是被问得最多的一类问题。原因通常不在Kreuzberg本身而是表格内部结构本来就不规整。比如PDF里表格用的是“无边框空白对齐”的排版方式单元格之间没有任何垂直线模型就很难判断列边界。我的排查顺序是先看原始PDF确认是否真的存在可见边框再看解析出的块是不是把所有文本都混成了一个表格块如果是就把它拆成普通段落处理或者用图像预处理方式增强边框对比度后再重试。遇到确实无法自动识别的表格我的兜底方案是把表格区域单独裁剪成图片交给专门做表格识别的模型去转成Excel然后再把结果拼回来。这个组合方案会比在同一个工具里死磕高效得多。5.2 多栏页面阅读顺序错乱双栏或三栏文档顺序重建偶尔会出错表现是左栏下半段接右栏上半段。这个问题的根源一般是模型对栏间分割线的判断不够肯定。我有两个实用技巧一是把页面宽度切成几个竖条分别提取内容再按栏拼接二是如果文档排版固定直接对提取出来的块按列坐标分组排序。def order_blocks_by_columns(blocks, num_columns2): blocks sorted(blocks, keylambda b: (b.bbox.y0 // 100, b.bbox.x0)) return blocks这个用纵坐标和横坐标混合排序的思路相当于人工给模型加上“栏优先”的约束。在固定版式的期刊文档上准确率能立竿见影提升。不过这一招对浮动元素多的页面不适用需要灵活判断。5.3 低质量扫描件的识别率优化扫描件质量差常见问题包括倾斜、阴影、模糊。Kreuzberg内置模型虽然有一定的抗噪能力但图像如果倾斜超过一定角度识别效果会明显下降。建议在正式解析前做预处理先做倾斜矫正再做灰度化和对比度增强最后提高导出分辨率。from PIL import Image, ImageOps image Image.open(page_scan.png) image ImageOps.exif_transpose(image) image image.convert(L) image image.point(lambda p: 255 if p 140 else 0) image.save(page_clean.png)需要说明的是这套预处理是通用的图像增强逻辑不是Kreuzberg特有的功能。如果你的文档扫描质量普遍不好建议在采集环节就规范扫描参数能从源头解决很多问题。5.4 常见问题速查表现象可能原因解决思路输出只有角色没有文字文字层被转成曲线路径先用PDF渲染成图片再走图像识别通道表格块被识别成普通段落表格无边框或全用空白对齐降低阈值或改用表格专项识别模型标题识别成正文标题字号与正文字号差距不大手动标注少量样本微调模型或后处理提取过程内存占用过高大图或超高DPI页面过多限制并发数分批处理页眉页脚混入正文页眉页脚和正文视觉距离太近用坐标和role联合过滤增加垂直偏移阈值这些坑大多数不是Kreuzberg独有的而是任何一个PDF解析工具都会遇到的共性问题。先理解现象背后的原因再去调整管道基本都能找到合适解法。6. 踩过几次坑之后的一些心得我在真正把Kreuzberg用进生产管线之前其实走了不少弯路。最开始我把所有PDF都当成同一个类型处理不管转出来的结构什么样直接一股脑塞进下游。结果有一批从旧系统导出的报表PDF版面上有大量嵌套表格和页眉水印解析结果里混进了很多噪音块导致知识库的检索准确率比之前用纯文本还差。后来发现问题不在工具而在我的管线缺少两个环节一是对解析结果的质量评估二是按文档类型分流处理。从那次教训之后我养成了两个习惯。第一个习惯是每次大规模解析前先抽10份不同类型的样本做人工审计统计每个role的准确率如果某个类型的准确率低于阈值就单独走专用处理流程。第二个习惯是所有解析结果都保留一份“原始结构JSON”而不是只留加工后的Markdown。这样无论后面清洗规则怎么改都不需要重新解析原始文件直接对JSON做后处理就行。如果你正在搭建类似的文档处理管道我建议多花点时间研究输出的role和bbox毕竟工具能做的只是把文档拆开并标注怎么利用这些标注信息去解决真实业务问题才是核心。实测下来一套基于Kreuzberg的文档结构提取方案加上适当的预处理和后处理完全可以在日常文档知识库、RAG、合规审查这些场景里落地而且效果远好于无脑OCR加正则的原始方案。