先に結論
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 ごとの判断基準
自分なら、最初はこの順番で実装します。
-
decorative除外 -
captionattach -
footnoteattach -
header/footerdedupe -
formulaatomic chunk -
codeatomic chunk -
reference/document_indexmetadata 化
理由は、ノイズ削減の効果が大きい順です。
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 を持っている。
参考
- Docling document labels: https://docling-project.github.io/docling/reference/docling_document/
- Azure Document Intelligence layout model: https://learn.microsoft.com/en-us/azure/ai-services/document-intelligence/prebuilt/layout
- Amazon Textract Layout: https://docs.aws.amazon.com/textract/latest/dg/how-it-works-analyzing.html
- Google Document AI Gemini layout parser: https://docs.cloud.google.com/document-ai/docs/layout-parse-chunk
- Qiita Markdown / Mermaid: https://qiita.com/Qiita/items/c686397e4a0f4f11683d