ChatGPT時代に必要かも!? Pythonで実行するファイルパース(PDF編)

あなたのその PDF もパースします
2023.05.02

こんちには。

データアナリティクス事業本部 インテグレーション部 機械学習チームの中村です。

今回は話題のChatGPTにコンテキストを与える際に必要となるファイルパース処理について見ていきたいと思います。

本記事ではPDFに焦点を絞ってみていきます。既存のライブラリ内の実装も確認していきます。

先行事例の実装

先行事例の実装として、よく話題となる以下のライブラリを見ていきます。

(LlamaIndexとLlamaHubはほぼ同じですが、parserとしては片方にしかないものもあるため)

LlamaIndex

LlamaIndexの場合、docs_parser.pyにPDFParserというクラスで実装されています。

依存しているライブラリはPyPDF2です。

コードの抜粋は以下となります。

text_list = []
with open(file, "rb") as fp:
    # Create a PDF object
    pdf = PyPDF2.PdfReader(fp)

    # Get the number of pages in the PDF document
    num_pages = len(pdf.pages)

    # Iterate over every page
    for page in range(num_pages):
        # Extract the text from the page
        page_text = pdf.pages[page].extract_text()
        text_list.append(page_text)
text = "\n".join(text_list)

LlamaHub

LlamaHubには、複数のローダーの実装があるようです。

  • PDFReader
    • 基本のPDFパーサー
  • CJKPDFReader
    • 中国語・日本語・韓国語に対応したパーサー
  • FlatPdfReader
    • PDFのそれぞれのページを画像と見なしてパースするパーサー(日本語対応は厳しい)

この中で日本語のことを考えた場合は、CJKPDFReaderが良い選択肢となりそうな印象です。以降、詳細を説明します。

PDFReader

こちらが基本のPDFパーサーになります。コードは以下になります。

確認したところ、LlamaIndexと同じ実装となっているようです。(依存ライブラリも同じくPyPDF2)

CJKPDFReader

こちらは、中国語・日本語・韓国語に対応したパーサーとなっているようです。コードは以下となります。

依存ライブラリも異なっており、こちらはpdfminer.sixが使用されます。

コードの抜粋は以下となります。

def _extract_text_by_page(self, pdf_path: Path) -> List[str]:
    # Import pdfminer
    from io import StringIO

    from pdfminer.converter import TextConverter
    from pdfminer.layout import LAParams
    from pdfminer.pdfinterp import PDFPageInterpreter, PDFResourceManager
    from pdfminer.pdfpage import PDFPage

    # Create a resource manager
    rsrcmgr = PDFResourceManager()
    # Create an object to store the text
    retstr = StringIO()
    # Create a text converter
    codec = "utf-8"
    laparams = LAParams()
    device = TextConverter(rsrcmgr, retstr, codec=codec, laparams=laparams)
    # Create a PDF interpreter
    interpreter = PDFPageInterpreter(rsrcmgr, device)
    # Open the PDF file
    fp = open(pdf_path, "rb")
    # Create a list to store the text of each page
    text_list = []
    # Extract text from each page
    for page in PDFPage.get_pages(fp):
        interpreter.process_page(page)
        # Get the text
        text = retstr.getvalue()
        # Add the text to the list
        text_list.append(text)
        # Clear the text
        retstr.truncate(0)
        retstr.seek(0)
    # Close the file
    fp.close()
    # Close the device
    device.close()
    # Return the text list
    return text_list

PyPDF2と比較すると少し扱いが煩雑そうですが、日本語データに対してはより適切な出力を得られる可能性があります。

FlatPdfReader

こちらは、PDFのそれぞれのページを画像と見なしてパースするパーサーとなっているようです。コードは以下となります。

依存するライブラリはPyMuPDF(import fitz)となっていますが、こちらはPDFを画像として抽出するために使用され、実際のテキスト抽出は、ImageReaderが担っています。

ImageReaderのコードは以下です。

