Yyatmita

本を「司書のように」検索する——RAG チャンクのためのエージェンティック・インデクサ

スキャンした書籍を RAG で検索可能にするとき、素朴なチャンク分割は意味の途中で切れた断片を量産する。目次を索引の骨格にする『司書サーチ』を、agent に本を読ませる素朴案が 7.5 時間で失敗したところから、生成=コード/検証=エージェントの分業に行き着くまでの build-log。

自分のエージェント基盤を組む#agent-stack#rag#ocr#indexing#search

スキャン(OCR)した書籍を RAG で検索可能にするとき、素朴なチャンク分割は「意味の途中でぶつ切りにされた断片」を量産する。本稿は、書籍の 目次そのものを索引の骨格 にして「司書が棚まで案内してくれる」ような検索を実現する取り組みの記録である。"agent が本を読んで索引を作る" という素朴案が 7.5 時間 かかって失敗したところから、"生成はコード・検証はエージェント" という分業に落ち着くまでを、試行錯誤ごと残す。

題材は手元の専門実務書(縦書き・章節が深い実務リファレンス/1,400 ページ級の大著)だが、本稿では分野を伏せ、汎用的な「書籍 RAG」の問題として書く。


1. 従来の RAG チャンクの問題点

RAG の定番は「本文を ~500 トークンの窓でスライドさせてチャンク化し、各チャンクを embedding して近傍検索」だ。手軽だが、書籍(特に OCR した専門書)では次の問題が出る。

  1. 意味の途中でぶつ切りにされる。 固定長窓は段落・節の境界を無視して切る。「ある節」と「隣の節」が 1 チャンクに混ざると、ベクトルが濁って検索精度が落ちる。
  2. 出自(provenance)が消える。 返ってくるのは "どこの何だか分からない本文片"。「これは第○章第○節の話です」と言えない。引用にも使えない。
  3. 前付けノイズが混入する。 目次ページ・凡例・奥付まで本文として chunk 化され、検索上位に「目次の断片(リーダー点付きの『……110』のような行)」が紛れ込む。

実際、素朴なフラット chunk で専門書を引くと、上位に 目次ページの断片見出しの無い本文片、ひどいときは 別の書籍の無関係な箇所 が混ざる。これでは「検索できている」とは言いがたい。

根っこの問題は チャンクが本の論理構造を一切持っていない ことだ。


2. アイデア — 目次を骨格にした「司書サーチ」

紙の本には、人間が長い時間をかけて作った完璧な論理構造がすでにある。目次 だ。

ならば、固定長窓ではなく 目次の節(leaf)を 1 チャンクの単位 にすればいい。各チャンクに「章 > 節 > 項」の 見出しパス(heading path) を付ければ、検索結果はこう返せる:

第N章 > 第M節 > 第K項   (pp. 132–136)
  └ ここに該当の本文

これは図書館の司書が「その話なら、第N章の第M節、棚でいうとこの辺りです」と案内するのに似ている。断片ではなく 構造化された節を、所在つきで 返す。

この発想自体は新しくない(見出し単位チャンキング)。難しいのは、スキャンしただけの本には構造マークアップが無い ことだ。OCR テキストはただのベタ文字列で、「ここが第M節の始まり」という情報を持たない。だから「目次を読んで構造を復元し、本文に対応づける」インデクサ が要る。これをどこまでエージェントにやらせるか、が本題になる。


3. 実装の旅(build-log)

3.1 素朴案 — 「エージェントに本を読ませる」→ 7.5 時間

最初に試したのは直球だった。「OCR 済みの本文に検索窓口(search/get)でアクセスできるエージェントを 1 体立て、自律で “奥付→書誌 / 本文を走査→章節境界 / サンプル章切り出し” をやらせ、受け入れチェックを自分で回して合格するまで自己修正させる」。

