「PDF要約のAIサービスは便利だが、機密文書を外部サーバーに送ることを情報セキュリティ部門が許可してくれない」
そんな悩みを抱えるインフラ担当者・情シス担当者は多いはずだ。この記事では、PythonのPDFライブラリ「pypdf」とOllamaを組み合わせ、PDFファイルのテキスト抽出・ページ別要約・自然言語での質問応答をUbuntu Server上で完全ローカルに実装する手順を解説する。
外部APIへの通信はゼロ。完成後はPythonスクリプトにPDFパスを渡すだけで、契約書・仕様書・報告書の要点が自動生成される。
この記事のポイント
・pypdfで抽出したテキストをOllamaの/api/generateに渡すと完全ローカルでPDF要約が実現できる
・pip install pypdf requests の2パッケージだけで動作し、クラウド依存ゼロで導入できる
・長いPDFは1,500字程度のチャンクに分割してOllama APIに渡すとコンテキスト超過を防げる
・日本語PDFのテキストが空になる場合はpdfplumberへの切り替えで解消できるケースが多い
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
PDFをローカルLLMで処理する業務上のメリット
企業で扱うPDFには、契約書・見積書・社内規程・顧客向け提案書など、外部送信が難しい機密情報が含まれることが多い。クラウドのAI要約APIにファイルをアップロードすると、サービス利用規約によっては入力テキストがモデルの再学習に使われるリスクがある。情報セキュリティ部門の承認が取れないケースも現場では少なくない。社内でChatGPTが使えないときの代替手段|機密データを守るローカルLLMという選択肢でも解説しているように、クラウドAI利用が制限される環境では「ローカル処理」が現実解になる。今回の構成で得られるメリットを整理する。
・文書が外部サーバーに一切送出されない(情報漏えいリスクがゼロ)
・API従量課金が発生しない(月額費用を実質ゼロにできる)
・ネットワーク断・速度低下に左右されず社内LAN内だけで完結する
・処理速度がクラウドAPIのネットワーク遅延に左右されない
知人のシステムインテグレーターから聞いた話では、月200件以上の契約書PDFをクラウドAIで要約していた企業がローカルLLM処理に切り替え、月額数十万円のAPI費用を削減できたという事例がある。運用コストの詳細な試算方法はローカルLLMの運用コストを試算する方法も参考になる。
前提環境とパッケージのインストール
1. 動作環境の確認
Ubuntu 22.04/24.04 LTSとPython 3.10以上を前提とする。OllamaのセットアップがまだのPCにはUbuntu ServerでローカルLLMを構築する方法|Ollamaで機密データを外に出さず業務AIを動かす完全ガイドを先に参照してほしい。Ollamaが起動済みであることを確認してから進める。# Ollamaの起動確認(11434番ポートでリスンしていればOK) $ curl http://localhost:11434/ Ollama is running # Pythonバージョン確認(3.10以上が必要) $ python3 --version Python 3.10.12
# Llama3.3(70Bの量子化版・VRAMが少ない環境向け) $ ollama pull llama3.3:70b-instruct-q4_0 # Mistral(7B・CPUのみの環境でも実用的な速度が出る) $ ollama pull mistral:7b-instruct-q4_0
2. Python仮想環境の作成
システム全体のPython環境を汚さないために仮想環境を使う。# プロジェクトディレクトリの作成 $ mkdir -p ~/pdf-llm && cd ~/pdf-llm # 仮想環境を作成して有効化 $ python3 -m venv venv $ source venv/bin/activate (venv) $
3. pypdfとrequestsのインストール
(venv) $ pip install pypdf requests ... Successfully installed pypdf-4.3.1 requests-2.32.3 # インストール確認 (venv) $ python3 -c "import pypdf; print('pypdf', pypdf.__version__)" pypdf 4.3.1
pypdfでPDFテキストを抽出する手順
1. 基本的なテキスト抽出
1枚のPDFからテキストを取り出すスクリプトを作成する。以下を `extract.py` として保存する。# extract.py import pypdf import sys def extract_text(pdf_path: str) -> str: reader = pypdf.PdfReader(pdf_path) pages = [] for page in reader.pages: text = page.extract_text() if text: pages.append(text) return "\n".join(pages) if __name__ == "__main__": result = extract_text(sys.argv[1]) print(f"総文字数: {len(result)}") print(result[:500]) # 最初の500文字だけ確認
(venv) $ python3 extract.py sample.pdf 総文字数: 4821 令和6年度 業務委託契約書 本契約は、株式会社○○(以下「甲」という)と株式会社△△(以下「乙」という)との間で、 以下のとおり業務委託契約を締結する。 ...
2. ページ数確認とページ単位の抽出
PDFのページ数が多い場合、特定のページだけ処理したいケースもある。インデックスは0始まりのため1ページ目はpages[0]になる。(venv) $ python3 - << 'EOF' import pypdf reader = pypdf.PdfReader("sample.pdf") print(f"総ページ数: {len(reader.pages)}") # 1ページ目だけ取得(インデックスは0始まり) page1 = reader.pages[0].extract_text() print(page1[:300]) EOF 総ページ数: 12 令和6年度 業務委託契約書 契約期間: 2026年4月1日~2027年3月31日
抽出テキストをOllamaのAPIに渡して要約する手順
1. 要約プロンプトの設計
日本語PDFを要約する場合、プロンプトに「日本語で回答すること」と明示すると安定する。temperature を 0.1 に設定することで毎回ほぼ同じ要約を安定して出力できる。0に近いほど再現性が高くなる。2. Pythonから要約を取得する実装
以下を `summarize.py` として保存する。# summarize.py import pypdf import requests import sys OLLAMA_URL = "http://localhost:11434/api/generate" MODEL = "llama3.3:70b-instruct-q4_0" MAX_CHARS = 3000 # Ollamaに渡す最大文字数 def extract_text(pdf_path: str) -> str: reader = pypdf.PdfReader(pdf_path) pages = [] for page in reader.pages: text = page.extract_text() if text: pages.append(text) return "\n".join(pages) def summarize(text: str) -> str: prompt = ( "以下のテキストを日本語で要約してください。\n" "重要なポイントを箇条書きで5点以内にまとめてください。\n\n" f"テキスト:\n{text}\n\n要約:" ) payload = { "model": MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.1} } response = requests.post(OLLAMA_URL, json=payload, timeout=180) return response.json()["response"] if __name__ == "__main__": text = extract_text(sys.argv[1]) print(f"抽出文字数: {len(text)}") summary = summarize(text[:MAX_CHARS]) print("\n=== 要約 ===") print(summary)
(venv) $ python3 summarize.py contract.pdf 抽出文字数: 4821 === 要約 === ・契約期間は2026年4月1日から2027年3月31日まで(1年間) ・業務委託範囲はWebシステムの保守・運用業務全般 ・月額委託料金は○○万円(税別)、毎月末日払い ・成果物の著作権は完成と同時に甲(委託者)に帰属する ・契約違反時の損害賠償上限は委託料金の総額
複数PDFをバッチ処理するスクリプトを作成する手順
1. バッチ処理スクリプトの全体像
ディレクトリ内のPDFを一括処理して、要約結果を個別テキストファイルに保存するスクリプトを作成する。以下を `batch_summarize.py` として保存する。# batch_summarize.py import pypdf import requests import sys from pathlib import Path OLLAMA_URL = "http://localhost:11434/api/generate" MODEL = "llama3.3:70b-instruct-q4_0" MAX_CHARS = 3000 def extract_text(pdf_path: str) -> str: reader = pypdf.PdfReader(pdf_path) pages = [] for page in reader.pages: text = page.extract_text() if text: pages.append(text) return "\n".join(pages) def summarize(text: str) -> str: prompt = "以下のテキストを日本語で5点以内の箇条書きで要約してください:\n\n" + text + "\n\n要約:" payload = { "model": MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.1, "num_ctx": 4096} } response = requests.post(OLLAMA_URL, json=payload, timeout=180) return response.json()["response"] def process_directory(input_dir: str, output_dir: str): input_path = Path(input_dir) output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) pdfs = list(input_path.glob("*.pdf")) print(f"処理対象: {len(pdfs)} ファイル") for i, pdf in enumerate(pdfs, 1): print(f"[{i}/{len(pdfs)}] {pdf.name} を処理中...") try: text = extract_text(str(pdf)) summary = summarize(text[:MAX_CHARS]) out_file = output_path / (pdf.stem + "_summary.txt") out_file.write_text(summary, encoding="utf-8") print(f" -> {out_file.name} に保存完了") except Exception as e: print(f" -> エラー: {e}") if __name__ == "__main__": if len(sys.argv) != 3: print("使い方: python3 batch_summarize.py 入力ディレクトリ 出力ディレクトリ") sys.exit(1) process_directory(sys.argv[1], sys.argv[2])
(venv) $ python3 batch_summarize.py ~/pdfs/inbox ~/pdfs/summaries 処理対象: 8 ファイル [1/8] contract_2026.pdf を処理中... -> contract_2026_summary.txt に保存完了 [2/8] spec_system_v3.pdf を処理中... -> spec_system_v3_summary.txt に保存完了 ... [8/8] minutes_20261001.pdf を処理中... -> minutes_20261001_summary.txt に保存完了
2. cronによる定期自動実行
毎週月曜9時に受信ディレクトリの新着PDFを自動処理するcron設定例を示す。cronではPATHが限定されるため、Pythonのパスは仮想環境内の絶対パスで指定するのが基本だ。# crontabの編集 $ crontab -e # 毎週月曜9時に新着PDFをバッチ処理(venv内のpython3を絶対パスで指定) 0 9 * * 1 /home/ubuntu/pdf-llm/venv/bin/python3 /home/ubuntu/pdf-llm/batch_summarize.py /home/ubuntu/pdfs/inbox /home/ubuntu/pdfs/summaries >> /var/log/pdf-llm-batch.log 2>&1
注意: cronで実行する場合、Ollamaが停止していると全件エラーになり空ファイルが生成されてしまう。cronスクリプトの冒頭に起動確認を入れるか、systemdサービスとして常時起動させておくことを推奨する。systemdへの登録手順は「OllamaをsystemdでサービスとしてLinuxに常時起動させる方法」が参考になる。
PDFに対して質問応答(Q&A)を実装する手順
1. 質問応答の基本実装
PDFの内容に基づいて自然言語で質問し、答えを得るスクリプトを作成する。以下を `pdf_qa.py` として保存する。# pdf_qa.py import pypdf import requests import sys OLLAMA_URL = "http://localhost:11434/api/generate" MODEL = "llama3.3:70b-instruct-q4_0" def extract_text(pdf_path: str) -> str: reader = pypdf.PdfReader(pdf_path) pages = [] for page in reader.pages: text = page.extract_text() if text: pages.append(text) return "\n".join(pages) def ask(context: str, question: str) -> str: prompt = ( "以下の文書を参照して、質問に日本語で答えてください。\n" "文書に記載がない場合は「文書に記載なし」と回答してください。\n\n" f"文書:\n{context}\n\n" f"質問: {question}\n\n回答:" ) payload = { "model": MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.0} # 再現性を最大にする } response = requests.post(OLLAMA_URL, json=payload, timeout=120) return response.json()["response"] if __name__ == "__main__": if len(sys.argv) < 2: print("使い方: python3 pdf_qa.py ファイル.pdf") sys.exit(1) text = extract_text(sys.argv[1])[:4000] print(f"文書を読み込みました({len(text)}文字)。質問を入力してください(quitで終了)") while True: question = input("\n質問: ").strip() if question.lower() in ("quit", "exit", "q"): break answer = ask(text, question) print(f"回答: {answer}")
(venv) $ python3 pdf_qa.py contract.pdf 文書を読み込みました(4821文字)。質問を入力してください(quitで終了) 質問: 契約期間はいつからいつまでですか? 回答: 契約期間は2026年4月1日から2027年3月31日までの1年間です。 質問: 委託料金の支払いサイクルは? 回答: 月額委託料金は毎月末日払いと規定されています。 質問: 瑕疵担保責任の期間は? 回答: 文書に記載なし
2. 質問応答をバッチ処理に組み込む
同じ質問を複数のPDFに対して繰り返し適用したい場合、質問リストをファイルで管理して一括処理する形に拡張できる。たとえば「契約期間は?」「支払いサイクルは?」「違約金条件は?」を全契約書PDFに対して自動実行してCSVに出力するといった使い方だ。実装の核心部分は上記 `ask()` 関数の呼び出しを繰り返すだけで成立する。長いPDFのチャンク分割処理とコンテキスト制限への対処
1. チャンク分割の考え方
Ollamaのモデルにはコンテキストウィンドウの上限があり、デフォルトでは2,048トークン程度(日本語換算で1,500字前後)だ。100ページを超えるPDFをそのまま渡すと後半のページ内容がLLMに届かない。`num_ctx` を増やす方法もあるが、VRAMを多く消費する。現実的な解決策は「チャンク分割→各チャンクを個別に要約→要約同士を統合」の2段階処理だ。処理に時間はかかるが、VRAM消費を抑えながら長文を処理できる。
2. チャンク分割による2段階要約の実装
以下を `chunk_summarize.py` として保存する。# chunk_summarize.py import pypdf import requests import sys OLLAMA_URL = "http://localhost:11434/api/generate" MODEL = "mistral:7b-instruct-q4_0" CHUNK_SIZE = 1500 # 文字数単位(日本語の場合1,500字が目安) def extract_text(pdf_path: str) -> str: reader = pypdf.PdfReader(pdf_path) pages = [] for page in reader.pages: text = page.extract_text() if text: pages.append(text) return "\n".join(pages) def chunk_text(text: str, size: int) -> list: return [text[i:i+size] for i in range(0, len(text), size)] def call_ollama(prompt: str) -> str: payload = { "model": MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.1} } response = requests.post(OLLAMA_URL, json=payload, timeout=120) return response.json()["response"] def summarize_chunk(chunk: str) -> str: return call_ollama( "以下のテキストを3点以内の日本語箇条書きで要約してください:\n\n" + chunk + "\n\n要約:" ) def final_summary(summaries: list) -> str: combined = "\n".join(summaries) return call_ollama( "以下は長文書の各セクション要約です。全体を5点以内の日本語箇条書きで最終要約してください:\n\n" + combined + "\n\n最終要約:" ) if __name__ == "__main__": text = extract_text(sys.argv[1]) chunks = chunk_text(text, CHUNK_SIZE) print(f"総文字数: {len(text)} / チャンク数: {len(chunks)}") chunk_summaries = [] for i, chunk in enumerate(chunks, 1): print(f" チャンク {i}/{len(chunks)} を処理中...") chunk_summaries.append(summarize_chunk(chunk)) print("\n=== 最終要約 ===") print(final_summary(chunk_summaries))
(venv) $ python3 chunk_summarize.py annual_report_2026.pdf 総文字数: 18324 / チャンク数: 13 チャンク 1/13 を処理中... チャンク 2/13 を処理中... ... チャンク 13/13 を処理中... === 最終要約 === ・2026年度の売上高は前年比12%増の○○億円を達成した ・主力サービスのクラウド事業が全体の45%を占め、最大の収益源となった ・人件費増加により営業利益率は前年の18%から15%へ低下した ・翌年度は東南アジア3カ国への展開を予定している ・研究開発費を前年比30%増に引き上げ、AIソリューション強化を推進する
トラブル対処|文字化け・空テキスト・タイムアウトが発生した場合
日本語PDFを処理する際によくある問題と対処法を整理する。問題1: extract_text()が空文字列を返す
スキャンPDFや画像ベースのPDFはテキストレイヤーがないため、pypdfではテキストを取得できない。まずpdfplumberを代替として試す。
(venv) $ pip install pdfplumber # pdfplumberでの代替抽出 (venv) $ python3 - << 'EOF' import pdfplumber with pdfplumber.open("scan.pdf") as pdf: for i, page in enumerate(pdf.pages): text = page.extract_text() if text: print(f"ページ{i+1}: {text[:200]}") break else: print(f"ページ{i+1}: テキストなし(スキャン画像の可能性)") EOF
問題2: 日本語テキストが文字化けする
pypdf 4.x はUnicode正規化の問題で特定フォントにおいて文字が崩れるケースがある。pdfplumberに切り替えると解消することが多い。フォント埋め込みが不完全な古いPDFほど発生しやすい。
問題3: requests.exceptions.ReadTimeout が発生する
長いテキストや複雑なプロンプトはモデルの推論に時間がかかる。timeout値を大きくするか、チャンクサイズを小さくする。
# timeout=180を300に変更して再試行 response = requests.post(OLLAMA_URL, json=payload, timeout=300)
`num_predict` パラメータがデフォルトで128トークンに制限されていることが原因のケースがある。optionsに `"num_predict": 512` を追加すると解消することが多い。長文要約では `"num_predict": 1024` まで引き上げてよい。
問題5: 同じPDFを毎回処理して無駄に時間がかかる
処理済みファイルのハッシュ値(md5sum)を記録しておき、次回実行時にスキップする仕組みを加えると効率が上がる。バッチスクリプトのtryブロック冒頭でハッシュ比較を行い、一致すれば `continue` で飛ばす設計が実務では使いやすい。
まとめ
OllamaとpypdfでPDFをローカル処理するための主要な操作を以下に整理する。| やること | コマンド・スクリプト |
|---|---|
| テキスト抽出確認 | python3 extract.py sample.pdf |
| 1PDFを要約 | python3 summarize.py contract.pdf |
| ディレクトリをバッチ処理 | python3 batch_summarize.py ./pdfs/ ./summaries/ |
| 対話Q&A | python3 pdf_qa.py contract.pdf |
| 長いPDFの2段階要約 | python3 chunk_summarize.py large.pdf |
| 空テキスト時の代替抽出 | pip install pdfplumber(pypdfの代替) |
スキャンPDFや複雑なレイアウトのPDFには別途OCR処理が必要になるが、テキストレイヤー付きのPDF(WordからのPDF出力・プリンタドライバ経由のPDF保存など)であれば今回の構成で実用レベルの精度が出る。まずは社内の汎用的なPDFで試し、精度と処理速度を確認してから本番運用に組み込む方法を推奨する。
ローカルLLMによるPDF処理・文書自動化を2日間のハンズオンで体験する
pypdfとOllamaを使ったPDF処理は、社内文書の自動化に直結する実用スキルだ。理論を理解するだけでなく、実際のPDFや業務シナリオで手を動かして習得したい方向けに、「ローカルAIマスターセミナー」を開催しています。
少人数(最大8名)ZOOMハンズオン形式で実施しています。
・Ubuntu ServerでローカルLLMを構築する方法|Ollamaで機密データを外に出さず業務AIを動かす完全ガイド
・社内でChatGPTが使えないときの代替手段|機密データを守るローカルLLMという選択肢
・ローカルLLMのモデルを比較する方法|Llama3.3・Mistral・Gemma・Phi-4をUbuntuで使い分けるポイント
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:OllamaとGitHub Actionsを連携させる方法|セルフホストランナーにローカルLLMを導入してPRレビューと自動要約を実装する手順
- この記事の属するカテゴリ:ローカルLLMへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。
登録無料・いつでも解除できます