ImageReaderの中身としては、donut-base-finetuned-cord-v2をHugging Faceから取得して使用しています。

こちらは、以前のLlamaIndexの以下の記事のときに少し調べました。

DonutモデルはOCRフリーであることを目指したVDU(Visual Document Understanding)モデルとなっており、その出力をJSONのようなkey-valueの形で得ることができます。

英語のCORDというデータセットでfine-tuningされているため、日本語対応していない点も注意が必要でした。

LangChain

LangChainの場合は以下に実装されています。

BasePDFLoaderのサブクラスとして、以下のように様々な実装が準備されています。

  • OnlinePDFLoader
    • unstructuredというライブラリを使用するパーサー
    • 内部的には3種類の処理があり、unstructuredのAPIで処理とローカルで実行する処理に分かれており、ローカルでの処理は、テキスト抽出が可能な場合はpdfminer.sixで処理され、テキスト抽出が可能でない場合、detectron2で物体検出後、tesseractでOCRによる処理が行われる
  • PyPDFLoader
    • pypdfというライブラリを使用するパーサー
  • PDFMinerLoader
    • pdfminer.sixというライブラリを使用するパーサー
  • PDFMinerPDFasHTMLLoader
    • pdfminer.sixというライブラリを使用するパーサーだが、PDFをHTMLコンテンツとして読み込むような形でパースする
  • PyMuPDFLoader
    • PyMuPDFというライブラリを使用するパーサー
  • MathpixPDFLoader
    • MathpixのAPIを使用するパーサー

OnlinePDFLoader

OnlinePDFLoaderは、unstructuredというライブラリで処理をするパーサーとなっています。

unstructuredでは、まず以下の部分でPDFからテキスト抽出が可能かを確認し、可能な場合はテキストとして処理され、可能でない場合は画像として処理されます。

テキストとして処理する場合

この場合はpdfminer.sixを用いて処理されています。コードは以下が該当します。

コードの抜粋は以下となります。

from pdfminer.converter import TextConverter
from pdfminer.layout import LAParams
from pdfminer.pdfinterp import PDFPageInterpreter, PDFResourceManager

rsrcmgr = PDFResourceManager(caching=False)
laparams = LAParams()

elements: List[Element] = []

for i, page in enumerate(PDFPage.get_pages(fp, check_extractable=True)):
    metadata = ElementMetadata(filename=filename, page_number=i + 1)
    with StringIO() as output_string:
        device = TextConverter(
            rsrcmgr,
            output_string,
            codec=encoding,
            laparams=laparams,
        )
        interpreter = PDFPageInterpreter(rsrcmgr, device)
        interpreter.process_page(page)
        text = output_string.getvalue()
        _elements = partition_text(text=text)
        for element in _elements:
            element.metadata = metadata
            elements.append(element)

    if include_page_breaks:
        elements.append(PageBreak())
画像として処理する場合

画像として処理する場合は依存関係が少し複雑になっていて追うのが大変でした。

  • unstructured
    • unstructured-inference
    • layout-parser
      • detectron2

unstructuredからunstructured-inferenceを呼び出しているのは以下です。

unstructured-inferenceの推論部分は以下です。

もう少し細かく見ると以下にあるように2ステージで処理されることが分かります。

  • レイアウト種類の判別と物体検出
  • そのレイアウト内のテキストをOCRで抽出

レイアウト種類の判別と物体検出は、layout-parser経由でdetectron2を使っています。

以下にdetectron2をロードするコードがあります。

detectron2の詳細は以下に記述されており、このモデルが判別可能なレイアウト種別などが確認できます。

重みファイルなどから、Faster-RCNNが使用されていることが分かります。

また、layoutparserライブラリ側にdetectron2自体の定義はありそうです。

詳細は、以下のlayoutparser側のコードを参照ください。

OCRのコードは以下で、tesseractというライブラリを使っています。

tesseractは日本語に対応しているため、日本語に対する処理も可能と考えられます。

PyPDFLoader

