先に結論
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 節約のために壊してはいけないです。
壊すなら、行・列の意味を持ったまま壊す 必要があります。
全体像
考え方はシンプルです。
- Table 全体の構造を復元する
- 大きい表は row group に分ける
- 各 row group に header と section context を繰り返し入れる
- 表内画像は該当 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 にするかがテーマです。
参考
- Azure Document Intelligence layout model: https://learn.microsoft.com/en-us/azure/ai-services/document-intelligence/prebuilt/layout
- Amazon Textract tables: https://docs.aws.amazon.com/textract/latest/dg/how-it-works-tables.html
- Amazon Textract AnalyzeDocument: https://docs.aws.amazon.com/textract/latest/APIReference/API_AnalyzeDocument.html
- Google Document AI Gemini layout parser: https://docs.cloud.google.com/document-ai/docs/layout-parse-chunk
- Unstructured table descriptions: https://docs.unstructured.io/concepts/enriching/table-descriptions