1
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設計(3/5)Form・Key-Value・Selection Markを回答できる形にする

1
Posted at

先に結論

Picture、Table の次に最適化したい category は、
Form / Key-Value / Selection Mark です。

理由はシンプルです。
業務文書の問い合わせは、かなりの割合で「どの項目に何を入れるか」「どのチェックを選ぶか」「値が空のときどうするか」に寄ります。

この category は、ただ OCR text として保存すると弱いです。

RAG では次の形にします。

項目: 申請区分
値: 新規
状態: selected
意味: 申請区分で新規を選択する
位置: page=2 bbox=...

つまり、Form は文章ではなく field evidence として扱います。

なぜ Form は特別扱いするのか

Document AI 系のサービスを見ると、Form や Key-Value は Table や Text とは別カテゴリで扱われています。

たとえば Amazon Textract は Forms を key-value pair として返し、selection element も form / table の中で扱います。
Google Document AI も FormField として fieldName / fieldValue を分けます。
Azure Document Intelligence も key-value pairs や selection marks を layout の重要要素として扱います。

これは自然です。

Form は見た目上は短い文字列でも、意味上はかなり強いです。

氏名       [          ]
対象       [x]
対象外     [ ]

これを普通の OCR text にすると、こうなります。

氏名 対象 対象外

これでは回答できません。
必要なのは「対象が選択されている」「氏名欄は空欄」などの状態です。

全体像

Form では reading order だけでは足りません。

横方向、縦方向、ラベルと値の距離、チェックボックスの状態を使って pair を作ります。

schema を決める

まず field を1つの evidence として表現します。

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


SelectionState = Literal["selected", "unselected", "unknown"]


@dataclass(frozen=True)
class FormFieldEvidence:
    field_id: str
    page: int
    key: str
    value: str = ""
    selection_state: SelectionState = "unknown"
    value_type: str = "text"
    key_bbox: tuple[float, float, float, float] | None = None
    value_bbox: tuple[float, float, float, float] | None = None
    confidence: float | None = None
    section_path: tuple[str, ...] = ()
    source_element_ids: tuple[str, ...] = ()
    metadata: dict[str, Any] = field(default_factory=dict)


@dataclass(frozen=True)
class RawLayoutElement:
    element_id: str
    page: int
    seq_no: int
    kind: str
    text: str
    bbox: tuple[float, float, float, float] | None
    confidence: float | None = None

ここで value が空でも field を捨てないのが重要です。

空欄そのものが回答になることがあるからです。

Key と Value を geometry で pair にする

Form parser が key-value を直接返してくれる場合は、それを使います。
返してくれない場合は fallback として geometry で pair を作ります。

よくある規則は次です。

  • value は key の右側にある
  • または value は key の直下にある
  • 同じ行の距離が近いものを優先する
  • checkbox は近い label と結びつける
def center(box: tuple[float, float, float, float]) -> tuple[float, float]:
    x1, y1, x2, y2 = box
    return ((x1 + x2) / 2, (y1 + y2) / 2)


def y_overlap_ratio(
    a: tuple[float, float, float, float],
    b: tuple[float, float, float, float],
) -> float:
    _, ay1, _, ay2 = a
    _, by1, _, by2 = b
    overlap = max(0.0, min(ay2, by2) - max(ay1, by1))
    height = max(1.0, min(ay2 - ay1, by2 - by1))
    return overlap / height


def pair_score(
    key: RawLayoutElement,
    value: RawLayoutElement,
) -> float:
    if not key.bbox or not value.bbox or key.page != value.page:
        return float("inf")

    kx, ky = center(key.bbox)
    vx, vy = center(value.bbox)
    same_row = y_overlap_ratio(key.bbox, value.bbox) >= 0.5

    if same_row and vx > kx:
        return (vx - kx) + abs(vy - ky) * 2

    below = vy > ky and abs(vx - kx) < 80
    if below:
        return (vy - ky) * 2 + abs(vx - kx)

    return float("inf")


