🌟

【自然言語】近傍検索を使って、PDFの関連部分にマークをつける

2024/10/12に公開

やること

chatGPTにしろperplexityにしろ、ハルシネーションのリスクは常に付きまとっており、間違った答えを返されるくらいなら、答えの生成まで不要というシーンも多々あります。

そこで、PDFの文書から探したい場所を見つけ出して、その部分にマーカーを引く機能を実装します。

今回はキリンのサスティナビリティレポートを題材にしてみます。
https://www.kirinholdings.com/jp/investors/files/pdf/kirinreport2024_10.pdf

(なぜサスティナビリティレポートかというと、私の本職が某製造業のサスティナビリティ部門だからです。ちなみにキリンの社員ではありません)

※ 以前PDFの読み込みやFAISSを用いた近傍検索の記事を書きました。これらの更新版です。
https://zenn.dev/yuta_enginner/articles/53a32cacef0e91

環境設定

2024年10月7日にpython3.13.0が正式にリリースされました。少し遅れて本日、pyenvもpython3.13.0をインストールできるようになり、早速私も試してみました。

ですが、現時点ではFAISS、chromaDBともにpython3.13環境でインストールしようとするとライブラリの依存関係でエラーが出ます。JITはもう少しお預けですね。

langchainについては2024年9月20日にv0.3がリリースされています。

このようにAI業界は更新が頻繁なので、バージョン管理が大切です。

アプリケーションを作成するときはpoetryが必須と言えるでしょう。

[tool.poetry.dependencies]
python = "^3.12"
langchain-unstructured = "^0.1.5"
faiss-cpu = "^1.9.0"
langchain-community = "^0.3.2"
langchain-openai = "^0.2.2"

PDF読み込みツール Unstructured

以前こちらの記事でPDFの読み込みツールをいくつか試しました。
https://zenn.dev/yuta_enginner/articles/9a87c53007ed81

その中でUnstructuredが最も精度が高かったので、今回もUnstructuredを使おうと思いましたが、Unstructuredは有料になったのですね
(APIキーを発行する必要があります)
https://unstructured.io

まあ、それでも月1000ページまでは無料ですし、何より精度が良くてメタデータにページや構成情報が入ってくるのが非常に嬉しい。

個人で使うにはちょっと費用は心配ですが、会社が金払ってくれるんならこれ使いましょう

実装

PDFファイルの読み込み

from typing import List

import os
os.environ["UNSTRUCTURED_API_KEY"] = "**************"

from langchain_unstructured import UnstructuredLoader
from langchain.schema import Document

file_path = "files/kirinreport2024_10.pdf"

loader = UnstructuredLoader(
    file_path=file_path,
    strategy="hi_res",
    partition_via_api=True,
    coordinates=True,
)

docs:List[Document] = []
for doc in loader.lazy_load():
    docs.append(doc)

3ページなのに、ちょっと時間はかかります。
統合報告書とか100ページ越えることも珍しくないので、そうなると時間が心配

得られたDocumentには、metadataとして「page_content, coordinates, page_number, parent_id, category, element_id」が入っています。

VectorStoreにデータをセットして保存

おそらくここの書き方はlangchain v0.2とv0.3で変わりません
https://python.langchain.com/docs/integrations/vectorstores/faiss/#saving-and-loading

from langchain_community.docstore.in_memory import InMemoryDocstore
from langchain_community.vectorstores import FAISS

import faiss

from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

index = faiss.IndexFlatL2(len(embeddings.embed_query("hello world")))

vector_store = FAISS(
    embedding_function=embeddings,
    index=index,
    docstore=InMemoryDocstore(),
    index_to_docstore_id={},
)

ids = [ d.metadata['element_id'] for d in docs]

vector_store.add_documents(documents=docs, ids=ids)

vector_store.save_local("faiss_index")

index = faiss.IndexFlatL2(len(embeddings.embed_query("hello world")))の部分、これはベクトルストアに保存する時の次元数を指定しています。別に"hello world"でなくても、どんな短文でもいいです。

