MENU

問い合わせ


    【FastAPIで作る社内ナレッジ検索(RAG) 第7回】社内のPDFを取り込み、ページ番号つきの出典で答えさせる

    社内のルールや規程は、Wordで書かれたものよりも、PDFで配られているもののほうが多いかもしれません。社内AIチャットに質問しても「PDFは読めません」では、現場では使ってもらえません。第6回では、部署ごとに見てよい文書だけを検索する仕組みを作りました。第7回では、PDFを取り込み、「どのPDFの何ページか」まで示して答えさせる部分を実装します。

    前回の記事:【第6回】部署ごとに見てよい文書だけを検索する、アクセス制御を実装する

    「社内の規程はほとんどPDFです。AIに質問して答えが返ってきても、PDFのどこに書いてあるのか分からないと、結局自分で確認し直すことになりませんか?」

    先に結論をお伝えします。PDFの取り込みは「ページごとに文字を取り出し、断片にページ番号を持たせる」だけで実現できます。 そうすれば、回答の出典を「出張旅費規程.pdf 2ページ」の形で示せます。ただし、文字が埋め込まれていないPDF(紙をスキャンしただけのもの)は、この方法では読めません。読めなかったことを黙って見逃さず、警告として残す作りにしておくのが、運用では大切です。

    この記事では、PDFからの文字の取り出し、ページ番号を出典に反映する方法、読めないPDFの扱いを実装します。あわせて、社内文書検索を開発会社に頼むときに「PDFが読めるか」を確認する観点も整理します。

    目次

    PDFを社内文書検索で扱うときの3つの壁

    PDFは「見た目を固定するための形式」です。人が読む分には便利ですが、プログラムが文章として読むには、次のような壁があります。

    壁内容この回の対応
    文字が無いPDF紙をスキャンした画像だけのPDFは、文字のデータを持たない読めなかったページを警告として表示し、検索の対象外にする
    ページの区切り文書全体を1つにつなぐと、どのページの話か分からなくなるページごとに断片にし、ページ番号を持たせる
    見出しの情報が無いMarkdownと違い、見出しの構造が取り出せない見出しの代わりに、ページ番号を出典にする

    筆者の見解として、最初の壁がもっとも重要です。「PDFに対応しています」と言われても、スキャンしたPDFまで読めるとは限りません。 スキャン画像から文字を読み取るにはOCR(=画像から文字を読み取る技術)が別に必要で、精度や費用も変わります。この連載ではOCRは扱わず、「読めなかったことが分かる」ところまでを作ります。

    この回で作るもの

    部品ファイル役割
    PDFの読み取りapp/ingest/pdf.pyページごとに文字を取り出す。取れなかったページ番号も返す
    取り込みapp/ingest/loader.py.pdfも読み込み対象にする
    断片化app/ingest/chunker.pyPDFはページ単位で断片にし、ページ番号を付ける
    保存db/migrations/004_page.sql、app/db/store.py断片にページ番号の列を足し、保存・検索で返す
    出典表示app/answer/prompt.pyPDFの出典を「文書名 Nページ」で示す
    サンプルtools/make_sample_pdf.py動作確認用のPDF(出張旅費規程・2ページ)を作る

    PDFを読む部品には、Pythonで広く使われているpypdfを使います。純粋なPythonで書かれているため、追加の実行ファイルを用意せずに導入できます。バージョンや配布元はPyPIのpypdfのページで確認できます(本連載では6系を固定し、pyproject.tomlに書いています)。

    📰 出典:pypdf(PyPI)

    実装1:PDFをページごとに読む(app/ingest/pdf.py)

    PDFを開き、ページごとに文字を取り出します。ここで決めておきたいのは、文字が取れなかったページを、黙って捨てないことです。

    # app/ingest/pdf.py
    def read_pdf_pages(path: Path) -> tuple[list[tuple[int, str]], list[int]]:
        reader = PdfReader(path)
        if reader.is_encrypted:
            raise ValueError(f"{path.name}: パスワード付きのPDFは読み込めません。解除してから取り込んでください。")
        pages: list[tuple[int, str]] = []
        empty: list[int] = []
        for number, page in enumerate(reader.pages, start=1):
            text = (page.extract_text() or "").replace("\r\n", "\n").strip()
            if text:
                pages.append((number, text))
            else:
                empty.append(number)
        return pages, empty

    ページ番号は人が数える番号に合わせ、1から始めています。戻り値は「文字のあるページ」と「文字が取れなかったページ番号」の2つです。パスワード付きのPDFは、理由が分かるメッセージを出して止めます。中身が読めないまま「0件登録」と表示されるより、原因に気づけるからです。

    実装2:断片にページ番号を持たせる

    まず、データベースにpageの列を足します。第3回・第6回と同じく、何度流しても同じ結果になる書き方です。

    -- db/migrations/004_page.sql
    ALTER TABLE chunks ADD COLUMN IF NOT EXISTS page INTEGER;

    Markdownやテキストにはページがないため、値はNULL(=空)のままです。次に、DocumentとChunkにページの情報を追加します(app/ingest/models.py)。

    # app/ingest/models.py(抜粋)
    @dataclass(frozen=True)
    class Document:
        source: str
        text: str
        department: str = "all"
        pages: tuple[tuple[int, str], ...] = ()     # PDFの (ページ番号, そのページの文字)
        empty_pages: tuple[int, ...] = ()           # 文字を取り出せなかったページ番号
    
    @dataclass(frozen=True)
    class Chunk:
        ...
        page: int | None = None                     # PDF由来の断片の元ページ番号

    断片にする処理(app/ingest/chunker.py)では、PDFだけ「見出しの代わりにページ」で区切ります。

    # app/ingest/chunker.py(抜粋)
    if document.pages:
        parts = [("", text, number) for number, text in document.pages]
    else:
        parts = [(heading, body, None) for heading, body in split_sections(document.text)]

    ページをまたいで1つの断片にまとめないのがポイントです。まとめてしまうと、1つの断片が2ページにまたがり、「何ページに書いてあるか」を1つに決められなくなります。1ページが長すぎる場合は、第1回で作った段落・句点での分割がそのまま働くため、同じページ番号の断片が複数できます。

    読み込み側(app/ingest/loader.py)は、拡張子に.pdfを加え、PDFのときだけ上のread_pdf_pagesを呼びます。フォルダ名が部署になる第6回のルールは、PDFにもそのまま効きます。

    実装3:ページ番号を保存し、出典に表示する

    保存と検索(app/db/store.py)には、pageを1つ加えるだけです。

    # app/db/store.py(抜粋)
    "INSERT INTO chunks (source, heading, chunk_index, body, department, page, embedding)"
    " VALUES (%s, %s, %s, %s, %s, %s, %s::vector)"
    ...
    "SELECT source, heading, body, embedding <=> %s::vector AS distance, page"

    出典の表示名を作る部分(app/answer/prompt.py)では、ページがあれば「文書名 Nページ」にします。

    # app/answer/prompt.py(抜粋)
    def source_label(hit: SearchHit) -> str:
        if hit.page is not None:
            return f"{hit.source} {hit.page}ページ"
        return f"{hit.source} > {hit.heading}" if hit.heading else hit.source

    この関数は、AIに渡す文書の見出しと、利用者に返す出典の両方で使っています。そのため、回答処理(app/answer/service.py)は1行も変えずに、PDFの出典がページ番号つきで出ます。

    動作確認:PDFを取り込んで質問する

    サンプルのPDF(sample_docs/business-trip.pdf)は、python tools/make_sample_pdf.pyで作れます。架空の出張旅費規程が2ページ入っています。取り込みの確認コマンドは次のとおりです。

    python -m app.ingest sample_docs
    business-trip.pdf (部署: all): 2件
      [0] 1ページ / 72文字 / 出張旅費規程 第1条(日当) 国内出張の日当は、1日あたり3…
      [1] 2ページ / 82文字 / 第2条(宿泊費) 宿泊を伴う出張の宿泊費は、1泊あたり10,…
    expense.txt (部署: all): 1件
    ...

    データベースに保存して検索すると、PDFの断片がページ番号つきで返ります。

    python -m app.db sample_docs
    python -m app.search "宿泊費の上限はいくら?"
    0.769  business-trip.pdf 2ページ
    0.850  vacation.md > 休暇規程(サンプル) > 特別休暇 > 慶弔休暇
    0.885  expense.txt

    /api/askで質問したときのsourcesにも、"business-trip.pdf 2ページ"が入ります。なお、この確認は開発環境(PostgreSQL 16 + pgvector)で、生成AIのAPIキーを設定しない状態で行いました。そのため、回答文は「開発用の仮回答」で、有効なAPIキーでの回答内容は確認していません。また、第2回と同様にDockerでの起動は今回の環境では確認できていません。

    自動テストは、今回追加した7件を含めて41件が成功しています(pytest・ruff check)。スキャン画像相当のページ(文字が無いページ)が警告側に分類されること、パスワード付きPDFがわかりやすいエラーになること、ページ番号が断片・保存・検索の結果まで引き継がれることを確かめています。

    つまずきやすい点・運用上の注意

    • PDFの文字の並びは、見た目どおりとは限りません。 段組みや表は、文章の順序が入れ替わって取り出されることがあります。重要な規程は、取り込み後に「期待した文章として読めているか」を人が確認してください
    • 文字が取れないPDFは検索に出ません。 取り込みの警告(「Nページは文字を取り出せませんでした」)を運用で見逃さない仕組みが必要です
    • PDFを作り直したら、取り込み直します。 第2回で作ったとおり、同じ文書は入れ替わるため、ページ数が減っても古い断片は残りません
    • ページ番号はPDFの通し番号です。 紙面に印字されたページ数(表紙を数えない等)とは、ずれることがあります
    • 実際のPDFでも確認してください。 今回は日本語フォントを指定して作った小さなPDFで確認しています。フォントの埋め込み方や作成ソフトによっては、文字が正しく取り出せない場合があります

    発注者向けメモ:PDF対応を頼むときの確認点

    「PDFに対応」とひと言で言っても、範囲は大きく異なります。見積もりの前に、次の点を開発会社に確認しておくと、後から「これは対応外でした」となりにくくなります。

    • ☐ 手元のPDFの種類を分けている(文字が選択できるPDFか、スキャンした画像か)
    • ☐ スキャンしたPDFも対象にしたいかを決めている(対象ならOCRが別途必要で、費用も精度の確認も増える)
    • ☐ 表・段組み・図が多い文書で、実際のPDFを使って読み取りを試してもらえる
    • ☐ 回答の出典が、文書名とページ番号まで示される
    • ☐ 文字が読めなかったPDFや、パスワード付きPDFが見つかったとき、誰にどう知らせるかが決まっている
    • ☐ PDFが更新されたとき、検索の内容を入れ替える手順と担当が決まっている

    開発会社に聞く質問例は次のとおりです。

    • 「実際にうちのPDFを3〜5本お渡ししたら、取り込んで、読み取り結果を見せてもらえますか」
    • 「スキャンしたPDFが混ざっていたときは、どのように扱われますか。OCRを使う場合は、費用と精度の見通しを教えてください」
    • 「回答の出典に、ページ番号は出ますか。PDFの通し番号と、紙面のページ数がずれる場合はどうしますか」

    工数やリスクが増えやすい条件

    • スキャン画像のPDFが多い(OCRの導入と、読み取り結果の確認が必要)
    • 表や段組みが多く、文章の順序が崩れやすい
    • PDFの本数が多く、更新の頻度も高い(取り込みの自動化や差分更新が必要)
    • 部署ごとの権限が細かく、PDFの置き場所と権限をそろえる必要がある

    まとめと次回予告

    今回のポイントは次の3つです。

    • PDFはページごとに文字を取り出し、断片にページ番号を持たせると、「文書名 Nページ」の出典が出せる
    • 文字が取れないPDF(スキャン画像)は、警告として残し、黙って見逃さない。読ませるにはOCRが別に必要
    • 出典の表示名を作る関数を1か所にそろえておいたので、回答処理は変えずにPDFに対応できた

    次回は第8回として、回答の品質を自動テストで測る方法を扱います。「文書に無いことは無いと答える」「出典が正しい」といった確認を、評価用の質問集にして繰り返し確かめられるようにする予定です(タイトル案:【第8回】社内ナレッジ検索の回答品質を、自動テストで測り続ける)。

    この連載の記事一覧

    この記事は連載「FastAPIで作る社内ナレッジ検索(RAG)」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (4件)

      目次