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設計(2/5)Table解析はHTMLではなく行・列・画像Evidenceで考える

1
Posted at

先に結論

RAG で Table を扱うとき、表を Markdown table に変換するだけでは足りません。

業務文書の表で本当に大事なのは、次の関係です。

  • table title
  • section heading
  • column header
  • row header
  • merged cell
  • table footer
  • cell bbox
  • table 内の画像
  • table 外にあるが table を説明している caption

つまり、Table は「文字列」ではなく 構造を持つ evidence block として扱うべきです。

特に、表の中に画像がある場合は、
画像を独立した Picture chunk にするのではなく、
該当する table / row / cell の evidence として合流させる のが実務では安定します。

よくある失敗

よくある実装です。

markdown = html_table_to_markdown(table_html)
chunks = split_by_chars(markdown, size=1000)

これで簡単な表は動きます。
でも複雑な表では壊れます。

  • header が分割され、値だけが残る
  • merged cell の意味が消える
  • table footer の注意書きが外れる
  • 1行に複数条件があると検索で拾いづらい
  • 表内画像が Picture として独立し、どの行の説明か分からなくなる

表は token 節約のために壊してはいけないです。
壊すなら、行・列の意味を持ったまま壊す 必要があります。

全体像

考え方はシンプルです。

  1. Table 全体の構造を復元する
  2. 大きい表は row group に分ける
  3. 各 row group に header と section context を繰り返し入れる
  4. 表内画像は該当 row group の evidence に入れる

Table用schema

まず table を RAG 用 schema に戻します。

from dataclasses import dataclass, field
from typing import Any


@dataclass(frozen=True)
class TableCell:
    row_index: int
    column_index: int
    row_span: int
    col_span: int
    text: str
    is_header: bool = False
    bbox: tuple[float, float, float, float] | None = None


@dataclass(frozen=True)
class TableStructure:
    table_id: str
    page: int
    bbox: tuple[float, float, float, float] | None
    caption: str = ""
    row_count: int = 0
    column_count: int = 0
    header_row_count: int = 0
    column_headers: tuple[str, ...] = ()
    rows: tuple[tuple[TableCell, ...], ...] = ()
    metadata: dict[str, Any] = field(default_factory=dict)


@dataclass(frozen=True)
class TableVisualEvidence:
    image_id: str
    page: int
    bbox: tuple[float, float, float, float]
    relationship: str
    image_text: str
    ocr_text: str = ""
    crop_path: str = ""

bbox は optional に見えますが、できるだけ残します。
表内画像を row group に割り当てるときに効きます。

HTML table を cell grid に戻す

Docling や各種 parser は table を HTML として出すことがあります。
HTML は表示には便利ですが、RAG では row/column 情報に戻した方が扱いやすいです。

from html.parser import HTMLParser
from typing import Any


class HTMLTableParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.rows: list[list[dict[str, Any]]] = []
        self.current_row: list[dict[str, Any]] | None = None
        self.current_cell: dict[str, Any] | None = None
        self.caption_parts: list[str] = []
        self.caption = ""
        self.in_caption = False

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        tag = tag.lower()
        if tag == "caption":
            self.in_caption = True
            self.caption_parts = []
        elif tag == "tr":
            self.current_row = []
        elif tag in {"td", "th"} and self.current_row is not None:
            self.current_cell = {
                "tag": tag,
                "attrs": dict(attrs),
                "parts": [],
            }
        elif tag == "br" and self.current_cell is not None:
            self.current_cell["parts"].append("\n")

    def handle_endtag(self, tag: str) -> None:
        tag = tag.lower()
        if tag in {"td", "th"} and self.current_cell is not None and self.current_row is not None:
            attrs = self.current_cell["attrs"]
            self.current_row.append(
                {
                    "tag": self.current_cell["tag"],
                    "rowspan": positive_int(attrs.get("rowspan")),
                    "colspan": positive_int(attrs.get("colspan")),
                    "text": normalize_text("".join(self.current_cell["parts"])),
                }
            )
            self.current_cell = None
        elif tag == "tr" and self.current_row is not None:
            self.rows.append(self.current_row)
            self.current_row = None
        elif tag == "caption":
            self.caption = normalize_text("".join(self.caption_parts))
            self.caption_parts = []
            self.in_caption = False

    def handle_data(self, data: str) -> None:
        if self.current_cell is not None:
            self.current_cell["parts"].append(data)
        elif self.in_caption:
            self.caption_parts.append(data)


def positive_int(value: object, default: int = 1) -> int:
    try:
        number = int(str(value or "").strip())
    except ValueError:
        return default
    return number if number > 0 else default


def normalize_text(value: str) -> str:
    return " ".join(value.replace("\xa0", " ").split())

次に rowspan / colspan を展開し、各 cell に位置を持たせます。

