powered by TechFeed
表示モード
ハウツー

目次のないPDFからセクション構造を復元してRAGの検索精度を上げる — ネイティブアウトライン・リンク・印刷目次のカスケード処理

6月22日、Towards Data Scienceが「Reconstructing the Table of Contents a PDF Forgot to Ship, So RAG Can Scope by Section」と題した記事を公開した。RAGシステムで実務文書を扱う際、「紙面には目次が印刷されているのに、機械が読める構造データがない」という状況は珍しくない。この記事では、そうしたPDFからセクション構造を復元し、RAGがセクション単位で検索スコープを絞れるようにする実装手法を詳しく紹介している。

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(目次データフレーム)を参照する。

PyMuPDFdoc.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 のスキーマが変わらない点にある。leveltitlestart_pageend_pagebreadcrumb という既存の構造をそのまま踏襲するため、検索・チャンキング・サマリー生成の下流処理は一切変更不要だ。

詳細はReconstructing the Table of Contents a PDF Forgot to Ship, So RAG Can Scope by Sectionを参照していただきたい。