戰(zhàn):解決多欄PDF與掃描水印的文檔解析難題)
1. 為什么 bbox 能治多欄排版和水印 PDF 的“病”先交代一下背景。我在給 RAG 知識(shí)庫做文檔解析時(shí)最頭疼的輸入不是那種干凈的單欄 PDF而是兩類“刺頭”一類是學(xué)術(shù)論文、財(cái)報(bào)、報(bào)紙截圖導(dǎo)出的多欄排版 PDF一類是帶各種水印的掃描件或網(wǎng)上下載的資料。這兩類文件如果直接用 pdfplumber 或 PyMuPDF 按默認(rèn)順序抽取文本抽出來的結(jié)果基本沒法看——多欄文件會(huì)把左欄和右欄的文字交錯(cuò)混在一起水印 PDF 會(huì)把“機(jī)密”“內(nèi)部資料”“僅供學(xué)習(xí)”這種水印文字當(dāng)成正文一并入庫。等檢索階段一查命中的 chunk 可能是一段被攔腰截?cái)嗟木渥踊蛘咭粭l沒頭沒尾的水印文本。整個(gè) RAG 管線的召回質(zhì)量直接從 90 分掉到 60 分而且你還很難定位問題出在哪一步。解決這個(gè)問題的關(guān)鍵就是標(biāo)題里的 bboxbounding box邊界框。說白了bbox 就是每個(gè)文字塊、每個(gè)單詞、每張圖片在頁面上的坐標(biāo)位置通常用(x0, y0, x1, y1)四個(gè)值表示。有了坐標(biāo)我們就可以回答三個(gè)靈魂問題這段文字在頁面的什么位置它和旁邊的文字是同一欄還是跨欄它到底屬于正文、頁眉、頁腳還是水印這三個(gè)問題回答清楚多欄排版和水印 PDF 的解析問題就解決了一大半。這篇文章是我“RAG 文檔解析”系列的第三篇前面的內(nèi)容分別講了整體解析管線的搭建和文本清洗這篇專門聚焦 bbox 實(shí)戰(zhàn)。我會(huì)把多欄排版怎么分欄、水印怎么識(shí)別和剔除從思路到代碼再到踩坑記錄完整過一遍。適合正在搭 RAG 知識(shí)庫、被文檔解析質(zhì)量折磨的工程師和數(shù)據(jù)愛好者參考哪怕你之前沒接觸過 bbox 概念跟著實(shí)操一遍也能上手。在繼續(xù)往下讀之前先建立一個(gè)基本認(rèn)知文檔解析本質(zhì)上不是“讀文字”而是“還原版式”。人類閱讀紙質(zhì)文檔時(shí)眼睛會(huì)自動(dòng)根據(jù)版面布局決定閱讀順序但機(jī)器沒有這個(gè)能力。如果不在解析階段顯式地利用版面信息任何 PDF 抽取工具給出的都只是“文本流”而不是“人類理解的文本順序”。bbox 的價(jià)值就是讓我們能把機(jī)器看到的“文本流”重新組織成符合人類閱讀習(xí)慣的“文本塊序列”。2. 前置準(zhǔn)備拿到可靠的 bbox 信息2.1 三種獲取 bbox 的途徑bbox 數(shù)據(jù)從哪來取決于 PDF 的類型。我實(shí)際工作中通常把它分成三種情況第一原生文本型 PDF。也就是文字還能被鼠標(biāo)選中的 PDF。這種情況下不需要 OCR直接用 PyMuPDFfitz或者 pdfplumber 就能拿到每個(gè)字符、單詞、行、塊的精確坐標(biāo)。PyMuPDF 的速度比 pdfplumber 快不少處理幾百頁的文檔也不覺得卡我主力用 PyMuPDFpdfplumber 用來做交叉驗(yàn)證。第二掃描圖片型 PDF。頁面本質(zhì)上是圖片沒有文本層必須先走 OCR。PaddleOCR 是我用得最多的工具它返回的識(shí)別結(jié)果里每一行都帶一個(gè)四邊形的四點(diǎn)坐標(biāo)左上、右上、右下、左下轉(zhuǎn)換成 bbox 就是x0 min(x坐標(biāo)),y0 min(y坐標(biāo)),x1 max(x坐標(biāo)),y1 max(y坐標(biāo))。這一步是 OCR 的標(biāo)配能力幾乎所有主流 OCR 都支持。第三帶文本層但排版混亂的 PDF。比如從某些網(wǎng)站導(dǎo)出的電子書有文本層但 Tagged PDF 結(jié)構(gòu)缺失或者文本層的順序是亂的。這種情況我一般先嘗試直接用 PyMuPDF 抽取再用 bbox 重新排序。如果文本層本身就是亂序的比如一段話被拆成幾十個(gè)碎片隨機(jī)存放那就要考慮對頁面渲染成圖片再做 OCR用 OCR 結(jié)果里的 bbox 重建閱讀順序。我這里有一個(gè)執(zhí)行順序上的建議能不開 OCR 就不開 OCR。OCR 不僅慢還會(huì)引入識(shí)別錯(cuò)誤而且對公式、代碼、特殊字符的識(shí)別能力參差不齊。PDF 有原生文本層時(shí)直接抽坐標(biāo)是又快又準(zhǔn)的方案。但是要注意原生文本層的坐標(biāo)也是“從 PDF 內(nèi)部結(jié)構(gòu)讀取的坐標(biāo)”并不保證是合理的閱讀順序所以即使有文本層分欄和水印過濾的邏輯依然需要。2.2 最小實(shí)戰(zhàn)環(huán)境準(zhǔn)備我這里用一個(gè)最小實(shí)驗(yàn)環(huán)境來演示避免依賴太多庫導(dǎo)致大家復(fù)現(xiàn)困難pip install pymupdf pdfplumber paddleocr paddlepaddle scikit-learn numpy如果你的機(jī)器裝 PaddlePaddle 比較麻煩可以用paddleocr的 CPU 版本識(shí)別速度會(huì)慢一點(diǎn)但處理少量調(diào)試頁面完全夠用。還有一個(gè)更輕量的替代方案是用rapidocr_onnxruntime它基于 ONNX Runtime不需要安裝 PaddlePaddle 全家桶對 CPU 環(huán)境更友好我這里演示以 PaddleOCR 為主但思路在 RapidOCR 上完全一致。另外如果你處理的 PDF 本身是帶文本層的那只需要pymupdf就夠了OCR 相關(guān)的依賴可以不裝。2.3 PyMuPDF 輸出的 bbox 數(shù)據(jù)結(jié)構(gòu)用 PyMuPDF 抽取單詞級(jí)坐標(biāo)非常簡單import fitz doc fitz.open(sample.pdf) page doc[0] # 獲取頁面尺寸單位是 point1/72 英寸 page_width page.rect.width page_height page.rect.height # 抽取所有單詞每個(gè)元素包含 # (x0, y0, x1, y1, word, block_no, line_no, word_no) words page.get_text(words) print(len(words)) print(words[:5])輸出大概是這樣的[(72.0, 106.5, 89.3, 119.2, Abstract, 0, 0, 0), (90.1, 106.5, 155.7, 119.2, —, 0, 0, 1), (156.5, 106.5, 178.9, 119.2, This, 0, 1, 0), ...]每個(gè) tuple 的前四個(gè)值是 bbox第五個(gè)是單詞文本最后三個(gè)是塊號(hào)、行號(hào)、詞號(hào)。這個(gè)數(shù)據(jù)對我們有用嗎有用但 block_no 和 line_no 是 PyMuPDF 自己根據(jù) PDF 內(nèi)部結(jié)構(gòu)算出來的在多欄排版時(shí)往往不符合人類閱讀順序。所以我們的原則是block_no、line_no 只能當(dāng)輔助參考真正的排序依據(jù)是 bbox 坐標(biāo)本身。再補(bǔ)充一個(gè)重要概念PyMuPDF 的坐標(biāo)單位是 point和 PDF 內(nèi)部坐標(biāo)一致。如果你渲染成圖片再 OCR得到的 bbox 單位是像素。兩種單位不一致但邏輯一致?lián)Q算關(guān)系是像素 point × 渲染分辨率 / 72。調(diào)試多欄排版時(shí)我建議統(tǒng)一用 point 坐標(biāo)系這樣不容易搞混。3. 多欄排版實(shí)戰(zhàn)把兩欄 PDF 還原成正確閱讀順序3.1 分欄的核心思路聚類而不是畫中線多欄排版最大的問題是什么兩欄文字從上到下、從左到右交叉排列。如果你直接按 y 坐標(biāo)從上到下排序取文本你會(huì)得到左欄第一行、右欄第一行、左欄第二行、右欄第二行……讀起來像兩段文字被做了拉鏈拼接語義完全斷裂。RAG 切塊時(shí)如果正好切在這個(gè)“拉鏈區(qū)”檢索效果會(huì)非常差。最常見的分欄做法是以頁面寬度中線為界左邊歸左欄右邊歸右欄。這個(gè)方案對“嚴(yán)格兩欄等寬排版”有效但遇到不等寬欄位、三欄混排、或者頁面中有一個(gè)跨欄標(biāo)題時(shí)就會(huì)翻車。比如論文首頁經(jīng)常是標(biāo)題和作者信息橫跨兩欄然后正文才分成兩欄如果一刀切按中線分標(biāo)題文字會(huì)被切成左半句和右半句。所以我的做法是用聚類算法按文字塊中心點(diǎn)的 x 坐標(biāo)來分欄而不是機(jī)械地畫中線。思路是這樣的把頁面里的所有文本塊或單詞收集起來計(jì)算每個(gè)文本塊中心點(diǎn)的x_center (x0 x1) / 2。把這些中心點(diǎn)作為一維樣本做聚類比如用 scikit-learn 的 KMeans簇?cái)?shù)設(shè)為 2 或根據(jù)頁面寬度自動(dòng)判斷。聚類完成后每個(gè)文本塊會(huì)得到一個(gè)“欄標(biāo)簽”然后按“欄標(biāo)簽 y 坐標(biāo)”排序輸出。我用一個(gè)簡化版的代碼演示import numpy as np from sklearn.cluster import KMeans def group_columns(words, max_columns2): 根據(jù)單詞的 x 中心點(diǎn)坐標(biāo)做聚類給每個(gè)單詞分配欄號(hào)。 words: PyMuPDF 的 get_text(words) 輸出 返回words 欄號(hào)列表 if not words: return [], [] centers_x np.array([(w[0] w[2]) / 2.0 for w in words]).reshape(-1, 1) # 如果所有詞都擠在頁面一小塊區(qū)域直接判定為單欄 x_std centers_x.std() if x_std 50: # 閾值需要根據(jù)實(shí)際情況調(diào)整 labels [0] * len(words) return words, labels n_clusters min(max_columns, len(set(np.round(centers_x.flatten() / 20)))) if n_clusters 2: labels [0] * len(words) return words, labels km KMeans(n_clustersn_clusters, n_init10, random_state42) labels km.fit_predict(centers_x) # 把欄號(hào)按左側(cè)坐標(biāo)排序保證左邊是第0欄右邊是第1欄 cluster_x0 {} for idx, label in enumerate(labels): cluster_x0.setdefault(label, []).append(words[idx][0]) sorted_labels sorted(cluster_x0.keys(), keylambda lbl: min(cluster_x0[lbl])) label_map {old: new for new, old in enumerate(sorted_labels)} labels [label_map[l] for l in labels] return words, labels然后按欄分組、組內(nèi)按 y 排序def reorder_by_columns(words, labels): columns {} for w, label in zip(words, labels): columns.setdefault(label, []).append(w) ordered [] for label in sorted(columns.keys()): col_words sorted(columns[label], keylambda w: (w[1], w[0])) # 按行聚合y 坐標(biāo)相近的視為同一行 lines merge_words_into_lines(col_words) ordered.extend(lines) return orderedmerge_words_into_lines的作用是把同一行的單詞合并成句子不能直接按單詞排序輸出否則一個(gè)句子被單詞級(jí)拆散。合并邏輯是基于 y 坐標(biāo)的相近性如果兩個(gè)單詞的 y 中心點(diǎn)差小于某個(gè)閾值比如 12pt就認(rèn)為是同一行。3.2 跨欄標(biāo)題和特殊元素的處理聚類分欄解決了大部分問題但還有一個(gè)隱藏陷阱跨欄元素。論文首頁的標(biāo)題、作者列表、摘要有時(shí)是跨欄的有時(shí)摘要本身又是單欄的。如果這些元素被聚類進(jìn)了某一個(gè)欄那就會(huì)導(dǎo)致欄內(nèi)文本順序錯(cuò)亂。我的處理方式是在分欄前先做一次“跨欄判斷”把橫跨大半頁面的文本塊單獨(dú)拿出來def is_spanning_block(w0, w1, page_width): 判斷一個(gè)文本塊是否橫跨頁面的大部分寬度 span_ratio (w1 - w0) / page_width return span_ratio 0.6如果文本塊的寬度占頁面寬度 60% 以上就認(rèn)為它是跨欄標(biāo)題或跨欄段落不參與聚類直接作為獨(dú)立區(qū)塊放在整頁文本的最前面因?yàn)榭鐧跇?biāo)題通常在正文之前。這個(gè) 60% 的閾值不是我拍腦袋定的而是測試了一批論文 PDF 后得到的經(jīng)驗(yàn)值。你可以根據(jù)自己的文檔類型調(diào)整。關(guān)鍵點(diǎn)是先識(shí)別跨欄塊再對剩余塊做聚類順序不能反。如果先聚類再識(shí)別標(biāo)題已經(jīng)被拆散分進(jìn)兩個(gè)欄了后面就很難再還原。3.3 多欄順序還原的校驗(yàn)方法排完序之后怎么知道對不對靠肉眼翻頁看太慢我一般用兩個(gè)快捷的校驗(yàn)手段。第一個(gè)是輸出順序?qū)彶榘阎嘏藕蟮奈谋景葱写蛴〕鰜砻啃星懊娲蛏闲刑?hào)隨機(jī)抽幾頁查看。重點(diǎn)看欄目銜接處——左欄最后一行是否完整右欄第一行是否應(yīng)該是下一句。如果出現(xiàn)明顯的“斷句”現(xiàn)象說明欄內(nèi)排序或行合并邏輯有問題。第二個(gè)是嵌入前驗(yàn)證在處理好的文本上先跑一遍檢索測試。拿幾個(gè)典型問題去查看命中的 chunk 是否語義完整。多欄解析做得好的話檢索質(zhì)量不會(huì)比單欄 PDF 差太多做得不好往往會(huì)召回一些“前言不搭后語”的文本。我強(qiáng)烈建議你在正式批量解析前先把一套“校驗(yàn)樣本”確定下來包含至少 5 種不同類型的多欄 PDF論文首頁、財(cái)報(bào)兩欄、報(bào)紙式多欄、含跨欄標(biāo)題的頁面、含表格的頁面。每次改動(dòng)解析邏輯先跑校驗(yàn)樣本肉眼確認(rèn)輸出質(zhì)量再放量處理。這套方法幫我避免了好幾次“改一處、炸一片”的事故。4. 水印 PDF 實(shí)戰(zhàn)識(shí)別、定位、剔除4.1 水印的形態(tài)分類水印是 RAG 文檔解析里最容易“看著不起眼、實(shí)際很致命”的問題。如果水印文本進(jìn)了知識(shí)庫輕則浪費(fèi) embedding 的存儲(chǔ)空間重則導(dǎo)致檢索結(jié)果返回?zé)o意義文本用戶一問問題召回的水印“機(jī)密”兩個(gè)字體驗(yàn)非常糟。我接觸過的水印場景大致分四類水印類型典型特征對解析的影響重復(fù)文字水印“機(jī)密”“禁止外傳”“樣例”等字樣在頁面上大量重復(fù)出現(xiàn)直接混入正文文本流污染 chunk半透明圖形水印圖片形式透明度高鋪滿整頁或斜向分布視覺有干擾但 OCR 一般不會(huì)把它識(shí)別為文本頁眉頁腳式水印“第 X 頁 / 共 Y 頁”“公司內(nèi)部文件”固定在頁面頂部或底部每個(gè) chunk 都可能重復(fù)包含稀釋有效信息斜向大字水印文字沿對角線排列字號(hào)大、顏色淺與正文文字區(qū)域重疊OCR 可能把水印文字和正文混在一起識(shí)別處理水印的核心原則和分欄一樣不能用“是否在文本流里”判斷一段文字是不是水印要用布局特征判斷。bbox 恰恰就是布局特征的量化表示。4.2 基于 bbox 特征的規(guī)則過濾方案我實(shí)戰(zhàn)中用的水印識(shí)別方案是一個(gè)“規(guī)則組合”不依賴機(jī)器學(xué)習(xí)模型解釋性和可控性都很好。對每個(gè)文本塊我計(jì)算下面這些特征重復(fù)性同一頁面中是否有大量文本塊的文本內(nèi)容完全一致。如果某個(gè)字符串在頁面上出現(xiàn) 3 次以上而且都分布在不同的 bbox 位置基本可以斷定是水印。位置分布水印文字塊通常分散在頁面各個(gè)區(qū)域但和正文不同它的位置與閱讀順序無關(guān)經(jīng)常落在頁面的中部或偏離常規(guī)正文區(qū)域。可以統(tǒng)計(jì)文本塊中心點(diǎn)坐標(biāo)的分布做一個(gè)簡單的一維聚類如果某個(gè)“位置簇”里的文本塊內(nèi)容高度重復(fù)判定為水印。面積占比水印文字塊常為“大面積稀疏文本”即 bbox 很寬但包含的字很少。比如斜向水印每個(gè)字的 bbox 跨度可能很大但實(shí)際文本只有兩三個(gè)字。旋轉(zhuǎn)特征PyMuPDF 的page.get_text(dict)或rawdict里可以拿到字符的旋轉(zhuǎn)信息。水印文字經(jīng)常有 45 度或 90 度的旋轉(zhuǎn)正文文字的旋轉(zhuǎn)角度通常為 0。顏色特征PDF 原生文本的dict結(jié)構(gòu)里有 color 信息。水印文字經(jīng)常是淺灰色灰度值接近 0.7~0.9而正文是黑色或深色。這里給一段基于 PyMuPDF 原始 dict 數(shù)據(jù)抽取顏色和旋轉(zhuǎn)信息的示例def extract_block_info(page): data page.get_text(dict) blocks_info [] for block in data[blocks]: if lines not in block: continue for line in block[lines]: for span in line[spans]: bbox (span[bbox][0], span[bbox][1], span[bbox][2], span[bbox][3]) blocks_info.append({ text: span[text], bbox: bbox, color: span[color], # 十進(jìn)制 RGB 值 size: span[size], font: span[font], # 旋轉(zhuǎn)角度需要從 rawdict 或 CharFlags 里提取不同版本 API 有差異 }) return blocks_infospan[color]是一個(gè)十進(jìn)制整數(shù)需要轉(zhuǎn)成 RGB。比如 0x999999 是淺灰色換算方式是color 0xFFFFFF再拆出 R、G、B。實(shí)戰(zhàn)中我主要看灰度是否偏高RGB 且數(shù)值大于 150 左右。再結(jié)合文本的重復(fù)性判斷from collections import Counter def detect_repeated_watermark(blocks_info, min_repeat3): text_list [b[text].strip() for b in blocks_info if b[text].strip()] counter Counter(text_list) repeated_texts {t for t, c in counter.items() if c min_repeat} watermark_blocks [ b for b in blocks_info if b[text].strip() in repeated_texts ] return watermark_blocks這個(gè)規(guī)則簡單有效尤其對付“機(jī)密”“內(nèi)部資料”這類整頁重復(fù)的水印。但要注意不要一看到重復(fù)就刪正文里完全可能有合法的重復(fù)內(nèi)容比如“目錄”兩個(gè)字在每章標(biāo)題前都會(huì)出現(xiàn)。所以需要結(jié)合其他特征交叉驗(yàn)證。4.3 與 OCR 文本置信度結(jié)合的水印過濾掃描型 PDF 的情況和原生文本型不一樣OCR 輸出的每行不僅帶 bbox還帶置信度。水印文字因?yàn)楹捅尘皩Ρ榷鹊?、可能被正文遮住一部分OCR 置信度普遍偏低。我在處理掃描件水印時(shí)會(huì)把置信度作為一個(gè)重要的過濾維度。具體做法是OCR 完成后把識(shí)別結(jié)果按 bbox 還原到頁面上統(tǒng)計(jì)每個(gè)文本行的置信度分布。如果某一行和其他行相比置信度明顯偏低比如低于 0.7而頁面上其他行都在 0.9 以上并且該行的內(nèi)容與頁面上其他行有重復(fù)就把它標(biāo)記為疑似水印。不過這里有個(gè)坑正文中有些元素本身 OCR 置信度就低比如公式、帶底色塊上的文字、印章蓋住的字。如果把“低置信度”一刀切過濾會(huì)誤刪正文。我的策略是水印文本必須同時(shí)滿足“低置信度”和“高重復(fù)性”才刪除只滿足一個(gè)條件就先保留留到人工審核階段再處理。還有一類特殊情況整頁都是水印圖片也就是 PDF 的每一頁底面都覆蓋了一張半透明的水印圖而正文是后印上去的文字。這種情況在 PDF 結(jié)構(gòu)層里有明確體現(xiàn)頁面上有圖片對象且圖片的 bbox 覆蓋頁面大部分區(qū)域圖片之上才是文字。PyMuPDF 可以直接讀取頁面圖片的 bboxdef get_image_bboxes(page): image_rects [] for img in page.get_images(fullTrue): # 獲取圖片在頁面上的放置位置 rects page.get_image_rects(img[0]) image_rects.extend([(round(r.x0, 1), round(r.y0, 1), round(r.x1, 1), round(r.y1, 1)) for r in rects]) return image_rects如果某張圖片的 bbox 與頁面尺寸相當(dāng)比如覆蓋超過 80% 頁面同時(shí)頁面上還有正常的文字塊那這張圖基本就是背景水印圖。它對文本抽取沒有影響但對圖片解析管線可能有影響——如果你后續(xù)要把頁面圖片也納入多模態(tài) RAG需要把這類背景水印圖排除掉不能讓它作為獨(dú)立圖片進(jìn)知識(shí)庫。注意這里說的“剔除水印文本”是針對 RAG 解析流程里的文本過濾不是要把 PDF 視覺上的水印刪掉那屬于 PDF 編輯和去水印軟件的工作范疇。我們是做知識(shí)庫整理目標(biāo)是讓入庫內(nèi)容干凈不需要改原文件。5. 常見問題與排查技巧實(shí)錄5.1 表格跨欄被切成兩半怎么辦多欄論文里經(jīng)常出現(xiàn)“表格橫跨兩欄”的情況比如一個(gè)寬表格寬度超過了單欄范圍排版時(shí)被放在頁面頂部或底部跨欄顯示。聚類分欄時(shí)這種跨欄表格塊因?yàn)閷挾却?、中心點(diǎn)在頁面中央可能會(huì)形成一個(gè)獨(dú)立的簇也可能被誤分到某一欄里。無論哪種情況表格內(nèi)容被夾在正文文本流中RAG 檢索時(shí)會(huì)把表格碎片和正文混在一起。我的處理方案分兩步第一步先用規(guī)則識(shí)別表格區(qū)域。PyMuPDF 的page.find_tables()新版本里叫page.find_tables()能返回表格的 bbox 和單元格結(jié)構(gòu)。pdfplumber 也提供類似能力。識(shí)別出來后可以把表格 bbox 從“分欄聚類”的樣本里排除掉讓表格單獨(dú)處理。第二步表格內(nèi)容單獨(dú)抽取、單獨(dú)入庫。表格按 bbox 落到“靠近頁面頂部”或“靠近頁面底部”時(shí)我會(huì)把它作為獨(dú)立的 block 追加到該頁文本流的開始或末尾而不是按坐標(biāo)插到正文中間。這樣雖然犧牲了“絕對位置還原度”但保住了表格數(shù)據(jù)的完整性RAG 檢索表格內(nèi)容時(shí)效果好很多。5.2 掃描件上的水印誤刪正文剛才提到低置信度規(guī)則這里展開講一個(gè)我踩過的坑。有一批掃描 PDF頁面左上角有淺的“掃描專用”水印同時(shí)正文中有一段“專用術(shù)語”也被 OCR 識(shí)別得很差置信度同樣很低。我第一版過濾規(guī)則把所有低置信度且重復(fù)出現(xiàn)的文本都刪了結(jié)果把正文里的術(shù)語表刪掉了一部分。后來排查發(fā)現(xiàn)被誤刪的文本塊雖然置信度低但它們的位置和上下文關(guān)系是連續(xù)的前一塊講的是 A 內(nèi)容后一塊講的是 A 內(nèi)容的延伸中間被刪的塊剛好是銜接部分。于是我改進(jìn)規(guī)則只有同時(shí)滿足“重復(fù)出現(xiàn)”“位置分散”“上下文不連續(xù)”三個(gè)條件才判定為水印。正文中的術(shù)語即使置信度低它在閱讀順序中的上下文是連貫的不會(huì)同時(shí)滿足位置分散和上下文不連續(xù)。5.3 頁眉頁腳和頁碼干擾嚴(yán)格來說頁眉頁腳不算水印但處理邏輯很接近。RAG 解析時(shí)頁碼和重復(fù)的頁眉會(huì)污染 chunk 一致性。比如“第 3 頁 / 共 120 頁”這種字符串出現(xiàn)在每個(gè) chunk 里檢索時(shí)既占 token 又沒信息量。我用 bbox 直接做一刀切定義頁眉區(qū)為頁面頂部 10% 高度范圍頁腳區(qū)為底部 8% 高度范圍。凡是 bbox 完全落在這些區(qū)域內(nèi)的文本塊直接過濾。這個(gè)規(guī)則簡單粗暴但有個(gè)反例有的論文頁眉里包含了章節(jié)標(biāo)題這對檢索其實(shí)有正面價(jià)值。所以我增加了白名單機(jī)制——保留包含“關(guān)鍵詞/摘要/引言/方法/結(jié)論”等章節(jié)關(guān)鍵詞的頁眉過濾純重復(fù)的頁眉。具體閾值上頂部 10% 底部 8% 是我處理 A4 學(xué)術(shù) PDF 的經(jīng)驗(yàn)值。如果你的文檔有比較寬的頁邊距需要根據(jù)實(shí)際渲染效果調(diào)整。我的建議是先對幾個(gè)典型頁面打印 bbox 坐標(biāo)分布看頁眉頁腳到底落在哪個(gè)區(qū)間再定閾值。5.4 代碼環(huán)境層面的經(jīng)典問題處理幾百個(gè) PDF 時(shí)PaddleOCR 偶爾會(huì)崩常見原因有三個(gè)一是顯存不足。如果批量 OCR 掃描 PDF建議單張圖長邊限制在 2000 像素以內(nèi)既能保證識(shí)別精度又不會(huì)讓顯存爆炸。如果機(jī)器性能差可以加--use_fp16或者干脆換成 RapidOCR。二是坐標(biāo)單位不統(tǒng)一。PaddleOCR 返回的坐標(biāo)是圖像像素坐標(biāo)而 PyMuPDF 返回的是 point 坐標(biāo)。同一套代碼里同時(shí)處理原生 PDF 和掃描 PDF 時(shí)一定要先做一個(gè)單位統(tǒng)一函數(shù)把所有坐標(biāo)轉(zhuǎn)成同一個(gè)坐標(biāo)系再進(jìn)入后續(xù)的邏輯否則分欄和水印判斷會(huì)全部錯(cuò)亂。三是多線程處理 PDF 時(shí)OCR 引擎的并發(fā)問題。PaddleOCR 的predict方法線程不安全我用的是進(jìn)程池而不是線程池每個(gè)進(jìn)程單獨(dú)初始化一個(gè) OCR 引擎。實(shí)測下來進(jìn)程數(shù)為 CPU 核心數(shù)的一半時(shí)速度和穩(wěn)定性最均衡。5.5 效果評(píng)估的量化方法最后分享一個(gè)評(píng)估解析效果的小工具。我手動(dòng)整理了一批“標(biāo)準(zhǔn)答案”拿 20 個(gè)多欄頁面手工標(biāo)出每一塊的閱讀順序然后寫腳本比較算法輸出順序和標(biāo)準(zhǔn)答案的順序一致性。指標(biāo)用簡單的“逆序?qū)Ρ壤薄?jì)算算法輸出中的相鄰塊關(guān)系與標(biāo)準(zhǔn)答案的差異逆序?qū)υ缴僭胶谩_@個(gè)指標(biāo)雖然粗糙但足夠用來做回歸測試。每次改進(jìn)解析邏輯就跑一遍這 20 個(gè)頁面看逆序?qū)Ρ壤欠裣陆?。如果下降了才繼續(xù)測試更大規(guī)模的 batch。這樣做的好處是避免“感覺修好了其實(shí)只是換了另一種錯(cuò)誤方式”的陷阱。在實(shí)際項(xiàng)目中我用這個(gè)評(píng)估方法調(diào)了三輪參數(shù)第一輪把聚類距離閾值從 50 調(diào)到了 80第二輪給跨欄表格加了預(yù)識(shí)別第三輪給頁眉加了白名單機(jī)制。每輪調(diào)整都有量化指標(biāo)支撐而不是靠感覺拍板。6. 一點(diǎn)個(gè)人經(jīng)驗(yàn)這套 bbox 處理流程我在本地 RAG 知識(shí)庫項(xiàng)目上反復(fù)打磨過。最大的感悟是文檔解析沒有銀彈規(guī)則越多越要控制在可解釋范圍內(nèi)。不要一上來就堆深度學(xué)習(xí)版面分析模型先從 bbox 規(guī)則入手把 80% 的常見問題解決掉剩下的邊角情況再考慮模型。給讀者一個(gè)實(shí)操順序建議第一步把你手頭所有 PDF 按“原生文本型”和“掃描型”分類第二步只對原生文本型做 PyMuPDF 坐標(biāo)抽取先跑通多欄排序第三步再處理掃描型的 OCR 水印過濾。每一步都拿 5 個(gè)典型頁面做肉眼驗(yàn)證確認(rèn)沒問題再擴(kuò)大批量。最后再分享一個(gè)調(diào)試技巧解析階段把 bbox 信息可視化輸出Windows 上可以直接用 PyMuPDF 把 bbox 畫到頁面上生成標(biāo)注圖看一眼哪里分欄錯(cuò)了、哪里水印沒刪干凈一目了然。我剛?cè)腴T時(shí)靠這個(gè)可視化方法省了大量時(shí)間——很多問題看文字日志死活找不到原因一看標(biāo)注圖就明白了。后續(xù)這個(gè)系列我可能還會(huì)寫一篇關(guān)于表格解析和版面模型選型的實(shí)戰(zhàn)對比如果你現(xiàn)在手頭有更刁鉆的文檔類型卡住了也歡迎在評(píng)論區(qū)分享你的案例我可以拿真實(shí)的項(xiàng)目數(shù)據(jù)繼續(xù)做測試。