PyPDFLoaderは、pypdfというライブラリを使用するパーサーとなっています。コードは以下となります。

コードの抜粋は以下となります。

def load(self) -> List[Document]:
    """Load given path as pages."""
    import pypdf

    with open(self.file_path, "rb") as pdf_file_obj:
        pdf_reader = pypdf.PdfReader(pdf_file_obj)
        return [
            Document(
                page_content=page.extract_text(),
                metadata={"source": self.file_path, "page": i},
            )
            for i, page in enumerate(pdf_reader.pages)
        ]

PDFMinerLoader

PDFMinerLoaderは、pdfminer.sixというライブラリを使用するパーサーとなっています。コードは以下となります。

コードの抜粋は以下となります。

def load(self) -> List[Document]:
    """Load file."""
    from pdfminer.high_level import extract_text

    text = extract_text(self.file_path)
    metadata = {"source": self.file_path}
    return [Document(page_content=text, metadata=metadata)]

LlamaHubと異なり、high_levelなAPIを使っているようです。

PDFMinerPDFasHTMLLoader

PDFMinerPDFasHTMLLoaderも、pdfminer.sixというライブラリを使用するパーサーとなっています。コードは以下となります。

コードの抜粋は以下となります。

def load(self) -> List[Document]:
    """Load file."""
    from pdfminer.high_level import extract_text_to_fp
    from pdfminer.layout import LAParams
    from pdfminer.utils import open_filename

    output_string = StringIO()
    with open_filename(self.file_path, "rb") as fp:
        extract_text_to_fp(
            fp,  # type: ignore[arg-type]
            output_string,
            codec="",
            laparams=LAParams(),
            output_type="html",
        )
    metadata = {"source": self.file_path}
    return [Document(page_content=output_string.getvalue(), metadata=metadata)]

high_levelなAPIを使う点は同じですが、少し別のAPIを使用しており、PDFファイルをHTMLコンテンツとして読み込むような形となっているようです。

PyMuPDFLoader

PyMuPDFLoaderはPyMuPDFというライブラリを使用するパーサーとなっています。コードは以下となります。

コードの抜粋は以下となります。

def load(self, **kwargs: Optional[Any]) -> List[Document]:
    """Load file."""
    import fitz

    doc = fitz.open(self.file_path)  # open document
    file_path = self.file_path if self.web_path is None else self.web_path

    return [
        Document(
            page_content=page.get_text(**kwargs).encode("utf-8"),
            metadata=dict(
                {
                    "source": file_path,
                    "file_path": file_path,
                    "page_number": page.number + 1,
                    "total_pages": len(doc),
                },
                **{
                    k: doc.metadata[k]
                    for k in doc.metadata
                    if type(doc.metadata[k]) in [str, int]
                },
            ),
        )
        for page in doc
    ]

MathpixPDFLoader

MathpixPDFLoaderは、MathpixのAPIを使用するパーサーとなっています。コードとしては以下となります。

コードの抜粋は以下となります。

def load(self) -> List[Document]:
    pdf_id = self.send_pdf()
    contents = self.get_processed_pdf(pdf_id)
    if self.should_clean_pdf:
        contents = self.clean_pdf(contents)
    metadata = {"source": self.source, "file_path": self.source}
    return [Document(page_content=contents, metadata=metadata)]

APIのエンドポイントにアクセスしており、使用するにはAPI KEYなどが必要となりそうです。

またこのエンドポイントへのアクセスのコードは以下の Daniel Gross のgistを参考にしているようです。

chat-gpt-retrieval-plugin

以下にその実装があります。

依存しているライブラリはPyPDF2です。

コードの抜粋は以下となります。

reader = PdfReader(file)
extracted_text = " ".join([page.extract_text() for page in reader.pages])

既存ライブラリの実装のまとめ

数が多いので以下のように整理しました。

