👏

PDFファイルからセクション情報を取り出す

に公開

はじめに

llms.txtは、ウェブサイトの情報をAIクローラーに参照させる方法です。
llms.txtを利用してタスク実行をする試みもあり、例えば、Lance's Blogでは、コード生成についての記事を検索しています。
また、以前の私の記事では、GitHubレポジトリのファイルをllms.txtで検索する手法を紹介しています。
そして次に、PDFファイルをllms.txtで検索する手法を試したいと思います。長いPDFドキュメントをAIで検索可能にする際、セクションごとにルーティングできると便利です。

「QA4AI AIプロダクト品質保証ガイドライン」(2024年5月時点:361ページ)を対象として、PDFをセクションごとにLLMに参照させることで、長文ドキュメントの効率的な検索を目指します。

本稿では、Google Gemini(gemini-2.5-pro-preview-03-25)や OpenAI
o3-mini)などのLLMを使い、PDFからセクション構造を自動抽出する方法を試してみました。
次の記事では、セクション情報をllms.txtに組み込んで、PDFドキュメントの検索を試します。

セクション情報を抽出する2つの手法

今回検証する手法は二つです。

  1. ページを画像化し、LLMに見出しを検出させる
  2. 目次ページをLLMで解析し、その後ページ画像を使って各セクションの開始位置を特定する

2つの手法でセクションの番号・タイトル・開始位置・終了位置を抽出します。
抽出結果を、手動で抽出した結果と比較することで、各手法の精度を比較していきます。

手法1:PDFページを画像化してセクションを検出

概要

1ページずつPDFを画像化し、その画像をLLMに送り、セクションの開始と終了をXML形式で抽出します。最後にCSVにまとめて出力します。

コード全文
import re
import os

import numpy as np
import pandas as pd
from PIL import Image
import pymupdf
from google import genai

from dotenv import load_dotenv
load_dotenv()

GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")


def call_llm_with_image(message_list):
    client = genai.Client(api_key=GOOGLE_API_KEY)
    response = client.models.generate_content(
        model="gemini-2.5-pro-preview-03-25",
        contents=message_list
    )
    return response.text


system_prompt = """
あなたはPDFドキュメントのセクション構造を抽出し、XML形式で返すAIアシスタントです。
PDFのページの一部を画像として添付しています。添付した画像はページ順に並んでいます。
部分的なページ情報と現在抽出されているセクション情報から、新たに開始または終了が検出されたセクション情報を抽出してください。
添付されている画像のページ番号は順に{page_num_list_str}です。

添付した画像から、新たに開始または終了が検出されたセクション情報を抽出し、下のスキーマに従ってXML形式で出力してください。
<Sections>
  <Section>
    <Number>セクション番号</Number>        <!-- 例: "2.2" -->
    <Title>セクションタイトル</Title>      <!-- 見出しテキスト -->
    <Depth>深さ</Depth>                   <!-- セクションの深さ。メインセクションを0、サブセクションを1、サブサブセクションを2としていく。 -->
    <StartPage>開始ページ番号</StartPage> <!-- 3 -->
    <EndPage>終了ページ番号</EndPage>    <!-- 未定ならNoneを設定 -->
  </Section>
  …
</Sections>


以下は現在抽出されているセクション情報です。
<ExistingSections>
{existing_sections}
</ExistingSections>


**注意**:  
- 開始されたセクションと終了したセクションをすべて出力してください。
- 既存の `<Section>` タグ内で `EndPage` が未設定のものについては、画像内の見出し検出結果に基づいて `EndPage` を埋めてください。  
- セクションは複数のDepthにまたがっています。例えば、セクション1のDepthは0ですが、セクション1.1のDepthは1です。すべてのDepthのセクションを出力してください。
- Depthの違いに注意してください。例えば、Depth=2のセクションが終わっても、Depth=1のセクションは終わらない可能性があります。同じDepthの次のセクションが現れない限りは、セクションは終わらないと考えてください。
"""


def get_pdf_page_num(pdf_path):
    with pymupdf.open(pdf_path) as doc:
        return len(doc)


def get_pdf_page_image(pdf_path, page_num_list: list[int], slide_num: int = 1):
    with pymupdf.open(pdf_path) as doc:
        pix_list = []
        for page_num in page_num_list:
            page = doc.load_page(page_num - slide_num)
            pix = page.get_pixmap()
            pix_list.append(pix)
        return pix_list

def pixmap_to_image(pix):
    mode = "RGBA" if pix.alpha else "RGB"
    img = Image.frombytes(mode, [pix.width, pix.height], pix.samples)
    return img


