
RAGパイプラインやAIエージェントにPDFを食わせるとき、最初につまずくのは意外と「前処理の入口」だったりします。
LLMに渡すテキストを取り出す前に、そのPDFがそもそもテキストを持っているのか、それとも画像として焼き付けられたスキャンなのかを判別しなければなりません。
で、この判別ステップ、自前で書くと思いのほか泥沼にはまります。
PDF前処理の「判別ステップ」が地味に厄介な理由
「スキャンPDFはOCRが必要、テキストPDFはそのまま抽出すればいい」という話は、頭では分かっています。
問題は、現実のPDFがそんなにきれいに分かれていないことです。
社内の稟議書を例に取ると、1〜3ページ目はWordから書き出したテキストPDF、4ページ目は手書き書類をスキャンして差し込んだ画像、5ページ目はExcelの表をそのまま貼り付けた混在ページ、という構成は珍しくありません。
ファイル単位で「スキャンか否か」を判定しようとすると、こういう 混在PDF に即座に破綻します。
判別をページ単位に落とし込んでも、今度は「テキストレイヤーはあるが中身がほぼ空」という問題が出てきます。
スキャンPDFにOCRをかけて保存したとき、元の画像の上にテキストレイヤーだけ薄く乗っているケースがあります。
PyMuPDFやpdfminerでテキストを取り出すと文字列は返ってくるのですが、文字化けだらけだったり、意味のない記号の羅列だったりします。
これを「テキストが取れた=成功」と判断してしまうと、LLMに渡るのはゴミデータです。
さらに厄介なのが エンコーディング異常PDF です。
CIDフォントやIdentity-Hエンコーディングを使ったPDFは、テキストレイヤーが存在するにもかかわらず、文字コードのマッピングが壊れていて正しく読み出せないことがあります。
こういったPDFは「テキストPDFだからOCR不要」と判断すると品質が劣化し、「念のためOCRをかける」と処理コストが無駄に増えます。
自前でこの判別ロジックを書こうとすると、たいてい「ページから取れたテキストが何文字以上なら正常とみなす」という閾値チューニングに迷い込みます。
50文字?100文字?でも表だけのページは正常でも文字数が少ない……という具合に、例外ケースが積み上がっていきます。
この泥沼を避けるために作られたのが pdf-inspector です。
pdf-inspectorが判別をどう解いているか
pdf-inspectorはFirecrawlが開発したPure Rustのライブラリで、GitHubのREADMEによるとコンテンツストリームをサンプリングして10〜50msで分類結果を返します。
MLモデルも外部サービスも使わず、依存ライブラリはPDFパーサのlopdfのみという軽量さが特徴です。
分類の結果は次の4種類で返ってきます。
- TextBased — テキストレイヤーが十分な密度で存在し、そのまま抽出できる状態
- Scanned — 画像として焼き付けられており、テキストレイヤーが実質ない状態
- ImageBased — ページの大部分が画像オブジェクトで構成されている状態
- Mixed — ページによってTextBasedとScanned/ImageBasedが混在している状態
この4分類に加えて、判定の確信度を示す 信頼スコア(0.0〜1.0) と、ページごとのOCRルーティング情報が返ってきます。
ページ単位のルーティング情報が返ってくる点が、自前実装との決定的な差です。
Mixedドキュメントを受け取ったとき、「このページはOCRルートへ、このページはテキスト抽出ルートへ」という振り分けをライブラリ側が教えてくれます。
判定ロジックの内部では、テキストレイヤーの有無だけでなく、テキストの密度、フォントエンコーディングの異常検出も行っています。
GitHubのREADMEには「Encoding issue detection — Automatically flags broken font encodings so callers can fall back to OCR」と明記されており、エンコーディングが壊れていると判断した場合は呼び出し側にフォールバックを促す設計になっています。
これが前述の「エンコーディング異常PDF」問題への回答です。
ドキュメントのパースは一度だけ行われ、検出と抽出で共有されます。
判別のためにもう一度ファイルを読み直す、という無駄なI/Oが発生しない設計になっている点も、実務のパイプラインに組み込むときに地味に効いてきます。
PyMuPDF・pdfminerと何が違うか
PyMuPDFもpdfminerも、優れたテキスト抽出エンジンです。
ただし、どちらも「テキストを取り出す」ことが本来の責務であって、「このPDFはOCRが必要か否かを判定する」機能は持っていません。
PyMuPDFでページからテキストを取り出すことはできます。
でも取り出した結果が空だったとき、それが「スキャンPDFだから空」なのか「エンコーディング異常で文字化けして空に見える」のかは、呼び出し側が自分で判断しなければなりません。
その判断ロジックを自前で書くと、前述の閾値チューニング問題に戻ってきます。
pdf-inspectorが担っているのは、まさにその「判別の責務」です。
テキスト抽出エンジンとしてPyMuPDFを使い続けながら、判別だけpdf-inspectorに任せる構成は十分に現実的です。
「既存スタックを捨てる」ではなく「判別ステップを一枚挟む」という導入の仕方になります。
依存の軽さも本番環境への導入判断に影響します。
Pure Rustでlopdf単一依存という構成は、Dockerイメージのサイズや依存解決の複雑さを抑えたい場面で助かります。
PyMuPDFはCライブラリのMuPDFをバンドルしているため、ビルド環境によっては依存関係の調整が必要になることがあります。
判別ロジックだけのために重い依存を増やしたくないケースで、pdf-inspectorの軽量さは選定理由になり得ます。
Pythonバインディングで最小構成を動かす
インストールはpipで一行です。
pip install pdf-inspector
基本的な呼び出しはこのような形になります。
import pdf_inspector
# PDFファイルのパスを渡して処理する
result = pdf_inspector.process(
"sample.pdf",
mode="DetectOnly" # 判別だけ行う最軽量モード
)
print(result.pdf_type) # TextBased / Scanned / ImageBased / Mixed
print(result.confidence) # 0.0〜1.0の信頼スコア
# ページ単位のルーティング情報
for page in result.pages:
print(f"page {page.page_number}: needs_ocr={page.needs_ocr}")
ProcessModeは3択あります。
DetectOnly は判別だけを行う最軽量モードで、OCRルートかテキスト抽出ルートかの振り分けだけしたいときに使います。
Full はMarkdown変換まで含む全処理モードです。
Analyze はその中間に位置しますが、各モードの詳細な挙動はGitHubの APIドキュメント で確認してください。
RAGパイプラインで pdf-inspector を使う場合、最初のステップは DetectOnly で十分です。
判別結果を受け取ってから、テキスト抽出は PyMuPDF など既存のツールに任せる構成が現実的です。
戻り値の読み方で注意したいのは confidenceスコア です。
0.8以上であれば判定に自信があると見ていいですが、0.5前後のときは境界ケースです。
Mixedドキュメントでconfidenceが低い場合は、ページ単位のneeds_ocrフラグを優先して判断するか、安全側に倒してOCRルートに流す方針にしておくと安定します。
RAGパイプラインへの組み込み設計
判別結果を受け取ってOCRルートとテキスト抽出ルートに分岐するパイプライン関数の基本形はこのようになります。
import pdf_inspector
import pymupdf # テキスト抽出はPyMuPDFを使う例
def process_pdf_for_rag(pdf_path: str) -> list[dict]:
"""
PDFを受け取り、ページごとにテキストを返す。
判別結果に応じてOCRルートとテキスト抽出ルートに分岐する。
"""
# Step1: 判別だけ先に行う
detection = pdf_inspector.process(pdf_path, mode="DetectOnly")
results = []
# TextBasedで信頼スコアが高ければ全ページテキスト抽出ルート
if detection.pdf_type == "TextBased" and detection.confidence >= 0.8:
doc = pymupdf.open(pdf_path)
for page_num in range(len(doc)):
text = doc[page_num].get_text()
results.append({
"page": page_num + 1,
"text": text,
"route": "text_extraction"
})
return results
# Scannedであれば全ページOCRルート
if detection.pdf_type == "Scanned":
for page in detection.pages:
results.append({
"page": page.page_number,
"text": run_ocr(pdf_path, page.page_number), # 別途実装
"route": "ocr"
})
return results
# Mixed または confidence が低い場合はページ単位で振り分け
doc = pymupdf.open(pdf_path)
for page in detection.pages:
page_idx = page.page_number - 1
if page.needs_ocr:
# needs_ocrフラグを優先。エンコーディング異常の検出結果も
# このフラグに統合されているか、実際のAPIで確認すること
text = run_ocr(pdf_path, page.page_number) # 別途実装
route = "ocr"
else:
text = doc[page_idx].get_text()
route = "text_extraction"
results.append({
"page": page.page_number,
"text": text,
"route": route
})
return results
Mixedドキュメントのページ単位ループがこの設計の核心です。
detection.pages を走査して、各ページの needs_ocr フラグを見て振り分けます。
GitHubのREADMEではエンコーディング異常の検出結果が呼び出し側に通知される設計であることが確認できますが、ページオブジェクトの具体的なフィールド名は実際のAPIドキュメントで確認してください。
コード中のコメントにもその旨を残しておくと、後からメンテナンスするときに意図が伝わりやすくなります。
confidenceスコアを閾値として使う判断ロジックも入れておくと、境界ケースで安定します。
上のコードでは TextBased でも confidence が 0.8 未満の場合は Mixed と同じページ単位ループに落とす設計にしています。
「判別に自信がないときは安全側に倒す」という方針を明示的にコードに書いておくのは、運用が長くなるほど効いてきます。
ひとつ補足しておくと、pdf-inspectorは2024年にFirecrawlがオープンソース化した比較的新しいライブラリです。
GitHubのスター数は執筆時点で約2,970(OSSInsight調べ)あり、Firecrawlという実績あるプロジェクトが母体ではありますが、本番投入する際はバインディングのバージョンをpinして、アップデートの際は動作確認を挟む運用を推奨します。
既存の成熟ライブラリと同列に扱うより、変更リスクを織り込んでおく方が無難です。
判別ステップを自前の閾値チューニングで支えていたパイプラインは、pdf-inspectorを一枚挟むだけでかなりすっきりします。
まずは DetectOnly モードを既存パイプラインの入口に差し込んで、needs_ocr フラグの精度を自分の手元のPDFで確かめてみるのが一番早いと思います。
株式会社ホコサキは山口県宇部を拠点に、RAGパイプラインの設計・実装を含むAI活用支援を行っています。
PDF前処理の設計や、社内文書をLLMに繋ぐ仕組みづくりについてご相談があれば、お問い合わせページ からお気軽にどうぞ。