def infer_key_value_pairs(
    keys: list[RawLayoutElement],
    values: list[RawLayoutElement],
) -> list[tuple[RawLayoutElement, RawLayoutElement]]:
    pairs: list[tuple[RawLayoutElement, RawLayoutElement]] = []
    used_values: set[str] = set()

    for key in sorted(keys, key=lambda item: (item.page, item.seq_no)):
        candidates = [
            (pair_score(key, value), value)
            for value in values
            if value.element_id not in used_values
        ]
        candidates = [(score, value) for score, value in candidates if score != float("inf")]
        if not candidates:
            continue

        _, best_value = min(candidates, key=lambda item: item[0])
        used_values.add(best_value.element_id)
        pairs.append((key, best_value))

    return pairs

これは最小実装です。
実運用では、field の罫線、入力欄の形、font size、section heading も使うとさらに安定します。

Checkbox / Radio は selected state を保存する

Selection mark は text ではなく state です。

def normalize_selection_state(raw: str) -> SelectionState:
    value = raw.strip().lower()
    if value in {"selected", "checked", "filled", ":selected:", "on", "true"}:
        return "selected"
    if value in {"unselected", "unchecked", "unfilled", ":unselected:", "off", "false"}:
        return "unselected"
    return "unknown"


def pair_selection_with_label(
    marks: list[RawLayoutElement],
    labels: list[RawLayoutElement],
) -> list[FormFieldEvidence]:
    fields: list[FormFieldEvidence] = []

    for mark in marks:
        if not mark.bbox:
            continue

        candidates: list[tuple[float, RawLayoutElement]] = []
        mx, my = center(mark.bbox)

        for label in labels:
            if label.page != mark.page or not label.bbox:
                continue
            lx, ly = center(label.bbox)
            if lx < mx:
                continue
            if abs(ly - my) > 24:
                continue
            candidates.append((abs(lx - mx) + abs(ly - my) * 3, label))

        if not candidates:
            continue

        _, label = min(candidates, key=lambda item: item[0])
        fields.append(
            FormFieldEvidence(
                field_id=f"{mark.element_id}-{label.element_id}",
                page=mark.page,
                key=label.text,
                value="",
                selection_state=normalize_selection_state(mark.text),
                value_type="selection_mark",
                key_bbox=label.bbox,
                value_bbox=mark.bbox,
                confidence=mark.confidence,
                source_element_ids=(mark.element_id, label.element_id),
            )
        )

    return fields

チェック状態は retrieval text に必ず入れます。

def form_field_to_text(field: FormFieldEvidence) -> str:
    lines = []
    if field.section_path:
        lines.append("関連見出し: " + " > ".join(field.section_path))

    lines.append(f"項目: {field.key}")

    if field.value_type == "selection_mark":
        state = {
            "selected": "選択されている",
            "unselected": "選択されていない",
            "unknown": "選択状態不明",
        }[field.selection_state]
        lines.append(f"状態: {field.key}{state}")
    else:
        lines.append(f"値: {field.value if field.value else '空欄'}")

    return "\n".join(lines)

empty value を落とさない

Form で一番やりがちなミスは、空欄を落とすことです。

if not field.value:
    continue

これは危険です。

空欄は、業務ルール上「未設定」「入力不要」「後で自動設定」などを意味する場合があります。

なので、空欄は empty_value として明示します。

def normalize_value(value: str) -> tuple[str, str]:
    text = value.strip()
    if not text:
        return "", "empty_value"
    return text, "text"

RAG text ではこう出します。

項目: 承認日
値: 空欄
値種別: empty_value

個人情報は field 単位で redaction する

Form は個人情報を含みやすいです。
記事用サンプルでは当然使いませんが、実装では redaction を入れた方がいいです。

import re


SENSITIVE_KEYWORDS = re.compile(
    r"(氏名|住所|電話|メール|口座|個人番号|マイナンバー|生年月日|name|address|phone|email)",
    re.IGNORECASE,
)


