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設計(4/5)List・Procedure・Section Headerを壊さずChunkingする

0
Posted at

先に結論

操作説明書や運用手順書の RAG では、ListSection Header がかなり重要です。

なぜなら、ユーザーの質問はだいたいこうだからです。

どの画面で操作するのか
何番の手順をやるのか
どのボタンを押すのか
条件がある場合は何が違うのか

これに答えるには、箇条書きをただの text として chunk してはいけません。

ListItem は、次の情報を持った procedure evidence に変換します。

  • section path
  • step number
  • action
  • target UI element
  • condition
  • result
  • caution
  • referenced table / picture / form
  • page / bbox

つまり、List は「短い文の集まり」ではなく、
操作の順序を持った構造 として扱います。

よくある失敗

固定長 chunking だとこうなります。

1. 申請区分を選択します。
2. 実行ボタンを押します。
...

この途中で split されると、
前提条件と実行結果が別 chunk に分かれます。

chunk A:
条件Aの場合は...
1. 申請区分を選択します。

chunk B:
2. 実行ボタンを押します。
この場合は確認画面が表示されます。

検索では chunk B だけが取れて、
「どの条件の場合か」が消えます。

ここが怖いところです。
RAG の回答が間違うとき、LLM が勝手に間違えたというより、
retrieval に渡した chunk が最初から足りていないことが多いです。

全体像

ポイントは、section header と list item を別々に扱わないことです。

ListItem は必ず section path を持つべきです。
そうしないと、同じ「実行ボタンを押します」でも何の実行なのか分からなくなります。

schema を決める

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)


@dataclass(frozen=True)
class ProcedureStep:
    step_id: str
    page: int
    seq_no: int
    step_number: str
    text: str
    action: str
    target: str
    condition: str = ""
    result: str = ""
    caution: str = ""
    section_path: tuple[str, ...] = ()
    source_element_ids: tuple[str, ...] = ()
    bbox: tuple[float, float, float, float] | None = None

step_number は string にします。

理由は、業務文書では 1 だけでなく、(1)A-1B-3 のような番号が普通に出るからです。

Section Path を作る

まず、reading order に沿って section path を更新します。

SECTION_KINDS = {"title", "section_header"}


def section_level(text: str, default: int = 2) -> int:
    stripped = text.strip()
    if stripped.startswith("# "):
        return 1
    if stripped.startswith("## "):
        return 2
    if stripped.startswith("### "):
        return 3
    if stripped[:2].isdigit() and "." in stripped[:5]:
        return 2
    if stripped.startswith(("", "(")):
        return 3
    return default


def attach_section_path(elements: list[LayoutElement]) -> list[LayoutElement]:
    stack: list[tuple[int, str]] = []
    results: list[LayoutElement] = []

    for element in sorted(elements, key=lambda item: (item.page, item.seq_no)):
        if element.kind in SECTION_KINDS and element.text.strip():
            level = section_level(element.text)
            stack = [(lv, text) for lv, text in stack if lv < level]
            stack.append((level, element.text.strip()))

        metadata = dict(element.metadata)
        metadata["section_path"] = [text for _, text in stack][-4:]
        results.append(
            LayoutElement(
                element_id=element.element_id,
                page=element.page,
                seq_no=element.seq_no,
                kind=element.kind,
                text=element.text,
                bbox=element.bbox,
                metadata=metadata,
            )
        )

    return results

ここでは簡単な例にしています。
実運用では parser が返す hierarchy を優先し、取れないときだけ text pattern を使います。

ListItem から ProcedureStep を作る

次に、手順っぽい list item を抽出します。

import re


STEP_PREFIX = re.compile(
    r"^\s*(?P<num>(?:\d+|[①②③④⑤⑥⑦⑧⑨⑩]|[A-Z]-\d+|[((]\d+[))]))[\.\s、。]*"
)

ACTION_WORDS = [
    "開く",
    "選択",
    "入力",
    "押す",
    "クリック",
    "登録",
    "更新",
    "保存",
    "確認",
    "出力",
    "検索",
]

TARGET_SUFFIX = re.compile(r"(?P<target>[^。、「」]+?)(?:ボタン|欄|画面|タブ|メニュー|チェックボックス)")


def extract_step_number(text: str) -> tuple[str, str]:
    match = STEP_PREFIX.search(text)
    if not match:
        return "", text.strip()
    return match.group("num"), text[match.end():].strip()


