先に結論
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 です。
操作説明書では、番号付き手順や箇条書きが回答の中心になります。
参考
- Amazon Textract AnalyzeDocument: https://docs.aws.amazon.com/textract/latest/APIReference/API_AnalyzeDocument.html
- Amazon Textract document analysis: https://docs.aws.amazon.com/textract/latest/dg/how-it-works-analyzing.html
- Amazon Textract query best practices: https://docs.aws.amazon.com/textract/latest/dg/bestqueries.html
- Google Document AI Document schema: https://docs.cloud.google.com/document-ai/docs/reference/rest/v1/Document
- Azure Document Intelligence general document: https://learn.microsoft.com/azure/ai-services/document-intelligence/prebuilt/general-document