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設計(5/5)Caption・Footnote・Header/Footer・Formula・Codeをどう扱うか

0
Posted at

先に結論

Picture、Table、Form、List まで作ると、RAG はかなり実用に近づきます。

ただ、最後に残る category を雑に扱うと、検索ノイズや citation のズレが出ます。

残りの category は、全部を同じように chunk にするのではなく、
役割ごとに policy を分ける のがいいです。

おすすめはこうです。

category 扱い
Caption 近くの Picture / Table に attach
Footnote 参照元 block に attach。単独 chunk は原則避ける
Page Header metadata。繰り返しなら RAG から除外
Page Footer metadata。page number / copyright は除外
Formula text 化して atomic chunk。周辺説明を attach
Code code block として atomic chunk。言語・前後説明を保存
Reference citation metadata。本文 retrieval には入れすぎない
Document Index navigation metadata。章見出し補強に使う
Decorative RAG から除外

つまり、残り category の設計は、
「入れるかどうか」ではなく どこに合流させるか が重要です。

全体像

すべてを chunk にする必要はありません。

RAG では、検索に必要なもの、回答に必要なもの、引用に必要なものが違います。

policy table をコード化する

最初に category policy を決めます。

from dataclasses import dataclass
from enum import Enum


class CategoryAction(str, Enum):
    ATOMIC_CHUNK = "atomic_chunk"
    ATTACH_TO_NEIGHBOR = "attach_to_neighbor"
    METADATA_ONLY = "metadata_only"
    EXCLUDE = "exclude"


@dataclass(frozen=True)
class CategoryPolicy:
    action: CategoryAction
    attach_targets: tuple[str, ...] = ()
    reason: str = ""


CATEGORY_POLICIES: dict[str, CategoryPolicy] = {
    "caption": CategoryPolicy(
        CategoryAction.ATTACH_TO_NEIGHBOR,
        attach_targets=("picture", "table"),
        reason="caption only makes sense with the described visual element",
    ),
    "footnote": CategoryPolicy(
        CategoryAction.ATTACH_TO_NEIGHBOR,
        attach_targets=("text", "table", "form"),
        reason="footnote modifies nearby evidence",
    ),
    "page_header": CategoryPolicy(
        CategoryAction.METADATA_ONLY,
        reason="usually repeated furniture, useful for provenance but noisy for retrieval",
    ),
    "page_footer": CategoryPolicy(
        CategoryAction.METADATA_ONLY,
        reason="usually page number/copyright/furniture",
    ),
    "formula": CategoryPolicy(
        CategoryAction.ATOMIC_CHUNK,
        reason="formula should not be split by fixed-length chunking",
    ),
    "code": CategoryPolicy(
        CategoryAction.ATOMIC_CHUNK,
        reason="code should preserve block boundaries",
    ),
    "reference": CategoryPolicy(
        CategoryAction.METADATA_ONLY,
        reason="reference supports citation, not always retrieval text",
    ),
    "document_index": CategoryPolicy(
        CategoryAction.METADATA_ONLY,
        reason="navigation metadata for section routing",
    ),
    "decorative": CategoryPolicy(
        CategoryAction.EXCLUDE,
        reason="no answer evidence",
    ),
}

policy は設定ファイルにしてもいいです。
ただ、最初はコードで固定してテストした方が速いです。

Caption は近くの Picture / Table に attach する

Caption を単独 chunk にすると、検索で caption だけが取れて中身がありません。

なので、近くの visual element に attach します。

from dataclasses import dataclass, field
from typing import Any


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


def bbox_distance(
    a: tuple[float, float, float, float],
    b: tuple[float, float, float, float],
) -> float:
    ax = (a[0] + a[2]) / 2
    ay = (a[1] + a[3]) / 2
    bx = (b[0] + b[2]) / 2
    by = (b[1] + b[3]) / 2
    return ((ax - bx) ** 2 + (ay - by) ** 2) ** 0.5


def attach_captions(elements: list[LayoutElement]) -> dict[str, list[LayoutElement]]:
    visuals = [item for item in elements if item.kind in {"picture", "table"} and item.bbox]
    captions = [item for item in elements if item.kind == "caption" and item.bbox]

    attached: dict[str, list[LayoutElement]] = {item.element_id: [] for item in visuals}

    for caption in captions:
        same_page_visuals = [item for item in visuals if item.page == caption.page]
        if not same_page_visuals:
            continue

        best = min(
            same_page_visuals,
            key=lambda visual: (
                abs(visual.seq_no - caption.seq_no),
                bbox_distance(visual.bbox, caption.bbox),  # type: ignore[arg-type]
            ),
        )
        attached[best.element_id].append(caption)

    return attached

chunk text ではこう入れます。

def caption_text(captions: list[LayoutElement]) -> str:
    lines = [caption.text.strip() for caption in captions if caption.text.strip()]
    if not lines:
        return ""
    return "キャプション: " + " / ".join(lines)

caption は「画像の説明」ではなく、画像と一体の根拠です。

