😸

【Langchain】PDF形式ドキュメントの近傍検索検証

2024/05/26に公開

前回の記事で、環境省リリースのpdf記事を読み取って、Document化するところまではできました。

ただし、人間が何か質問した時の回答がずれていたので、調整する課題がありました。

昨日のosakaGPTでもちかけたところ、近傍検索で検証してみては?という意見をいただいたので、今回は文書から近傍検索をしてみることにしました。

ちなみに近傍検索については、過去にadoさんのwiki記事で検証しています。

検証手順

ライブラリのインストール

!pip install -U -q -q  langchain langchain-openai langchain_community langchain_core

!pip install -U -q -q unstructured[pdf]

!pip install -U -q -q faiss-cpu

!pip install -U -q -q matplotlib
import os
os.environ["OPENAI_API_KEY"] = "sk-***"

from openai import OpenAI
client = OpenAI()

検証1. PDFをページ分割でロード

前回と同じく、UnstructuredPDFLoaderを使い、pdfをページで区切ってロードします

from langchain_community.document_loaders import UnstructuredPDFLoader

loader = UnstructuredPDFLoader("000213033.pdf", mode="paged", languages=['ja'])

pages = loader.load()

これをFAISSを用いてembeddingします

from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

faiss_index = FAISS.from_documents(pages, OpenAIEmbeddings(model="text-embedding-3-small"))

faiss_indexを用いて近傍検索します。
全pageの近傍検索結果(コサイン類似度)を得たいので、kはpagesの長さを指定しました。

results = faiss_index.similarity_search_with_score("研究開発・技術実証支援として、どういった施策がありますか",k=len(pages))

結果はDocumentとコサイン類似度がタプルになった配列で返されます。
なお配列はコサイン類似度が小さい順になっています。

print(len(results))
#-> 27

for res in results:
  (doc, similarity) = res
  print('simirality:', similarity, 'page_number:',  doc.metadata['page_number'])

なので、ページ順に並び替えてやりましょう

results.sort(key=lambda res:res[0].metadata['page_number'])

ここまでできたら、pltを用いて図示します

import matplotlib.pyplot as plt

similarities =[ res[1] for res in results ]

# グラフの作成
plt.figure(figsize=(10,6))
plt.plot(range(1, len(similarities)+1), similarities, marker='o')

# グラフの設定
plt.title('splitted with page')
plt.xlabel('page')
plt.ylabel('similarity')
plt.xticks(range(1, len(similarities) + 1))
plt.grid(True)

# グラフ描画
plt.show()

結果は図の通り。

19ページと20ページが最も近いと出ました。

ちなみに今回読み込ませた文書では、19ページにヒントとなる文言("研究開発・技術実証支援")が載っており、20ページに答えとなる内容が載っています。

ですので、この近傍検索の結果は、近傍の順位だけ見るとほぼ正解に近いということになります。

ただ、類似度が1.2以下のページが10ページもあり、スコアにそれほど差がないので、今回たまたま正解に近い結果を返しましたが不安は残ります

検証2. 文字数でチャンクを作成して近傍検索

次はテキストの文字数でチャンクを作成します。

UnstructuredPDFLoaderのmodeを"single"とすることで、ページ関係なく1つのDocumentとして取得します

loader_single = UnstructuredPDFLoader("000213033.pdf", mode="single", languages=['ja'])

single_doc = loader_single.load()
print(len(single_doc))
#-> 1
print(type(single_doc[0]))
#-> <class 'langchain_core.documents.base.Document'>

Documentからプレーンなテキストを抽出し、これを1000文字ごとのチャンクに分割しました。
ちなみに39つのチャンクに分割できました

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000, chunk_overlap=200
)

all_splits = text_splitter.create_documents([single_doc[0].page_content])

print(all_splits)
#-> 39

このまま近傍検索してしまうと順番の情報がなくなってしまうので、チャンクの前のほうから番号をつけます

for i,s in enumerate(all_splits):
  s.metadata['index'] = i

print(all_splits[5].metadata)
#-> {'index': 6}  メタデータとしてindexが付与された

あとは先ほどと同じように近傍検索しましょう

# embeddingを作成
faiss_index2 = FAISS.from_documents(all_splits, OpenAIEmbeddings(model="text-embedding-3-small"))

# 近傍検索
results2 = faiss_index2.similarity_search_with_score("研究開発・技術実証支援として、どういった施策がありますか",k=len(all_splits))

# 前から順にソート
results2.sort(key = lambda x: x[0].metadata['index'])

# ソートされたデータから類似度のリストを作成
similarities2 = [ res[1] for res in results2]
import matplotlib.pyplot as plt

# グラフの作成
plt.figure(figsize=(10,6))
plt.plot(range(1, len(similarities2)+1), similarities2, marker='o')

# グラフの装飾
plt.title('splitted with text')
plt.xlabel('index')
plt.ylabel('similarity')
plt.xticks(range(1, len(similarities2) + 1))
plt.grid(True)

plt.show()

結果はこちら。
先ほどと極端に違いがでたわけではありませんが、気持ち近傍ベスト5が明確に出た感じがします

この5つについて内容を読んでみると、正解の箇所を取り出していることがわかりました。

