0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

業務文書RAGのLayout Category設計(1/5)Picture解析を「画像説明」ではなくEvidence化する

0
Posted at

先に結論

業務文書RAGで Picture を扱うときに一番大事なのは、
画像を「なんとなく説明する」ことではありません。

大事なのは、画像を 回答で使える evidence に変換することです。

そのためには、次の形にするのが現実的です。

  • Picture 単体の crop を高解像度で見る
  • 同じページの周辺 context crop も一緒に見る
  • context crop では対象画像を赤枠などで明示する
  • OCR 抽出テキストは「原文」として分けて持つ
  • 小さい footer logo や飾り画像は decorative として RAG から外す
  • ボタンやアイコンのように近くの操作説明と強く結びつく小画像は、隣の text chunk に merge する

つまり、Picture 解析は画像キャプション生成ではなく、
検索、引用、回答生成まで含めた evidence modeling として設計した方がいいです。

よくある失敗

最初にありがちな実装はこうです。

description = vlm.describe(cropped_picture)
chunk_text = description

これで簡単な図は動きます。
でも業務文書ではすぐに壊れます。

  • crop だけだと、どの章・表・操作ステップに属するか分からない
  • 小さいアイコンやロゴまで VLM に投げてしまう
  • OCR 結果と VLM 説明がただ連結され、どれが原文か分からない
  • 図の周辺にある注意書きや番号との関係が落ちる
  • citation で元ページへ戻っても、どこを見ればいいか分からない

個人的には、Picture RAG の難しさは「画像を読むこと」より、
画像が文書の中で何の役割を持っているかを保存すること にあります。

全体像

実装の全体像はこうです。

ここでポイントになるのは、VLM に渡す画像を1枚にしないことです。

Picture の crop は細部を見るために必要です。
一方で、業務文書では周辺 context がないと意味が確定しません。

なので、少なくとも次の2枚を渡します。

image 1: target picture crop
image 2: context crop with red rectangle

必要であれば、3枚目としてページ全体の縮小画像を入れてもよいです。
ただし、まずは2枚構成から始めるのが扱いやすいです。

RAG用の共通schemaを決める

まず parser の出力をそのまま後段に流さない方がいいです。

Docling、Azure Document Intelligence、Amazon Textract、Google Document AI などは、それぞれ良い構造を返します。
ただし、そのまま使うと後段が vendor 固有の JSON に依存します。

いったん自分の RAG 用 schema に戻します。

from dataclasses import dataclass, field
from typing import Any, Literal


ContentKind = Literal[
    "text",
    "section_header",
    "table",
    "picture",
    "picture_ocr_text",
    "caption",
    "page_header",
    "page_footer",
]


@dataclass(frozen=True)
class LayoutElement:
    element_id: str
    page: int
    seq_no: int
    kind: ContentKind
    text: str = ""
    bbox: tuple[float, float, float, float] | None = None
    page_width: float | None = None
    page_height: float | None = None
    section_path: tuple[str, ...] = ()
    metadata: dict[str, Any] = field(default_factory=dict)


@dataclass(frozen=True)
class PictureEvidence:
    element_id: str
    page: int
    bbox: tuple[float, float, float, float]
    role: Literal["content", "decorative", "inline_icon"]
    retrieval_text: str
    surrounding_context: str
    correction_notes: str
    ocr_text: str
    crop_path: str
    context_crop_path: str
    source_element_ids: tuple[str, ...]
    metadata: dict[str, Any] = field(default_factory=dict)

最低限残したい metadata はこれです。

page
seq_no
bbox
page_width / page_height
section_path
nearby_heading
previous_element
next_element
containing_table_id
paired_ocr_text
crop_path
context_crop_path
visual_role

ここを落とすと、あとから回答の根拠表示や highlight ができません。

decorative と inline_icon を先に落とす

すべての Picture に VLM をかけるのはおすすめしません。

footer logo、透かし、コピーライト近辺の小さい画像は RAG の evidence として弱いです。
逆に、操作説明のすぐ横にある小さいアイコンは、その text と一体で意味を持ちます。

なので、VLM の前に role を決めます。

from dataclasses import dataclass
import re


DECORATIVE = "decorative"
INLINE_ICON = "inline_icon"
CONTENT = "content"

OPERATION_WORDS = re.compile(
    r"(クリック|押す|押下|ボタン|アイコン|選択|入力|更新|登録|表示|実行|"
    r"click|press|button|icon|select|input|update|execute)",
    re.IGNORECASE,
)


@dataclass(frozen=True)
class PictureRole:
    role: str
    reason: str = ""
    merge_target_id: str = ""

    @property
    def skip_vlm(self) -> bool:
        return self.role in {DECORATIVE, INLINE_ICON}

    @property
    def exclude_from_rag(self) -> bool:
        return self.role == DECORATIVE