def position_cells(raw_rows: list[list[dict[str, Any]]]) -> list[list[TableCell]]:
    positioned_rows: list[list[TableCell]] = []
    covered: set[tuple[int, int]] = set()

    for row_index, raw_row in enumerate(raw_rows, start=1):
        column_index = 1
        row_cells: list[TableCell] = []

        for raw_cell in raw_row:
            while (row_index, column_index) in covered:
                column_index += 1

            row_span = int(raw_cell["rowspan"])
            col_span = int(raw_cell["colspan"])

            cell = TableCell(
                row_index=row_index,
                column_index=column_index,
                row_span=row_span,
                col_span=col_span,
                text=raw_cell["text"],
                is_header=raw_cell["tag"] == "th",
            )
            row_cells.append(cell)

            for r in range(row_index, row_index + row_span):
                for c in range(column_index, column_index + col_span):
                    if (r, c) != (row_index, column_index):
                        covered.add((r, c))

            column_index += col_span

        if row_cells:
            positioned_rows.append(row_cells)

    return positioned_rows

header を毎回入れる

表を row group に分けるとき、header を落とすと検索が弱くなります。

たとえば次の2つは意味が違います。

NG:
行 12: 可能 / 30日 / 対象外

OK:
列見出し: 状態 / 期限 / 備考
行 12: 状態=可能 / 期限=30日 / 備考=対象外

実装はこうします。

def column_headers(rows: list[list[TableCell]]) -> dict[int, str]:
    headers: dict[int, str] = {}
    for row in rows:
        if not any(cell.is_header for cell in row):
            break
        for cell in row:
            if not cell.is_header or not cell.text:
                continue
            for c in range(cell.column_index, cell.column_index + cell.col_span):
                headers[c] = cell.text
    return headers


def row_to_text(row: tuple[TableCell, ...], headers: dict[int, str]) -> str:
    parts: list[str] = []
    for cell in row:
        if not cell.text:
            continue
        header = headers.get(cell.column_index, "")
        if header and header != cell.text:
            parts.append(f"{header}={cell.text}")
        else:
            parts.append(cell.text)
    return " / ".join(parts)


def table_row_group_text(
    table: TableStructure,
    rows: tuple[tuple[TableCell, ...], ...],
    *,
    section_path: tuple[str, ...] = (),
) -> str:
    headers = {index + 1: value for index, value in enumerate(table.column_headers)}
    lines: list[str] = []

    if section_path:
        lines.append("関連見出し: " + " > ".join(section_path))
    if table.caption:
        lines.append(f"表タイトル: {table.caption}")
    if table.column_headers:
        lines.append("列見出し: " + " / ".join(table.column_headers))

    for row in rows:
        text = row_to_text(row, headers)
        if text:
            lines.append(f"{row[0].row_index}: {text}")

    return "\n".join(lines)

大きい表は row group にする

表全体を1 chunk にすると大きすぎます。
ただし固定長 split は避けます。

row group 単位にします。

def split_table_rows(
    table: TableStructure,
    *,
    target_chars: int = 900,
    section_path: tuple[str, ...] = (),
) -> list[tuple[tuple[TableCell, ...], ...]]:
    groups: list[list[tuple[TableCell, ...]]] = []
    current: list[tuple[TableCell, ...]] = []

    data_rows = table.rows[table.header_row_count:] or table.rows

    for row in data_rows:
        candidate = [*current, row]
        candidate_text = table_row_group_text(
            table,
            tuple(candidate),
            section_path=section_path,
        )

        if current and len(candidate_text) > target_chars:
            groups.append(current)
            current = [row]
        else:
            current = candidate

    if current:
        groups.append(current)

    return [tuple(group) for group in groups]

ここで重要なのは、各 row group に header と section context を繰り返すことです。
retrieval では chunk が単独で取られるため、単独で意味が閉じている必要があります。

表内画像を table に合流する

表の中にスクリーンショットや小図がある場合、Picture として独立させると文脈が切れます。

表 bbox と画像 bbox を見て、表内画像として取り込みます。

def intersects(
    a: tuple[float, float, float, float],
    b: tuple[float, float, float, float],
) -> bool:
    ax1, ay1, ax2, ay2 = a
    bx1, by1, bx2, by2 = b
    return min(ax2, bx2) > max(ax1, bx1) and min(ay2, by2) > max(ay1, by1)


def contains(
    outer: tuple[float, float, float, float],
    inner: tuple[float, float, float, float],
) -> bool:
    ox1, oy1, ox2, oy2 = outer
    ix1, iy1, ix2, iy2 = inner
    return ox1 <= ix1 <= ix2 <= ox2 and oy1 <= iy1 <= iy2 <= oy2


def table_contained_pictures(
    table: LayoutElement,
    pictures: list[LayoutElement],
) -> list[LayoutElement]:
    if not table.bbox:
        return []

    results = []
    for picture in pictures:
        if picture.page != table.page or picture.kind != "picture" or not picture.bbox:
            continue
        if contains(table.bbox, picture.bbox) or intersects(table.bbox, picture.bbox):
            results.append(picture)

    return sorted(results, key=lambda item: item.seq_no)

そのうえで、画像説明を table の text に入れます。