パターン 依存するライブラリ
LlamaIndex PyPDF2
LlamaHub (PDFReader) PyPDF2
LlamaHub (CJKPDFReader) pdfminer.six
LlamaHub (FlatPdfReader) Hugging Face Transformers経由のDonutモデル
LangChain (OnlinePDFLoader) unstructuredのAPI
pdfminer.six
detectron2(Faster-RCNN) + tesseract
LangChain (PyPDFLoader) pypdf
LangChain (PDFMinerLoader) pdfminer.six
LangChain (PDFMinerPDFasHTMLLoader) pdfminer.six
LangChain (PyMuPDFLoader) PyMuPDF
LangChain (MathpixPDFLoader) MathpixのAPI

種別にすると以下のようになります。

  • API経由
    • unstructured API, Mathpix API
  • 画像に対するアプローチ
    • Donutsモデル, detectron2(Faster-RCNN) + tesseract
  • テキスト抽出
    • PyPDF2, pdfminer.six, pypdf, PyMuPDF

どれもページ単位で処理を行うため、ページ番号をmetadataとして保持しておけば、どのページを根拠にChatGPTが回答したのか、などの実装も可能になると思います。

ここからはテキスト抽出に着目して、pypdf, pdfminer.six, PyMuPDFそれぞれを試していきたいと思います。

なお、PyPDF2は3.0.xで開発が止まっており、3.1.0以降がpypdfに引き継がれ、使用方法も変わらないため、今回は省きました。この話についての詳細は、下記も参照ください。

試してみた

サンプルデータの準備

まずサンプルのPDFファイルをWordを使って作成します。以下のような構成のページのものにしました。

均等割り付けや段組構成、表などよく登場しそうな形式を含むようにしています。

データはsample.pdfとしてワークディレクトリ直下に配置しておきます。

実行環境

Google Colaboratoryを使います。特にスペックは求められないので、どのバージョンでも動作すると思います。

Pythonのバージョンは以下でした。

!python --version
Python 3.10.11

以下でライブラリをそれぞれ入れていきます。

!pip install PyPDF2
!pip install pypdf
!pip install PyMuPDF
!pip install pdfminer.six

インストール後のバージョンは以下となりました。

!pip freeze | grep -e "PyPDF2" -e "pypdf" -e "PyMuPDF" -e "pdfminer.six"
pdfminer.six==20221105
PyMuPDF==1.22.2
pypdf==3.8.1
PyPDF2==3.0.1

pypdfの場合

以下がパースするコードになります。

import pypdf

reader = pypdf.PdfReader("sample.pdf")
extracted_text = "\n".join([page.extract_text() for page in reader.pages])

with open("result_pypdf.txt", "wt") as fp:
    fp.writelines(extracted_text)

結果は以下のようになりました。時折空白が入ってきますが、均等割り付けや段組構成、表などなど意図通りに抽出できています。

何 か の 報 告 書  
 
 
はい、みなさんこんばんは。  クラスメソッ
ドがですね、ラスベガスからリインベント
の様子をお届けします。  Developers IEO in 
Las Vegas 2022 ということで始めさせてい
ただきます。  どうぞよろしくお願いします。  
日本は今ですね、 12月1日のお昼 12時の
時間帯だと思いますけれども、  こちらはで
すね、まだ 11月30日の夜 7時ということ
で、 ちょっと夜でですね、お疲れもあって
ちょっと変な感じになるんですけれども、  
暖かく聞いていただければと思います。  は
い、ちなみに今何人くらい入ってらっしゃ
るんですか ?視聴者の方は。  104名です。  
素晴らしいですね。  ありがとうございます。  
この配信ではですね、  AWS Re -Invent 2022
でですね、たくさんのアップデートだった
りとかですね、  セッション、キーノート等
ありましたので、ここまでで出ている情報
をもとにですね、  クラスメソッドのエンジ
ニアが注目した内容とか、  そういったもの
をですね、エンジニアメンバーから聞いて
いきたいと思います。  今回ですね、人数の
都合上ですね、 二部制となっておりまして、  今回今出ているのが前半のメンバーという
ことで、  大体30分くらい目途でですね 、
後半のメンバーにチェンジしてお送りして
いきたいと思います。  じゃあまずですね、
今出ている前半メンバーの自己紹介をした
いと思いますので、  順によろしくお願いし
ます。  はい、 ARXジオン本部のコンサルテ
ィング部で働いてます。  門別と申します。  
WebIOとか Twitterとかは MOKOという
名前でやってます。 よろしくお願いします。  
イェーイ。  ク ラ ス メ ソ ッ ド の
PRISMATICS 事業部というところでエン
ジニアをしています。  エラオーカーと申し
ます。  インターネットではトバシという名
前で存在しています。よろしく お願いしま
す。  おぉー。  クラスメソッドの CXジオ
本部というところにいます。  主にサーバー
サイドエンジニアとかバックエンドのエン
ジニアをやっています。  あと普段の活動と
しては JawsUG のCDK支部みたいなとこ
ろで運営とかをやったりしています。  今回
CDKのアップデートとか気になってきて
いたりします。よろしくお願いします。   
  
 
 カラム1  カラム2  カラム3  