def area(box: tuple[float, float, float, float]) -> float:
    x1, y1, x2, y2 = box
    return max(0.0, x2 - x1) * max(0.0, y2 - y1)


def is_small_picture(element: LayoutElement) -> bool:
    if not element.bbox or not element.page_width or not element.page_height:
        return False

    x1, y1, x2, y2 = element.bbox
    width = abs(x2 - x1)
    height = abs(y2 - y1)
    page_area = max(1.0, element.page_width * element.page_height)

    return (
        area(element.bbox) / page_area <= 0.006
        and width / element.page_width <= 0.08
        and height / element.page_height <= 0.08
    )


def near_page_edge(element: LayoutElement) -> bool:
    if not element.bbox or not element.page_height:
        return False
    _, y1, _, y2 = element.bbox
    center_y = (y1 + y2) / 2
    return center_y <= element.page_height * 0.12 or center_y >= element.page_height * 0.88


def vertical_distance(
    a: tuple[float, float, float, float],
    b: tuple[float, float, float, float],
) -> float:
    _, ay1, _, ay2 = a
    _, by1, _, by2 = b
    if ay2 < by1:
        return by1 - ay2
    if by2 < ay1:
        return ay1 - by2
    return 0.0


def find_near_operation_text(
    picture: LayoutElement,
    elements: list[LayoutElement],
) -> str:
    if not picture.bbox:
        return ""

    candidates: list[tuple[float, int, str]] = []
    for other in elements:
        if other.page != picture.page or other.element_id == picture.element_id:
            continue
        if other.kind in {"picture", "picture_ocr_text", "page_header", "page_footer"}:
            continue
        if not other.bbox or not OPERATION_WORDS.search(other.text):
            continue

        seq_gap = abs(other.seq_no - picture.seq_no)
        y_gap = vertical_distance(picture.bbox, other.bbox)
        if seq_gap <= 2 or y_gap <= 96:
            candidates.append((y_gap, seq_gap, other.element_id))

    if not candidates:
        return ""
    return sorted(candidates)[0][2]


def classify_picture(
    picture: LayoutElement,
    elements: list[LayoutElement],
) -> PictureRole:
    if picture.kind != "picture" or not is_small_picture(picture):
        return PictureRole(CONTENT)

    merge_target_id = find_near_operation_text(picture, elements)
    if merge_target_id and not near_page_edge(picture):
        return PictureRole(INLINE_ICON, "small_picture_near_operation_text", merge_target_id)

    if near_page_edge(picture):
        return PictureRole(DECORATIVE, "small_picture_in_page_edge_band")

    if re.search(r"logo|copyright|all rights reserved|ロゴ|著作権", picture.text, re.I):
        return PictureRole(DECORATIVE, "small_logo_or_boilerplate_picture")

    return PictureRole(CONTENT)

この判断は完璧でなくていいです。
大事なのは、VLM に投げる前に「画像の役割」を決めることです。

target crop と context crop を作る

crop は2種類作ります。

  • target crop: 画像だけを切り出す
  • context crop: 周辺の見出し、表、説明文を含め、対象を赤枠で囲む
from pathlib import Path
from PIL import Image, ImageDraw


def expand_bbox(
    bbox: tuple[float, float, float, float],
    page_width: float,
    page_height: float,
    *,
    x_ratio: float = 1.0,
    y_ratio: float = 1.4,
    min_padding: float = 48.0,
) -> tuple[int, int, int, int]:
    x1, y1, x2, y2 = bbox
    w = x2 - x1
    h = y2 - y1
    pad_x = max(min_padding, w * x_ratio)
    pad_y = max(min_padding, h * y_ratio)

    return (
        int(max(0, x1 - pad_x)),
        int(max(0, y1 - pad_y)),
        int(min(page_width, x2 + pad_x)),
        int(min(page_height, y2 + pad_y)),
    )


def save_picture_crops(
    page_image_path: str,
    picture: LayoutElement,
    output_dir: str,
) -> tuple[str, str]:
    if not picture.bbox:
        raise ValueError("picture bbox is required")

    output = Path(output_dir)
    output.mkdir(parents=True, exist_ok=True)

    image = Image.open(page_image_path).convert("RGB")
    page_width, page_height = image.size
    x1, y1, x2, y2 = map(int, picture.bbox)

    target = image.crop((x1, y1, x2, y2))
    target_path = output / f"{picture.element_id}.png"
    target.save(target_path)

    cx1, cy1, cx2, cy2 = expand_bbox(picture.bbox, page_width, page_height)
    context = image.crop((cx1, cy1, cx2, cy2))
    draw = ImageDraw.Draw(context)
    draw.rectangle((x1 - cx1, y1 - cy1, x2 - cx1, y2 - cy1), outline=(255, 0, 0), width=4)
    context_path = output / f"{picture.element_id}.context.png"
    context.save(context_path)

    return str(target_path), str(context_path)

