はじめに
今回初めてQiitaに投稿します。
理由はタイトルにも書きましたが、RAGを学んでコンペに参加しましたのでその備忘録と、
最後ゴールドは取れましたが11位と入賞(10位まで)に入れませんでした。
悔しかったので、何とか企業賞に滑り込めないだろうかとコンペ延長戦的な気持ちで書いております。
※2025/2/15執筆時点では順位やメダルは暫定です。変更あり次第修正します。
またタイトルがわかりやすいほうがいいとのことで大げさに書いております。
そのため急遽Qiitaの登録を済ませ書いておりますので見にくい部分や、技術的な面よりブログ的な感想が多くなってしまっているかもしれませんがご了承ください。
事前情報
・非エンジニア
・証券関連の仕事
・SIGNATEのコンペは3回目
参加したコンペ
全体像
- Retrieverについて
- クエリの処理
- PDFの処理について
- プロンプトについて
RAGコンペで学んだこと:RetrieverとTextSplitterの重要性
今年に入るまでRAGのことを知らず、このコンペで初めて触れました。過去2回のコンペ参加で表データやPandas、XGBoostなどの知識はそれなりに身につけたつもりでしたが、LLMなどはChatGPTやNLPとしてなんとなく理解している程度で詳しくはなく、コンペなどを見ていると生成AI関連やNLPなどが増えてきているのでどこかで取り組みたいなと思っていたので良い機会だと思い、このコンペが始まる直前くらいから調べ始めました。
RAGで重要なのはRetriever
調べ始めた中でRAGで重要なのがRetrieverというのが出てきたのでいくつか調べ、今回のコンペでは以下を使用しました。
ParentDocumentRetriever|チャンクを親チャンク、子チャンクで二つ使用する
BM25Retriever|単語の関連性?からの検索、ただ日本語対応してないので要処理
TFIDFRetriever|単語の”レア度”からの検索、こちらもMeCabでの処理が必要
EnsembleRetriever|それぞれのRetrieverをアンサンブルするもの
ContextualCompressionRetriever|ReRANKするための
ConversationalRetrievalChain|retrieverではないですが参考に
基本的にはハイブリッド検索として「セマンティック検索(ベクトル検索)」と「キーワード検索」の組み合わせが良いとのことでそれをベースにしました。結論を先に書くと今回は最終的には
ParentDocumentRetriever k=10 :0.4
BM25Retriever_2048チャンク k=10 :0.2
BM25Retriever_512チャンク k=10 :0.2
TFIDFRetriever k=10 :0.2
でアンサンブル(k=20くらいで幅広く抽出)したあとContextualCompressionRetriever k=10 でReRANKしたものを最終的なRetrieverにしました。ConversationalRetrievalChainは考え方は採用し、直接は使用してません。
セマンティック検索
まずベクトル検索用のRetrieverですが、基本的なretrieverだけだとチャンク数など悩みが出てくるので1の「ParentDocumentRetriever」を採用しました。こちらは親チャンク(例2048)と子チャンク(例512)など二つのチャンクを用意して、子チャンクで文章のベクトルの近さを探しながらヒットした子チャンクの親チャンクを返すというものらしいです。Retrieverのポイントとしていかに該当する文章を検索してその文章の必要な部分をもれなく拾ってくるかが重要なのでそれが工夫されているのだと思います。
後述しますが、その点はすごい重要なのでコンペ自体では別の工夫を入れるようにしてます。また2000文字(トークン)、500文字(トークン)でもいいのですが、何かの論文か記事で512などの2の倍数のほうがベクトル化でよい成績が出たと見かけたのでお守り程度に2048、1024、512などで試しました。この辺りも英語直接でしたら影響しそうですが、日本語だし、トークン判定されるときにずれそうだからどこまで効果あるかは不明です。
キーワード検索
BM25Retrieverが日本語に対応していないとのことではじめかなり苦労しました。いくつかの記事を調べ、MeCabで形態素解析して単語を分解してやる必要があります。TFIDFRetrieverも同様でこちらもMeCabを使用しました。私のやり方が悪いのか、BM25はいろいろ組み合わせですがTFIDFはすっきりしたコードでできました。またBM25はチャンクサイズがかかわるようなのでParentDocumentRetrieverに合わせて2048と512別々で作成しました。TFIDはレア度を測定するためなのかチャンク数が関係ないようなのか1つだけの作成です。今回質問から適切なチャンクを拾ってこれるかをそれぞれ調べていた際に正解率としては、
ParentDocumentRetriever > 7割
BM25Retriever > 6割
TFIDFRetriever > 6割
という感じでした。BM25はチャンク数で得な問題、不得意な問題があり、2つ組み合わせたほうがいいと感じました。総合的には512、2048も同じくらいだったと思うのでどちらかに絞るより組み合わせだと思います。
TextSplitter
Retrieverと同じくらい重要(Retrieverとセット?)なのが「TextSplitter」とのことです。Retriever自体が分割した文章をもとに検索を行うのでそのように分割されているかは重要というのは感覚的にもわかります。TextSplitterで代表的なのが以下とのことです。
CharacterTextSplitter|単純に〇〇文字で分割|
MarkdownTextSplitter|マークダウンに従って分割|
NLTKTextSplitter|Pythonで英語による自然言語処理をする上で役に立つNLTKで分割|
SpacyTextSplitter|SpaCyは、PythonでNLPを行うための強力なライブラリ|
RecursiveCharacterTextSplitter|再帰的チャンキング|
出来れば単純に〇〇文字と一律的に分割するのではなく、段落や章など意味のあるところで切ったほうが検索の際も役立ちます。今回はRetrieverが拾ってきたものを別処理するようにしてますので、RecursiveCharacterTextSplitterをベースに活用してます。でもほんとはマークダウンとか意味のあるとこで分割したほうがいいと思います。ただ今回はコンペで他にやらないといけないことが多かったのと、後処理の工夫でそこまでTextSplitterで差が出ないかなと思いほかに時間かけることにしました。(PDF処理のほうが大変で手が回らなかったというのが感想です。)
参考にしたサイト
https://zenn.dev/buenotheebiten/articles/af5cfba98b1b8f
クエリの処理
上記でRetrieverができましたが、そのままクエリ(質問)で検索してもそれぞれ6~7割の正解率(答えを導き出せるチャンクを返してくれる)という感じでした。
そのため、クエリを処理する必要があります。
クエリを別の言葉に言い換えるなどです。今回クエリの処理には以下の方法を試しました。
1.クエリの言いかえ(内容・答えが変わらないように)を3つ作成
2.クエリを無理やり3つの文章に分割(言い換えるのではなく因数分解のように分ける)
3.クエリを回答するにあたり必要な手順をLLMに考えさせる。
4.必要な参照部分を一旦、答えさせる。
5.クエリ回答のプロンプトをLLMに作成させる。
6.クエリに工夫なしでLLMに答えさせる。
1については以下のようなプロンプトでLLMに言い換えを指示しました。
query_prompt = f"""
あなたは高度な自然言語処理と情報検索の専門家です。企業のESGレポートや統合報告書に関連する質問を、文書検索システムでの検索効率と回答精度を高めるように「答え」が変わらないように質問を変えてください。
## 元の質問
{question}
## 質問の内容
1.日本の企業に関する質問です。
2.各企業が出している資料を参照に答えます。
3.参照する資料は以下のものです。
- 統合報告書
- 統合レポート
- ステナビリティデータブック
-
## 指示
絶対にハルシネーションを避けてください。
文章を変えた質問を3つ作成してください。
元の質問と答えが変わってしまう質問を作成するとペナルティです。
出力形式以外の質問を作成するとペナルティです。
## ヒント
四捨五入の指示がある場合は間違えないように気を付ける。
海外・国内の違いによく注意する。
社内・社外の違いにはよく注意する。
常勤・非常勤の違いにはよく注意する
数字を扱う場合単位には気を付けてください。
## 出力形式
質問1:
質問2:
質問3:
"""
言い換えた質問を組み合わせてRetrieverに検索させることで検索の精度を高めることができるようです。
ただLLMが結構違う意味の質問に変えてしまっている場合もあり、あくまで検索用ですが、多用すると無駄なチャンクを拾ってきてしまう可能性があると思います。
「2.クエリを無理やり3つの文章に分割(言い換えるのではなく因数分解のように分ける)」も同じような感じで文章を作成させます。
ただ1では、3つのそれぞれ同じ意味を持つクエリができますが、2番目は3つ(複数)合わせて元のクエリと同じ意味を成します。
あなたは高度な自然言語処理と情報検索の専門家です。与えられた質問を正しく理解するため説明してください。
## 最終指示
絶対にハルシネーションを避けてください。
対象の質問を理解するためいくつかの要素に分けてください。
分けた要素を分かりやすく説明してください。
分けた要素から対象の質問の理解が深まるようにしてください。
順番を問う質問は対象の範囲を確認する。
比較を問う質問は比べる対象を確認する。
## 出力形式
回答の仕方は以下です。
要素1:
要素1の説明:
要素2:
要素2の説明:
要素3:
要素3の説明:
あなたは高度な自然言語処理と情報検索の専門家です。与えられた質問を正しく理解し回答を導き出す手順を教えてください
## 対象の質問
{query}
## 質問の内容
1.質問に答える必要がります。
2.質問を理解するために補足説明があります。
3.質問に回答するため参照する資料は以下のものです。
- 統合報告書
- 統合レポート
- ステナビリティデータブック
## 指示
補足質問の内容を理解し、どのような手順を踏めばいいかわかりやすく説明してください。
計算問題が含まれる場合は計算方法を教えてください。
質問に答えるために複数の情報が必要な場合はどのような情報を取得する必要があるか明確にしてください。
手順だけを考え、外部の情報を参照するとペナルティです。
手順だけを考え、答えまで出すとペナルティです。
あなたのタスクは企業のESGレポートや統合報告書に関連する質問に対して、参照する文章を見つけ出すことです。
## 指示
- 質問に答えるために必要な文章を探してください。
- 質問に答えるための方法は回答手順を参考にしてください。
- 参照する文章は変更せずそのままに抜き出してください。
- 複数の文章を参照とした場合は複数の文章を提示してください。
## 質問
{question}
## 回答手順
{direction}
## コンテキスト
{context}
## 出力形式
参照となる文章:
"""
シンプルに答えをまず出してもらう。
分からない場合、アドバイスをもらう。
あなたのタスクは企業のESGレポートや統合報告書に関連する質問に対して、文書検索システムで得たコンテキストを参考に自然言語で40トークン以内で正確に回答することです。
## 制約
- コンテキスト以外からは回答しないでください。
- コンテキストに回答が含まれない場合、「分かりません」と答えたうえでどんなことを探したらいいかアドバイスをください。
- アドバイスする場合は検索しやすい単語などを指摘してください。
## 質問
{question}
## コンテキスト
{context}
クエリの処理 & Retrieverの評価 & ReRANK
いくつかパターンを変えながら1つのクエリに対して、10くらいのsub queryを作成してpandasのデータフレームに格納して複数のRetrieverでどれくらい元のクエリに比べ精度が上がったか検証しました。
結果的には最後の「元クエリ」+「シンプル回答 or わからない場合アドバイス」で検索するのが精度が良かったです。
大体正しいコンテキストを95%くらいで拾ってくるイメージでした。(問95の難問や複数のページを拾ってこないと答えれない問題もありましたので制度的にはほぼ目標達成している感じです。)
失敗談
最終的には上記の方法で行いましたが、途中の試行錯誤としては、10個くらいに複製させたクエリをそれぞれのRetriever(PD、BA25, TFIDF)で検索し、上位に挙がってきた順でポイントを振り、最終的にポイントで並べなおすというのもしました。(ReRANKの自己処理)
ただRetrieverごとに違う要素がありうまくはまって正しいコンテキストが上位に並びなおされる場合もあれば、言い換え時点で全く違う質問になったうえで関係ないコンテキストを拾ってきて、逆に精度を落としてしまっている質問もあったりとReRANKするにも一筋縄ではいかなかったです。
そのため最終的にはReEANKについてはこちらを使ったほうが手間がなくそこそこいい結果が出ました。
from langchain.retrievers import EnsembleRetriever
from langchain.chat_models import ChatOpenAI
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor
from langchain.retrievers.document_compressors import LLMListwiseRerank
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
ensemble_retriever = EnsembleRetriever( retrievers=[pd_retriever, retriever_bm_parent, retriever_bm_child, tfidf_retriever], weights=[0.4, 0.2, 0.2, 0.2], k=20)
compressor = LLMListwiseRerank.from_llm(llm, top_n=10)
compression_retriever = ContextualCompressionRetriever(base_compressor=compressor, base_retriever=ensemble_retriever)
Retriever の大幅修正
実はRetrieverに関して最終的には大きな変更を加えています。
変更といってもベースの仕組みは上記に記載の通りなのですが、変えたのが読み込ませるコンテキストを変えました。
初めは課題となっているレポートPDFを全て読み込ませてRetrieverを作成していたのですが、どうしても間違いコンテキストが含まれて精度が上がらなかったです。
そこで出来るだけ不純物のないRetrieverを作成したいと考え、結論的には19のPDFをそれぞれのRetrieverを作成し、問題にそって正しいRetrieverを選択するモデルにしました。
(最終的には19×5個のRetrieverを作成)
そのほうが精度も上がってLLMの間違いも減りました。
ただこの方法は今回の質問が複数企業(複数PDF)にまたぐものがなかったからできたのだと思います。
例えば「ハウス食品と日産の従業員はどちらが多いか?」
などの質問があれば今回の方法は取れなかった、もしくは複数のRetrieverを選定して両方からコンテキストを引っ張ってくるなどの必要があったかもしれません。
ただ今回は質問ごとに明確に参考にするPDFが違ったことなど、質問で明確にRetrieverを指定できるなら出来るだけ分割して正しいRetrieverを検索できるようにしたほうがいいかと思います。
なにかに「ごみを入れてもゴミが出てくるだけ」とありましたが、Retrieverに不要な情報が多くなるのも精度を下げる原因になると思います。
※あくまで個人的感想です。
特に企業毎のデータを調べるのであれば、証券コードなど明確に指定できるので有用かもしれません。社内情報で人事や庶務いろいろな書類を参照しないといけないなどの場合はまた別の工夫が必要かと思います。
PDF処理について
今回のコンペのキモはこのPDFの処理をいかに正確にできるかだったと思います。
実際この処理にコンペ期間中 6割くらいの時間を費やしました。
普段テーブルデータしか触ったことない身としては、ここに悪戦苦闘しました。
その意味でも失敗したことも含めて、取り組んだ順に紹介していこうと思います。
ここまで長々と書いてしまい見にくいページが出来上がりますが、初投稿ということでとりあえずこのままチャレンジしてみようと思います。
PDF 難所
いまだPDFをNLP的に処理しやすいツールがない。(なくはないが簡単じゃない)
これだけAI OCRや技術が進化しているのに人間がぱっと認識できるレベルでPDFをテキスト化してくれるツールが全然見当たらなかったです。
特にAPI接続なしのライブラリで完璧なものは見当たらず、工夫が必要でした。
PDFリーダー(ローダー)
まずPDFをLLMが認識できるようにするには処理が必要です。
そのためのライブラリ・方法は多くありますが考えるうえで大きな選択肢が2つです。
1.費用なしのライブラリを使う
2.コストがかかるがAPI接続等でLLMモデルやAI OCRの高性能なものを使う
これは大きな違いで誰でも無駄な費用はかけたくないですから、通常であれば1の選択肢になると思います。 ただ結果、今回も最終的には2の方法をとりましたので無駄な手間をかけたくない、企業でお金がある等であれば初めから2の方法をとったほうがいいと思います。
先に2のツールを費用感を書いておくと、
ツールは今回「Azure AI Document Intelligence」を使用しました。
費用はたぶん初めの失敗やテストなど含め30~40個のPDF(ページで3000ページくらい)を読み込ませましたが請求金額は4000円程度です。
ただこちら初めての登録の場合200ドル(約3万円)の無料クレジットがつくので実質費用は掛かってません。 無料クレジットがなかったら使ったか微妙ですが性能は1のライブラリに比べて段違いです。最終的にはAzureで取得したデータしか使ってないです。
手っ取り早く試してみたいなら1、正確さを求めるなら2という感じでしょうか。
PDFライブラリについて
初めはできるだけコスト掛けないようにPDFを読み込めるライブラリから始めました。
調べていくとPDFライブラリだけでかなりあり、それぞれ精度や特徴があります。
調べたライブラリ
・pymupdf4llm
・pdfplumber
・PDFPlumberLoader
・PyPDFium2Loader
・pdfminer
・PyMuPDF
・pypdf
・PDFMinerPDFasHTMLLoader
・PyPDFDirectoryLoader
・UnstructuredFileLoader
・MathpixPDFLoader
・easyocr
・pdf spire
だいたいが langchain_community.document_loaders に入っているのでそちらから検索して用途や特徴を調べたほうがいいと思います。なんでこんなに調べたかというと一発でうまくPDFの文章を抽出してくれなかったからです。
とくに日本語というのがハードルが上がっているのだと思います。
pymupdf4llm
こちらはLLMでの処理を前提としているのかマークダウンでの抜き出しなどLLM,RAGに対応するように改良がされたものらしくてかなり精度はよく表なども正確に抜き出すことが多かったです。
ただ後ほど触れますが見開き問題として横ページで段落が横に2つ、3つ並んでいると一番左の段落の1行目の次のテキストが真ん中の段落の1行目につながるなど段落が横並びの際に誤抽出があります。
ただ難点は処理速度が遅いということです。大体、1PDFを処理するのに1~2分かかります。他のが1秒~数十秒くらいなのに比べて10倍くらいのイメージです。
今回大きな演算処理がないので基本CPUで処理していましたのでGPUで行えば気にならないかもしれませんが他に比べてかなり遅い印象です。
PyMuPDF
こちらは一番ベースのライブラリというイメージです。処理も早いですし、精度もそこそこいいです。
オプションで変えれる設定も多く段落の判定も文字の何倍に指定するかなど返れました。
その辺をうまく調整すれば同じレイアウトの文章には強力になるかもしれません。
処理的にもまずこちらを試してみてうまく抽出できていなければ上にするというのがいいかもしれないです。
spire
基本は上の2つの使い分けでいい気がしますが、特徴があるのでこちらも触れておきます。
こちらは抽出するときにそのままのレイアウトで抽出してくれます。
そのままというのはあえて段落のつながりなど判断せず文章がない部分はそのまま空白で置き換えてくれます。
その分、文章のつながりはおかしくなりますがprintで表示したときは原本の見た目に忠実に表現できます。
その意味でもこのライブラリを使って基本文字データだけにして、それをもう一度PDF化してほかのライブラリに読ませるのはどうかと考えていました。
1ページごと再度PDFにしてほかに読ませるという手間で断念しましたがもしかしたら文章だけであればうまくいったのではないかと思ってます。
ほかも試しましたがそもそも日本語対応してなさそうなものなどもあり、基本は上の組み合わせで行こうというのがベースになりました。
見開き問題 :PDF分割でRAGの精度向上!
同じページに段落が横並びであると、隣り合う段落が繋がってしまう現象、ありますよね。
特に見開きのページでよく起こるのですが、今回扱ったPDFの半分くらいが1ページ目は表紙でA4縦、2ページ目以降がA3横(A4縦の横並び)という構成でした。
このように縦横が混在していると、たまに見開きでも1ページに丸ごと表や絵などが載っている部分もあり、一律的な処理が難しく、テキスト抽出にも誤りが生じていることが確認できました。(コンテキストと原本を照らし合わせる根気のいる作業…)
どうしようか悩んだ結果、思い切ってPDFを分割するようにしました。
特にA4縦は分割してしまうとおかしくなることがあるので横ページ(縦のインチより横のインチが大きい)場合のみPDFのページを分割するようにしました。
結果コンテキストの抽出で精度が上がるページが増えました。
中には見開きで左、真ん中、右と3段落構成にしているレイアウトもあったので3分割も作成しました。
流れは
1.PDFをそれぞれ1ページごと読み取り
2.メタデータを格納
3.横ページであればPDFを2分割
4.横分割したページを読み取り
5.で保存したメタデータを紐づけ かつ分割した要素をメタデータに追加
6.3~5を3分割で繰り返し
分割によるデータ増大と後処理
こうすることで少なくとも原本、2分割、3分割のどこかで正しい段落でのテキスト抽出ができるようになりました。ただ、これだとデータが約3倍に膨れますし、集計問題などは3倍の数をカウントしてしまうようになりますので後処理が必要です。
PDFリーダーについても、Retrieverには全モデル入れて、引っかかってくるページを選定して最終的には1つのライブラリのコンテキストだけにソートし直すというのを考えていました。ただ、上記でいったようにAPIを使うように変更したのでこの辺の処理は未実行となりました。
コンテキストの再構築
これはAzureでも行っているのですが、コンテキストには正しく読み取れていないものもあるので、いくつかの方法でコンテキスト化して、上位に引っかかったページから再度コンテキストを作成するようにしています。コンテキストにIDが振られてその前後のコンテキストをLLMに与えるというのがあったのですが、それを拡大しました。
LLMの性能が上がり入力コンテキストが100万トークンなどもできる中でコンテキストを過度に少なくする必要はないかと思ってます。ただ、10万トークンとか入れたら小説1冊分とかになるのでどうしても情報が薄まって適切な答えが出ないです。(もしかしたらもう少し性能が上がれば解決することかもしれませんが。)
今回でいえばレポートの性質上、
・1ページごとに内容が完結している
・ページごとで順序だって内容が関連している
・ページごとのコンテキストを順番通り渡したほうが正しく判断できる
と思ってまず該当ページに当たりをつけてそのページ(一部オーバーラップで前後のページも含め)の情報をすべてLLMに渡すとしています。
Retrieverのコンテキストだけで渡すと関連性の高い順で並んでいるので、該当箇所のコンテキスト、1つ前のコンテキスト、2つ後ろのコンテキストなどの順番で渡すこともあり人間でも文脈判断しづらくLLMが混乱するかと思います。
メタデータとPDFの分析
いくつかのライブラリは自動的にメタデータを付けてくれます。ただそれだけでは不足するデータもありますので個別にメタデータを追加しました。
メタデータを追加するにあたり、企業名、レポート名、レポートの概要を入れてRetrieverでヒットしやすくしようと考えました。(最終的に企業別にしたのでレポート名とレポートの概要はいらなかったです。)
手動でのタグ付けは禁止されてましたのでLLMにさせました。19個のPDFを読み取ったデータの10ページくらい(節約のため)をコンテキストとして企業名とレポート名、要約を答えさせるようにしました。PDFの冒頭10ページには何度も企業名がありますからさすがにこのレベルを間違えるほどではなかったです。もちろん上場の3000社とかになれば似た名前の会社などありますから工夫が必要だったかもしれないです。またこのメタ情報を参考に質問ごとの該当の企業名を答えさせると、参照すべきPDFは問95以外は100%の回答率でした。
追加の仕方は辞書と同じなので簡単ですが、英語的には作成者がauthor レポート名がtitleとするようです。PDFリーダーのライブラリ名も追加しておきます。
メタデータに入れておくことで最後コンテキストを再検索するときに簡単にできます。ソート検索ができますのでPDF名、PDFリーダー名、ページで該当コンテキストを再帰的に取得できます。
Azure AI Document Intelligence でRAG精度向上
このままでは限界を感じて、Azureを使ってみることにしました。
Azureは情報量が段違いに多く、レイアウトからしっかり分析して位置情報なども出してくれるので、後々の処理に役立ちました。
自動マークダウン変換と課題
自動的にマークダウン方式にしてくれるのでチャンク化も簡単です。初めはこちらのデータを用いていましたが、簡単にマークダウン化してくれるものの、たまに段落の読み取りが間違えていたり、表データやグラフデータの読み取りができていないところがあります。イメージとしては、全体で90~95%正しいという感じです。グラフや表に限ると6~7割のイメージです。
Azureの追加情報と活用
グラフや表に限界を感じてAzureにしたので、このマークダウン化そのままではそこまで精度は上がりませんでした。特にマークダウン化だとそれぞれのページのメタデータがありません。それも問題でした。
ただ、Azureは別途、以下の項目のデータを返してくれます。
sections:パラグラフをまとめかたまり
paragraphs:構成的に一番小さい(下層)の情報
page:ページごとの含まれるパラグラフなど
tables:表(テーブル)データを格納
figures:図や絵を格納
パラグラフドキュメント
パラグラフをすべて抽出し、ページのメタデータを持つパラグラフドキュメントの作成
テーブルドキュメント
テーブルデータを抽出し、pandas DFに格納し、表のタイトル(coution)がある場合は紐づけ、ない場合は一つ上のパラグラフをタイトルが代わりにする。パラグラフドキュメントからテーブルデータの部分を置き換えしテーブルドキュメントの作成
フィギュアドキュメント
フィギュアデータを抽出し、ポリゴンデータ(ページの位置情報)pandas DFに格納。
Azureのデータの構成を読み取るのとそれを使いやすいように処理するのが結構手間でした。ただ、処理したデータはかなり精度が上がって基本的には正しくLLMに渡せば正しく答えてくれるようになりました。
特にAzureのフィギュアのデータだけだとタイトルがついていない場合があり、何のグラフかわからないことが多いので一度その領域を切り出し、タイトルと注釈を加えた画像を作り、別保存しました。
参考にした記事
https://qiita.com/nohanaga/items/1263f4a6bc909b6524c8
テーブルなどは上記のサイトを参考で崇徳出来ると思うので図の取得のコード載せておきます。
フィギュアのデータをデータベース化
AzureAIDI_documents_path = "Azure AI Document Intelligenceで読み取ったデータを19個保存"
pdf_num ='PDFファイルを1~19でナンバリング'
figures_df = pd.DataFrame(columns=['pdf_num', 'idx', 'pageNumber', 'polygon', 'paragraphs', 'caption', 'caption_polygon', 'caption_paragraphs', 'footnotes', 'footnotes_polygon', 'footnotes_paragraphs'])
for pdf_num, path in tqdm(enumerate(AzureAIDI_documents_path)):
f = open(path,"rb")
docs=pickle.load(f)
if docs[0].metadata['figures']:
for idx, figure in enumerate(docs[0].metadata['figures']):
pageNumber = ''
polygon = ''
paragraphs = []
caption = ''
caption_polygon = ''
caption_paragraphs = []
footnotes = ''
footnotes_polygon = ''
footnotes_paragraphs = []
# print(f"--------Analysis of Figures #{idx + 1}--------")
if 'caption' in figure and figure['caption']:
title = figure['caption'].get("content")
if title:
# print(f"Caption: {title}")
caption = title
elements = figure['caption'].get("elements")
if elements:
# print("...caption elements involved:")
caption_paragraphs.extend(elements)
# for item in elements:
# print(f"......Item #{item}")
captionBR = []
caption_boundingRegions = figure['caption'].get("boundingRegions")
if caption_boundingRegions:
# print("...caption bounding regions involved:")
for item in caption_boundingRegions:
#print(f"...Item #{item}")
# print(f"......Item pageNumber: {item.get('pageNumber')}")
# print(f"......Item polygon: {item.get('polygon')}")
captionBR = item.get('polygon')
caption_polygon = item.get('polygon')
if 'footnotes' in figure and figure['footnotes']:
f_title = figure['footnotes'][0].get("content")
if f_title:
footnotes = f_title
f_elements = figure['footnotes'][0].get("elements")
if f_elements:
footnotes_paragraphs.extend(f_elements)
footnotes_BR = []
footnotes_boundingRegions = figure['footnotes'][0].get("boundingRegions")
if footnotes_boundingRegions:
for item in footnotes_boundingRegions:
footnotes_BR = item.get('polygon')
footnotes_polygon = item.get('polygon')
if 'elements' in figure and figure['elements']:
# print("Elements involved:")
paragraphs.extend(figure['elements'])
# for item in figure['elements']:
# print(f"...Item #{item}")
boundingRegions = figure.get("boundingRegions")
if boundingRegions:
# print("Bounding regions involved:")
for item in boundingRegions:
#print(f"...Item #{item}")
if captionBR != item.get('polygon'): #caption の polygon を除外したい
# print(f"......Item pageNumber: {item.get('pageNumber')}")
# print(f"......Item polygon: {item.get('polygon')}")
pageNumber = item.get('pageNumber')
polygon = item.get('polygon')
# figures_df.loc[idx] = [pdf_num, idx, pageNumber, polygon, paragraphs, caption, caption_polygon, caption_paragraphs]
figures_df.loc[len(figures_df)] = [pdf_num, idx, pageNumber, str(polygon), str(paragraphs), caption, str(caption_polygon), str(caption_paragraphs), footnotes, str(footnotes_polygon), str(footnotes_paragraphs)]
基本のPDFからの領域の切り出し保存
from PIL import Image
import fitz # PyMuPDF
import mimetypes
from mimetypes import guess_type
from IPython.display import display_jpeg
def crop_image_from_image(image_path, page_number, bounding_box):
"""
Crops an image based on a bounding box.
:param image_path: Path to the image file.
:param page_number: The page number of the image to crop (for TIFF format).
:param bounding_box: A tuple of (left, upper, right, lower) coordinates for the bounding box.
:return: A cropped image.
:rtype: PIL.Image.Image
"""
with Image.open(image_path) as img:
if img.format == "TIFF":
# Open the TIFF image
img.seek(page_number)
img = img.copy()
# The bounding box is expected to be in the format (left, upper, right, lower).
cropped_image = img.crop(bounding_box)
return cropped_image
def crop_image_from_pdf_page(pdf_path, page_number, bounding_box):
"""
Crops a region from a given page in a PDF and returns it as an image.
:param pdf_path: Path to the PDF file.
:param page_number: The page number to crop from (0-indexed).
:param bounding_box: A tuple of (x0, y0, x1, y1) coordinates for the bounding box.
:return: A PIL Image of the cropped area.
"""
doc = fitz.open(pdf_path)
page = doc.load_page(page_number)
# Cropping the page. The rect requires the coordinates in the format (x0, y0, x1, y1).
# The coordinates are in points (1/72 inch).
bbx = [x * 72 for x in bounding_box]
rect = fitz.Rect(bbx)
pix = page.get_pixmap(matrix=fitz.Matrix(300/72, 300/72), clip=rect)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
doc.close()
return img
def crop_image_from_file(file_path, page_number, bounding_box):
"""
Crop an image from a file.
Args:
file_path (str): The path to the file.
page_number (int): The page number (for PDF and TIFF files, 0-indexed).
bounding_box (tuple): The bounding box coordinates in the format (x0, y0, x1, y1).
Returns:
A PIL Image of the cropped area.
"""
mime_type = mimetypes.guess_type(file_path)[0]
if mime_type == "application/pdf":
return crop_image_from_pdf_page(file_path, page_number, bounding_box)
else:
return crop_image_from_image(file_path, page_number, bounding_box)
切り出したPDFの領域にタイトルと注釈を追加
import ast
import matplotlib.pyplot as plt
figures_df = '上記で作成'
def save_figures(pred_company, pred_pages):
save_files = []
company_num = comp_num[pred_company]
for page_num in pred_pages:
sort_df = figures_df[(figures_df.pdf_num == company_num)&(figures_df.pageNumber == page_num)].reset_index(drop=True)
polygons = []
titles = []
footnotes = []
for i in range(len(sort_df)):
polygon = sort_df.loc[i, 'polygon']
title_ = sort_df.loc[i, 'caption']
titles.append(title_)
footnote_ = sort_df.loc[i, 'footnotes']
footnotes.append(footnote_)
polygon_list = ast.literal_eval(polygon)
polygons.append(polygon_list)
# print('figures num : ' + str(len(polygons)))
for i in range(len(polygons)):
save_name = 'pdf_' + str(company_num) + '_page_' + str(page_num) + '_' + str(i) + '.png'
if os.path.exists(save_name):
save_files.append(save_name)
else:
polygon = polygons[i]
bounding_box = (polygon[0], polygon[1], polygon[4], polygon[5])
image = crop_image_from_pdf_page(pdfs[company_num], page_num-1, bounding_box)
graph_title = titles[i]
graph_footnote = footnotes[i]
plt.imshow(image,)
plt.axis('off') # 縦軸と横軸のメモリを無くす
if type(graph_title) == float:
pass
else:
plt.title(graph_title)
if type(graph_footnote) == float:
pass
else:
plt.figtext(0.2, 0.05, graph_footnote)
plt.savefig(save_name, bbox_inches="tight") # プロットされた図を保存
save_files.append(save_name)
# plt.show()
plt.close()
return save_files
プロンプト
今回は、そのデータをLLMに効果的に渡し、一番良いの回答を得るための具体的な流れを解説します。
全体の流れはこんな感じです。
- Retriever作成:各ライブラリとAzureのドキュメントを参考にRetrieverを作成
- コンテキスト検索:「クエリとシンプル答え」でコンテキストを検索
- ヒットページ特定:検索でヒットしたページを確定(場合によって前後のページを追加)
- テーブルデータ付きコンテキスト作成:Azureで作成したテーブルデータ込みのコンテキストを該当ページ丸ごとコンテキストとして渡す(コンテキスト①)
- 画像データ抽出&保存:Azureで作成したフィギュアデータから該当ページの絵・グラフをPDFから抜き出し個別の画像として保存
- 画像部分のパラグラフ抽出:パラグラフドキュメントから絵・グラフの部分(パラグラフ番号で照合)を抜き出し(重複してしまうので省く)、コンテキスト②を作成
- LLMへテーブルデータ送信:LLMにコンテキスト①(テーブル)を渡し回答させる
- LLMへ画像データ送信:LLMにコンテキスト②と画像データ(複数の場合は複数)を渡し回答させる
- 最終回答:LLMに7と8の回答を渡し、総合的な答えを判断させる
ポイントはハイブリッド回答
Azureで作成した個別のパラグラフから表データを置き換えたものと絵・グラフを抜き出して画像として渡していくハイブリッドでの回答をさせる形にしました。
PDF処理完了とプロンプト工夫
PDF処理が完了し、いよいよプロンプトの工夫に取り掛かることができました。しかし、最終日1週間前という状況だったので、かなり時間を使ってしまったという印象です。
プロンプト試行錯誤の日々
プロンプトというか、LLMにどう答えさせるかの部分について書いていきます。一通りプロンプトの工夫の方法を学び、それらを組み合わせて作成したのですが、質問ごとに良い結果と悪い結果に分かれました。特に答えさせるごとに回答が変わるので、何度も試行錯誤を繰り返しました。LLMにもSeedの指定ができるようで、それをしていればもっと検証が楽だったと思います。
プロンプトの基本パターン
・Few-shot
・Chain-of-Thought(CoT)
・自己整合性 Self-Consistency
・Tree-of-Thought(ToT)
・MAGIシステム
・仮想スクリプトエンジン
・Mock Prompt
このあたりの基本の作法を学び組わせました。
量より質!プロンプトは絞り込むべし
まず工夫を知って、とりあえずたくさん書き出しました。しかし、各質問ごとに前に書いてうまくいっていたものができなくなったり、指示についても多くなると一つ一つが薄まる感じで、量を書けばいいんじゃないということが分かりました。
MAGIシステムで役割分担
特にMAGIシステムはうまくいったと思います。役割を変えることで変化をつけれるので、もう少し時間があれば深堀できたと思います。
例えば、
• レイヤー1:①財務担当、②経理担当、③広報担当
• レイヤー2:①経営者、②アナリスト、③エコノミスト
• レイヤー2-2:①経済学者、②数学者、③国家機関
などとすると、国家機関の役割が勝手に質問の答えとは関係ない会社の施策の良し悪しを評価しだしたり、今回の精度には無駄ですが、LLMの奥が深いなと感じました。
最終的なプロンプト構成
最終的な構成としては、
レイヤー1
① シンプルなプロンプトで答えさせる(openAI)
② シンプルなプロンプトで答えさせる(gemini)
分からない場合はわからないと答えさせる。
レイヤーを分けた理由でもあるのですが、半分くらいはシンプルなプロンプトで正解を答えてくれます。逆にシンプルなプロンプトだと正解なのに難しい問題と同様に複雑な指示をするとかえって間違うことがありました。またAPIの料金の問題も含め、簡単に正解する場合をふるいにかけ掛けてしまいたかったからです。
ただシンプルだけで判断すると両方間違えることがあるのでレイヤー2には基本的には進みます。
レイヤー2 Generate Answer1
③ ①②の答えとMAGIシステムで役割を与えた4人に「テーブルデータコンテキスト」で推論させて答えを出させる
④ ①②の答えとMAGIシステムで役割を与えた4人に「画像データ&コンテキスト」でと③での会話を新たなクエリとしてretrieverに検索し、コンテキストの範囲を拡大させてから推論させて答えを出させる
⑤ ③と④を会話として渡して総合プロンプト(役割なし)で「画像データ&コンテキスト」で答えさせる
途中の処理として③での会話の文章と④の会話の文章はChat_histryとして格納してそれぞれコンテキストの検索やLLMが回答を考える参考に渡してます。
レイヤー3 Generate Answer2 (レイヤー2の画像・テーブルの順番入れ替え)
⑥ ①②の答えとMAGIシステムで役割を与えた4人(③とは別の役割)に「画像データ&コンテキスト」で推論させて答えを出させる
⑦ ①②の答えとMAGIシステムで役割を与えた4人(④とは別の役割)に「テーブルデータコンテキスト」でと⑥での会話を新たなクエリとしてretrieverに検索し、コンテキストの範囲を拡大させてから推論させて答えを出させる
⑧ ③と④を会話として渡して総合プロンプト(役割なし)で「画像データ&コンテキスト」で答えさせる
レイヤー4 Check Answer1
⑨ ⑤と⑧の答えを見比べさせて同じであれば答え確定。同じでない場合を抽出
⑩ レイヤー2を繰り返して答えを出させる(多少内容を変えて)
レイヤー5 Check Answer2
⑨ ⑧と⑩の答えを見比べさせて同じであれば答え確定。同じでない場合を抽出
⑩ レイヤー3を繰り返して答えを出させる(多少内容を変えて)
レイヤーLAST
⑪ チェックを2回行ったので最後の答えをFinalとして提出
こちらでモデル作成しました。最終的に95や集計問題はこのままでは対応できないと思うのでもう少し改良が必要だったと思います。MAGIシステムの役割に関しても抽出、計算、疑い、確認などを振ってましたが他にも試せたと思うので改良の余地ありです。
レイヤー2のプロンプトは以下のような感じです。
もっと洗練した文章にできると思いますがつぎはぎでこのような形までしかできませんでした。
answer_prompt = f"""
- 4人の専門家が協力して質問に答えてください。
- その4人は財務担当の代表、広報担当の代表、数字を疑う担当の代表、確認担当の代表です。
- それぞれが多角的に考え、自分の回答への手順と参考としたデータを詳しく説明します。
- 各ステップで、各専門家はほかの人の考えを洗練し、その貢献を認めながらその考えを発展させます。
- 他人の説明に間違いがないか確認してから、再度「コンテキスト」を確認して、別の確認方法や違うデータで検証して考えを説明してください。
- 質問に対する答えが見つかるまでこのステップを繰り返し続けます。
- 全員の意見をまとめて、結論を出してください。
- 「質問」、「コンテキスト」、「table data」を参考にしてください。
- 文章で答える場合は要約せず必要な部分のみ抜き出し回答してください。
- コンテキストに回答が含まれない場合、「分かりません」と答えたうえでどんなことを探したらいいかアドバイスをください。
- アドバイスする場合は検索しやすい単語などを指摘してください。
- コンテキストは完全な文章ではなくretrieverで抜き出した文章であることに注意してください。
- コンテキストをつなぎ合わせて推論を行ってください。
- 数値を計算するときは参考値ではなく実績で計算すること。
- 最終的な回答は50トークン以内で答えれることを要求していますが議論は幅広く行ってください。
- 「小数第二位で四捨五入してください。」とあるのに回答に小数点第二位がある場合はペナルティ -1点。
- 「小数第一位で四捨五入してください。」とあるのに回答に小数点第一位がある場合はペナルティ -1点。
## 数字を疑う担当の代表への特別指示
- 財務担当の代表、広報担当の代表の意見を疑いながら間違いがないか再検証してください。
## 確認担当は以下のヒントから回答が正しいか確認してください。
## ヒント
- 「simple_answer」は6割くらい正しいですが、4割くらい間違えなのであくまで参考にしてください。
- 通年、通期、上期、下期、半期、四半期の期間の違いによく注意する。
- 海外・国内の違いによく注意する。
- 社内・社外の違いにはよく注意する。
- 常勤の反対は非常勤、であることに注意してください。
- 年、月、日などの時間の指定にはよく注意する。
- 2つ中から選択する問題には理由でなく、選択肢のみ回答する。
- 上期と下期を足して1年間の数字になる。
- 2023年上期 123件、2023年下期 234件で、2023年は123+234=357件です。
- 順位を出す問題か判定してください。
- 順位を考える問題の場合は、まず大きい順(もしくは小さい順)に並び替えてください。
- 一番大きい、二番目に大きいなどを考える場合は大きい順に5つ抽出してそのうえで並び書てください。
- ひらがな・カタカナ・漢字を判定する場合、「ひらなが」があることに1点、「カタカナ」があることに1点、「漢字」があることに1点、を与え合計3点以上になっていることを確認してください。
- 問題文に回答を入れてフォーマットがあっているか確認してください。
- 例えばAを「111」として、Bを「222」のもの、Cは「333」の表記を同じようにする必要があることを確認してください。
- 東京、大阪、四国4か所、の合計は6か所です。
- 東京、名古屋、九州(8県)の合計は10か所です。
- 全てを答える場合、原文の要素を抜き出す。
- 回答に不必要な部分があれば省く。
- 抽出問題はまず初めに該当する項目をすべて洗い出してください。
## 確認担当は以下の参考から回答が正しいか確認してください。
## 回答には次を参考にしてください。
- Q:プロダクト・ライフサイクルの「導入期」、「成長期」、「成熟期」、の次はなんでしょうか?
- A:衰退期
- Q:12.345% を「小数第二位で四捨五入してください。」
- A:12.3%
- Q:12.345% を「小数第一位で四捨五入してください。」
- A:12%
- Q:進捗率が2023年度12.3%、2022年度23,4%である場合、2023年度は2022年度対比何%増加した?
- A:23.4% - 12.3% = 11,1% 答え:11.1%
- Q:甲100、乙200、どちらが大きいか?
- A:甲
- Q:〇〇と挙げていることは何ですか?
- A:原文をそのまま回答する。
- Q:「日本大和株式会社、東京営業所」の会社名にひらがなは含むか?
- A:含まない。「、」は会社名に含まれず、ひらがな扱いでもない。
答えが同一かの判定
# 比較 モデルを準備
model_img_gemini = "gemini-2.0-flash-exp"
def compare_answers(question, answer1, answer2):
compare_prompt = f"""
「question」の「answer1」と「answer2」を比較してその結果を"Same", "Acceptable", "Different"の中から一つだけ選んで答えてください. それぞれの定義は以下の通り.
# 定義
Same: answer1、answer2が問題に正しく回答しており, 同じ内容である.
Acceptable: answer1、answer2が全く同じではないが, ほぼ同じ意味である.
Different: answer1、answer2同じ内容でない。
## question
{question}
## answers
answer1:{answer1}
answer2:{answer2}
## 出力形式
Same or Acceptable or Different
"""
response = model_gemini.generate_content(answer_prompt)
answer = response.text
return answer
最終的にはこちらを10回くらい回しました。
まだプロンプトが不安定なのか、seedの関係なのか、ちゃんと答えてくれる時と答えてくれないときがありました。
そのため複数回実行し一番よさそうなものを提出しました。
コンペに参加することでLLM/RAGのことを学ぶ機会が得れてほんとによかったです。
これからも重要な技術になると思うので引き続き勉強していこうかと思います。
コンペを主宰していただいた皆様ありがとうございました。