サンプル A 100 90 ○ 
サンプル B 80 30 △ 
サンプル C 20 0 × 
サンプル D 10 10 △

なお、旧版のPyPDF2も同じパース結果となりました。

pdfminer.sixの場合

pdfminer.sixはいくつかの種類のAPIがあるのですが、今回はもっともシンプルそうな高水準API方法を選択して試してみました。以下がコードになります。

from pdfminer.high_level import extract_text

extracted_text = extract_text("sample.pdf")
with open("result_pdfminer.six.txt", "wt") as fp:
    fp.writelines(extracted_text)

結果は以下のようになりました。全体的に少し意図とは異なるパース結果となっています。

何

か

の

報

告

書 

はい、みなさんこんばんは。  クラスメソッ

今回今出ているのが前半のメンバーという

ドがですね、ラスベガスからリインベント

ことで、  大体 30 分くらい目途でですね、

の様子をお届けします。  Developers IEO in 

後半のメンバーにチェンジしてお送りして

Las Vegas 2022 ということで始めさせてい

いきたいと思います。  じゃあまずですね、

ただきます。  どうぞよろしくお願いします。 

今出ている前半メンバーの自己紹介をした

日本は今ですね、12 月 1 日のお昼 12 時の

いと思いますので、  順によろしくお願いし

時間帯だと思いますけれども、  こちらはで

ます。  はい、ARX ジオン本部のコンサルテ

すね、まだ 11 月 30 日の夜 7 時ということ

ィング部で働いてます。  門別と申します。 

で、  ちょっと夜でですね、お疲れもあって

WebIO とか Twitter とかは MOKO という

ちょっと変な感じになるんですけれども、 

名前でやってます。よろしくお願いします。 

暖かく聞いていただければと思います。  は

イ ェ ー イ 。   ク ラ ス メ ソ ッ ド の

い、ちなみに今何人くらい入ってらっしゃ

PRISMATICS 事業部というところでエン

るんですか?視聴者の方は。  104 名です。 

ジニアをしています。  エラオーカーと申し

素晴らしいですね。  ありがとうございます。 

ます。  インターネットではトバシという名

この配信ではですね、  AWS Re-Invent 2022

前で存在しています。よろしくお願いしま

でですね、たくさんのアップデートだった

す。  おぉー。  クラスメソッドの CX ジオ

りとかですね、  セッション、キーノート等

本部というところにいます。  主にサーバー

ありましたので、ここまでで出ている情報

サイドエンジニアとかバックエンドのエン

をもとにですね、  クラスメソッドのエンジ

ジニアをやっています。  あと普段の活動と

ニアが注目した内容とか、  そういったもの

しては JawsUG の CDK 支部みたいなとこ

をですね、エンジニアメンバーから聞いて

ろで運営とかをやったりしています。  今回

いきたいと思います。  今回ですね、人数の

CDK のアップデートとか気になってきて

