6月22日、Towards Data Scienceが「Reconstructing the Table of Contents a PDF Forgot to Ship, So RAG Can Scope by Section」と題した記事を公開した。RAGシステムで実務文書を扱う際、「紙面には目次が印刷されているのに、機械が読める構造データがない」という状況は珍しくない。この記事では、そうしたPDFからセクション構造を復元し、RAGがセクション単位で検索スコープを絞れるようにする実装手法を詳しく紹介している。
なぜ「目次のないPDF」が問題になるのか
RAGシステム(Retrieval-Augmented Generation)でエンタープライズ文書を扱う際、PDFの構造情報は検索精度に直結する。チャンクをセクション境界で切る、セクション単位で検索スコープを絞る、セクション順にサマリーを生成する——いずれの処理も toc_df(目次データフレーム)を参照する。
PyMuPDFの doc.get_toc() はPDFのネイティブアウトライン(しおり)を取得する標準的な手段だが、多くの実務文書にはこのアウトラインが存在しない。LaTeXからエクスポートした論文、スキャンして印刷されたPDFに変換された契約書、政府標準文書——これらは紙面に目次を印刷していても、機械が利用できる構造データを持っていないことが多い。
具体例として挙げられているのがNIST FIPS 202(SHA-3標準)だ。7ページ目に綺麗な目次が印刷されているが、PDFビューアのブックマークペインは空である。toc_df が空のまま処理が進むと、検索はページ全体をスキャンするフォールバックに切り替わり、チャンカーはブラインドなページ区切りで分割する。文書自身が持つ構造が完全に失われる。
3段階のカスケード処理
記事が提案するアプローチは、コストの低い順に3つのケースを試し、最初に成功したもので止めるカスケード構造だ。
Case 1: ネイティブアウトライン(コスト最小)
doc.get_toc() で取得できるPDF標準のアウトライン。存在すれば正確で階層情報も付く。構造データが埋め込まれているため追加処理は不要で、そのまま toc_df として利用できる。このケースに該当しない文書が、以降のCase 2・Case 3の処理対象となる。
Case 2: リンク付き目次ページ(コスト低)
アウトラインはないが、文書内の早いページにセクションタイトルがハイパーリンクとして並んでいるケース。リンクのターゲットは物理ページそのものなので、ページ番号の対応付け問題が発生しない。
検出条件は「内部リンクが5件以上あるページ」というシンプルな密度チェック。PyMuPDFでリンクのターゲットページを直接取得し、リンク矩形の下にあるテキストを結合するだけで完結する。
def extract_toc_from_links(pdf_path, min_links=5):
doc = fitz.open(pdf_path)
best = []
for page in doc:
entries = []
for link in page.get_links():
if link["kind"] != fitz.LINK_GOTO:
continue
label = clean(text_under_rect(page, link["from"]))
if label:
entries.append({"title": label,
"start_page": link["page"] + 1,
"level": 1})
if len(entries) >= min_links and len(entries) > len(best):
best = entries
return best
NIST Cybersecurity Frameworkはこのケースに該当し、LLM不使用・決定論的にすべてのセクションタイトルと対応物理ページが取得できる。
Case 3: リンクなしの印刷目次(コスト高)
最も多く遭遇するケース。「Contents」や「Table of contents」と書かれたページに、ドットリーダーで繋がれたタイトルとページ番号が並んでいるが、リンクは一切ない。FIPS 202がこれに相当する。
ここには2段階の処理が必要になる。
Case 3の核心:「印刷ページ番号 ≠ 物理ページ番号」
記事が最も詳しく解説しているのがこの問題だ。
目次に「Introduction .... 1」と書いてあっても、PDFファイルの物理1ページ目は表紙だ。表紙、前書き、目次ページ自体がフロントマターとして先頭に積み重なっているため、印刷上のページ番号と物理ページ番号は異なる番号空間に属する。
ステップ1:ドットリーダーのパターンマッチングで行を抽出
DOTTED = re.compile(r"^(.*?\S)[.…](?:[.…\s]){2,}(\d{1,3})$")
TRAILING = re.compile(r"^(.{2,70}?\S)\s{2,}(\d{1,3})$")
これで各行からタイトルと「表示ページ番号」(displayed_page)を分離する。
ステップ2:一定オフセットの推定でラベルを物理ページに変換
最も重要な処理がオフセット推定だ。フロントマターが一定ページ数続く文書では、物理ページ = 表示ラベル + 定数シフト という関係が成り立つ。
def infer_page_shift(line_df, entries, max_shift=40):
page_text = {p: text_of(line_df, p) for p in pages(line_df)}
sample = [(e["displayed_page"], norm(e["title"])) for e in entries][:20]
best_shift, best_score = 0, -1
for shift in range(-max_shift, max_shift + 1):
hits = sum(1 for label, title in sample
if title in page_text.get(label + shift, ""))
if hits > best_score:
best_score, best_shift = hits, shift
return best_shift
最大20件のサンプルエントリで総当たりし、「シフト後のページに実際にそのタイトルのテキストが存在する」件数が最多になるシフト値を採用する。FIPS 202ではフロントマターが8ページあるため、シフト値は +8 と推定される。
付録でページ番号がリセットされるような複雑な構造には、セクションタイトルのテキストを本文とファジーマッチング(あいまいマッチング)してページを特定するコンテンツマッチングフォールバックも用意されている。
LLMは「最終手段の検証役」として使う
記事が強調しているのはLLMの役割分担だ。
印刷目次のパターンが不規則すぎる場合(2カラムレイアウト、折り返しタイトル等)にはLLMが抽出を担うが、あくまで「最終手段」であり「デフォルトではない」と明記されている。また、LLMは目次ページを読むだけであり、目次を持たない文書の構造を発明することはしない。
むしろLLMの主な用途として提案されているのは「ヒューリスティックが返した結果の検証」だ。Pydanticで型付けされた構造化出力スキーマを使い、ページ番号が単調増加しているか、階層に矛盾がないかを判定させる。文書全体をLLMに流すのではなく、ヒューリスティックが提案したTOCの整合性確認に限定することで、コストと監査可能性を両立させている。
出力の形式は既存パイプラインと同一
この手法の実用上の利点は、出力 toc_df のスキーマが変わらない点にある。level、title、start_page、end_page、breadcrumb という既存の構造をそのまま踏襲するため、検索・チャンキング・サマリー生成の下流処理は一切変更不要だ。
詳細はReconstructing the Table of Contents a PDF Forgot to Ship, So RAG Can Scope by Sectionを参照していただきたい。