def get_existing_sections(section_info_df):
    existing_sections = ""
    for index, section in section_info_df.iterrows():
        if pd.isna(section["EndPage"]):
            existing_sections += f"<Section>\n"
            existing_sections += f"<Number>{section['Number']}</Number>\n"
            existing_sections += f"<Title>{section['Title']}</Title>\n"
            existing_sections += f"<Depth>{section['Depth']}</Depth>\n"
            existing_sections += f"<StartPage>{section['StartPage']}</StartPage>\n"
            existing_sections += f"<EndPage>None</EndPage>\n"
            existing_sections += f"</Section>\n"
    return existing_sections


def get_section_info_from_response(response, existing_sections):
    section_info_list = re.findall(r"<Section>(.*?)</Section>", response, re.DOTALL)
    records = []
    for section_info in section_info_list:
        number = re.search(r"<Number>(.*?)</Number>", section_info, re.DOTALL).group(1)
        title = re.search(r"<Title>(.*?)</Title>", section_info, re.DOTALL).group(1)
        depth = re.search(r"<Depth>(.*?)</Depth>", section_info, re.DOTALL).group(1)
        start_page = re.search(r"<StartPage>(.*?)</StartPage>", section_info, re.DOTALL).group(1)
        end_page = re.search(r"<EndPage>(.*?)</EndPage>", section_info, re.DOTALL).group(1)
        if end_page == "None":
            end_page = None
        if len(existing_sections[existing_sections["Title"] == title]) > 0 and end_page is not None:
            existing_sections.loc[existing_sections["Title"] == title, "EndPage"] = end_page
        else:
            records.append({
                "Number": number,
                "Title": title,
                "Depth": depth,
                "StartPage": start_page,
                "EndPage": np.nan if end_page is None else end_page
        })
    add_section_info_df = pd.DataFrame.from_records(records, columns=["Number", "Title", "Depth", "StartPage", "EndPage"])
    section_info_df = pd.concat([existing_sections, add_section_info_df], ignore_index=True)
    return section_info_df


if __name__ == "__main__":
    pdf_path = "QA4AI.Guidelines.202504.pdf"
    start_page = 11
    slide_size = 1
    end_page = get_pdf_page_num(pdf_path)
    print(f"start_page: {start_page}, end_page: {end_page}")
    section_info_df = pd.DataFrame(columns=["Number", "Title", "Depth", "StartPage", "EndPage"])
    try:
        for page_num in range(start_page, end_page + 1, slide_size):
            page_num_list = list(range(page_num, page_num + slide_size))
            pix_list = get_pdf_page_image(pdf_path, page_num_list)
            image_list = []
            for pix in pix_list:
                img = pixmap_to_image(pix)
                image_list.append(img)
            existing_sections = get_existing_sections(section_info_df)
            page_num_list_str = "[" + ",".join(map(str, page_num_list)) + "]"
            message_list = [
                system_prompt.format(existing_sections=existing_sections, page_num_list_str=page_num_list_str)
            ]
            for img in image_list:
                message_list.append(img)
            response = call_llm_with_image(message_list)
            print(response)
            print("--------------------------------")
            section_info_df = get_section_info_from_response(response, section_info_df)
            # print(section_info_df)
            # print("--------------------------------")
    except Exception as e:
        print(f"Error Occured in page {page_num}: {e}")
    section_info_df.to_csv("section_info_from_image.csv", header=True, index=False)

実行結果

実行した結果は以下の通りです。

項目
正しく読み込めていたセクション数 199 / 227
ページ情報を間違えて抽出していたセクション数 25 / 227
抽出できていないセクション数 3 / 227
余計に抽出していたセクション数 53
  • メリット: セクションの表記が明確なPDFなら高精度で自動抽出できる。
  • デメリット: 誤抽出や未抽出の確認のために手作業は必要。対象ファイルが少ない場合には実用的。

手法2:目次テキストから抽出し、開始位置を特定

概要

  1. まずPDFの目次テキストをLLMに渡し、セクション番号・タイトル・深さ(depth)を抽出する。
  2. つぎに各ページを画像化し、開始位置が未設定のセクションをLLMで検出して、開始ページを特定する。
コード全文
import re
import os

import pandas as pd
import pymupdf

import openai 

from dotenv import load_dotenv
load_dotenv()

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")


def call_llm(messages):
    client = openai.OpenAI(api_key=OPENAI_API_KEY)
    response = client.chat.completions.create(
        model="o3-mini",
        messages=messages
    )
    return response.choices[0].message.content


