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の精度が出ないとき、チャンクサイズを疑う前に見るべき3つの場所 ― PDFの表が泣き別れてLLMが自信満々に間違えた事件簿

0
Posted at

Noir Data Investigation Desk.png

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 のデバッグにもそのまま当てはまります。やったのは本当にこれだけです。

rag_debug.py
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. 管理物件一覧 のようなもの)を付ける

チャンカーの実装はこれです。依存ライブラリはありません。

chunkers.py
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 もよく名前が挙がります。レイアウト解析のモデルを使う分、処理は重くなりますが、表の再現性は高めです。

extract.py
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回だけ開いてみてください。たぶん、何かが見つかります。自分のときは、見つかりました。

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?