PDF解析・テキスト抽出ライブラリを変えたら処理速度が激速になった件

こんにちは、広野です。

最近、データ分析前の前処理として PDF ファイル内のデータを構造化データとして抽出する機会がちょくちょくあり、AWS Step Functions でオーケストレーションしたり、処理するための AWS Lambda 関数をつくったりしておりました。

やりたいことは AWS Lambda で大量の PDF から構造化データを JSON フォーマットで抽出し Amazon S3 に保存。AWS Glue、Amazon Athena で SQL をかけて任意のデータ検索ができるようにしたという話ですが、それは次の機会に。

当初は小さい PDF ファイルが大量にある状況から始めたのですが、その後比較的大きいファイルを処理させたところ処理時間が肥大化してしまったため、PDF解析・テキスト抽出ライブラリ (以下、省略してライブラリ) を変えたことで改善させた、という話です。

この手の対応をよくされている方にとっては当たり前なのかもしれませんが、ライブラリひとつで全然処理時間が異なることに衝撃を受けたため、ブログに書き残しておきます。

要件

以下の要件でいくつかのライブラリを調査、検証し、Python で動く pdfplumber というライブラリで処理を実装していました。

  • PDF ファイル内にあるテーブルデータをテキストとして抽出し、その後 JSON データに加工したい。(OCR は不要)
  • PDF ファイル内にはヘッダー、フッターがあり、データ抽出前にそれらを座標ベースでトリミング (削除) したい。
  • なるべくサーバーレスで実装したい。(AWS Lambda, Amazon S3, AWS Step Functions, Amazon Athena 等を使用)
  • ライブラリは商用利用可能。

発生した課題

当初は小さい PDF ファイルをターゲットに検証を進めていましたが、PDF のページ数が増加するにつれ、処理時間が爆発的に増加することに気が付きました。

PDF ページ数 サイズ 処理時間 メモリ使用量 実行環境 
小規模 (当初想定) 4 50 KB 780 ミリ秒 130 MB Lambda 
中規模 150 500 KB 43 秒 600 MB Lambda
大規模 1000 3 MB 30 分 2.4 GB EC2 (t3a.large)
  • 数値は若干丸めています。
  • 計測には、他の短い処理も少しオーバーヘッドとして加算されていますが誤差として扱いました。
  • 大規模 PDF は処理時間の関係で Lambda では計測できず、Amazon EC2 インスタンスで計測しました。

pdfplumber だと、Lambda 関数の限界までメモリ量は増やしたとしてもタイムアウトの問題が発生し、大きな PDF は処理できなくなることがわかりました。そもそも 500 KB 程度の PDF 処理にも 43 秒かかっていたこと自体、時間かかりすぎな印象を受けたので、将来スケーリング可能な環境を求めてライブラリの変更を考え始めました。

ライブラリを変えてみた

結論から言いますと、pdf_oxide というライブラリに入れ替えたところ、劇的に処理速度が改善しました。pdfplumber で検証したときと同じ PDF ファイルで同じ出力結果が得られるコードで処理を実施しました。

PDF ページ数 サイズ 処理時間 メモリ使用量 実行環境 
大規模 1000 3 MB 19秒 1.5 MB EC2 (t3a.large)
  pdfplumber pdf_oxide 削減効果
処理時間 30分 19秒 94 倍高速化
メモリ使用量 2.4 GB 1.5 MB 1,600分の1 に削減

なお、小規模、中規模ではだいたい 1 – 3 秒で処理が終了しました。比較しづらかったので掲載しませんでしたが、PDF のサイズが大きくなるほど pdf_oxide の方が効率が良くなると考えられます。

移行コード例

pdfplumber から pdf_oxide にコード移行したときの例を紹介します。

PDF の 2 ページ目以降からテキストを抽出する関数です。各ページのヘッダー (上部 10%) とフッター (下部 13%) を座標ベースでトリミングしてから、テキストを取得しています。このトリミングにより、ページ番号や定型の注釈を除外し、取得したいコンテンツデータだけを抽出します。

移行前: pdfplumber

import pdfplumber
import io

HEADER_RATIO = 0.1
FOOTER_RATIO = 0.13

def extract_text(pdf_bytes):
    pdf = pdfplumber.open(io.BytesIO(pdf_bytes))
    texts = []
    for page in pdf.pages[1:]:
        h = page.height
        cropped = page.crop((0, h * HEADER_RATIO, page.width, h * (1 - FOOTER_RATIO)))
        texts.append(cropped.extract_text() or '')
    return '\n'.join(texts)

移行後: pdf_oxide

from pdf_oxide import PdfDocument

HEADER_RATIO = 0.1
FOOTER_RATIO = 0.13

def extract_text(pdf_bytes):
    doc = PdfDocument.from_bytes(pdf_bytes)
    h = doc[0].height
    w = doc[0].width
    lly = h * FOOTER_RATIO
    ury = h * (1 - HEADER_RATIO)
    texts = []
    for i in range(1, len(doc)):
        doc.set_page_crop_box(i, 0, lly, w, ury)
        page_text = doc.to_plain_text(i)
        lines = [l for l in page_text.split('\n') if l.strip()]
        texts.append('\n'.join(lines))
    return '\n'.join(texts)

変更点

  • pdfplumber.open() → PdfDocument.from_bytes()(io.BytesIO は非対応、bytes を直接渡す)
  • page.crop() → doc.set_page_crop_box() + doc.to_plain_text()
  • 座標系が異なる: pdfplumber は Y 軸の原点がページ上部、pdf_oxide は PDF 標準の左下原点。ヘッダー除去とフッター除去の座標計算が逆になる点に注意
  • pdf_oxide にはテキスト取得 API が複数あり、出力が大きく異なるため注意

まとめ

いかがでしたでしょうか。

サーバーレスは従量課金なので、なるべく処理時間を短くしたいですよね。選択したライブラリひとつでここまで変わるとは思っておらず、良い教訓になりました。

pdf_oxide は比較的新しいライブラリのため、まだ情報が少ないと感じています。AI を利用した開発だとまだ AI が pdf_oxide のリファレンスを十分学習済みでないことがあるので、公式リファレンスを参照させながら開発するのが安全です。検証して用途が合えば非常に高速という恩恵が受けられるので今後も活用していきたいと思います。

本記事が皆様のお役に立てれば幸いです。

著者について
広野 祐司

AWS サーバーレスアーキテクチャと React を使用して社内向け e-Learning アプリ開発とコンテンツ作成に勤しんでいます。React でアプリを書き始めたら、快適すぎて他の言語には戻れなくなりました。近年は社内外への AWS 技術支援にも従事しています。AWS サービスには AWS が考える IT 設計思想が詰め込まれているので、いつも AWS を通して勉強させて頂いてまます。
取得資格:AWS 認定は13資格、IT サービスマネージャ、ITIL v3 Expert 等
2020 - 2026 Japan AWS Top Engineer 受賞
2022 - 2026 AWS Ambassador 受賞
2023 当社初代フルスタックエンジニア認定
好きなAWSサービス:AWS AppSync Events / AWS Step Functions / AWS CloudFormation

広野 祐司をフォローする

クラウドに強いによるエンジニアブログです。

SCSKクラウドサービス(AWS)は、企業価値の向上につながるAWS 導入を全面支援するオールインワンサービスです。AWS最上位パートナーとして、多種多様な業界のシステム構築実績を持つSCSKが、お客様のDX推進を強力にサポートします。

AWSアプリケーション開発クラウドソリューションデータ分析・活用基盤
シェアする
×
タイトルとURLをコピーしました