def extract_action(text: str) -> str:
    for word in ACTION_WORDS:
        if word in text:
            return word
    return ""


def extract_target(text: str) -> str:
    match = TARGET_SUFFIX.search(text)
    if not match:
        return ""
    return match.group("target").strip()


def list_item_to_step(element: LayoutElement) -> ProcedureStep | None:
    if element.kind not in {"list_item", "text"}:
        return None

    number, body = extract_step_number(element.text)
    action = extract_action(body)
    if not number and not action:
        return None

    return ProcedureStep(
        step_id=f"step-{element.element_id}",
        page=element.page,
        seq_no=element.seq_no,
        step_number=number,
        text=body,
        action=action,
        target=extract_target(body),
        section_path=tuple(element.metadata.get("section_path") or ()),
        source_element_ids=(element.element_id,),
        bbox=element.bbox,
    )

ここでやりすぎないのが大事です。
LLM で全部抽出してもよいですが、まず deterministic な rule で「手順っぽいもの」を拾い、その後で補正した方が安定します。

条件・結果・注意を分ける

業務文書の手順では、条件文がとても大事です。

条件Aの場合は、確認画面が表示されます。
条件Bの場合は、エラー一覧を確認してください。

これを単なる text にすると、回答時に条件が混ざります。

CONDITION_PATTERNS = [
    r"(?P<condition>.+?場合)[、,](?P<result>.+)",
    r"(?P<condition>.+?とき)[、,](?P<result>.+)",
    r"(?P<condition>.+?なら)[、,](?P<result>.+)",
]

CAUTION_WORDS = ("注意", "ただし", "対象外", "できません", "不要", "確認してください")


def enrich_condition_result(step: ProcedureStep) -> ProcedureStep:
    condition = ""
    result = ""

    for pattern in CONDITION_PATTERNS:
        match = re.search(pattern, step.text)
        if match:
            condition = match.group("condition").strip()
            result = match.group("result").strip()
            break

    caution = step.caution
    if any(word in step.text for word in CAUTION_WORDS):
        caution = step.text

    return ProcedureStep(
        step_id=step.step_id,
        page=step.page,
        seq_no=step.seq_no,
        step_number=step.step_number,
        text=step.text,
        action=step.action,
        target=step.target,
        condition=condition,
        result=result,
        caution=caution,
        section_path=step.section_path,
        source_element_ids=step.source_element_ids,
        bbox=step.bbox,
    )

こうしておくと、後段で query routing しやすくなります。

条件質問 → condition_result_pairs を優先
操作質問 → action / target / step_number を優先
注意質問 → caution を優先

Procedure Block にまとめる

1 step = 1 chunk にすると細かすぎることがあります。
逆に全部まとめると大きすぎます。

そこで、同じ section path の手順を block にします。

def procedure_step_to_text(step: ProcedureStep) -> str:
    parts = []
    if step.step_number:
        parts.append(f"手順番号: {step.step_number}")
    if step.action:
        parts.append(f"操作: {step.action}")
    if step.target:
        parts.append(f"対象: {step.target}")
    parts.append(f"本文: {step.text}")
    if step.condition:
        parts.append(f"条件: {step.condition}")
    if step.result:
        parts.append(f"結果: {step.result}")
    if step.caution:
        parts.append(f"注意: {step.caution}")
    return "\n".join(parts)


def group_steps_by_section(
    steps: list[ProcedureStep],
    *,
    target_chars: int = 900,
) -> list[list[ProcedureStep]]:
    groups: list[list[ProcedureStep]] = []
    current: list[ProcedureStep] = []

    for step in steps:
        same_section = current and current[-1].section_path == step.section_path
        candidate = [*current, step] if same_section else [step]
        candidate_text = "\n\n".join(procedure_step_to_text(item) for item in candidate)

        if current and (not same_section or len(candidate_text) > target_chars):
            groups.append(current)
            current = [step]
        else:
            current = candidate

    if current:
        groups.append(current)

    return groups

周辺の Picture / Table / Form を参照として付ける

手順の近くに画像や表がある場合、step だけでは足りません。

ただし、画像説明を全部混ぜると noise になります。
metadata として関連を持ち、必要なところだけ text に入れます。