def table_visual_text(images: list[TableVisualEvidence]) -> str:
    if not images:
        return ""

    lines = ["表内画像:"]
    for image in images:
        lines.append(f"- {image.image_id} / p.{image.page} / {image.relationship}")
        if image.image_text:
            lines.append(f"  画像説明: {image.image_text}")
        if image.ocr_text:
            lines.append("  OCR抽出テキスト:")
            for line in image.ocr_text.splitlines():
                if line.strip():
                    lines.append(f"  {line.strip()}")

    return "\n".join(lines)

こうすると、表内画像が表の検索語として効きます。
また、回答時には table context と image evidence の両方を見せられます。

row group に画像を割り当てる

大きい表を row group に分ける場合、すべての表内画像を全 row group に入れるとノイズになります。

bbox から近い row group にだけ入れます。

def estimate_row_group_bbox(
    table_bbox: tuple[float, float, float, float],
    row_start: int,
    row_end: int,
    total_rows: int,
) -> tuple[float, float, float, float]:
    x1, y1, x2, y2 = table_bbox
    row_height = (y2 - y1) / max(1, total_rows)
    top = y1 + (row_start - 1) * row_height
    bottom = y1 + row_end * row_height
    padding = min(row_height * 0.25, (y2 - y1) * 0.05)
    return (x1, max(y1, top - padding), x2, min(y2, bottom + padding))


def visual_matches_row_group(
    image: TableVisualEvidence,
    row_group_bbox: tuple[float, float, float, float],
) -> bool:
    if not intersects(image.bbox, row_group_bbox):
        return False

    _, iy1, _, iy2 = image.bbox
    center_y = (iy1 + iy2) / 2
    return row_group_bbox[1] <= center_y <= row_group_bbox[3]

これは完璧な cell detection ではありません。
でも cell bbox が取れない parser でも動く、実用的な fallback です。

cell bbox が取れる場合は、もちろん cell bbox を優先します。

Chunk metadata の形

Table chunk は、text と metadata の両方を持たせます。

def table_chunk_payload(
    table: TableStructure,
    rows: tuple[tuple[TableCell, ...], ...],
    visual_evidence: list[TableVisualEvidence],
) -> dict:
    text = table_row_group_text(table, rows)
    visual_text = table_visual_text(visual_evidence)
    if visual_text:
        text = f"{text}\n{visual_text}" if text else visual_text

    return {
        "text": text,
        "metadata": {
            "source_categories": ["Table"],
            "contains_table": True,
            "contains_image_evidence": bool(visual_evidence),
            "table_context": [
                {
                    "table_id": table.table_id,
                    "page": table.page,
                    "bbox": list(table.bbox or ()),
                    "structure": {
                        "caption": table.caption,
                        "row_count": table.row_count,
                        "column_count": table.column_count,
                        "header_row_count": table.header_row_count,
                        "column_headers": list(table.column_headers),
                    },
                    "visual_evidence": [
                        {
                            "image_id": image.image_id,
                            "page": image.page,
                            "bbox": list(image.bbox),
                            "relationship": image.relationship,
                            "crop_path": image.crop_path,
                        }
                        for image in visual_evidence
                    ],
                }
            ],
        },
    }

この metadata は回答画面の highlight にも使えます。
RAG は検索だけでなく、根拠を戻せて初めて運用できます。

Table のテスト観点

最低限はこのあたりです。

def test_table_row_group_repeats_headers():
    table = TableStructure(
        table_id="table-1",
        page=1,
        bbox=(0, 0, 600, 300),
        caption="処理結果一覧",
        row_count=3,
        column_count=2,
        header_row_count=1,
        column_headers=("状態", "説明"),
        rows=(
            (TableCell(1, 1, 1, 1, "状態", True), TableCell(1, 2, 1, 1, "説明", True)),
            (TableCell(2, 1, 1, 1, "OK"), TableCell(2, 2, 1, 1, "登録できます")),
        ),
    )

    text = table_row_group_text(table, table.rows[1:])

    assert "表タイトル: 処理結果一覧" in text
    assert "列見出し: 状態 / 説明" in text
    assert "状態=OK" in text


def test_table_keeps_contained_picture_as_visual_evidence():
    table = LayoutElement("table-1", 1, 1, "table", bbox=(0, 0, 600, 300))
    picture = LayoutElement("pic-1", 1, 2, "picture", bbox=(50, 80, 200, 180))

    assert table_contained_pictures(table, [picture]) == [picture]

まとめ

Table RAG の設計で大事なのは、表を text にすることではありません。

表の意味を壊さずに、検索できる単位へ変換することです。

今回のポイントです。

  • HTML / Markdown の見た目より row / column / header を優先する
  • 大きい表は固定長ではなく row group で分ける
  • 各 row group に header と section context を入れる
  • 表内画像は独立 Picture ではなく table visual evidence にする
  • bbox と source refs を残して citation / highlight に戻せるようにする

次回は Form / Key-Value / Selection Mark です。
業務文書では、表よりもフォームの方が問い合わせに直結することがあります。
「項目名」「値」「チェック状態」をどう chunk にするかがテーマです。

参考

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?