先に結論
操作説明書や運用手順書の RAG では、List と Section 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-1、B-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 をどう扱うかをまとめます。
参考
- Google Document AI Gemini layout parser: https://docs.cloud.google.com/document-ai/docs/layout-parse-chunk
- Azure Document Intelligence paragraph roles: 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
- RAGFlow child chunking strategy: https://ragflow.com.cn/docs/configure_child_chunking_strategy
- LlamaIndex RAG concepts: https://docs.llamaindex.ai/en/v0.10.17/getting_started/concepts.html