results.sort(key = lambda res:res[1])

top5_results = results[:4]

for r in top5_results:
  print(r[1],':',r[0].page_content[:30])

#->
1.076036 : <施策の方向性> ○ DX の進展は、価値創造プロセスの全般
1.076054 : 報発信することは、企業による各取組の検討を容易にしたり、実施
1.1115494 : 省】

 国内外の金融機関におけるネイチャーポジティブを巡
1.1194731 : の活用等の統合的アプローチを推進する。

<具体的施策>

検証3. elementsで取得してみる

UnstructuredPDFLoaderの読み取りモードにはpagesとsingleの他にelementsがあります。
このelementsについても試してみました。

loader_elements = UnstructuredPDFLoader("000213033.pdf", mode="elements", languages=['ja'])

elements = loader_elements.load()

なんと567つもの要素に分かれてしまいました。
ちなみにどうやって分けているかですが、ほとんどは段落にわけていましたが、中には改行で切れている文もありました

print(len(elements))
#-> 567

for i,e in enumerate(elements):
  if i<20:
    content = e.page_content
    print(content[:30],'length:',len(content))

#->
ネイチャーポジティブ経済移行戦略 length: 16
~自然資本に立脚した企業価値の創造~ length: 18
令和6年3月 length: 6
環境省 length: 3
農林水産省 length: 5
経済産業省 length: 5
国土交通省 length: 5
はじめに length: 4
生物多様性 COP15 にて採択された「昆明・モントリオール length: 127
緊急行動をとる」ことが、それぞれ掲げられた。2030 年ミッ length: 46
G7 で合意された「ネイチャーポジティブ(自然再興)」と同趣 length: 130
ッションを達成するための「5つの基本戦略」を掲げており、その length: 38
イチャーポジティブ経済の実現」が位置付けられている。 length: 26
◯ この基本戦略3における重点施策として、ネイチャーポジティ length: 98
◯ 「ネイチャーポジティブ経済」とは、生物多様性国家戦略にお length: 199
図られ、また、そうした企業の取組を消費者や市場等が評価する社 length: 38
とを通じ、自然への配慮や評価が組み込まれるとともに、行政や市 length: 159
1 生物多様性国家戦略 2023-20302023 年3月 length: 65
2022 年3月に立ち上げたネイチャーポジティブ経済研究会を length: 558
・生物多様性:人間などの動物や、植物や菌類などの微生物まで、 length: 140

ここまできたら、先ほどと同様に近傍検索しましょう

# 後で前からの順番に並び替えられるよう、metadataにインデックスを入れる
for i,e in enumerate(elements):
  e.metadata['element_index'] = i

# embeddingを作成
faiss_index3 = FAISS.from_documents(elements, OpenAIEmbeddings(model="text-embedding-3-small"))

# 近傍検索
results3 = faiss_index3.similarity_search_with_score("研究開発・技術実証支援として、どういった施策がありますか",k=len(elements))

# 前からの順番にソート
results3.sort(key = lambda x: x[0].metadata['element_index'])

# ソートされたデータから類似度のリストを作成
similarities3 = [ res[1] for res in results3]
import matplotlib.pyplot as plt

# グラフの作成
plt.figure(figsize=(10,6))
plt.plot(range(1, len(similarities3)+1), similarities3, marker='o')

# グラフの装飾
plt.title('splitted with element')
plt.xlabel('element')
plt.ylabel('similarity')
plt.xticks(range(1, len(similarities3) + 1))
plt.grid(True)

plt.show()

結果としては、以下のようにけっこうシャープに出ました。

一番近かった19ページの"(研究開発・技術実証支援)"というチャンクは、そりゃここで区切ったらドンピシャに近くなるよね、という内容です。

ただし、2位以降は必要な内容である19ページ、20ページの内容を網羅できておらず、この結果だけをもとに回答を生成すると、答えとして不十分となってしまう恐れがあります

for res in top5_results:
  (doc, similarity) = res
  page_number = doc.metadata['page_number']
  head_text = doc.page_content[:30]
  similarity = round(similarity*100)/100
  print(f'{page_number}ページ:{head_text}:similarity:{similarity}')

#->
19ページ:(研究開発・技術実証支援):similarity:0.45
15ページ:開発支援等を通じて支援する。:similarity:0.79
20ページ:オミミクリー)」に係る研究や技術実証等を通じたイノベーション:similarity:0.93
3ページ:て支援することについて、価値創造プロセスの各ステップにおける:similarity:0.94
20ページ:<具体的施策>  バイオテクノロジーや再生可能な生物資源等:similarity:0.96

結論

今回は、ページ分割・テキスト長分割・要素分割の3つの分割を試し、同じ質問に対してどれだけ近傍検索がシャープに出るかを検証しました。

細かく分割する要素分割が一番シャープに出ましたが、近いチャンクだけを取り出してしまうと回答の読み落としが発生してしまう可能性があるということもわかりました

テキスト長分割が一番回答に近いところを抜け漏れなく取り出せている気がします

まだ、この分割をすれば正解に辿り着ける!と確信を持てるところには至らなかったので、もう少し工夫してみます。

Discussion