Footnote は参照元に attach する

Footnote は単独では弱いです。
ただし、条件や例外を持っていることが多いので捨ててはいけません。

近い block に attach します。

FOOTNOTE_MARKERS = ("", "*", "", "Note")


def has_footnote_marker(text: str) -> bool:
    stripped = text.strip()
    return any(stripped.startswith(marker) for marker in FOOTNOTE_MARKERS)


def attach_footnotes(elements: list[LayoutElement]) -> dict[str, list[LayoutElement]]:
    bodies = [
        item
        for item in elements
        if item.kind in {"text", "table", "form", "list_item"} and item.bbox
    ]
    footnotes = [
        item
        for item in elements
        if item.kind == "footnote" or has_footnote_marker(item.text)
    ]

    attached: dict[str, list[LayoutElement]] = {item.element_id: [] for item in bodies}

    for footnote in footnotes:
        if not footnote.bbox:
            continue
        same_page = [item for item in bodies if item.page == footnote.page and item.bbox]
        if not same_page:
            continue

        # footnote は多くの場合、参照元より下にあるので、seq_no が近くて上にある block を優先する
        candidates = [item for item in same_page if item.seq_no < footnote.seq_no]
        if not candidates:
            candidates = same_page

        best = min(
            candidates,
            key=lambda item: (
                abs(item.seq_no - footnote.seq_no),
                bbox_distance(item.bbox, footnote.bbox),  # type: ignore[arg-type]
            ),
        )
        attached[best.element_id].append(footnote)

    return attached

Footnote は回答に効くことがあります。
特に「ただし」「対象外」「不要」「別途」などの語は落とさない方がいいです。

Header / Footer は dedupe する

Page Header / Footer をそのまま全ページ chunk に入れると、検索ノイズになります。

やることは2つです。

  • 繰り返し出る header/footer は metadata only
  • ページ固有の重要 footer は近い block に attach
from collections import Counter


def normalize_furniture_text(text: str) -> str:
    return " ".join(text.split()).lower()


def repeated_furniture_texts(elements: list[LayoutElement], *, threshold: int = 3) -> set[str]:
    texts = [
        normalize_furniture_text(item.text)
        for item in elements
        if item.kind in {"page_header", "page_footer"} and item.text.strip()
    ]
    counts = Counter(texts)
    return {text for text, count in counts.items() if count >= threshold}


def should_exclude_furniture(
    element: LayoutElement,
    repeated_texts: set[str],
) -> bool:
    if element.kind not in {"page_header", "page_footer"}:
        return False

    text = normalize_furniture_text(element.text)
    if text in repeated_texts:
        return True

    if element.kind == "page_footer" and text.isdigit():
        return True

    if "copyright" in text or "all rights reserved" in text:
        return True

    return False

Header/Footer を全部消すのではなく、metadata に残すのがポイントです。
引用や page label に使えることがあるからです。

Formula は atomic chunk にする

Formula は途中で切ると意味が壊れます。

LaTeX、MathML、OCR text のどれでもいいので、まず正規化します。

def normalize_formula_text(text: str) -> str:
    normalized = text.strip()
    if not normalized:
        return ""

    if normalized.startswith("$$") and normalized.endswith("$$"):
        return normalized

    if "\\" in normalized:
        return f"$$\n{normalized}\n$$"

    return f"式: {normalized}"


def formula_chunk(element: LayoutElement, section_path: tuple[str, ...]) -> dict:
    formula = normalize_formula_text(element.text)
    text = "\n".join(
        part
        for part in [
            "関連見出し: " + " > ".join(section_path) if section_path else "",
            formula,
        ]
        if part
    )

    return {
        "text": text,
        "metadata": {
            "source_categories": ["Formula"],
            "atomic": True,
            "page": element.page,
            "bbox": list(element.bbox or ()),
            "source_record_refs": [
                {
                    "record_id": element.element_id,
                    "page": element.page,
                    "bbox": list(element.bbox or ()),
                    "category": "Formula",
                }
            ],
        },
    }

Formula は周辺説明と一緒に取れないと検索が弱いです。
section path と直前直後の text を metadata に持たせるとよいです。

Code は code block として保存する

Code も fixed length split しない方がいいです。

def guess_code_language(text: str) -> str:
    stripped = text.strip()
    if "def " in stripped or "import " in stripped:
        return "python"
    if "SELECT " in stripped.upper() and " FROM " in stripped.upper():
        return "sql"
    if stripped.startswith("{") or stripped.startswith("["):
        return "json"
    if "<" in stripped and ">" in stripped:
        return "xml"
    return ""


def code_chunk(element: LayoutElement, section_path: tuple[str, ...]) -> dict:
    language = guess_code_language(element.text)
    fenced = f"```{language}\n{element.text.strip()}\n```"
    text = "\n".join(
        part
        for part in [
            "関連見出し: " + " > ".join(section_path) if section_path else "",
            fenced,
        ]
        if part
    )

    return {
        "text": text,
        "metadata": {
            "source_categories": ["Code"],
            "atomic": True,
            "code_language": language,
            "page": element.page,
            "bbox": list(element.bbox or ()),
        },
    }

