はじめに
LegalOn Technologiesで2025年12月から2026年6月までの約7ヶ月間、SWE(Software Engineer)としてインターンをしていた tsukune です。 本記事では、インターンで取り組んだ技術課題とその体験から得ることができた学びについてまとめます。
インターン開始時の自分のスペック
- 情報専攻のB2(今はB3です)
- インターン経験はあったが簡単なAPI・DB設計ができる程度
- Pythonの業務経験やインフラ知識は皆無
与えられた課題
今回はメインのプロダクトである「LegalOn」ではなく、新規プロダクト「TomoniAI(トモニアイ)」の前身となるアプリの開発に配属されました。
「TomoniAI」は全てのビジネスパーソン向けアプリとして6月に発表しましたが、配属当時はアーリーフェーズのCEO・CXOをサポートするAIアシスタントというコンセプトで進んでいました。その機能の一つに「官報の配信」がありました。 官報とは、日本政府が平日に定期発行している機関紙で、国の動向を素早くキャッチアップできる重要な情報源です。しかし、配信形態はPDFのみで、データソースが完全一致するAPIも存在しません。情報量も多く、求める情報を見つけるには手間が大きすぎるという課題がありました。そこでこれを解析し、ユーザーにキュレーションするという機能の開発をインターンで担当することになりました。 当初課題を渡された時は、以下のような疑問があり、全くイメージが湧きませんでした。
- PDFをどういった手法で解析するのが良いのか
- 精度測定の際、どういった基準を使えば良いか
- どうやってデプロイするのか
課題に対するアプローチ
与えられた課題に対してどうアプローチすれば良いか全くイメージが湧かなかったため、課題を調査、PoC、実装の段階に分けて不確実性の高い部分から潰していくことにしました。今回はPDFをどう解析するかが最大の不確実性であり、それによってインフラ構成やDBモデリングも変わると考えました。そのため、不確実性の高い部分から、以下の順序で進めることにしました。
- 官報PDFの性質の調査
- PDF解析の技術検証
- デプロイ方法の検討
- 全体設計
- 実装
この順序とすることで、最大の不確実性であるPDF解析の検証から着手できました。
PDFの性質調査
まず、官報PDFをプログラムからどのように読み取れるのかを調査しました。PDF解析ライブラリを使うと、ページ内の文字列や画像などを、bbox(bounding box)と呼ばれる矩形領域として取得できます。bboxは日本語では「境界ボックス」と呼ばれることが多く、簡単に言うと「ページ上のどの範囲に文字やブロックが配置されているか」を表す座標情報です。
調査を進めていくと、官報のPDFレイアウトには以下の特徴があるとわかりました。
- bboxが読み順でソートされていない
- ページ内で複数レイアウトが共存
- 列数・行数がページで不規則に変化する
実際にbboxを重ね合わせ(赤枠・緑枠・青枠)、ライブラリが返すbboxの順番(コンテンツストリーム順)にオレンジ色の番号を振ったものが以下の画像です。両方とも2026年6月4日に公開された官報の1つのPDFから2ページ分を切り出したものです。