def nearby_elements(
    step: ProcedureStep,
    elements: list[LayoutElement],
    *,
    seq_window: int = 3,
) -> list[LayoutElement]:
    related = []
    for element in elements:
        if element.page != step.page:
            continue
        if element.kind not in {"picture", "table", "form"}:
            continue
        if abs(element.seq_no - step.seq_no) <= seq_window:
            related.append(element)
    return related

ここでは reading order の近さだけを見ています。
実運用では bbox overlap、同じ table 内か、同じ section path かも scoring に入れます。

chunk payload はこうします。

def procedure_chunk_payload(
    steps: list[ProcedureStep],
    related_elements: list[LayoutElement],
) -> dict:
    section_path = steps[0].section_path if steps else ()
    text_parts = []

    if section_path:
        text_parts.append("関連見出し: " + " > ".join(section_path))

    text_parts.extend(procedure_step_to_text(step) for step in steps)

    return {
        "text": "\n\n".join(text_parts),
        "metadata": {
            "source_categories": ["ListItem", "Procedure", "SectionHeader"],
            "section_path": list(section_path),
            "procedure_steps": [
                {
                    "step_id": step.step_id,
                    "step_number": step.step_number,
                    "action": step.action,
                    "target": step.target,
                    "condition": step.condition,
                    "result": step.result,
                    "caution": step.caution,
                    "page": step.page,
                    "bbox": list(step.bbox or ()),
                }
                for step in steps
            ],
            "related_elements": [
                {
                    "element_id": element.element_id,
                    "kind": element.kind,
                    "page": element.page,
                    "bbox": list(element.bbox or ()),
                }
                for element in related_elements
            ],
            "source_record_refs": [
                {
                    "record_id": source_id,
                    "page": step.page,
                    "bbox": list(step.bbox or ()),
                    "category": "ListItem",
                }
                for step in steps
                for source_id in step.source_element_ids
            ],
        },
    }

親子chunkにする

操作手順は、child chunk と parent chunk を分けると使いやすいです。

  • child: 1つの section 内の数 step
  • parent: 前後の section context を含む大きめ block
def build_parent_chunks(child_chunks: list[dict], *, max_children: int = 6) -> list[dict]:
    parents = []
    current = []

    for child in child_chunks:
        if current and len(current) >= max_children:
            parents.append(make_parent(current))
            current = []
        current.append(child)

    if current:
        parents.append(make_parent(current))

    return parents


def make_parent(children: list[dict]) -> dict:
    return {
        "text": "\n\n---\n\n".join(child["text"] for child in children),
        "metadata": {
            "chunk_level": "parent",
            "child_ids": [child["metadata"].get("chunk_id") for child in children],
            "source_categories": sorted(
                {
                    category
                    for child in children
                    for category in child["metadata"].get("source_categories", [])
                }
            ),
        },
    }

child は recall に強く、parent は回答生成に強いです。
この分担を作ると、検索精度と回答の文脈量を両立しやすいです。

テスト観点

def test_list_item_keeps_section_path():
    header = LayoutElement("h1", 1, 1, "section_header", "1. 申請登録")
    item = LayoutElement("i1", 1, 2, "list_item", "1. 登録ボタンを押します。")

    enriched = attach_section_path([header, item])
    step = list_item_to_step(enriched[1])

    assert step is not None
    assert step.section_path == ("1. 申請登録",)
    assert step.action == "押す"


def test_condition_and_result_are_split():
    step = ProcedureStep(
        step_id="s1",
        page=1,
        seq_no=2,
        step_number="1",
        text="承認済みの場合、確認画面が表示されます。",
        action="確認",
        target="",
    )

    enriched = enrich_condition_result(step)

    assert enriched.condition == "承認済みの場合"
    assert enriched.result == "確認画面が表示されます。"

まとめ

List / Procedure / Section Header は、RAG の回答品質に直結します。

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

  • section path を各 list item に付ける
  • step number を文字列として残す
  • action / target / condition / result / caution を分ける
  • fixed length split より procedure block を優先する
  • child chunk と parent chunk の役割を分ける
  • 関連する picture / table / form は metadata でつなぐ

操作説明書では、手順が壊れると回答が壊れます。
逆にここを丁寧に作ると、RAG はかなり実務っぽくなります。

次回は最終回です。
Caption、Footnote、Page Header/Footer、Formula、Code、Reference、Decorative など、残りの category をどう扱うかをまとめます。

参考

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?