system_prompt = """あなたのタスクは、与えられたPDFの目次ページのテキストからPDFのセクション情報を抽出することです。

各セクションごとに以下の情報を抽出してください。
- section_number: セクション番号
- section_title: セクションタイトル
- depth: セクションの深さ。メインセクションを0、サブセクションを1、サブサブセクションを2としていく。

抽出した情報はxml形式で出力してください。
## 出力形式
<section>
    <section_number>1</section_number>
    <section_title>セクションタイトル</section_title>
    <depth>0</depth>
</section>
...(複数ある場合はこれを繰り返す)

すべてのセクションを抽出してください。


## PDFの目次ページのテキスト
<agenda_text>
{agenda_text}
</agenda_text>


それではタスクを開始してください。
"""


def get_pdf_page_text(pdf_path, page_number_list: list[int]):
    with pymupdf.open(pdf_path) as doc:
        text = ""
        for page_number in page_number_list:
            page = doc.load_page(page_number)
            text += page.get_text()
        return text


def parse_section_info(response):
    section_list = re.findall(r"<section>.*?</section>", response, re.DOTALL)
    section_info_list = []
    for section in section_list:
        section_info = {}
        section_info["section_number"] = re.search(r"<section_number>(.*?)</section_number>", section).group(1)
        section_info["section_title"] = re.search(r"<section_title>(.*?)</section_title>", section).group(1)
        section_info["depth"] = re.search(r"<depth>(.*?)</depth>", section).group(1)
        section_info_list.append(section_info)
    return section_info_list



if __name__ == "__main__":
    pdf_path = "QA4AI.Guidelines.202504.pdf"
    page_list = [
        3, 4, 5, 6, 7, 8, 9,
    ]
    agenda_text = get_pdf_page_text(pdf_path, page_list)
    # print(agenda_text)

    messages = [
        {"role": "system", "content": system_prompt.format(agenda_text=agenda_text)},
    ]
    response = call_llm(messages)
    print(response)
    section_info_list = parse_section_info(response)
    df = pd.DataFrame(section_info_list)
    df.to_csv("section_info.csv", header=True, index=False)

結果は以下の通りです。

項目
正しく読み込めていたセクション数 222 / 227
抽出できていないセクション数 5 / 227

付録のセクションを抽出できていませんが、それ以外の本文のセクションは抽出できているようです。
付録のセクションは手動で追加したのち、PDFの各ページ画像からセクションの開始位置を特定します。

続いて開始ページ特定のコードです:

コード全文
import re
import os

from google import genai
import numpy as np
import pandas as pd
import pymupdf
from PIL import Image

from dotenv import load_dotenv
load_dotenv()

GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")


def call_llm_with_image(message_list):
    client = genai.Client(api_key=GOOGLE_API_KEY)
    response = client.models.generate_content(
        model="gemini-2.5-pro-preview-03-25",
        contents=message_list
    )
    return response.text


system_prompt = """
あなたはPDFドキュメントのセクション構造を抽出し、XML形式で返すAIアシスタントです。
PDFのページの一部を画像として添付しています。添付した画像はページ順に並んでいます。
部分的なページ情報とセクション情報から、新たに開始が検出されたセクション情報を抽出してください。
添付されている画像のページ番号は順に{page_num_list_str}です。

添付した画像から、新たに開始が検出されたセクション情報を抽出し、下のスキーマに従ってXML形式で出力してください。
<Sections>
  <Section>
    <Number>セクション番号</Number>        <!-- 例: "2.2" -->
    <Title>セクションタイトル</Title>      <!-- 見出しテキスト -->
    <Depth>深さ</Depth>                   <!-- セクションの深さ。メインセクションを0、サブセクションを1、サブサブセクションを2としていく。 -->
    <StartPage>開始ページ番号</StartPage> <!-- 3 -->
  </Section>
  …
</Sections>


以下はまだ開始していないセクション情報です。
<ExistingSections>
{existing_sections}
</ExistingSections>


**注意**:  
- 開始されたセクションをすべて出力してください。
- セクションは複数のDepthにまたがっています。例えば、セクション1のDepthは0ですが、セクション1.1のDepthは1です。すべてのDepthのセクションを出力してください。
"""


def get_pdf_page_num(pdf_path):
    with pymupdf.open(pdf_path) as doc:
        return len(doc)


def get_pdf_page_image(pdf_path, page_num_list: list[int], slide_num: int = 1):
    with pymupdf.open(pdf_path) as doc:
        pix_list = []
        for page_num in page_num_list:
            page = doc.load_page(page_num - slide_num)
            pix = page.get_pixmap()
            pix_list.append(pix)
        return pix_list

def pixmap_to_image(pix):
    mode = "RGBA" if pix.alpha else "RGB"
    img = Image.frombytes(mode, [pix.width, pix.height], pix.samples)
    return img