出典:令和8年6月4日官報 1ページ目 / 3ページ目
一見するとbboxをそのまま抽出すれば良いように見えますが、よく見ると番号の順番が人間の読み順とは異なっていることがわかります。PDF内部では、文字や図形が人間の読む順番ではなく、描画命令の順番で保持されていることがあります。そのため、ライブラリで取得した順番をそのまま使っても、本文を正しい順序で復元できるとは限りません。
1つのPDFでページごとのレイアウトが揃っていれば、bboxの座標を見てルールベースで並べ替えることもできます。しかし、今回は1つのPDF内に縦書き・横書き・異なる列数・行数が不規則に混在していたため、ルールベースで読み順を復元するのは難しいと判断しました。
そこで次に、このようなPDFに対してどのような解析手法が有効なのかを技術検証することにしました。
PDF解析手法の技術検証
上記の問題を持つPDFをまず解析できるのか、できるとしてどのような方法があるのかを調べるために技術検証を実施しました。
PDFを解析する方法はいくつか考えられます。PDF解析ライブラリでテキストや座標情報を取り出して処理する方法、LLMにPDFを入力して読み順を復元させる方法、Vision API (画像認識系のAPI) を使ってOCR(Optical Character Recognition)する方法などです。Vision APIとは、PDFや画像を入力として渡し、画像内の文字やレイアウト情報を抽出するためのAPIです。PDFを画像として扱えるため複雑なレイアウトに強い可能性がありますが、今回必要だった「人間が読む順番の復元」まで安定して実現できるかは未知数でした。
具体的には以下の方法を試しました。
- LLMにPDFバイナリを入力し、読み順を復元させる
- ライブラリでbboxをルールベースで並べ替える
- LLMにbboxを投げる
- Google Cloud Vision API
1と3はプロバイダー・モデルごとの組み合わせも多く、検証には時間を要しました。結果として、最新のマルチモーダルモデルに適切なプロンプトを与えれば、1つ目が最も有用そうだという感触を得られました。また2つ目の案については、一部の号種ではルールベースで読み取れる箇所があると判明しました。 3つ目は試した結果1つ目と比べてコストが増える割に精度が大きく向上せず却下、4つ目もVision APIでは読み順がほぼ再現できなかったことから却下しました。
この時点で1つ目の案と2つ目の案のハイブリッドで進める方針を立てました。1つ目の案については、LLMの不確定な出力がどの程度信頼できるか、どのモデルがコストパフォーマンス的に許容できるかの判断理由を作る必要がありました。そこで、即席で信頼性は限定的ながらground truthを作成し、複数の指標で各モデルの出力を比較することにしました。 指標の選択として、まず単純な文字一致率を測定するためにCER(Character Error Rate)を測定しました。次に、官報は法人名や金額が重要であり単純な文字一致率だけでは品質を評価できないと考えたため、数値の網羅性を測るRecall(再現率)と正確性を測るPrecision(適合率)、固有名詞のRecallなどの指標でも測定しました。
時間が限られていたこともあり、当時利用可能だったモデルの中ではGemini系列が最も有望でした。そのため、Gemini系列のみを評価対象として測定することにしました。
評価設計は実装を並行して進めながら1日程度で組み上げたものです。ground truthはまずgemini-3-flash-previewの出力を土台に人手で誤りを修正して作成したため、ground truth自体にgemini-3-flash-previewへの有利バイアスが乗ってしまっています。一方、数値のPrecisionはground truthの作り方に依存しない独立した指標として扱えます。完全に公平な評価設計とは言えませんが、技術選定の意思決定材料としては十分と判断しました。
以上の前提を踏まえた測定結果が以下です。
| モデル | CER | 数値のRecall | 数値のPrecision | 固有名詞のRecall |
|---|---|---|---|---|
| gemini-3-flash-preview | 0.036 | 0.934 | 0.934 | 0.927 |
| gemini-3.1-flash-lite | 0.171 | 0.798 | 0.927 | 0.865 |
| gemini-2.5-pro | 0.265 | 0.766 | 0.779 | 0.822 |
| gemini-2.5-flash | 0.406 | 0.684 | 0.763 | 0.690 |
モデルとしてはgemini-2.5-proが最も高価だったため、この結果は少し意外でした。要因としては2点考えられます。 1点目は、サードパーティーのベンチマークでもOCR用途でgemini-3-flash-previewの方が若干優位な傾向にあることです。
2点目は、proモデルで過剰な推論が走ってしまい、原文を読み順通りに復元するというタスクに対して余計な付け足しを生んでしまうことです。 実際のデータを見ても、proはプロンプトに反して冗長と判断した表データを省略したり、余計な付け足しを入れたりする挙動で評価を落とす傾向にありました。また、ground truthへの有利バイアスとは無関係に、gemini-2.5系では数値のPrecisionで2割前後の捏造が観測された点も問題として捉えられます。文脈を解釈してmarkdownで出力するといったユースケースであればproが優位になる場合もあります。一方、今回はそういった最適化を行わず原文をそのまま読み取りたかったため、費用対効果が最も良かったgemini-3-flash-previewを採用しました。 コストも1日1回のバッチ実行なので月に数千円程度で収まりました。
実行基盤の設計
官報の発行スケジュールは以下のとおりです。
官報は、行政機関の休日を除き毎日発行しています。 本紙、号外、政府調達 8:30 発行
この性質から、上記のバッチ処理は平日にコンテナとして定期実行します。
当時はインフラ知識や社内基盤の知識がなかったため、チーム内のSRE・バックエンドエンジニアの方たちと相談しました。その結果、Vertex AI(2026年4月にGemini Enterprise Agent Platformへ改称)とAlloyDBへの権限を付与したKubernetes CronJob workloadとしてデプロイする方針を教わりました。周辺知識を調べて、実装は自分で進めました。
GKE (Google Kubernetes Engine) CronJobを選んだ理由
当時迷っていたのは以下の2案でした。
- 既存のPub/Subイベント駆動の実行基盤を使う
- GKE CronJob を新規に立てる
このプロダクトではPub/Subのイベント駆動で処理を行う実行の基盤がすでに実装されていました。定期バッチそのものを実現するにはCronJobの方が素直ですが、既存基盤には認証・リトライ・監視などが揃っていたため、そこに載せる案も検討しました。
一方で、今回の処理は官報PDFの取得、OCR、構造化抽出、DB保存までを行う比較的重い日次バッチです。既存基盤はイベント駆動で処理を行うことに最適化されており、タイムアウトなどもその用途に合わせて設定されていました。そのため、初期実装は簡単でも、処理時間や失敗時の扱い、責務分離の面で後から運用しづらくなる可能性があると判断し、最終的にはチームのバックエンドエンジニアの方たちと相談し、GKE CronJobを採用しました。
Workload IdentityとCloud IAMの責務分離
今回の構成ではGKE CronJobにはAlloyDBへのread/writeとVertex AIのAPI権限が必要でした。これらはGoogle Cloudのリソースで、Cloud IAMの管理対象となるのでKubernetes manifestだけではなくGoogle Cloud側のService Accountと協調させる必要がありました。 そのため、GKE側のServiceAccountとGoogle Cloud側のServiceAccountをWorkload Identityで結びつけ、Cloud IAMの権限付与はTerraformで管理するという構成を取りました。
AlloyDB Auth Proxyをnative sidecarで同居させる
GKE CronJobからAlloyDBに接続するにはAlloyDB Auth Proxyを経由する必要があり、これをsidecarとして同居させる構成を取りました。
最初は通常のsidecarとしてAuth Proxyを立てる構成で進めていました。しかしAuth Proxyはメインコンテナと独立に動くため、以下の対応が必要でした。
- 起動順序の制御: メインコンテナがAuth Proxyより先に走り出すとDB接続に失敗するため、メイン側でAuth Proxyのreadinessをpollingして待つ必要がある
- 終了の制御: メインコンテナの処理が終わってもAuth Proxyは走り続けるので、明示的に停止させないとPodが終わらない
メインコンテナ側でcurlでAuth Proxyのreadinessをpollingし、処理終了後にAuth Proxyを止めるスクリプトを書き込んでいたのですが、チームのバックエンドエンジニアの方にnative sidecar(restartPolicy: Always付きのinitContainers)についてレビューで教えてもらいました。
これを使うことで起動順序と終了の両方をKubernetesに委譲する形に整理できました。
採用アーキテクチャ
最終的には以下のようなシステム設計になりました。ジョブやDBモデリングの設計についても実施しましたが、長くなるので割愛します。
flowchart TD
subgraph TF["Terraform (IaC)"]
SA["ServiceAccount<br/>Workload Identity"]
P_ADB["AlloyDB"]
P_VAI["Vertex AI"]
SA -->|IAM 権限付与| P_ADB
SA -->|IAM 権限付与| P_VAI
end
KANPO["kanpo.go.jp"]
subgraph GKE["GKE CronJob"]
SCRAPE["PDF取得<br/>httpx + lxml"]
DL["PDF ダウンロード<br/>httpx"]
subgraph PIPELINE["処理パイプライン"]
direction LR
subgraph ARTICLE["本紙・号外・特別号外"]
LIB_A["PyMuPDF<br/>画像フィルタ"]
LLM_A["Vertex AI Gemini<br/>OCR・構造化抽出"]
LIB_A --> LLM_A
end
subgraph PROCURE["政府調達"]
LIB_P["PyMuPDF<br/>テキスト抽出・チャンキング"]
LLM_P["Vertex AI Gemini<br/>メタデータ抽出"]
LIB_P --> LLM_P
end
end
DL --> LIB_A
DL --> LIB_P
end
subgraph ALLOYDB["AlloyDB (PostgreSQL)"]
PROXY(["AlloyDB Auth Proxy"])
DB[("官報データ")]
PROXY --> DB
end
SCRAPE -->|PDF取得リクエスト| KANPO
KANPO -->|PDF| DL
LLM_A -->|INSERT| PROXY
LLM_P -->|INSERT| PROXY
P_ADB -. Workload Identity 認証 .-> PROXY
P_VAI -. API 認証 .-> LLM_A
P_VAI -. API 認証 .-> LLM_P

参考リンク
- 官報(インターネット版官報)
- PyMuPDF 公式ドキュメント
- Vertex AI(Gemini Enterprise / Agent Platform)
- AlloyDB for PostgreSQL
- Workload Identity 連携
まとめ
今回のインターンでは、官報PDFの解析という課題を通して、LLMを使った情報抽出だけでなく、その処理を支えるバックエンドやインフラの仕組みにも触れることができました。
それまではアプリケーションコードの単一の部分を実装することが中心でしたが、Workload IdentityやAlloyDB、Kubernetesといった要素がどのように組み合わさって1つのサービスが動いているのか、その全体像を初めて実感できた気がします。
一方で、十分に理解できていない領域も広いです。今回触れた技術も社員のエンジニアの方々の助けや社内のドキュメント・ガードレールがあって扱えたものばかりです。
なので、今回見えた全体像を出発点にして、今後はより深く理解し、自信を持って設計や実装の判断ができるエンジニアになりたいと思います。
謝辞
インターン期間中、開発に限らず多くの場面で支えてくださったチームの皆さまに深く感謝します。本当にありがとうございました!