Skip to content
Published on

Document Parsing技術ガイド: PDF解析・OCR・レイアウト分析・LLMベース文書抽出の実践パイプライン

Authors
  • Name
    Twitter

Document Parsing技術ガイド

はじめに

企業が保有する知識の大部分は、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パイプラインは以下のステージで構成される。

  1. 文書収集: PDF、画像、Word、HTMLなど多様なフォーマットの文書入力
  2. 前処理: 画像補正、ノイズ除去、ページ分離
  3. テキスト抽出: ネイティブPDFテキスト抽出またはOCR
  4. レイアウト分析: 文書構造の認識(見出し、本文、表、図)
  5. 構造化抽出: テーブル解析、キー・バリューペア抽出、NER
  6. 後処理: テキスト整形、チャンキング、メタデータ付与
  7. 格納/インデキシング: ベクトル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 5100以上中~高中間オープンソース、最も広く使用
EasyOCR80以上中~高遅いPyTorchベース、インストール簡単
PaddleOCR80以上速いBaidu開発、高精度
Google Vision API100以上最高速いクラウドサービス、有料
Azure Document Intelligence100以上最高速いエンタープライズ、構造化抽出対応

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ベースのモデルがこの分野をリードしている。

主要レイアウト分析モデル

モデル開発元核心技術特徴
LayoutLMv3MicrosoftマルチモーダルTransformerテキスト+画像+レイアウト統合
DiT (Document Image Transformer)MicrosoftVision Transformer画像ベースの文書理解
DonutNAVER CLOVAOCR-freeアプローチOCRなしで直接文書理解
Table TransformerMicrosoftDETRベーステーブル検出/構造認識特化
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パイプラインを構築する。

パイプラインアーキテクチャ

全体パイプラインは以下のステージで構成される。

  1. 入力処理: 多様な文書フォーマットの検出と正規化
  2. 解析戦略選択: 文書タイプに応じた最適パーサーの選択
  3. テキスト/構造抽出: OCR、レイアウト分析、テーブル抽出
  4. LLM強化: マルチモーダルLLMによる品質向上
  5. チャンキングとインデキシング: 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を適切に組み合わせたハイブリッドアプローチが最も現実的な選択である。

参考資料