1,400 ページの大著で走らせた結果 —— 約 7.5 時間。しかも章レベル(26 項目)までは出たが、節レベルまで細かくしようとすると 各イテレーションが制限時間切れ で未達に終わった。

原因ははっきりしている。エージェントに rote(単純作業)を背負わせすぎた: 検索窓口を何度も往復し、数百行の崩れた OCR を解釈し、全エントリのページ算術をして、巨大な JSON を書く —— これを 1 回の制限時間内に全部やろうとして終わらない。

教訓1: 重さの正体は「知能が要る作業」ではなく「エージェントに押し付けた単純作業」。

3.2 気づき — タスクは「目次を読んでギャップを記録する」だけ

立ち止まって考えると、やるべきことは本文の総なめではない。目次にはもう章・節・ページがすべて載っている。 連続する目次エントリ自体が境界になる(エントリ N の範囲 = N のページ 〜 N+1 のページ手前)。本文を読んで境界を探す必要などなかった。

必要なのは事実上 2 つだけ:

  • 目次の (タイトル, 階層, 目次ページ番号) のリスト
  • 目次の印刷ページ番号と PDF 物理ページの ギャップ(offset)

そこで、目次ページのテキストだけ をエージェントに渡し、「崩れた OCR を入れ子アウトラインの JSON に構造化する」判断だけをさせた。検索窓口の往復も、ページ算術も渡さない。

結果、1,400 ページ級の大著の目次が 43 秒・1 パス で 124 エントリ(部/章/節/款)の構造に化けた。7.5 時間との差は 手段ではなく、エージェントに何をやらせるかの設計 だった。

教訓2: 検索窓口に「安い primitive(目次ページだけ取得)」を与え、rote はコードに退避する。

3.3 さらに気づき — クリーンなら「コードで足りる」。エージェントは検証へ

ここで OCR の中身をちゃんと見た。目次ページの OCR は 平均 conf 0.96、行の 74% が行末にページ番号を持つ 高品質だった。しかも各行の X 座標がインデント=階層 を綺麗に表していた(章 > 節 > 項 で字下げが階段状)。

つまり クリーンな目次なら、LLM すら要らない。 「X 座標→階層、行末数字→ページ、継続行を結合」という決定的なパーサ(コード)でほとんど取れる。

では、エージェントの価値はどこにあるのか。検証 だ。コードの決定的抽出は速くて再現性があるが、"それらしいが間違った" 出力(plausible-but-wrong)を出す。たとえば:

  • OCR の桁落ち: 行末の「64」が「6」に、「115」が「11」に化ける。コードはこの偽の数字を信じてページ計算を壊す。
  • 崩れたタイトル: 見出しの一字が近い字形に化け(例: 「費」→「管」、「儀」→「像」)、本文との文字列一致に失敗する。
  • 物理的な破損: 製本ミスでページの綴じ順が乱れている本さえある(後述)。

これらは決定的ルールでは弾けない/拾えない。ここがエージェントの出番 —— 本文に接地(grounding)して「この見出しは本当にこのページにあるか」を意味で判断し、間違いを捕まえて直す。

教訓3: 索引はできるだけコードで生成・接地し、エージェントは品質保証(接地検証)に回す。データが汚いほどエージェントが効く。

3.4 確定したアーキテクチャ — 生成=コード / 接地=コード / 検証=エージェント

[生成 = コード]   OCR の目次行を決定的にパース
                 X座標→階層 / 行末数字→ページ / 継続行を結合
                 さらに「ページ番号は本全体で単調増加するはず」という制約で
                 桁落ち(64→6)を機械的に排除

[接地 = コード]   各見出しを本文(実OCR)で検索し、実ページにスナップ
                 (見出しは本文中に section marker として実在する)

[検証 = エージェント]  コードが文字列一致できなかった「崩れ見出し」を、
                 本文の「ページ→見出し」マップを手渡して接地し直す。
                 ・確信が持てないものは動かさない(捏造しない)
                 ・綴じ順スクランブル等の構造異常を自分で指摘する
                 round-trip ゼロ・数十秒で完了