VectorStoreから近傍検索

先ほど保存したベクトルストア(FAISS)から近傍検索しましょう。

データベースのロードおよび検索は、特に説明しなくてもコードを読めばわかると思います。

まずはスコープ1,2の削減目標について、どこに載っているか探してみましょう。

import os
os.environ["OPENAI_API_KEY"] = "*****************"

from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

new_vector_store = FAISS.load_local(
    "faiss_index", embeddings, allow_dangerous_deserialization=True
)

search_results = new_vector_store.similarity_search_with_score("スコープ1,2の削減目標")

for doc, score in search_results:
    print(doc)
    print(score)

この検索結果では、一番近かったのは3ページ目の右上の図とでました。

Document(
    metadata={
        'coordinates': {
            'points': [[1770.03466796875, 329.1324157714844], [1770.03466796875, 359.9294128417969], [2075.5751953125, 359.9294128417969], [2075.5751953125, 329.1324157714844]], 
            'system': 'PixelSpace', 
            'layout_width': 3308, 
            'layout_height': 2339
        }, 
        'page_number': 3, 
        'parent_id': '', 
        'category': 'Title', 
        'element_id': 'e9beac36f726e9b9f3ceb9b137700962'
    }, 
    page_content='Scope1 Scope2'
), 
0.87465835

PDFにマークを引く

先ほどの結果では座標が出てきましたが、このままでは該当箇所がどこかわかりにくいので、PDFにマーカーを引いて視覚的に見やすくしましょう。

PDFにマークを引く方法はいくつかありますが、PyMuPDFを使う方法が最もシンプルでした。

[tool.poetry.dependencies]
・・・
pymupdf = "^1.24.11" #追加

PyMuPDFはfitzでインポートできます。

import fitz  # PyMuPDFをインポート

# PDFファイルを開く
pdf_document = fitz.open('files/kirinreport2024_10.pdf')

・・・

for doc, score in search_results:
    print( doc.page_content, doc.metadata['page_number'],score)

    # pdfのハイライトを追加する領域を設定
    target_page_index = int(doc.metadata['page_number'] - 1)
    target_page = pdf_document[target_page_index] # 0始まりなので、ページ番号から1引く
    左上,右上,右下,左下 = doc.metadata['coordinates']['points'] # これらはpt
    c = 25.4 / 72 # ptをmmに換算する(cはcoefficientの略)
    target_area = fitz.Rect(左上[0]*c,左上[1]*c,右下[0]*c,右下[1]*c)

    # ハイライトを追加
    highlight = target_page.add_highlight_annot(target_area)

# ドキュメントを保存
pdf_document.save('highlighted_example.pdf')

一点注意として、Unstructuredではcoordinatesは全てポイントで返されます。
https://docs.unstructured.io/platform/document-elements#element-coordinates

一方でPyMuPDFで範囲を選択する時はミリメートルですので、換算する必要があります。

コードに書いてあるとおり、ptとmmは25.4/72で換算できます。

以下のようにマークをつけた状態でファイルを保存できました。

簡単な検証

サンプルコードに挙げたように、スコープ1,2の質問については妥当な場所が抽出されました。
(補足すると、スコープ1は事業者が使用したガソリンやガスなど直接的なGHG排出、スコープ2は電気で使用時にGHGは排出されませんが発電時にGHGが排出されますので間接的なGHG排出量ということになります)

一方、「RSPO」という単語で調べると、さすがに探し当てるのは難しかったようです。
※ RSPOは持続可能なパーム油に関する認証制度のことです。
このレポートには「パーム油」は3ページ目に載っていますが、FAISSはRSPOとパーム油を結びつけることはできなかったようです。

docs = new_vector_store.similarity_search_with_score("RSPO")

for doc, score in docs:
    print( doc.page_content, doc.metadata['page_number'],score)
prrse 2 1.1442897
ESG 1 1.1695752
Scope3 3 1.2439206
Scope2 3 1.2796075

単語検索ではなく、文で検索することで近傍検索に引っ掛かるようにする必要がありそうです。

Discussion