Q: サンハイツ泉の管理費はいくらですか?
A: サンハイツ泉の管理費は月額 11,500 円です。(出典:管理業務ハンドブック)
この回答、どこがおかしいか分かるでしょうか。
出典もついているし、口調も落ち着いていて、金額にもカンマが入っています。満点に見えますよね。ただ、元の PDF を開くと、サンハイツ泉の管理費は 13,000 円 です。11,500 円という数字は、たしかに同じ表の同じ行にあります。ただし「修繕積立金」の列にです。
数字そのものは実在していて、列だけを取り違えている。RAG の誤答でいちばんタチが悪いのは、こういう「半分だけ本当」のやつです。完全なデタラメなら誰でも気づくのに、これは表を見慣れた人ほど一瞬信じてしまいます。
前回の記事で予告した、2年をあけて同じ穴に二度落ちた件です。実案件のデータはもちろん出せないので、構造だけを再現したダミーの表で話を進めます。
第1章 容疑者たち
最初にこの誤答を見たとき、真っ先に疑ったのはチャンクサイズでした。RAG の精度が出ないときの定番の容疑者です。検索すると「最適なチャンクサイズは」という記事が山ほど出てきます。
容疑者その1:チャンクサイズ
512 トークンを 1024 にしました。重なりも増やしました。
精度は少しだけ動きました。上がった質問もあれば、下がった質問もある。全体としては誤差の範囲です。この時点でやめておけばよかったのに、256 や 768 も試し、表計算ソフトに結果を並べて、ちょっといい感じのグラフまで描きました。グラフの見栄えだけは上がりました。
容疑者その2:埋め込みモデル
次に、埋め込みモデルを日本語に強いものへ替えました。検索結果の順位は確かに変わりました。誤答は変わりませんでした。
容疑者その3:LLM そのもの
最後に、生成側のモデルを上位のものに替えました。
結果、間違いがより流暢になりました。
これは笑い話のようで、けっこう大事なことを示しています。モデルが賢くなるほど、手元の材料から「それらしい答え」を組み立てる力も上がります。材料が壊れていれば、壊れた材料から、より説得力のある間違いを作ってくれるわけです。
今週も Meta が Muse Spark Open という、総パラメータ 816B(アクティブ 42B)のオープンウェイトモデルを出しました。Mac Studio 2台でローカル動作するそうで、社外に出せない文書を扱う RAG にはうれしい話です。ただ、どれだけ賢いモデルをローカルに置いても、渡す表が壊れていれば、この事件は普通に起きます。
第2章 現場検証
行き詰まってから、ようやくやったことがあります。パイプラインの途中経過を、全部ファイルに書き出して目で見ることです。
ブライアン・カーニハンの古い言葉があります。
The most effective debugging tool is still careful thought, coupled with judiciously placed print statements.
(いちばん効くデバッグ道具は、今でも、注意深い思考と、慎重に置かれた print 文である)
1979年の言葉ですが、RAG のデバッグにもそのまま当てはまります。やったのは本当にこれだけです。
import json
from pathlib import Path
OUT = Path("rag_debug")
def dump(stage, items):
"""パイプラインの各段階の中身を、人間が読める形で残す。"""
OUT.mkdir(exist_ok=True)
with open(OUT / f"{stage}.jsonl", "w", encoding="utf-8") as f:
for item in items:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
# dump("01_extracted", pages) # PDF から抜いた直後のテキスト
# dump("02_chunks", chunks) # 分割したチャンク
# dump("03_retrieved", hits) # 質問ごとに検索で返ったチャンク
# dump("04_prompt", [prompt]) # LLM に実際に渡したプロンプト全文
02_chunks.jsonl を開いた瞬間に、犯人が分かりました。
| サンハイツ本町 | 9,000 | 14,000 |
| サンハイツ泉 | 13,000 | 11,500 |
| サンハイツ高砂 | 20,500 | 17,500 |
| グランメゾン桜丘 | 24,000 | 20,000 |
...
ヘッダー行がありません。 表の頭の「物件名 / 管理費 / 修繕積立金」は、ひとつ前のチャンクに置き去りになっていました。このチャンクだけを渡された LLM には、13,000 と 11,500 のどちらが管理費なのか知る手段がありません。それでも何か答えなければいけないので、それらしいほうを選んだ、というのが真相でした。
シャーロック・ホームズがワトソンに言った有名な台詞があります。
You see, but you do not observe.
(君は見ているが、観察していない)
自分はチャンクサイズのグラフを何枚も描いていたのに、チャンクの中身は1つも読んでいませんでした。灯台下暗しとはこのことです。
ちなみに2年前の1回目は、もう一段手前に犯人がいました。PDF からテキストを抜いた時点で表の罫線が消え、セルがスペース区切りの数字の羅列になっていたんです。しかも表がページをまたいでいて、2ページ目にはヘッダー行が印刷されていませんでした。ページ単位で処理していたので、2ページ目の数字は最初から完全な孤児でした。
第3章 再現実験:何割の行が「孤児」になるのか
感覚で語るのはよくないので、数えてみました。
40物件ぶんの「物件名 / 管理費 / 修繕積立金」の表を含むダミー文書を作ります。それを2通りに分割して、物件名・列名「管理費」・その値の3つが、同じチャンクに揃っているかを数えました。これを「自己完結」と呼ぶことにします。揃っていなければ、LLM は推測するしかありません。
比較したのは次の2つです。
- 固定長:300文字ずつ、50文字重ねて切る。よくある方式
-
表を壊さない:表は行の途中で切らない。表が大きすぎるときは行のかたまりで分割し、各パートにヘッダー行を複製する。さらに、各チャンクの先頭に見出しのパス(
管理業務ハンドブック > 3. 管理物件一覧のようなもの)を付ける
チャンカーの実装はこれです。依存ライブラリはありません。
import re
TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$")
TABLE_SEP = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$")
HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
def naive_chunks(text, size=300, overlap=50):
"""よくある「N文字ずつ、少し重ねて切る」方式。"""
step = size - overlap
return [text[i:i + size] for i in range(0, max(len(text) - overlap, 1), step)]
def _blocks(markdown):
"""Markdown を (種類, 行のリスト, 見出しパス) のブロック列に分解する。"""
path = []
lines = markdown.splitlines()
i = 0
while i < len(lines):
line = lines[i]
if m := HEADING.match(line):
path = path[:len(m[1]) - 1] + [m[2].strip()]
i += 1
continue
if TABLE_ROW.match(line) and i + 1 < len(lines) and TABLE_SEP.match(lines[i + 1]):
rows = [line, lines[i + 1]]
i += 2
while i < len(lines) and TABLE_ROW.match(lines[i]):
rows.append(lines[i])
i += 1
yield "table", rows, list(path)
continue
if line.strip():
para = []
while i < len(lines) and lines[i].strip() and not HEADING.match(lines[i]) \
and not TABLE_ROW.match(lines[i]):
para.append(lines[i])
i += 1
yield "text", para, list(path)
continue
i += 1
def _split_table(rows, budget):
"""表が大きすぎるときは行で分割し、各パートにヘッダー行を複製する。"""
header, body = rows[:2], rows[2:]
head_len = sum(len(r) + 1 for r in header)
parts, cur, cur_len = [], [], head_len
for row in body:
if cur and cur_len + len(row) + 1 > budget:
parts.append(header + cur)
cur, cur_len = [], head_len
cur.append(row)
cur_len += len(row) + 1
if cur:
parts.append(header + cur)
return parts
def table_aware_chunks(markdown, size=300):
"""見出しパスを前置きし、表は「ヘッダー付きの行のかたまり」単位で切る。"""
chunks = []
for kind, lines, path in _blocks(markdown):
context = " > ".join(path)
prefix = f"[{context}]\n" if context else ""
budget = size - len(prefix)
if kind == "table":
parts = _split_table(lines, budget)
for n, part in enumerate(parts, 1):
note = f"(表 {n}/{len(parts)})\n" if len(parts) > 1 else ""
chunks.append(prefix + note + "\n".join(part))
else:
chunks.append(prefix + "\n".join(lines))
return chunks
検索は、文字バイグラムの重なりで上位3件を返すだけのおもちゃです。埋め込みは使っていません。わざとそうしています。検索器の賢さで差をごまかせない条件で、チャンクの作り方だけの違いを見たかったからです。
結果はこうなりました(Python 3.14 で実行)。
| 分割方式 | チャンク数 | 自己完結した行 | 検索上位3件で拾えた行 |
|---|---|---|---|
| 固定長 300字 / 重なり50 | 6 | 7 / 40 | 7 / 40 |
| 表を壊さない 300字 | 8 | 40 / 40 | 40 / 40 |
固定長では、40行のうち33行が、ヘッダーと泣き別れた孤児でした。重なり50文字程度では、表の頭はまったく救えません。チャンクを大きくすれば孤児は減りますが、表が長くなればまた同じことが起きます。チャンクサイズのチューニングでこの問題を解こうとするのは、症状を薄めているだけなんですね。
一方で、ヘッダーを複製する方式は、チャンク数が2つ増えただけで全行が自己完結しました。
もちろんダミーデータとおもちゃの検索器での話なので、この 7/40 という数字自体に意味はありません。見てほしいのは「固定長で切ると、表の大部分はヘッダーを失う」という構造のほうです。手元の文書でも、02_chunks を開けばすぐ確かめられます。
第4章 見るべき3つの場所
ここまでを踏まえて、RAG の精度が出ないときにチャンクサイズより先に見る場所を3つ挙げます。どれも、さっきの dump で書き出したファイルを開くだけです。
1. 抽出直後のテキスト(01_extracted)
すべての上流です。ここで表が壊れていたら、その下で何をしても直りません。Garbage in, garbage out です。
チェックするのは次のような点です。
- 表が表の形(Markdown の表や HTML の
<table>)で残っているか、ただの数字の羅列になっていないか - 段組みの PDF で、左右の段が1行ずつ交互に混ざっていないか
- ページをまたぐ表で、2ページ目以降にヘッダーの手がかりが残っているか
- 「¥」「〒」「①」のような文字が化けていないか
ここは、パーサー選びでかなり変わります。最近だと PyMuPDF4LLM が 2026年7月の 1.28 で表の構造認識(行・列の分割とセルのグリッド再構成)を強化していて、API はそのままで恩恵を受けられます。重めの文書には IBM 発の Docling もよく名前が挙がります。レイアウト解析のモデルを使う分、処理は重くなりますが、表の再現性は高めです。
from pathlib import Path
import pymupdf4llm
from docling.document_converter import DocumentConverter
src = "handbook.pdf"
Path("rag_debug").mkdir(exist_ok=True)
# 軽くて速い
Path("rag_debug/01_pymupdf4llm.md").write_text(
pymupdf4llm.to_markdown(src), encoding="utf-8")
# 重いが、表やレイアウトに強い
doc = DocumentConverter().convert(src).document
Path("rag_debug/01_docling.md").write_text(
doc.export_to_markdown(), encoding="utf-8")
自分は、新しい種類の PDF が来たら、まず両方で変換してエディタで左右に並べて見比べます。どちらが良いかは文書によって本当に違うので、ここは決め打ちしないほうが安全です。
2. 検索で実際に返ったチャンク(03_retrieved)
RAG の精度は「検索」と「生成」の掛け算です。ここを分けて測らないと、どちらが悪いのか永遠に分かりません。
おすすめは、20〜50問くらいの小さな評価セットを手で作ることです。質問と「この質問に答えるには、どのチャンク(どの表のどの行)が必要か」の正解を書いておきます。そして生成を一切挟まずに、検索だけで正解チャンクが上位 k 件に入る割合を測ります。
Go の生みの親のひとり、ロブ・パイクのプログラミングの規則にこうあります。
Rule 2. Measure. Don't tune for speed until you've measured, and even then don't unless one part of the code overwhelms the rest.
(規則2:計測せよ。計測するまでは速度をチューニングするな。計測した後でも、一部分が他を圧倒していない限りはするな)
速度の話ですが、精度にもそのまま当てはまります。自分のチャンクサイズのグラフは、この規則を盛大に破っていました。どこがボトルネックか測る前に、いちばん目立つつまみを回していたわけです。
3. LLM に渡したプロンプト全文(04_prompt)
最後に、モデルが実際に何を見たのかを確認します。
ここで見るのは、たとえばこんな点です。
- 検索で拾えたはずのチャンクが、トークン上限で後ろから切り捨てられていないか
- 似た表のチャンクが複数入っていて、モデルが混同していないか
- システムプロンプトに「分からなければ分からないと答える」が入っているか
もうひとつ効いたのが、表の値を答えるときは、行の名前と列の名前を必ず添えさせることです。
表の値を答えるときは、次の形式で答えてください。
「<行の名前> の <列の名前> は <値> です(出典:<見出し> / <表の番号>)」
列の名前が、渡された資料の中で確認できない場合は、推測せずに「資料からは確認できません」と答えてください。
ヘッダーのないチャンクしか渡されなかった場合、モデルは列の名前を引用できません。そうなると、黙って推測する代わりに「確認できません」と言うようになります。間違った答えより、正直な「分かりません」のほうがずっとましです。業務で使う RAG では特にそうです。
第5章 事件のあとで
2回ハマって、ようやく自分の中でルールになったことがあります。
RAG がおかしいときは、つまみを回す前に、中身を読む。
当たり前すぎて、書くのが少し恥ずかしいくらいです。でも、当たり前のことほど急いでいると飛ばしてしまいます。急がば回れ、です。チャンクサイズのグラフを描くのに半日かけるくらいなら、02_chunks.jsonl を5分眺めたほうがずっと早く犯人にたどり着けます。
それから、今回の件でもうひとつ思ったことがあります。RAG の精度の問題は、モデルやアルゴリズムの問題に見えて、実は文書の問題であることがかなり多いです。ページをまたぐ表、セルの結合、段組み、注釈の「※」。これらは全部、紙で読む人間のために作られたレイアウトです。人間には自然に読めるものが、機械には読めない。その翻訳を丁寧にやるのが、RAG を作る人間のいちばん地味で、いちばん効く仕事なんだと思います。
最後に、ひとつだけ質問させてください。
あなたの RAG の、抽出直後のテキストを最後に開いたのはいつですか?
もし「一度も開いたことがない」なら、今日のうちに1回だけ開いてみてください。たぶん、何かが見つかります。自分のときは、見つかりました。