def get_existing_sections(section_info_df):
    existing_sections = ""
    depth_list = sorted(section_info_df["Depth"].unique())
    for index, section in section_info_df.iterrows():
        if pd.isna(section["StartPage"]):
            existing_sections += f"<Section>\n"
            existing_sections += f"<Number>{section['Number']}</Number>\n"
            existing_sections += f"<Title>{section['Title']}</Title>\n"
            existing_sections += f"<Depth>{section['Depth']}</Depth>\n"
            # existing_sections += f"<StartPage>{section['StartPage']}</StartPage>\n"
            existing_sections += f"</Section>\n"
    return existing_sections


def get_section_info_from_response(response, existing_sections):
    section_info_list = re.findall(r"<Section>(.*?)</Section>", response, re.DOTALL)
    for section_info in section_info_list:
        number = re.search(r"<Number>(.*?)</Number>", section_info, re.DOTALL).group(1)
        title = re.search(r"<Title>(.*?)</Title>", section_info, re.DOTALL).group(1)
        depth = re.search(r"<Depth>(.*?)</Depth>", section_info, re.DOTALL).group(1)
        start_page = re.search(r"<StartPage>(.*?)</StartPage>", section_info, re.DOTALL).group(1)
        if (
            len(existing_sections[(existing_sections["Number"] == number) | (existing_sections["Title"] == title)]) > 0
        ):
            existing_sections.loc[
                (existing_sections["Number"] == number) | (existing_sections["Title"] == title), "StartPage"
            ] = start_page
            # ] = int(start_page)
        else:
            print(f"Section {number}: {title} not found in existing sections")
    return existing_sections


def get_end_page(section_info_df, end_page):
    depth_list = sorted(section_info_df["Depth"].unique())
    for depth in depth_list:
        section_info_df_depth = section_info_df[section_info_df["Depth"] <= depth]
        index_list = section_info_df_depth.index.tolist()
        for idx in range(len(index_list)):
            if pd.isna(section_info_df_depth.loc[index_list[idx], "EndPage"]): 
                if idx < len(index_list) - 1:
                    section_info_df.loc[index_list[idx], "EndPage"] = section_info_df.loc[index_list[idx + 1], "StartPage"]
                else:
                    print(f"Section {section_info_df.loc[index_list[idx], "Number"]}: {section_info_df.loc[index_list[idx], "Title"]} has no end page")
            else:
                section_info_df.loc[index_list[idx], "EndPage"] = end_page
    return section_info_df


if __name__ == "__main__":
    pdf_path = "QA4AI.Guidelines.202504.pdf"
    start_page = 11
    slide_size = 1
    end_page = get_pdf_page_num(pdf_path)
    print(f"start_page: {start_page}, end_page: {end_page}")
    section_info_df = pd.read_csv("section_info.csv")
    section_info_df["StartPage"] = np.nan
    try:
        for page_num in range(start_page, end_page + 1, slide_size):
            page_num_list = list(range(page_num, page_num + slide_size))
            pix_list = get_pdf_page_image(pdf_path, page_num_list)
            image_list = []
            for pix in pix_list:
                img = pixmap_to_image(pix)
                image_list.append(img)
            existing_sections = get_existing_sections(section_info_df)
            page_num_list_str = "[" + ",".join(map(str, page_num_list)) + "]"
            message_list = [
                system_prompt.format(existing_sections=existing_sections, page_num_list_str=page_num_list_str)
            ]
            for img in image_list:
                message_list.append(img)
            response = call_llm_with_image(message_list)
            print(response)
            print("--------------------------------")
            section_info_df = get_section_info_from_response(response, section_info_df)
    except Exception as e:
        print(f"Error Occured in page {page_num}: {e}")
    section_info_df["EndPage"] = np.nan
    section_info_df = get_end_page(section_info_df, end_page)
    section_info_df.to_csv("section_info_from_agenda.csv", header=True, index=False)

実行結果

結果は以下の通りです。

項目
正しく読み込めていたセクション数 13 / 227
ページ情報を間違えて抽出していたセクション数 214 / 227
  • メリット: 目次テキストを一括解析できるため、余計なセクションを削除する手間は少ない。
  • デメリット: 開始位置特定の精度が低く、大量の誤抽出が発生する。

まとめ

手法1(画像化+直接検出)のほうが全体精度が高かったです。
PDFのセクションの表記が分かりやすいことが理由の一つだと推測します。
一方セクション数が多かったからか、存在するセクションの情報を与えて(手法2)も、ページからうまく抽出するのは難しいようです。
少量ファイルの処理であれば手法1が利用できそうです。

次のステップとして、抽出したセクション情報をllms.txtのルーティングに組み込み、大規模ドキュメント検索への適用を検証します。

Discussion