実書での内訳(縦書き実務リファレンスの例): 目次 315 行 → 216 エントリを生成。本文接地で 140 件を実ページにスナップ、65 件(崩れ見出し)を検証エージェントへ。エージェントは 16 件を修正・数件を確認・49 件は捏造せず保留し、いくつかの 構造異常を自力で検知 した。生成も検証も、それぞれ ~数十秒。

3.5 暴走対策は「設計」に内蔵する(人間の監視に依存しない)

自己修正するエージェントは歯止めが無いと延々走る(実際に最初の 7.5 時間がそれだった)。だが対策は「人間が張り付いて見張る」ことではない。エージェントが自律安全であるよう設計に組み込む —— サーキットブレーカー / 最大反復 / コスト上限 / タイムアウト / スコープ固定、そして 失敗したら止まって状態を正直に記録し、本文検索は生かしたまま劣化する(graceful degrade)。「本を登録したら立ち去れる」UX に必要な性質で、これ自体がエージェント基盤の設計テーマになる。


4. 結果

4.1 チャットで見る before / after

「ある専門制度(制度 X)の取り扱いを一通り確認したい」という同じ依頼を、3 つのやり方に投げてみる。

① 従来の RAG — 資料を後ろ手に隠したまま、それらしい答えを返す

ユーザー: 制度Xの取り扱いを一通り確認したい
ボット  : 制度Xは概ね次のように扱われます。まず手続として……、要件としては……、
          効力が争われる場合は……(もっともらしい要約が数段落)

何が問題か: 出典が出てこない。 どの本の何ページか分からず、版が古いかもしれず、そもそも合っているか 検証できない。retrieve したチャンクを LLM が要約し直した "適当な翻訳" で、原典は後ろ手に隠されている。

② 道具はあるが索引が無いエージェント — ページを当て推量する

ユーザー: 体系書Aと教科書Bから、制度Xの手続・要件・効力の争い方の該当箇所を切り抜いて
ボット  : 目次データが未抽出のため、本文から直接検索します。
ボット  : 索引からの特定が難しいため、標準的な教科書構成から推測します。
ボット  : 体系書Aでは制度Xは中盤〜後半なので、700-850ページあたりを確認します。
ボット  : テキスト検索から、制度Xの章は820ページ前後から始まるようです。820-900ページを抽出します。
(数分が経過)

何が問題か: 目次がコンパイルされていないので「推測」している。 当たることもあるが、遅く・不確実で、近似のページ範囲を切り抜くしかない。実際これで「820ページ前後 のようです」と確信なく走るログが残った。

③ 司書サーチ — 索引済み。所在を即答し、実ページを切り抜く

ユーザー: 制度Xの取り扱いを一通り確認したい
ボット  : 索引から3冊ヒットしました。
          【推薦】体系書A(1,122p)— 制度全般を網羅
          【代替】教科書B(494p)— 簡潔な標準書 / 実務書C(252p)— 実務寄り
          目的は体系理解ですか、実務寄りですか?
ユーザー: 体系書Aと教科書Bで、手続・要件・効力の争い方を。該当箇所を切り抜いて
ボット  : 索引済みの目次から即特定しました。
          ・体系書A 第N編 第M章「制度X」第1節 手続 〜 第3節 効力(pp.820–900)→ 切り抜き [PDF]
          ・教科書B 第K章「制度X」(pp.350–400)→ 切り抜き [PDF]
          パンくず: 体系書A > 第N編 > 第M章 制度X > 第1節 手続 …

何が違うか: 出典・所在が明示され、実ページが手元に届く。 司書が本を棚から出して、該当ページを開いて渡してくれる感覚だ。要約を信じる代わりに、原典そのものを自分で読める。①の「後ろ手に隠した適当な翻訳」とは正反対になる。