context crop の範囲は、固定倍率だけでなく「同じ section / table に属する要素の bbox」を含めて決めると、さらに安定します。

VLMには構造化JSONで返させる

VLM の出力を自由文にすると、後段が不安定になります。
最初から schema を決めます。

from pydantic import BaseModel, Field


class PictureDescription(BaseModel):
    image_summary: str = ""
    surrounding_context: str = ""
    correction_notes: str = ""
    main_topic: str = ""
    menu_route: str = ""
    visible_screen_names: list[str] = Field(default_factory=list)
    visible_buttons: list[str] = Field(default_factory=list)
    visible_fields: list[str] = Field(default_factory=list)
    visible_values: list[str] = Field(default_factory=list)
    operation_steps: list[str] = Field(default_factory=list)
    condition_result_pairs: list[str] = Field(default_factory=list)
    exception_or_cautions: list[str] = Field(default_factory=list)
    answerable_questions: list[str] = Field(default_factory=list)
    query_rewrites: list[str] = Field(default_factory=list)
    search_keywords: list[str] = Field(default_factory=list)
    look_at: list[str] = Field(default_factory=list)
    retrieval_text: str = ""

prompt では、画像の役割を明示します。

画像1は対象Pictureだけを切り出した画像です。
細部の読み取りは画像1を優先してください。

画像2は同じページの周辺文脈です。
赤枠内が対象Pictureです。
赤枠外の無関係な本文は対象Pictureの説明に混ぜないでください。

paired_ocr_text がある場合は読み取り補助として使ってください。
ただし OCR 文字列をそのまま retrieval_text に貼り付けず、
意味・条件・操作・検索語として正規化してください。

この設計にすると、surrounding_contextcorrection_notes を分けられます。

回答用本文: ...
周辺コンテキスト: ...
修正・補足: ...
OCR抽出テキスト:
...

ここがかなり大事です。
OCR は原文に近い情報です。
VLM 説明は正規化された情報です。
同じ文字列として混ぜると、後で何を信用すべきか分からなくなります。

OCR抽出テキストは見出しを付けて分ける

Picture に対して OCR aggregate がある場合、単純に連結しない方がいいです。

OCR_LABEL = "OCR抽出テキスト"


def format_picture_ocr_text(text: str) -> str:
    normalized = text.strip()
    if not normalized:
        return ""
    if normalized.startswith(f"{OCR_LABEL}:"):
        return normalized
    if normalized.startswith("Docling OCR:"):
        normalized = normalized[len("Docling OCR:"):].strip()
    return f"{OCR_LABEL}:\n{normalized}"


def picture_text_for_chunk(description: PictureDescription, ocr_text: str) -> str:
    lines: list[str] = []
    if description.retrieval_text:
        lines.append(f"回答用本文: {description.retrieval_text}")
    if description.surrounding_context:
        lines.append(f"周辺コンテキスト: {description.surrounding_context}")
    if description.correction_notes:
        lines.append(f"修正・補足: {description.correction_notes}")
    if description.operation_steps:
        lines.append("操作: " + " / ".join(description.operation_steps))
    if description.search_keywords:
        lines.append("検索語: " + " / ".join(description.search_keywords))

    formatted_ocr = format_picture_ocr_text(ocr_text)
    if formatted_ocr:
        lines.append(formatted_ocr)

    return "\n".join(lines).strip()

検索では 回答用本文検索語 が効きます。
回答生成では OCR抽出テキスト が読み取り根拠として効きます。

Chunk metadata に image evidence を残す

画像説明を text に変換して終わりではありません。
後から根拠画面に戻るため、image evidence を metadata に残します。

def build_picture_chunk_metadata(evidence: PictureEvidence) -> dict:
    return {
        "source_categories": ["Picture"],
        "contains_picture": True,
        "contains_image_evidence": evidence.role == "content",
        "image_evidence": [
            {
                "image_id": evidence.element_id,
                "page": evidence.page,
                "bbox": list(evidence.bbox),
                "crop_path": evidence.crop_path,
                "context_crop_path": evidence.context_crop_path,
                "visual_role": evidence.role,
            }
        ] if evidence.role == "content" else [],
        "source_record_refs": [
            {
                "record_id": evidence.element_id,
                "page": evidence.page,
                "bbox": list(evidence.bbox),
                "category": "Picture",
            }
        ],
    }