def redact_form_field(field: FormFieldEvidence) -> FormFieldEvidence:
    if not SENSITIVE_KEYWORDS.search(field.key):
        return field

    return FormFieldEvidence(
        field_id=field.field_id,
        page=field.page,
        key=field.key,
        value="[REDACTED]" if field.value else "",
        selection_state=field.selection_state,
        value_type=field.value_type,
        key_bbox=field.key_bbox,
        value_bbox=field.value_bbox,
        confidence=field.confidence,
        section_path=field.section_path,
        source_element_ids=field.source_element_ids,
        metadata={**field.metadata, "redacted": True},
    )

値は伏せても、key と bbox は残します。
そうしないと citation と highlight ができなくなります。

Chunk metadata

Form chunk は、text と structured fields を両方持ちます。

def form_chunk_payload(fields: list[FormFieldEvidence]) -> dict:
    text = "\n\n".join(form_field_to_text(field) for field in fields)

    return {
        "text": text,
        "metadata": {
            "source_categories": ["Form", "KeyValue", "SelectionMark"],
            "contains_form": True,
            "form_fields": [
                {
                    "field_id": field.field_id,
                    "page": field.page,
                    "key": field.key,
                    "value": field.value,
                    "selection_state": field.selection_state,
                    "value_type": field.value_type,
                    "key_bbox": list(field.key_bbox or ()),
                    "value_bbox": list(field.value_bbox or ()),
                    "source_element_ids": list(field.source_element_ids),
                }
                for field in fields
            ],
            "source_record_refs": [
                {
                    "record_id": source_id,
                    "page": field.page,
                    "bbox": list(field.value_bbox or field.key_bbox or ()),
                    "category": "Form",
                }
                for field in fields
                for source_id in field.source_element_ids
            ],
        },
    }

ここで source_record_refs を残すと、回答画面で該当 field を highlight できます。

Query に効く書き方

Form の retrieval text は、OCR の順番通りに並べるだけでは弱いです。

次のように、問い合わせで使われる言い方も入れます。

def field_query_keywords(field: FormFieldEvidence) -> list[str]:
    keywords = [field.key]

    if field.value:
        keywords.append(f"{field.key} {field.value}")

    if field.selection_state == "selected":
        keywords.append(f"{field.key} を選択")
        keywords.append(f"{field.key} がチェック済み")
    elif field.selection_state == "unselected":
        keywords.append(f"{field.key} を選択しない")
        keywords.append(f"{field.key} が未チェック")

    if field.value_type == "empty_value":
        keywords.append(f"{field.key} が空欄")
        keywords.append(f"{field.key} 未入力")

    return keywords

特に checkbox は、ユーザーが「チェック」「選択」「ON」「対象」など揺れた言い方をするので、query rewrite に効きます。

テスト観点

def test_empty_field_is_kept():
    field = FormFieldEvidence(
        field_id="field-1",
        page=1,
        key="承認日",
        value="",
        value_type="empty_value",
    )

    text = form_field_to_text(field)

    assert "項目: 承認日" in text
    assert "空欄" in text


def test_selected_mark_becomes_answerable_text():
    field = FormFieldEvidence(
        field_id="field-2",
        page=1,
        key="対象",
        selection_state="selected",
        value_type="selection_mark",
    )

    assert "対象 は 選択されている" in form_field_to_text(field)
    assert "対象 を選択" in field_query_keywords(field)

まとめ

Form / Key-Value / Selection Mark は、普通の text chunk として扱うと情報が落ちます。

大事なのはこのあたりです。

  • key と value を分ける
  • checkbox / radio は state として持つ
  • empty value を捨てない
  • field 単位で redaction する
  • bbox と source refs を残す
  • retrieval text には問い合わせ表現も入れる

Form は短いですが、回答に直結します。
だからこそ、Table や Picture と同じくらい丁寧に category 設計した方がいいです。

次回は List / Procedure / Section Context です。
操作説明書では、番号付き手順や箇条書きが回答の中心になります。

参考

1
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
1
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?