- Published on
Document Parsing技術ガイド: PDF解析・OCR・レイアウト分析・LLMベース文書抽出の実践パイプライン
- Authors
- Name
- はじめに
- Document Parsingの概要
- PDF解析技術とツール
- OCRベースの文書認識
- レイアウト分析と構造抽出
- テーブル抽出技法
- LLMベースの文書理解
- RAGのための文書チャンキング戦略
- 実践パイプライン構築
- まとめ
- 参考資料

はじめに
企業が保有する知識の大部分は、PDFレポート、スキャンされた契約書、研究論文、請求書、医療記録などの非構造化文書に格納されている。McKinseyの調査によると、企業データの約80%がこのような非構造化形式で存在しており、これを効果的に活用できなければ、データ資産の大部分を放置していることになる。
RAG(Retrieval-Augmented Generation)システム、知識検索エンジン、文書自動化システムの品質は、最終的に入力文書をどれだけ正確に解析できるかにかかっている。「Garbage in, garbage out」という原則が最も強く当てはまる分野が、まさにDocument Parsingである。
本記事では、PDF解析ライブラリの比較、OCRエンジンの選択基準、レイアウト分析モデル、テーブル抽出技法、LLMベースのマルチモーダル文書理解、RAG最適化のためのチャンキング戦略、プロダクションパイプライン構築まで、Document Parsingの全プロセスを実践コードとともに体系的に解説する。
Document Parsingの概要
なぜDocument Parsingが重要なのか
Document Parsingは、非構造化文書から構造化された情報を抽出する技術である。単純なテキスト抽出を超えて、文書の論理的構造(見出し、本文、表、図のキャプションなど)を理解し、意味のある単位で情報を組織化することが核心である。
Document Parsingが必要な主要なシナリオは以下の通りである。
| シナリオ | 説明 | 核心技術 |
|---|---|---|
| RAGパイプライン | 文書をチャンクに分割しベクトルDBに格納 | チャンキング、エンベディング |
| ナレッジベース構築 | 社内文書から構造化された知識を抽出 | NER、関係抽出 |
| 文書自動化 | 請求書、契約書から核心フィールドを抽出 | テンプレートマッチング、キー・バリュー抽出 |
| 規制コンプライアンス | 規制文書の変更を自動追跡 | 変更検出、比較分析 |
| 研究論文分析 | 論文から方法論、結果、引用を抽出 | セクション分類、メタデータ抽出 |
Document Parsingパイプラインアーキテクチャ
一般的なDocument Parsingパイプラインは以下のステージで構成される。
- 文書収集: PDF、画像、Word、HTMLなど多様なフォーマットの文書入力
- 前処理: 画像補正、ノイズ除去、ページ分離
- テキスト抽出: ネイティブPDFテキスト抽出またはOCR
- レイアウト分析: 文書構造の認識(見出し、本文、表、図)
- 構造化抽出: テーブル解析、キー・バリューペア抽出、NER
- 後処理: テキスト整形、チャンキング、メタデータ付与
- 格納/インデキシング: ベクトルDBまたは検索エンジンに格納
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
class DocumentType(Enum):
NATIVE_PDF = "native_pdf" # テキストレイヤーがあるPDF
SCANNED_PDF = "scanned_pdf" # スキャンされた画像PDF
IMAGE = "image" # JPG、PNGなどの画像
MIXED_PDF = "mixed_pdf" # ネイティブ + スキャン混合
@dataclass
class ParsedDocument:
text: str
pages: list = field(default_factory=list)
tables: list = field(default_factory=list)
images: list = field(default_factory=list)
metadata: dict = field(default_factory=dict)
doc_type: Optional[DocumentType] = None
def get_chunks(self, strategy: str = "recursive", chunk_size: int = 1000):
"""指定された戦略で文書をチャンキング"""
if strategy == "recursive":
return self._recursive_chunk(chunk_size)
elif strategy == "semantic":
return self._semantic_chunk(chunk_size)
elif strategy == "structure":
return self._structure_based_chunk()
return []
def _recursive_chunk(self, chunk_size: int):
separators = ["\n\n", "\n", ". ", " "]
return self._split_text(self.text, separators, chunk_size)
def _split_text(self, text: str, separators: list, chunk_size: int):
chunks = []
if len(text) <= chunk_size:
return [text]
sep = separators[0] if separators else " "
parts = text.split(sep)
current = ""
for part in parts:
if len(current) + len(part) + len(sep) > chunk_size:
if current:
chunks.append(current.strip())
current = part
else:
current = current + sep + part if current else part
if current:
chunks.append(current.strip())
return chunks
def _semantic_chunk(self, chunk_size: int):
# セマンティックチャンキング実装(エンベディングベース)
return self._recursive_chunk(chunk_size)
def _structure_based_chunk(self):
# 文書構造ベースのチャンキング
return [page.get("text", "") for page in self.pages if page.get("text")]
PDF解析技術とツール
PDFは最も広く使われている文書フォーマットであるが、解析の観点からは最も難しいフォーマットでもある。PDFは本質的に「印刷レイアウト」フォーマットであるため、テキストの論理的順序がファイル構造で保証されていない。
主要PDF解析ライブラリの比較
| ライブラリ | 長所 | 短所 | 適した用途 |
|---|---|---|---|
| PyMuPDF (fitz) | 高速、豊富な機能、画像抽出 | ライセンス(AGPL) | 汎用PDF処理 |
| pdfplumber | 正確なテーブル抽出、座標ベースアクセス | 速度が遅い | テーブル中心の文書 |
| PyPDF2 | 純粋Python、インストール簡単 | 複雑なPDFで不正確 | 単純なテキスト抽出 |
| Camelot | テーブル抽出専用 | PDF全体処理不可 | テーブルのみ必要な場合 |
| pdfminer.six | 詳細なレイアウト情報 | APIが複雑 | レイアウト分析が必要な場合 |
PyMuPDFを活用したPDF解析
import fitz # PyMuPDF
class PyMuPDFParser:
"""PyMuPDFベースのPDFパーサー"""
def __init__(self, pdf_path: str):
self.doc = fitz.open(pdf_path)
self.pages = []
def extract_text_with_layout(self) -> list:
"""ページごとのテキストをレイアウト情報と共に抽出"""
results = []
for page_num, page in enumerate(self.doc):
blocks = page.get_text("dict")["blocks"]
page_data = {
"page_num": page_num + 1,
"width": page.rect.width,
"height": page.rect.height,
"blocks": []
}
for block in blocks:
if block["type"] == 0: # テキストブロック
text_content = ""
for line in block["lines"]:
line_text = ""
for span in line["spans"]:
line_text += span["text"]
text_content += line_text + "\n"
page_data["blocks"].append({
"type": "text",
"bbox": block["bbox"],
"text": text_content.strip(),
"font_size": block["lines"][0]["spans"][0]["size"]
if block["lines"] and block["lines"][0]["spans"] else 0
})
elif block["type"] == 1: # 画像ブロック
page_data["blocks"].append({
"type": "image",
"bbox": block["bbox"],
"image_data": block.get("image", None)
})
results.append(page_data)
return results
def extract_images(self, output_dir: str) -> list:
"""PDFから全画像を抽出"""
import os
os.makedirs(output_dir, exist_ok=True)
image_paths = []
for page_num, page in enumerate(self.doc):
images = page.get_images(full=True)
for img_idx, img in enumerate(images):
xref = img[0]
pix = fitz.Pixmap(self.doc, xref)
if pix.n < 5: # GRAYまたはRGB
img_path = os.path.join(
output_dir,
f"page_{page_num + 1}_img_{img_idx + 1}.png"
)
pix.save(img_path)
image_paths.append(img_path)
pix = None
return image_paths
def detect_document_type(self) -> DocumentType:
"""PDFがネイティブかスキャンかを判別"""
total_text_len = 0
total_images = 0
for page in self.doc:
total_text_len += len(page.get_text())
total_images += len(page.get_images())
if total_text_len < 100 and total_images > 0:
return DocumentType.SCANNED_PDF
elif total_text_len > 100 and total_images > len(self.doc) * 0.5:
return DocumentType.MIXED_PDF
return DocumentType.NATIVE_PDF
def close(self):
self.doc.close()
pdfplumberを活用した精密解析
pdfplumberは特にテーブル抽出に強みがあり、各文字の正確な座標情報を提供する。
import pdfplumber
class PdfPlumberParser:
"""pdfplumberベースの精密PDFパーサー"""
def __init__(self, pdf_path: str):
self.pdf = pdfplumber.open(pdf_path)
def extract_tables(self) -> list:
"""全ページからテーブルを抽出"""
all_tables = []
for page_num, page in enumerate(self.pdf.pages):
tables = page.extract_tables(
table_settings={
"vertical_strategy": "lines",
"horizontal_strategy": "lines",
"snap_tolerance": 3,
"join_tolerance": 3,
"edge_min_length": 3,
"min_words_vertical": 3,
"min_words_horizontal": 1,
}
)
for table_idx, table in enumerate(tables):
if table and len(table) > 1:
headers = table[0]
rows = table[1:]
all_tables.append({
"page": page_num + 1,
"table_index": table_idx,
"headers": headers,
"rows": rows,
"num_rows": len(rows),
"num_cols": len(headers) if headers else 0
})
return all_tables
def extract_text_outside_tables(self) -> str:
"""テーブル領域を除いたテキストのみを抽出"""
full_text = []
for page in self.pdf.pages:
# テーブルバウンディングボックスを収集
table_bboxes = []
tables = page.find_tables()
for table in tables:
table_bboxes.append(table.bbox)
# テーブル領域をクロップして除外
filtered_page = page
for bbox in table_bboxes:
filtered_page = filtered_page.outside_bbox(bbox)
text = filtered_page.extract_text()
if text:
full_text.append(text)
return "\n\n".join(full_text)
def close(self):
self.pdf.close()
OCRベースの文書認識
スキャンされた文書や画像ベースのPDFを処理するには、OCR(Optical Character Recognition)が不可欠である。OCR技術は従来のルールベースの手法からディープラーニングベースへと急速に進化している。
主要OCRエンジンの比較
| エンジン | 対応言語 | 精度 | 速度 | 特徴 |
|---|---|---|---|---|
| Tesseract 5 | 100以上 | 中~高 | 中間 | オープンソース、最も広く使用 |
| EasyOCR | 80以上 | 中~高 | 遅い | PyTorchベース、インストール簡単 |
| PaddleOCR | 80以上 | 高 | 速い | Baidu開発、高精度 |
| Google Vision API | 100以上 | 最高 | 速い | クラウドサービス、有料 |
| Azure Document Intelligence | 100以上 | 最高 | 速い | エンタープライズ、構造化抽出対応 |
Tesseract OCRの活用
import pytesseract
from PIL import Image
import cv2
import numpy as np
class TesseractOCR:
"""TesseractベースのOCRプロセッサー"""
def __init__(self, lang: str = "jpn+eng"):
self.lang = lang
self.config = "--oem 3 --psm 6" # LSTMエンジン + 均一テキストブロック
def preprocess_image(self, image_path: str) -> np.ndarray:
"""OCR精度向上のための画像前処理"""
img = cv2.imread(image_path)
# グレースケール変換
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# ノイズ除去
denoised = cv2.fastNlMeansDenoising(gray, h=10)
# 二値化(Otsuの方法)
_, binary = cv2.threshold(
denoised, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
)
# 傾き補正
coords = np.column_stack(np.where(binary > 0))
if len(coords) > 0:
angle = cv2.minAreaRect(coords)[-1]
if angle < -45:
angle = -(90 + angle)
else:
angle = -angle
if abs(angle) > 0.5:
h, w = binary.shape
center = (w // 2, h // 2)
matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
binary = cv2.warpAffine(
binary, matrix, (w, h),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE
)
return binary
def extract_text(self, image_path: str, preprocess: bool = True) -> str:
"""画像からテキストを抽出"""
if preprocess:
img = self.preprocess_image(image_path)
else:
img = Image.open(image_path)
text = pytesseract.image_to_string(
img, lang=self.lang, config=self.config
)
return text.strip()
def extract_with_boxes(self, image_path: str) -> list:
"""バウンディングボックスと共にテキストを抽出"""
img = self.preprocess_image(image_path)
data = pytesseract.image_to_data(
img, lang=self.lang, config=self.config,
output_type=pytesseract.Output.DICT
)
results = []
for i in range(len(data["text"])):
if data["text"][i].strip():
results.append({
"text": data["text"][i],
"confidence": data["conf"][i],
"bbox": {
"x": data["left"][i],
"y": data["top"][i],
"w": data["width"][i],
"h": data["height"][i]
},
"block_num": data["block_num"][i],
"line_num": data["line_num"][i]
})
return results
PaddleOCRで高精度OCRを実現
PaddleOCRは特にアジア言語(韓国語、日本語、中国語)で高い精度を示す。
from paddleocr import PaddleOCR
class PaddleOCRProcessor:
"""PaddleOCRベースの高精度OCR"""
def __init__(self, lang: str = "japan"):
self.ocr = PaddleOCR(
use_angle_cls=True, # テキスト方向検出
lang=lang,
use_gpu=True,
det_db_thresh=0.3,
det_db_box_thresh=0.5,
rec_batch_num=16
)
def process_image(self, image_path: str) -> dict:
"""画像からテキストとレイアウト情報を抽出"""
result = self.ocr.ocr(image_path, cls=True)
extracted = {
"lines": [],
"full_text": "",
"confidence_avg": 0.0
}
if not result or not result[0]:
return extracted
total_conf = 0
lines = []
for line in result[0]:
bbox = line[0] # 4頂点の座標
text = line[1][0]
confidence = line[1][1]
lines.append({
"text": text,
"confidence": confidence,
"bbox": bbox,
"y_center": (bbox[0][1] + bbox[2][1]) / 2
})
total_conf += confidence
# y座標で並べ替え(読み順)
lines.sort(key=lambda x: (x["y_center"], x["bbox"][0][0]))
extracted["lines"] = lines
extracted["full_text"] = "\n".join(l["text"] for l in lines)
extracted["confidence_avg"] = (
total_conf / len(lines) if lines else 0
)
return extracted
def process_pdf(self, pdf_path: str) -> list:
"""PDFの全ページをOCR処理"""
import fitz
doc = fitz.open(pdf_path)
results = []
for page_num, page in enumerate(doc):
# ページを高解像度画像に変換
mat = fitz.Matrix(2.0, 2.0) # 2倍スケール
pix = page.get_pixmap(matrix=mat)
img_path = f"/tmp/page_{page_num}.png"
pix.save(img_path)
# OCR実行
ocr_result = self.process_image(img_path)
ocr_result["page_num"] = page_num + 1
results.append(ocr_result)
doc.close()
return results
レイアウト分析と構造抽出
レイアウト分析は、文書内のテキストブロック、見出し、表、図、キャプションなどの領域を識別し、論理的な読み順を決定するプロセスである。近年ではTransformerベースのモデルがこの分野をリードしている。
主要レイアウト分析モデル
| モデル | 開発元 | 核心技術 | 特徴 |
|---|---|---|---|
| LayoutLMv3 | Microsoft | マルチモーダルTransformer | テキスト+画像+レイアウト統合 |
| DiT (Document Image Transformer) | Microsoft | Vision Transformer | 画像ベースの文書理解 |
| Donut | NAVER CLOVA | OCR-freeアプローチ | OCRなしで直接文書理解 |
| Table Transformer | Microsoft | DETRベース | テーブル検出/構造認識特化 |
| Unstructured | オープンソース | ハイブリッド | 複数モデル組み合わせパイプライン |
LayoutLMv3を活用した文書構造分析
from transformers import (
LayoutLMv3ForTokenClassification,
LayoutLMv3Processor,
)
from PIL import Image
import torch
class LayoutAnalyzer:
"""LayoutLMv3ベースの文書レイアウト分析器"""
LABEL_MAP = {
0: "O",
1: "B-TITLE",
2: "I-TITLE",
3: "B-TEXT",
4: "I-TEXT",
5: "B-TABLE",
6: "I-TABLE",
7: "B-FIGURE",
8: "I-FIGURE",
9: "B-LIST",
10: "I-LIST",
11: "B-HEADER",
12: "I-HEADER",
13: "B-FOOTER",
14: "I-FOOTER",
}
def __init__(self, model_name: str = "microsoft/layoutlmv3-base"):
self.processor = LayoutLMv3Processor.from_pretrained(
model_name, apply_ocr=True
)
self.model = LayoutLMv3ForTokenClassification.from_pretrained(
model_name, num_labels=len(self.LABEL_MAP)
)
self.model.eval()
def analyze(self, image_path: str) -> list:
"""文書画像のレイアウト分析"""
image = Image.open(image_path).convert("RGB")
encoding = self.processor(
image,
return_tensors="pt",
truncation=True,
max_length=512
)
with torch.no_grad():
outputs = self.model(**encoding)
predictions = outputs.logits.argmax(-1).squeeze().tolist()
tokens = self.processor.tokenizer.convert_ids_to_tokens(
encoding["input_ids"].squeeze()
)
# トークンごとの予測結果をマッピング
elements = []
current_label = None
current_text = ""
for token, pred in zip(tokens, predictions):
label = self.LABEL_MAP.get(pred, "O")
if label.startswith("B-"):
if current_text and current_label:
elements.append({
"type": current_label,
"text": current_text.strip()
})
current_label = label[2:]
current_text = token.replace("##", "")
elif label.startswith("I-") and current_label:
current_text += token.replace("##", "")
else:
if current_text and current_label:
elements.append({
"type": current_label,
"text": current_text.strip()
})
current_label = None
current_text = ""
if current_text and current_label:
elements.append({
"type": current_label,
"text": current_text.strip()
})
return elements
Unstructuredライブラリを活用した統合解析
Unstructuredは多様な文書フォーマットをサポートするオープンソースライブラリで、複数の解析エンジンを統合して提供する。
from unstructured.partition.pdf import partition_pdf
from unstructured.partition.auto import partition
from unstructured.chunking.title import chunk_by_title
class UnstructuredParser:
"""Unstructuredベースの統合文書パーサー"""
def __init__(self, strategy: str = "hi_res"):
self.strategy = strategy # "fast", "ocr_only", "hi_res"
def parse_pdf(self, pdf_path: str) -> list:
"""PDFを構造化された要素に解析"""
elements = partition_pdf(
filename=pdf_path,
strategy=self.strategy,
infer_table_structure=True,
languages=["jpn", "eng"],
extract_images_in_pdf=True,
extract_image_block_output_dir="./extracted_images"
)
parsed = []
for element in elements:
parsed.append({
"type": type(element).__name__,
"text": str(element),
"metadata": {
"page_number": element.metadata.page_number,
"coordinates": (
element.metadata.coordinates
if hasattr(element.metadata, "coordinates")
else None
),
"parent_id": element.metadata.parent_id,
}
})
return parsed
def parse_and_chunk(
self, file_path: str, max_characters: int = 1000
) -> list:
"""解析後にタイトルベースのチャンキングまで実行"""
elements = partition(
filename=file_path,
strategy=self.strategy
)
chunks = chunk_by_title(
elements,
max_characters=max_characters,
combine_text_under_n_chars=200,
new_after_n_chars=800
)
return [
{
"text": str(chunk),
"type": type(chunk).__name__,
"metadata": chunk.metadata.to_dict()
}
for chunk in chunks
]
テーブル抽出技法
文書からテーブルを正確に抽出することは、Document Parsingにおいて最も挑戦的な課題の一つである。テーブルは複雑なセル結合、ネスト構造、多様なスタイルを持つ可能性があるためだ。
テーブル抽出の主要課題
- テーブル検出: 文書内のテーブル領域を正確に識別
- 構造認識: 行/列構造、結合セル、ヘッダー行の認識
- セル内容抽出: 各セルのテキストを正確に抽出
- 罫線なしテーブル(borderless table): 罫線のないテーブルの構造認識
Table Transformerを活用したテーブル検出
from transformers import (
TableTransformerForObjectDetection,
AutoImageProcessor,
)
from PIL import Image
import torch
class TableExtractor:
"""Table Transformerベースのテーブル抽出器"""
def __init__(self):
self.processor = AutoImageProcessor.from_pretrained(
"microsoft/table-transformer-detection"
)
self.detection_model = TableTransformerForObjectDetection.from_pretrained(
"microsoft/table-transformer-detection"
)
self.structure_processor = AutoImageProcessor.from_pretrained(
"microsoft/table-transformer-structure-recognition"
)
self.structure_model = TableTransformerForObjectDetection.from_pretrained(
"microsoft/table-transformer-structure-recognition"
)
def detect_tables(self, image_path: str, threshold: float = 0.7) -> list:
"""画像内のテーブル領域を検出"""
image = Image.open(image_path).convert("RGB")
inputs = self.processor(images=image, return_tensors="pt")
with torch.no_grad():
outputs = self.detection_model(**inputs)
target_sizes = torch.tensor([image.size[::-1]])
results = self.processor.post_process_object_detection(
outputs, threshold=threshold, target_sizes=target_sizes
)[0]
tables = []
for score, label, box in zip(
results["scores"], results["labels"], results["boxes"]
):
tables.append({
"score": score.item(),
"label": self.detection_model.config.id2label[label.item()],
"bbox": box.tolist() # [x1, y1, x2, y2]
})
return tables
def recognize_structure(
self, image_path: str, table_bbox: list
) -> dict:
"""検出されたテーブルの内部構造を認識"""
image = Image.open(image_path).convert("RGB")
# テーブル領域をクロップ
table_image = image.crop(table_bbox)
inputs = self.structure_processor(
images=table_image, return_tensors="pt"
)
with torch.no_grad():
outputs = self.structure_model(**inputs)
target_sizes = torch.tensor([table_image.size[::-1]])
results = self.structure_processor.post_process_object_detection(
outputs, threshold=0.5, target_sizes=target_sizes
)[0]
structure = {"rows": [], "columns": [], "cells": []}
for score, label, box in zip(
results["scores"], results["labels"], results["boxes"]
):
label_name = self.structure_model.config.id2label[label.item()]
entry = {"bbox": box.tolist(), "score": score.item()}
if "row" in label_name:
structure["rows"].append(entry)
elif "column" in label_name:
structure["columns"].append(entry)
else:
structure["cells"].append(entry)
# 座標で並べ替え
structure["rows"].sort(key=lambda x: x["bbox"][1])
structure["columns"].sort(key=lambda x: x["bbox"][0])
return structure
Camelotを活用した簡便なテーブル抽出
import camelot
import pandas as pd
def extract_tables_with_camelot(
pdf_path: str, pages: str = "all", flavor: str = "lattice"
) -> list:
"""CamelotでPDFからテーブルを抽出
Args:
pdf_path: PDFファイルパス
pages: 抽出するページ("all" または "1,2,3")
flavor: "lattice"(罫線ベース)または "stream"(空白ベース)
"""
tables = camelot.read_pdf(
pdf_path,
pages=pages,
flavor=flavor,
strip_text="\n"
)
results = []
for i, table in enumerate(tables):
df = table.df
results.append({
"table_index": i,
"page": table.page,
"accuracy": table.accuracy,
"data": df.to_dict(orient="records"),
"shape": df.shape,
"dataframe": df
})
return results
# 使用例
if __name__ == "__main__":
tables = extract_tables_with_camelot(
"financial_report.pdf",
pages="1-5",
flavor="lattice"
)
for t in tables:
print(f"テーブル {t['table_index']}(ページ {t['page']})")
print(f" 精度: {t['accuracy']:.1f}%")
print(f" サイズ: {t['shape']}")
print(t["dataframe"].head())
LLMベースの文書理解
最近のGPT-4V、Claude 3.5などのマルチモーダルLLMの登場により、文書理解の手法が根本的に変化している。従来のOCR + 後処理パイプラインの代わりに、文書画像を直接LLMに入力して内容を理解し構造化できるようになった。
マルチモーダルLLMを活用した文書処理
import anthropic
import base64
from pathlib import Path
class LLMDocumentProcessor:
"""LLMベースのマルチモーダル文書プロセッサー"""
def __init__(self, model: str = "claude-sonnet-4-20250514"):
self.client = anthropic.Anthropic()
self.model = model
def _encode_image(self, image_path: str) -> tuple:
"""画像をbase64にエンコード"""
path = Path(image_path)
suffix = path.suffix.lower()
media_type_map = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
media_type = media_type_map.get(suffix, "image/png")
with open(image_path, "rb") as f:
data = base64.standard_b64encode(f.read()).decode("utf-8")
return data, media_type
def extract_structured_data(
self, image_path: str, schema_description: str
) -> str:
"""文書画像から構造化データを抽出"""
data, media_type = self._encode_image(image_path)
prompt = f"""この文書画像を分析して、以下のスキーマに合う構造化データをJSONで抽出してください。
スキーマ:
{schema_description}
注意事項:
- 全テキストを正確に抽出してください
- テーブルがある場合は行/列構造を維持してください
- 不確実な内容はnullで表示してください
- JSON形式でのみ応答してください"""
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
},
},
{
"type": "text",
"text": prompt,
},
],
}
],
)
return response.content[0].text
def analyze_document_layout(self, image_path: str) -> str:
"""文書レイアウト分析と構造抽出"""
data, media_type = self._encode_image(image_path)
prompt = """この文書のレイアウトを分析して、以下の情報をJSONで返してください:
1. 文書タイプ(論文、報告書、請求書、契約書など)
2. セクション構造(タイトルと階層)
3. テーブルの有無と位置の説明
4. 図/チャートの有無と説明
5. キー・バリューペア(ある場合)
6. 全テキストの読み順
JSON形式でのみ応答してください。"""
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
},
},
{"type": "text", "text": prompt},
],
}
],
)
return response.content[0].text
def compare_documents(
self, image_path_1: str, image_path_2: str
) -> str:
"""2つの文書画像を比較分析"""
data1, mt1 = self._encode_image(image_path_1)
data2, mt2 = self._encode_image(image_path_2)
prompt = """2つの文書を比較して以下を分析してください:
1. 共通点
2. 相違点
3. 追加された内容
4. 削除された内容
5. 変更された内容
構造化されたJSON形式で応答してください。"""
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": mt1,
"data": data1,
},
},
{
"type": "image",
"source": {
"type": "base64",
"media_type": mt2,
"data": data2,
},
},
{"type": "text", "text": prompt},
],
}
],
)
return response.content[0].text
ハイブリッドアプローチ: OCR + LLM補正
OCRで先にテキストを抽出した後、LLMでエラーを補正し構造を整理するハイブリッド方式が実務で高い効果を発揮している。
class HybridDocumentProcessor:
"""OCR + LLMハイブリッド文書プロセッサー"""
def __init__(self):
self.ocr = PaddleOCRProcessor(lang="japan")
self.llm = LLMDocumentProcessor()
def process(self, image_path: str) -> dict:
"""ハイブリッド方式で文書処理"""
# ステップ1: OCRでテキスト抽出
ocr_result = self.ocr.process_image(image_path)
raw_text = ocr_result["full_text"]
confidence = ocr_result["confidence_avg"]
# ステップ2: LLMで補正・構造化
correction_prompt = (
"以下のOCR抽出テキストを検証し補正してください。"
"信頼度の低い部分を文脈に合わせて修正し、"
"文書の論理的構造(見出し、本文、リストなど)を"
"Markdown形式で整理してください。\n\n"
f"OCR抽出テキスト(平均信頼度: {confidence:.2f}):\n"
"---\n"
f"{raw_text}\n"
"---\n\n"
"補正されたMarkdownを返してください。"
)
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
messages=[{"role": "user", "content": correction_prompt}],
)
corrected_text = response.content[0].text
return {
"raw_ocr": raw_text,
"ocr_confidence": confidence,
"corrected_text": corrected_text,
"method": "hybrid_ocr_llm"
}
RAGのための文書チャンキング戦略
Document Parsingの最終目的がRAGパイプラインであれば、チャンキング(Chunking)戦略が検索品質を決定づける核心要素となる。不適切なチャンキングは検索精度を大幅に低下させ、文脈の喪失によるハルシネーション(hallucination)を引き起こす。
チャンキング戦略の比較
| 戦略 | 説明 | 長所 | 短所 |
|---|---|---|---|
| 固定サイズ | 一定のトークン/文字数で分割 | 実装簡単、均一なサイズ | 文脈の断絶 |
| 再帰的分割 | 区切り文字の優先順位で分割 | 構造維持、柔軟 | サイズ不均一 |
| セマンティック | エンベディング類似度ベースで分割 | 意味単位の保存 | 計算コスト高 |
| 文書構造ベース | 見出し/セクションベースで分割 | 論理的構造維持 | 構造認識が必要 |
| スライディングウィンドウ | オーバーラップ付きで分割 | 文脈の連続性 | ストレージ増加 |
高度なチャンキング実装
from typing import Optional
import numpy as np
class AdvancedChunker:
"""多様なチャンキング戦略をサポートする高度なチャンカー"""
def __init__(self, embedding_model=None):
self.embedding_model = embedding_model
def fixed_size_chunk(
self, text: str, chunk_size: int = 1000, overlap: int = 200
) -> list:
"""固定サイズ + オーバーラップチャンキング"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunk = text[start:end]
# 文の境界でカット
if end < len(text):
last_period = chunk.rfind("。")
last_newline = chunk.rfind("\n")
cut_point = max(last_period, last_newline)
if cut_point > chunk_size * 0.5:
chunk = chunk[:cut_point + 1]
end = start + cut_point + 1
chunks.append({
"text": chunk.strip(),
"start": start,
"end": end,
"index": len(chunks)
})
start = end - overlap
return chunks
def recursive_chunk(
self,
text: str,
chunk_size: int = 1000,
separators: Optional[list] = None,
) -> list:
"""再帰的分割チャンキング"""
if separators is None:
separators = ["\n\n\n", "\n\n", "\n", "。", "、", " "]
chunks = []
self._recursive_split(text, separators, chunk_size, chunks)
return [
{"text": c, "index": i}
for i, c in enumerate(chunks)
if c.strip()
]
def _recursive_split(
self, text: str, separators: list, chunk_size: int, result: list
):
if len(text) <= chunk_size:
result.append(text)
return
sep = separators[0] if separators else " "
remaining_seps = separators[1:] if len(separators) > 1 else []
parts = text.split(sep)
current = ""
for part in parts:
test = current + sep + part if current else part
if len(test) > chunk_size:
if current:
if len(current) > chunk_size and remaining_seps:
self._recursive_split(
current, remaining_seps, chunk_size, result
)
else:
result.append(current)
current = part
else:
current = test
if current:
if len(current) > chunk_size and remaining_seps:
self._recursive_split(
current, remaining_seps, chunk_size, result
)
else:
result.append(current)
def semantic_chunk(
self, text: str, threshold: float = 0.5, min_size: int = 100
) -> list:
"""セマンティックチャンキング - エンベディング類似度ベース"""
if not self.embedding_model:
raise ValueError(
"セマンティックチャンキングにはエンベディングモデルが必要です"
)
# 文単位で分離
sentences = [s.strip() for s in text.split("。") if s.strip()]
if len(sentences) <= 1:
return [{"text": text, "index": 0}]
# 各文のエンベディングを計算
embeddings = self.embedding_model.encode(sentences)
# 隣接文間の類似度を計算
similarities = []
for i in range(len(embeddings) - 1):
sim = np.dot(embeddings[i], embeddings[i + 1]) / (
np.linalg.norm(embeddings[i])
* np.linalg.norm(embeddings[i + 1])
)
similarities.append(sim)
# 類似度が閾値以下のポイントで分割
chunks = []
current_chunk = sentences[0]
for i, sim in enumerate(similarities):
if sim < threshold and len(current_chunk) >= min_size:
chunks.append(current_chunk)
current_chunk = sentences[i + 1]
else:
current_chunk += "。" + sentences[i + 1]
if current_chunk:
chunks.append(current_chunk)
return [
{"text": c, "index": i}
for i, c in enumerate(chunks)
]
def structure_based_chunk(self, parsed_elements: list) -> list:
"""文書構造ベースのチャンキング - レイアウト分析結果を活用"""
chunks = []
current_chunk = {
"title": "",
"content": "",
"tables": [],
"metadata": {}
}
for element in parsed_elements:
elem_type = element.get("type", "")
elem_text = element.get("text", "")
if elem_type in ("Title", "TITLE"):
# 新しいセクション開始
if current_chunk["content"]:
chunks.append(current_chunk.copy())
current_chunk = {
"title": elem_text,
"content": "",
"tables": [],
"metadata": element.get("metadata", {})
}
elif elem_type in ("Table", "TABLE"):
current_chunk["tables"].append(elem_text)
else:
current_chunk["content"] += elem_text + "\n"
if current_chunk["content"]:
chunks.append(current_chunk)
return chunks
実践パイプライン構築
ここまで取り上げた個別技術を組み合わせて、プロダクション環境で使用できるエンドツーエンドのDocument Parsingパイプラインを構築する。
パイプラインアーキテクチャ
全体パイプラインは以下のステージで構成される。
- 入力処理: 多様な文書フォーマットの検出と正規化
- 解析戦略選択: 文書タイプに応じた最適パーサーの選択
- テキスト/構造抽出: OCR、レイアウト分析、テーブル抽出
- LLM強化: マルチモーダルLLMによる品質向上
- チャンキングとインデキシング: RAG用チャンキング後にベクトルDBへ格納
import os
import json
import logging
from pathlib import Path
from typing import Optional
from dataclasses import dataclass, field
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@dataclass
class PipelineConfig:
"""パイプライン設定"""
ocr_engine: str = "paddleocr" # tesseract, paddleocr, easyocr
ocr_lang: str = "japan"
layout_model: str = "unstructured" # layoutlm, unstructured
chunking_strategy: str = "recursive" # fixed, recursive, semantic, structure
chunk_size: int = 1000
chunk_overlap: int = 200
use_llm_correction: bool = True
llm_model: str = "claude-sonnet-4-20250514"
output_format: str = "json" # json, markdown
@dataclass
class ProcessedDocument:
"""処理された文書の結果"""
source_path: str
doc_type: str
pages: list = field(default_factory=list)
full_text: str = ""
tables: list = field(default_factory=list)
chunks: list = field(default_factory=list)
metadata: dict = field(default_factory=dict)
processing_log: list = field(default_factory=list)
class DocumentParsingPipeline:
"""プロダクションDocument Parsingパイプライン"""
def __init__(self, config: Optional[PipelineConfig] = None):
self.config = config or PipelineConfig()
self.chunker = AdvancedChunker()
def process(self, file_path: str) -> ProcessedDocument:
"""文書をエンドツーエンドで処理"""
result = ProcessedDocument(source_path=file_path, doc_type="")
logger.info(f"Processing: {file_path}")
try:
# 1. 文書タイプの検出
doc_type = self._detect_type(file_path)
result.doc_type = doc_type
result.processing_log.append(
f"Document type detected: {doc_type}"
)
# 2. 解析戦略の選択と実行
if doc_type == "native_pdf":
raw_result = self._parse_native_pdf(file_path)
elif doc_type in ("scanned_pdf", "image"):
raw_result = self._parse_with_ocr(file_path)
elif doc_type == "mixed_pdf":
raw_result = self._parse_mixed_pdf(file_path)
else:
raw_result = self._parse_generic(file_path)
result.full_text = raw_result.get("text", "")
result.tables = raw_result.get("tables", [])
result.pages = raw_result.get("pages", [])
# 3. LLM補正(オプション)
if self.config.use_llm_correction and result.full_text:
result.full_text = self._llm_correct(result.full_text)
result.processing_log.append("LLM correction applied")
# 4. チャンキング
result.chunks = self._chunk_document(result)
result.processing_log.append(
f"Created {len(result.chunks)} chunks "
f"with strategy: {self.config.chunking_strategy}"
)
# 5. メタデータ生成
result.metadata = {
"source": file_path,
"doc_type": doc_type,
"total_pages": len(result.pages),
"total_tables": len(result.tables),
"total_chunks": len(result.chunks),
"text_length": len(result.full_text),
"config": {
"ocr_engine": self.config.ocr_engine,
"chunking_strategy": self.config.chunking_strategy,
"chunk_size": self.config.chunk_size,
}
}
logger.info(
f"Processing complete: "
f"{len(result.chunks)} chunks created"
)
except Exception as e:
logger.error(f"Error processing {file_path}: {e}")
result.processing_log.append(f"Error: {str(e)}")
return result
def _detect_type(self, file_path: str) -> str:
"""文書タイプの自動検出"""
ext = Path(file_path).suffix.lower()
if ext in (".jpg", ".jpeg", ".png", ".tiff", ".bmp"):
return "image"
elif ext == ".pdf":
import fitz
doc = fitz.open(file_path)
total_text = sum(len(page.get_text()) for page in doc)
total_images = sum(len(page.get_images()) for page in doc)
doc.close()
if total_text < 100:
return "scanned_pdf"
elif total_images > len(doc) * 0.5:
return "mixed_pdf"
return "native_pdf"
return "unknown"
def _parse_native_pdf(self, file_path: str) -> dict:
"""ネイティブPDFの解析"""
parser = PyMuPDFParser(file_path)
pages = parser.extract_text_with_layout()
plumber = PdfPlumberParser(file_path)
tables = plumber.extract_tables()
text = plumber.extract_text_outside_tables()
parser.close()
plumber.close()
return {"text": text, "tables": tables, "pages": pages}
def _parse_with_ocr(self, file_path: str) -> dict:
"""OCRベースの文書解析"""
if self.config.ocr_engine == "paddleocr":
processor = PaddleOCRProcessor(lang=self.config.ocr_lang)
if file_path.lower().endswith(".pdf"):
results = processor.process_pdf(file_path)
text = "\n\n".join(r["full_text"] for r in results)
return {"text": text, "pages": results, "tables": []}
else:
result = processor.process_image(file_path)
return {
"text": result["full_text"],
"pages": [result],
"tables": []
}
else:
ocr = TesseractOCR(lang="jpn+eng")
text = ocr.extract_text(file_path)
return {"text": text, "pages": [], "tables": []}
def _parse_mixed_pdf(self, file_path: str) -> dict:
"""混合PDFの解析 - ネイティブ + OCR"""
native_result = self._parse_native_pdf(file_path)
ocr_result = self._parse_with_ocr(file_path)
combined_text = native_result["text"] or ocr_result["text"]
return {
"text": combined_text,
"tables": native_result["tables"],
"pages": native_result["pages"]
}
def _parse_generic(self, file_path: str) -> dict:
"""汎用文書解析(Unstructured活用)"""
parser = UnstructuredParser(strategy="hi_res")
elements = parser.parse_pdf(file_path)
text = "\n\n".join(e["text"] for e in elements)
return {"text": text, "pages": [], "tables": []}
def _llm_correct(self, text: str) -> str:
"""LLMを使用したテキスト補正"""
if len(text) < 100:
return text
import anthropic
client = anthropic.Anthropic()
sample = text[:3000] if len(text) > 3000 else text
response = client.messages.create(
model=self.config.llm_model,
max_tokens=4096,
messages=[
{
"role": "user",
"content": (
"以下のOCR抽出テキストのエラーを補正してください。"
"原文の意味と構造を維持しながら、誤字、文字化け、"
f"改行エラーのみを修正してください。\n\n{sample}"
),
}
],
)
return response.content[0].text
def _chunk_document(self, doc: ProcessedDocument) -> list:
"""文書チャンキング"""
strategy = self.config.chunking_strategy
if strategy == "fixed":
return self.chunker.fixed_size_chunk(
doc.full_text,
self.config.chunk_size,
self.config.chunk_overlap
)
elif strategy == "recursive":
return self.chunker.recursive_chunk(
doc.full_text,
self.config.chunk_size
)
elif strategy == "structure":
if doc.pages:
return self.chunker.structure_based_chunk(doc.pages)
return self.chunker.recursive_chunk(
doc.full_text, self.config.chunk_size
)
return self.chunker.recursive_chunk(
doc.full_text, self.config.chunk_size
)
def save_results(
self, result: ProcessedDocument, output_dir: str
):
"""処理結果の保存"""
os.makedirs(output_dir, exist_ok=True)
base_name = Path(result.source_path).stem
# チャンクの保存
chunks_path = os.path.join(output_dir, f"{base_name}_chunks.json")
with open(chunks_path, "w", encoding="utf-8") as f:
json.dump(result.chunks, f, ensure_ascii=False, indent=2)
# メタデータの保存
meta_path = os.path.join(output_dir, f"{base_name}_metadata.json")
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(result.metadata, f, ensure_ascii=False, indent=2)
# 全テキストの保存
text_path = os.path.join(output_dir, f"{base_name}_full.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(result.full_text)
logger.info(f"Results saved to {output_dir}")
# 使用例
if __name__ == "__main__":
config = PipelineConfig(
ocr_engine="paddleocr",
chunking_strategy="recursive",
chunk_size=1000,
chunk_overlap=200,
use_llm_correction=True
)
pipeline = DocumentParsingPipeline(config)
# 単一文書の処理
result = pipeline.process("research_paper.pdf")
print(f"合計 {len(result.chunks)} チャンク生成")
print(f"テーブル {len(result.tables)} 個抽出")
# 結果の保存
pipeline.save_results(result, "./output")
バッチ処理とモニタリング
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
class BatchProcessor:
"""大量文書バッチプロセッサー"""
def __init__(self, pipeline: DocumentParsingPipeline, max_workers: int = 4):
self.pipeline = pipeline
self.max_workers = max_workers
def process_directory(self, input_dir: str, output_dir: str) -> dict:
"""ディレクトリ内の全文書をバッチ処理"""
supported_ext = {".pdf", ".png", ".jpg", ".jpeg", ".tiff"}
files = [
str(f) for f in Path(input_dir).rglob("*")
if f.suffix.lower() in supported_ext
]
logger.info(f"Found {len(files)} documents to process")
stats = {
"total": len(files),
"success": 0,
"failed": 0,
"total_chunks": 0,
"processing_time": 0
}
start_time = time.time()
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures = {
executor.submit(
self._process_single, f, output_dir
): f for f in files
}
for future in as_completed(futures):
file_path = futures[future]
try:
result = future.result()
stats["success"] += 1
stats["total_chunks"] += len(result.chunks)
logger.info(f"Success: {file_path}")
except Exception as e:
stats["failed"] += 1
logger.error(f"Failed: {file_path} - {e}")
stats["processing_time"] = time.time() - start_time
logger.info(
f"Batch complete: {stats['success']}/{stats['total']} "
f"in {stats['processing_time']:.1f}s"
)
return stats
def _process_single(
self, file_path: str, output_dir: str
) -> ProcessedDocument:
"""単一文書の処理と保存"""
result = self.pipeline.process(file_path)
self.pipeline.save_results(result, output_dir)
return result
まとめ
Document Parsingは、AI/LLMアプリケーションのデータ品質を決定づける核心的な基盤技術である。本記事で取り上げた内容を整理すると以下の通りである。
PDF解析: PyMuPDFは汎用処理に、pdfplumberはテーブル抽出に強みがある。文書タイプ(ネイティブ/スキャン/混合)に応じて最適なツールを選択する必要がある。
OCR: PaddleOCRはアジア言語で高い精度を、Tesseractは汎用性を提供する。画像前処理(二値化、傾き補正など)が精度に大きな影響を与える。
レイアウト分析: LayoutLMv3、Unstructuredなどのツールを活用すれば、文書の論理的構造を自動的に把握できる。特にUnstructuredは迅速なプロトタイピングに適している。
テーブル抽出: Table Transformer、Camelotなどを活用するが、複雑なテーブル(結合セル、罫線なしテーブル)には追加の後処理が必要である。
LLMベースの文書理解: GPT-4V、ClaudeなどのマルチモーダルLLMは、OCR補正、構造分析、情報抽出で画期的な性能向上をもたらした。OCR + LLMハイブリッドアプローチが現在の最善の実務戦略である。
チャンキング戦略: RAGパイプラインにおいて検索品質はチャンキング戦略に直接的に依存する。文書の論理的構造を反映する構造ベースのチャンキングが最も高い検索精度を提供する。
Document Parsing技術は急速に進化しており、特にマルチモーダルLLMの登場は既存のパイプラインパラダイムを根本的に変えつつある。しかし、コストとレイテンシの制約から、実務では従来のツールとLLMを適切に組み合わせたハイブリッドアプローチが最も現実的な選択である。
参考資料
- PyMuPDF公式ドキュメント: https://pymupdf.readthedocs.io/
- pdfplumber公式ドキュメント: https://github.com/jsvine/pdfplumber
- PaddleOCR公式ドキュメント: https://github.com/PaddlePaddle/PaddleOCR
- Tesseract OCR: https://github.com/tesseract-ocr/tesseract
- LayoutLMv3論文: "LayoutLMv3: Pre-training for Document AI with Unified Text and Image Masking" (Huang et al., 2022)
- Table Transformer: https://github.com/microsoft/table-transformer
- Unstructured公式ドキュメント: https://docs.unstructured.io/
- Donut論文: "OCR-free Document Understanding Transformer" (Kim et al., 2022)
- LangChain Document Loaders: https://python.langchain.com/docs/modules/data_connection/document_loaders/
- Camelot: https://camelot-py.readthedocs.io/