この差は精度以前の問題で、「答えを作る」か「所在を案内して原典を渡す」か という姿勢の違いだ。司書サーチを支えているのが、本稿のエージェンティック・インデクサ(目次を骨格にした索引)である。

③ は作文ではない。実際に索引(司書ナビ)KB を 1 クエリ叩くと、ひとつのトピックに対して複数の専門書から該当章を「書名 + 章 + ページ範囲」つきで横断的に 返す(例: あるトピックで体系書・実務書・判例解説書の 3 冊から、それぞれ pp.95–160 / pp.119–144 / pp.25–27 の該当章を集めてくる)。索引済みなので推測は一切ない。

4.2 チャンクの before / after

同じクエリを、フラット chunk と「目次骨格 + 見出しパス」chunk で引き比べる(節タイトルは伏せ、構造のみ示す)。

フラット chunk(従来)目次骨格 chunk(本手法)
返るもの目次ページの断片 / 見出し無し本文片 / 別書籍の無関係箇所第N章 > 第M節 > 第K項 という完全なパンくず付きの該当節
出自不明章・節・ページまで言える(引用可)
前付けノイズ混入する本文セクションのみなので自然に除外

「司書サーチ」は狙いどおり、所在つきの節 を返すようになった。

4.3 物理破損(綴じスクランブル)の検知

題材の一冊は、製本段階で一部のページが 物理的に綴じ違え られていた。印刷ページ番号で素朴に補間すると破綻する。接地(本文の実在位置とのつき合わせ)はこれを定量的に暴く:

セクション目次が置く位置本文に実在する位置
正常な部の某章p468p470(ほぼ一致)
破損域 セクションAp1166p1078(88p 前)
破損域 セクションBp1342p1151(191p 前・順序も逆転)

正常な部は「目次≈本文」で信頼でき、破損域は巨大な乖離+順序逆転として現れる。コーパスが汚いほど検証層が要る ことの、これ以上ない実例になった。


5. 正直な限界

  • OCR の桁落ちと番号欠落(実書では約 6 割の leaf がページ番号を持たない)により、ページ境界には ±数ページの残差が残る。接地で大半は詰まるが、ゼロにはならない。
  • 「コードで足りる」のは目次 OCR がクリーンな場合。崩れ・スクランブルが多いほどエージェント検証の比重が上がる(=設計どおりだが、コストもそちらに寄る)。
  • 本稿の実装はデモ用の薄いドライバを含む。プロダクション化(全書籍の移行・非同期パイプライン・UI)は別途。

6. これは何の概念実証か

題材が何であれ、本稿の主張は分野に依らない。

エージェンティック・インデクサの肝は「エージェントが本を読んで索引を作る」ことではない。索引は決定的なコードで生成・接地し、エージェントは品質保証(本文への接地検証)に回す。データが汚いほどエージェントが効く。

「7.5 時間 → 数十秒」も、魔法ではなく分業の結果だ。生成と単純作業はコード、判断と検証はエージェント。 これは書籍 RAG に限らず、汎用のエージェント基盤を作るときの設計指針そのものだと考えている。


付録: パイプライン段(非同期インデクサの設計メモ)

実運用では「登録 → 非同期で索引化」を、性質の違う段に分けるのが要点になる。

register(即時)        本のアップロード・登録だけ。すぐ返す
  └ ocr      PDF→テキスト        GPU・長い(〜時間)
  └ ingest   本文を検索可能に     ★ここで本文検索が開通(部分成功が使える)
  └ index    目次→構造抽出+接地+検証   律速はここ(生成コード+検証agent)
  └ compile  索引をDBへ / 司書ナビKBへ   index の成果物を食う速い消費者

各段は独立・冪等・成果物ベースで「どこまで出来たか」を判定し、失敗段から再開できる。索引(LLM が絡む段)が失敗しても、本文検索は生きたまま残る。


前回の記事: ハイブリッド検索で「レシピだけ」を引く——構造フィルタの pre/post と遅延スキーマ進化