社内のルールや規程は、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.py | PDFはページ単位で断片にし、ページ番号を付ける |
| 保存 | db/migrations/004_page.sql、app/db/store.py | 断片にページ番号の列を足し、保存・検索で返す |
| 出典表示 | app/answer/prompt.py | PDFの出典を「文書名 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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【FastAPIで作る社内ナレッジ検索(RAG) 第0回】全体像と、動く最小APIを作る
- 【FastAPIで作る社内ナレッジ検索(RAG) 第1回】社内文書を読み込んで、検索しやすい断片に分ける
- 【FastAPIで作る社内ナレッジ検索(RAG) 第2回】PostgreSQLとpgvectorをDocker Composeで動かし、文書の断片を保存する
- 【FastAPIで作る社内ナレッジ検索(RAG) 第3回】質問に近い社内文書を探す(ベクトル検索)を実装する
- 【FastAPIで作る社内ナレッジ検索(RAG) 第4回】見つけた社内文書を根拠に、出典つきで生成AIに回答させる
- 【FastAPIで作る社内ナレッジ検索(RAG) 第5回】答えが社内文書に無いときは「見つかりません」と返す仕組みを作る
- 【FastAPIで作る社内ナレッジ検索(RAG) 第6回】部署ごとに見てよい文書だけを検索する、アクセス制御を実装する
- 【FastAPIで作る社内ナレッジ検索(RAG) 第7回】社内のPDFを取り込み、ページ番号つきの出典で答えさせる(この記事)
- 【FastAPIで作る社内ナレッジ検索(RAG) 第8回】回答の品質を評価ケースで測り、直したつもりの後退を自動テストで見つける










コメント
コメント一覧 (4件)
[…] […]
[…] 社内AIチャットは、作った直後よりも「直したあと」のほうが怖い仕組みです。しきい値を少し変えた、文書を入れ替えた、AIのモデルを新しくした。どれも小さな変更なのに、ある質問には答えられなくなったり、見てはいけない部署の文書が出てしまったりします。しかも画面で数件試しただけでは気づけません。第7回ではPDFを取り込み、ページ番号つきの出典で答えさせるところまで作りました。 […]
[…] […]
[…] […]