ここで decorativeimage_evidence に入れません。
inline_icon も独立 chunk にはせず、近くの text chunk の source_record_refs にだけ残します。

最小パイプライン

ここまでをつなぐと、最小実装はこうなります。

def build_picture_evidence(
    picture: LayoutElement,
    all_elements: list[LayoutElement],
    page_image_path: str,
    output_dir: str,
    vlm_client,
) -> PictureEvidence | None:
    role = classify_picture(picture, all_elements)

    if role.role == DECORATIVE:
        return PictureEvidence(
            element_id=picture.element_id,
            page=picture.page,
            bbox=picture.bbox or (0, 0, 0, 0),
            role=DECORATIVE,
            retrieval_text="",
            surrounding_context="",
            correction_notes="",
            ocr_text="",
            crop_path="",
            context_crop_path="",
            source_element_ids=(picture.element_id,),
            metadata={"rag_excluded": True, "vision_skipped": True, "reason": role.reason},
        )

    if role.role == INLINE_ICON:
        return None

    target_crop, context_crop = save_picture_crops(page_image_path, picture, output_dir)
    paired_ocr_text = find_same_bbox_ocr_text(picture, all_elements)

    response = vlm_client.describe_picture(
        images=[target_crop, context_crop],
        metadata={
            "page": picture.page,
            "bbox": picture.bbox,
            "section_path": picture.section_path,
            "paired_ocr_text": paired_ocr_text,
            "image_inputs": [
                {"index": 1, "role": "target_picture_crop"},
                {"index": 2, "role": "page_context_crop_with_target_box"},
            ],
        },
        output_schema=PictureDescription,
    )

    description = PictureDescription.model_validate(response)

    return PictureEvidence(
        element_id=picture.element_id,
        page=picture.page,
        bbox=picture.bbox or (0, 0, 0, 0),
        role=CONTENT,
        retrieval_text=description.retrieval_text,
        surrounding_context=description.surrounding_context,
        correction_notes=description.correction_notes,
        ocr_text=paired_ocr_text,
        crop_path=target_crop,
        context_crop_path=context_crop,
        source_element_ids=(picture.element_id,),
        metadata={"description": description.model_dump()},
    )

find_same_bbox_ocr_text は、同じ page かつ同じ bbox の OCR aggregate を拾うだけです。

def same_bbox(
    a: tuple[float, float, float, float] | None,
    b: tuple[float, float, float, float] | None,
    tolerance: float = 1.0,
) -> bool:
    if not a or not b:
        return False
    return all(abs(x - y) <= tolerance for x, y in zip(a, b))


def find_same_bbox_ocr_text(
    picture: LayoutElement,
    elements: list[LayoutElement],
) -> str:
    for element in elements:
        if element.page != picture.page:
            continue
        if element.kind != "picture_ocr_text":
            continue
        if same_bbox(picture.bbox, element.bbox):
            return format_picture_ocr_text(element.text)
    return ""

テスト観点

最低限、このあたりはテストした方がいいです。

def test_footer_logo_is_excluded():
    logo = LayoutElement(
        element_id="p1-logo",
        page=1,
        seq_no=20,
        kind="picture",
        bbox=(480, 760, 520, 790),
        page_width=600,
        page_height=800,
    )

    role = classify_picture(logo, [logo])

    assert role.role == "decorative"
    assert role.exclude_from_rag is True


def test_operation_icon_is_merged_with_near_text():
    text = LayoutElement(
        element_id="p1-text",
        page=1,
        seq_no=5,
        kind="text",
        text="検索ボタンを押して一覧を表示します。",
        bbox=(100, 200, 420, 220),
        page_width=600,
        page_height=800,
    )
    icon = LayoutElement(
        element_id="p1-icon",
        page=1,
        seq_no=6,
        kind="picture",
        bbox=(430, 198, 450, 218),
        page_width=600,
        page_height=800,
    )

    role = classify_picture(icon, [text, icon])

    assert role.role == "inline_icon"
    assert role.merge_target_id == "p1-text"

まとめ

Picture は、RAG ではただの画像ではありません。

検索に効く text、回答に効く structured evidence、引用に効く bbox、そして不要な decorative 判定まで含めて扱う必要があります。

今回の設計で大事なのはこの4つです。

  • target crop と context crop を分ける
  • OCR 原文と VLM 正規化説明を分ける
  • decorative / inline icon / content を先に分類する
  • chunk text だけでなく image evidence metadata を残す

ここまでやると、Picture は「雰囲気のある説明」ではなく、
回答に使える根拠になります。

次回は Table です。
表は Picture よりさらに壊れやすいです。
特に、表の中に画像がある場合は、行・列・セル・画像説明をどう結合するかが勝負になります。

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?