都合上ですね、二部制となっておりまして、 

いたりします。よろしくお願いします。   

サンプル A 

サンプル B 

サンプル C 

サンプル D 

カラム1 

カラム2 

カラム3 

100 

80 

20 

10 

90 

30 

0 

10 

○ 

△ 

× 

△

改行が増えている点は、後処理でどうにかできそうですが、段組のパース順序が異なったりもするため、ここは注意が必要そうです。

低水準なAPIも試してみましたが、結果はあまり変わりませんでした。

PyMuPDFの場合

以下がパースするコードになります。

import fitz # this is PyMuPDF

extracted_text = "\n".join([page.get_text() for page in fitz.open("sample.pdf")])

with open("result_PyMuPDF.txt", "wt") as fp:
    fp.writelines(extracted_text)

結果は以下のようになりました。段組構成は意図通りにパースされていますが、均等割り付けや表は少し意図とは異なるパース結果となっています。

何
か
の
報
告
書 
 
 
はい、みなさんこんばんは。 クラスメソッ
ドがですね、ラスベガスからリインベント
の様子をお届けします。 Developers IEO in 
Las Vegas 2022 ということで始めさせてい
ただきます。 どうぞよろしくお願いします。 
日本は今ですね、12 月 1 日のお昼 12 時の
時間帯だと思いますけれども、 こちらはで
すね、まだ 11 月 30 日の夜 7 時ということ
で、 ちょっと夜でですね、お疲れもあって
ちょっと変な感じになるんですけれども、 
暖かく聞いていただければと思います。 は
い、ちなみに今何人くらい入ってらっしゃ
るんですか?視聴者の方は。 104 名です。 
素晴らしいですね。 ありがとうございます。 
この配信ではですね、 AWS Re-Invent 2022
でですね、たくさんのアップデートだった
りとかですね、 セッション、キーノート等
ありましたので、ここまでで出ている情報
をもとにですね、 クラスメソッドのエンジ
ニアが注目した内容とか、 そういったもの
をですね、エンジニアメンバーから聞いて
いきたいと思います。 今回ですね、人数の
都合上ですね、二部制となっておりまして、 
今回今出ているのが前半のメンバーという
ことで、 大体 30 分くらい目途でですね、
後半のメンバーにチェンジしてお送りして
いきたいと思います。 じゃあまずですね、
今出ている前半メンバーの自己紹介をした
いと思いますので、 順によろしくお願いし
ます。 はい、ARX ジオン本部のコンサルテ
ィング部で働いてます。 門別と申します。 
WebIO とか Twitter とかは MOKO という
名前でやってます。よろしくお願いします。 
イ ェ ー イ 。  ク ラ ス メ ソ ッ ド の
PRISMATICS 事業部というところでエン
ジニアをしています。 エラオーカーと申し
ます。 インターネットではトバシという名
前で存在しています。よろしくお願いしま
す。 おぉー。 クラスメソッドの CX ジオ
本部というところにいます。 主にサーバー
サイドエンジニアとかバックエンドのエン
ジニアをやっています。 あと普段の活動と
しては JawsUG の CDK 支部みたいなとこ
ろで運営とかをやったりしています。 今回
CDK のアップデートとか気になってきて
いたりします。よろしくお願いします。  
 
 
 
 
カラム1 
カラム2 
カラム3 
サンプル A 
100 
90 
○ 
サンプル B 
80 
30 
△ 
サンプル C 
20 
0 
× 
サンプル D 
10 
10 
△

まとめ

いかがでしたでしょうか。1つのサンプルデータで見る挙動の範囲内では、pypdfがもっとも意図に近いパースを行ってくれそうです。

これ以外の以下については今回は動かしてみることができませんでしたので、今後余力があれば試したいと思います。

  • API経由
    • unstructured API, Mathpix API
  • 画像に対するアプローチ
    • Donutsモデル, detectron2(Faster-RCNN) + tesseract

本記事がPDFをパースしようと苦労されている方の参考になれば幸いです。