問い合わせでは「このSQLは何をしているか」「この設定値は何か」のように聞かれます。
コードだけでなく、近くの説明文も parent chunk に入れると安定します。

Reference / Document Index は navigation として使う

Reference や Document Index は、本文 chunk としては弱いですが、
retrieval routing には効きます。

def build_document_index(elements: list[LayoutElement]) -> dict[str, list[str]]:
    index: dict[str, list[str]] = {}

    for element in elements:
        if element.kind not in {"title", "section_header", "document_index"}:
            continue
        if not element.text.strip():
            continue
        key = f"p{element.page}"
        index.setdefault(key, []).append(element.text.strip())

    return index

質問が章名やメニュー名に近い場合、この index を使って候補ページを絞れます。

policy を適用する

最後に、category policy を使って chunk に流します。

def apply_category_policy(elements: list[LayoutElement]) -> list[dict]:
    repeated = repeated_furniture_texts(elements)
    caption_map = attach_captions(elements)
    footnote_map = attach_footnotes(elements)

    chunks: list[dict] = []

    for element in elements:
        kind = element.kind

        if kind in {"page_header", "page_footer"} and should_exclude_furniture(element, repeated):
            continue

        policy = CATEGORY_POLICIES.get(kind, CategoryPolicy(CategoryAction.ATOMIC_CHUNK))

        if policy.action == CategoryAction.EXCLUDE:
            continue

        if policy.action == CategoryAction.METADATA_ONLY:
            continue

        if policy.action == CategoryAction.ATTACH_TO_NEIGHBOR:
            continue

        if kind == "formula":
            chunks.append(formula_chunk(element, tuple(element.metadata.get("section_path") or ())))
            continue

        if kind == "code":
            chunks.append(code_chunk(element, tuple(element.metadata.get("section_path") or ())))
            continue

        extra_lines = []
        if element.element_id in caption_map:
            extra_lines.append(caption_text(caption_map[element.element_id]))
        if element.element_id in footnote_map:
            notes = " / ".join(note.text for note in footnote_map[element.element_id])
            if notes:
                extra_lines.append(f"注記: {notes}")

        text = "\n".join([element.text, *extra_lines]).strip()
        if text:
            chunks.append(
                {
                    "text": text,
                    "metadata": {
                        "source_categories": [kind],
                        "page": element.page,
                        "bbox": list(element.bbox or ()),
                    },
                }
            )

    return chunks

この設計では、Caption や Footnote を単独 chunk にしません。
しかし、情報は消していません。
適切な近傍 evidence に合流させています。

category ごとの判断基準

自分なら、最初はこの順番で実装します。

  1. decorative 除外
  2. caption attach
  3. footnote attach
  4. header/footer dedupe
  5. formula atomic chunk
  6. code atomic chunk
  7. reference/document_index metadata 化

理由は、ノイズ削減の効果が大きい順です。

RAG は情報を増やすほど良くなるわけではありません。
回答に使わない情報を減らすことも同じくらい大事です。

テスト観点

def test_repeated_footer_is_excluded():
    elements = [
        LayoutElement(f"f{i}", i, 99, "page_footer", "Copyright Example Corp")
        for i in range(1, 5)
    ]

    repeated = repeated_furniture_texts(elements, threshold=3)

    assert should_exclude_furniture(elements[0], repeated)


def test_caption_attaches_to_nearest_picture():
    picture = LayoutElement("pic-1", 1, 10, "picture", "", bbox=(100, 100, 300, 240))
    caption = LayoutElement("cap-1", 1, 11, "caption", "図1 登録画面", bbox=(100, 245, 300, 270))

    attached = attach_captions([picture, caption])

    assert attached["pic-1"][0].text == "図1 登録画面"


def test_formula_is_atomic_chunk():
    formula = LayoutElement("eq-1", 1, 5, "formula", r"E = mc^2", bbox=(10, 10, 100, 40))
    chunk = formula_chunk(formula, ("計算式",))

    assert chunk["metadata"]["atomic"] is True
    assert "E = mc^2" in chunk["text"]

まとめ

5回に分けて、業務文書RAGの Layout Category 設計を整理しました。

最終的な考え方はこうです。

  • Picture は target crop + context crop + OCR + role classification
  • Table は row/column/header + row group + table visual evidence
  • Form は key/value/selection state + empty value + redaction
  • List は procedure step + section path + condition/result
  • その他 category は policy table で attach / metadata / exclude を分ける

ここまで作ると、parser の出力をただ text にする RAG から一段進めます。

RAG の品質は、embedding model だけでは決まりません。
むしろ、LLM に渡す前の段階で、文書の構造をどこまで壊さず evidence にできるかでかなり決まります。

個人的には、ここが一番大事です。

良い RAG は、良い chunk の前に、良い category policy を持っている。

参考

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?