税大講本3年分24冊をPDFから全文検索できる形にするまで
税大講本3年分24冊をPDFから全文検索できる形にするまで
ダウンロードフォルダに 税大講本_令和8年度版 が置きっぱなしになっていた。いつもの連番フォルダに中身を出して、リネームして、蔵書DBに取り込むところまでやってほしい。そう頼んだのが7時23分。9分待っても応答が一行も始まらなかった。
「いや、これ何が原因だ?マジで。動かないんですか?」
返ってきたのは API Error: Connection refused だった。セッション側で止まっていただけで、手元の環境は壊れていない。動き出してからは、そのまま24冊ぶんを走り切ることになった。
結論
- 8冊のつもりが、令和6年度版と令和7年度版も足して24冊3,846ページになった。OCRは23冊すべて成功し、失敗もスキップもゼロ
- 元のPDFにテキストレイヤーが入っていることが途中で分かったが、切り替えずにOCRで完走させた。表と図が落ちるため
- ページの柱と「持出可」を消す掃除が、章番号の正規化、編名、中黒の文字種、旧章名と、理由を変えて何度も空振りした
- 年度版の目次とページ番号オフセットは流用できない。同じ書名でもR6/R7/R8で毎回違った
- OCR検算を1冊だけ試した。一致率は63.64%から84%まで上がったが、残差の大半は誤認識ではなく整形の差だった
8冊が24冊になる
最初に数えさせた時点で8冊、合計1,274ページ。連番フォルダの次の番号は No896 からだった。そこに令和7年度版と令和6年度版も足すことにしたので、3年分×8冊で24冊、3,846ページになる。
うち1冊、令和8年度版の税法入門だけは4月に取り込み済みで、ファイルサイズが No863 と完全一致していた。実質の作業対象は23冊、3,739ページ。リネームと移動はすぐ終わった。
OCRはローカルのGPU(RTX 3070 / 8GB)で回す。1冊目を流して速度を測らせたら41ページ/分で、全体で1.5時間ほどの見込みが立った。1冊目は189ページを5.0分、Markdown 189ファイルと図44枚が出てきた。
この見積もりが出た時点で方針を決めた。OCRが回っている間は待たずに、後工程を先に組んでしまう。
1冊でパイプラインを通してから残りを流す
工程は4つある。OCRしてMarkdownを作り、チャンクに割ってDBへ入れ、章と節の構造に組み直し(restructure)、最後にノイズを掃除する(cleanup)。
いきなり23冊に広げず、1冊目でパイプラインを最後まで通した。170チャンクと図44枚がR2に載ったのを確認してから、restructureをサブエージェントに投げて手順を検証させる。この順にしたのは、23冊ぶんの失敗を後から巻き戻す気になれなかったからだ。
判断は正しかったが、それでも取りこぼした。OCRがまだ走っている書籍を投入してしまい、233ページ中204ページ分だけがDBに入った。エージェントが書き込む前に止められたので不完全なチャンクを消して済んだが、同じことを23回繰り返すわけにはいかない。投入スクリプトにOCR完了チェックを足させ、冊ごとに完了を検知する監視も仕掛けた。
以降は「OCR完了の通知が来たら、その科目の担当エージェントに投入からcleanupまで一括で渡す」形に固まった。メイン側の仕事は、監視と割り当てだけになる。
「もともと検索可能なPDFなんじゃないですか」
令和7年度版の最後の1冊がOCR待ちになったころ、ふと気になって聞いた。このPDF、もともとテキストが入っているのでは。だとしたらOCRを回さずに、全文をそのまま変換すれば済むのではないか。
答えは「入っている」だった。しかも最初にPDFを調べた時点で確認していて、報告されていなかった。
間接税法R6の156ページで品質を実測させたところ、テキストレイヤーの精度は高かった。それでも切り替えは勧めない、と返ってきた。理由は、テキスト抽出だと表と図が落ちるからだ。実際、あとでブラウザで見たときに、本文の表はMarkdownのテーブルとして描画されていた。テキスト抽出では、この構造がただの文字列に潰れる。
それで、そのままOCRで完走させることにした。全文を最短で手に入れるだけなら別の道があったが、欲しかったのは検索できる本文と、そこに刷られていた表や図のほうだった。
柱の除去が理由を変えて空振りする
税大講本には各ページの端に柱(章名などの見出し)が刷ってあり、ページ末尾には「持出可」の文字が入る。OCRはこれも律儀に拾うので、チャンクの真ん中に紛れ込む。ここを掃除するスクリプトは、理由を変えて何度も空振りした。
| 空振りの理由 | 気づいた冊 | 対処と結果 |
|---|---|---|
正規化関数が章番号を落とす(第1章 → 第章)ため、組み立てたラベルと一致しない | 国税通則法R8 | 12行を検出できるようになった |
| 柱が「章名」ではなく「編名」で刷られている | 間接税法R8 | 0行から50行に |
OCRが中黒を ·(U+00B7)で出し、DBの ・(U+30FB)と別文字だった | 消費税法R7 | 19行を追加で検出 |
| 柱が旧章名のまま(章タイトルは「純資産の部」、柱は「税法上の資本の部」) | 法人税法R6 | 章タイトル由来のラベルでは7行が素通しだった |
| 「索 引」のような後付セクションは章の列からラベルを作れず、2文字なので最小長フィルタでも落ちる | 相続税法R7 | 全冊のOCRが終わってから一括で対応 |
3つ目の中黒は、後の検算でもう一度出てくる。同じ文字コードの罠を、別の工程で二度踏んだことになる。
逆方向の失敗もあった。柱を消しにいったら、目次チャンクの章名リストごと消えた。目次を除外対象に加え、restructure前の書籍は目次フラグが未設定なので、それを検知するガードも足した。「消す」側のコードは、効きすぎたときのほうが被害が大きい。
もうひとつ、しでかした。cleanupスクリプトを読み込むだけのつもりが、末尾の sys.exit(main()) が走って本実行され、全文検索インデックスの再構築まで通ってしまった。restructure作業中の相続税法R7を巻き込んだ可能性があったので目次を検証したら、13章分が正しく揃っていた。エージェントが整形済みの目次で置き換えた後だったため実害は出ていない。運が良かっただけなので、全スクリプトに __main__ ガードを入れさせた。
年度版は流用できない
3年分をまとめて扱うと、当然「去年の設定を使い回せばいい」と考える。それが危ないことは、担当エージェントが実データで照合して見つけてくれた。
- 国税通則法のページ番号オフセットは、R8が+11、R7が+12
- 税法入門は3年とも別で、R6が+12、R7が+10、R8が+11
- 間接税法R7は全編でR8より+1。巻頭の白紙が1枚多いため
- 相続税法R7のオフセットは、いったん「白紙スキップのため」と報告されたあと、真因は印刷ページ番号を持たない無番ページ(扉裏)だったと自ら訂正が入った
目次の中身も動いていた。国税通則法は、R6とR7で目次が完全に一致していた(節名も項目数もページも差分ゼロ)。構造が動いたのはR8で、第7章第2節が「事前手続」から「事前通知」に改称され、第8章第2節の「処分の理由付記」が消えている。所得税法はR8で新設された「特定親族特別控除」がR7に無く、所得控除の項目番号が1つずつずれていた。
R8の値をそのまま当てていたら、全章が1ページずつずれた状態で組み上がっていた。
年度がそろったことで、比較できる形にもなった。税法入門は3年とも46セクション、国税通則法は3年とも58チャンク、国税徴収法は3年とも91チャンクから32チャンクにまとまった。数を合わせにいったのではなく、各年の実データで独立に判定した結果だという。消費税法では、R6の時点で適格請求書等保存方式が独立項目ですらなく、届出書の項目内に参考として入っているだけだったことが構造に出ていた。
途中でエージェントが作れなくなる
作業の途中から、新規サブエージェントの作成がシステム側で無効化された。解除できない。
ただし、すでに立てた8体は再開して追加の仕事を頼める。そこで、令和8年度版で各科目を担当したエージェントに、そのまま令和7年度版と令和6年度版を引き継がせる形に切り替えた。結果として、同じ科目の3年分を同じエージェントが見ることになり、年度差分の指摘が具体的になった。制約から出た形のほうが、当初の割り当てより良かった。
報告が二重に届く事故も2回あった。消費税法R8で60チャンクと51チャンク、消費税法R7で53チャンクと50チャンク。どちらもDBの実物を見て、2通目が正しいと確認した。エージェントの報告文ではなく、DBの状態を正とする。
バッジが片方しか出ない
全冊が通ったので、ブラウザで見た。左サイドバーに24冊が並び、restructure完了を示す緑の「済」バッジが付いている。ただ、もう一方のバッジが付いていない。そっちはやっていないのか、と聞いた。
こちらは data/cleanup-history/<book_id>.md の有無で判定される仕組みだった。cleanup自体は実行済みで、履歴ファイルを作っていなかっただけ。既存分と同じ形式で24冊分を生成させたら、両方のバッジがそろった。
このとき、OCRの限界も見えた。セル結合のある表は、一部で行が割れている。本文が欠けているのではなく、セルの分割に失敗している。
そして、4月に取り込んだ税法入門R8だけ図が入っていなかった。既存のOCR結果を退避して図付きで再OCRさせたら、107ページから図が14枚出てきて、R7と同数になった。構成は前回と完全に一致した。政府と企業と家計の相互関係図がR2から配信され、本文に埋め込まれて表示されるところまで確認した。あわせて、エージェントの申し送りにあった落とし穴(税法入門R8だけが黙って対象外になる)も直させた。
後片付け
未了がないか確認させたら、作業自体は終わっていて、後始末が4件残っていると出た。全部片付けさせた。削除対象がgit追跡下にあって復元できることを確かめてから消し、devサーバーを止め、コミットは意味のある単位で3つに分けた。
コミット時に学習ゲートに止められた。このリポジトリでは学習ゲートを通さずスキップする方針にして、その旨をプロジェクトのCLAUDE.mdに書かせた。次のセッションが同じところで迷わないように。
OCR検算を1冊だけ試す
最後に、OCRの結果が元のPDFとどれだけ合っているかを測る方法を知りたくなった。全部やる前に、税法入門R8の1冊だけで試してもらう。
最初に素直に突き合わせた結果は、一致率63.64%だった。ところが中身を見ると、大半は意図的な整形だった。最大の欠落区間13,400字は目次まるごとで、DB側は点線リーダーを消して - 第1節 … 1 の形に整えてある。PDFの生テキストと一致するはずがない。
除外処理を入れて測り直すと、文字数はほぼ同じ(92,976字と93,017字)なのに一致率は74%にとどまった。差分を追うと、中黒を含む文だけが「無い」判定になっていた。OCRが ·(U+00B7)で出し、PDFは ・(U+30FB)。cleanupで踏んだのと同じ罠を、検算スクリプトでも踏んでいた。
正規化を入れる過程で、検算側のバグも2つ出た。長音の「ー」を「-」に変換していて、「サービス」が「サ-ビス」に化けていた。ページ番号の全角ハイフンも拾えていなかった。両方を直して84%。
ここから先は伸びない。残る差分の大半は「文末と次の見出しが連結された箇所」で、セクション分割による構造の違いであって誤認識ではないからだ。そこで、誤認識だけを狙い撃ちする比較に切り替えた。数値と条文参照は前後の文脈に依存しないので、集合として突き合わせられる。図タグの width="600px" の600がノイズとして混じったが、2014 がPDFに5回あってDBに無いのは誤認識の候補として浮かんだ。
1冊やって分かったのは、検算そのものは機能するということだった。ただし、そのままでは「意図的な整形」と「本物の誤認識」が同じ差分として並ぶので、除外処理を先に用意しないと、後者が前者に埋もれる。全冊に広げるとしたら、この除外処理をどこまで作り込むかが実質の作業量になる。
今日の学び
- 消す側のコードは、効かないより効きすぎるほうが怖い。柱の除去で目次の章名リストごと消しかけた
- 同じ文字に見えて別のコードポイントという罠は、一度直しても別の工程でまた出る。中黒で二度踏んだ
- 「同じシリーズの去年の版」を根拠に設定を使い回すと、1ページずれた成果物が黙って出来上がる
- スクリプトの
__main__ガードは、事故が起きてから足すものではない