GPT-4o(Claude)に危険物取扱者試験(甲種試験)を解かせてみる-その8: 試験対策本のOCRと、それを使ったRAGでの回答
はじめに
専門的なことに回答可能な大規模言語モデルのシステムを作る練習として、危険物取扱者試験に挑戦しています。
士業の仕事は、解くべきタスクの定型性が比較的高いのが特徴(?)です。一連の作業過程が、マニュアルや対策本などのテキストで明文・データベース化されているため、AIによる代替が可能かもしれない、という仮説が成立します。
これまでの検討で、システムのエージェント化の有効性などを検証してきました。
本記事では、解くべきタスクを丁寧に整理した叡智の結晶(?)として、試験対策本の自炊とデータ抽出を行った上で、Claude-3.5-sonnetに回答させました。
結果的には、危険物取扱者試験(甲種試験)の過去問で、合格ライン(60%)を大幅に超える、ほぼ満点(45問中、43問で正解)を取ることができました。
残りの2問についても、もう少しデータベースを作り込めば正答できそうな手応えで、100%の正答率を十分に狙えることが分かりました。
試験対策本の自炊
試験対策本を使うモチベーション
専門分野をあまり勉強していない人でも、試験対策本を参照しながらであれば、ある程度は専門試験で高得点を取れるはずです。この作業をLLMに行わせることが目標です。
スキャンする
以下の本を自炊します。
評判の良い試験対策本のようで、これを暗記すれば(≒Q&Aのパターンを記憶すれば)合格できるとの噂です。
scansnapというスキャナーで自炊しました。このスキャナーに付属している、標準的なOCRソフトでテキスト化も行いました。
500ページほどの書籍ですが、スキャン自体は10分ほどで終わり、27MBのpdfデータになりました。
とりあえず標準のOCRテキストを抽出する
scansnapで生成したpdfからテキストを抜いて、jsonにしておきます。
import fitz # PyMuPDFをimport
from tqdm import tqdm
import json
# PDFファイルを開く
pdf_path = "book/book.pdf"
document = fitz.open(pdf_path)
page_dict={}
# ページごとにテキストを取得して表示
for page_num in tqdm(range(document.page_count)):
page = document[page_num]
text = page.get_text("text") # テキスト形式で取得
#print(f"Page {page_num + 1}:\n{text}\n")
page_dict[page_num]=text
with open("raw_text.json","w") as f:
json.dump(page_dict,f,
ensure_ascii=False,
indent=4)
解析の結果、約40万文字のテキストが得られました。
標準的なOCRによる読み取り性能の所感
良かった点
9割程度の精度で読み取りに成功
問題点
認識ミスに基づく変な文字は多数
例: 「覇函四国画危険物に関する法令」
黒抜き文字の「第1章」を「覇函四国」と認識
不規則なレイアウトの文字は認識が困難
例: 目次における「1. 消防法の法体系 ………….5」 を
「1. 消防法の法体系」として認識
大規模言語モデルでテキストを修正する
テキスト整形の必要性
標準的なOCRでは読み取りミスがあったり、不規則なレイアウトに対応できないなどの問題があります。
そこで得られた文章を、大規模言語モデルによって加工整形します。
基礎検討は以下の記事で行いました。
今回のアプローチ
今回、思いついたアイデアとして、GPT-4oのマルチモーダル機能と推論能力を活かしたテキスト生成をすることにしました。

先述の通り、標準的なOCR文章には認識ミスがあります。GPT-4oもOCR機能を有していますが、こちらも認識ミスをします。
そこで、プロンプトに標準的なOCR文章を与えつつ、スキャン画像を入力するというアプローチを取ることにしました。これにより、ダブルチェックでミスに対応できます。
pngの生成
pdf2imageというライブラリで、pdfの各ページをpng化しておきます。
1ページあたり300kb程度のファイルサイズでした。
#pip install pdf2image
from pdf2image import convert_from_path
from tqdm import tqdm
# PDFファイルのパスを指定
pdf_path = "book.pdf"
output_folder = "png" # 出力フォルダのパスを指定
poppler_path='aaaaa' # Popplerのbinフォルダのパスを指定
# PDFを1ページずつPNGに変換
for i in tqdm(range(527)):
images = convert_from_path(pdf_path, dpi=300,
poppler_path=poppler_path,
thread_count=8,
first_page=i+1,
last_page=i+1,
) # DPIは解像度、300が推奨
# 画像を保存
for _, image in tqdm(enumerate(images)):
output_path = f"{output_folder}/page_{i+1}.png"
image.save(output_path, "PNG")
print(f"Saved: {output_path}")
テキストの生成
標準OCRで読み込んだテキストも参考にしながら、テキスト抽出するような指示を出すことにしました。せっかくなので、markdown形式を指定しました。
初期化など
import base64
import os
import json
from openai import OpenAI
with open("key.txt") as f:
key = f.read().strip()
client = OpenAI(api_key=key)
# Function to encode the image
def encode_image(image_path):
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
with open("raw_text.json") as f:
ocr_data = json.load(f)
テキスト生成
template_instruct="""テキストをもれなく厳密に抽出し、markdown形式で出力してください。
以下はOCRで読み取った不完全なテキストなので、こちらの情報も参考にすること
{}"""
for page_num in range(1,527):
# Path to your image
image_path = f"png/page_{page_num}.png"
out_path=image_path.replace("png/","txt_from_png/")
out_path=out_path.replace(".png",".txt")
ocr_text=ocr_data[str(page_num-1)]
prompt=template_instruct.format(ocr_text)
if os.path.exists(out_path):
print(f"skip {out_path}")
continue
# Getting the base64 string
base64_image = encode_image(image_path)
response = client.chat.completions.create(
#model="gpt-4o-mini",
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": prompt,
},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
},
},
],
}
],
)
with open(out_path,"w") as f:
f.write(response.choices[0].message.content)
備考
1ページあたり15秒程度でした
500ページちょっとで6.5ドルでした
GPT-4o-miniだと、「カルシウム」を「カリウム」と誤読するミスがあったので、使うのをやめました。
標準OCRだと、表の正確な読み取りができず、一部の項目が抜けてしまうケースがあったので、それをGPT-4o側でカバーできました
ページの個別チェック
p1-13までを個別にチェックしました。残りの500ページほどはチェックしていません。
書籍の趣旨: OK
資格の趣旨: OK
目次: OK (ページ番号も含めて正確)
目次: OK (ページ番号も含めて正確)
消防法の法体系
模式の読み取りに軽微なミス
「法律(国会) → 政令(内閣) → 省令(各省の大臣)」と抽出されたが、元の図では、法令→省令 にも矢印がついていた
同様に、「消防法-危険物の規制に関する政令-危険物の規制に関する規則」の階層関係もうまくテキスト化できていなかった
法令で定める危険物 (表)
化合物の読み取りミス
「硫化りん」を「衣料虫ひん」と読み取り
法令で定める危険物 (表): OK
備考: OK
危険物の試験
表の読み取りでミス
類別1で、「粉粒状」、「粉粒状以外」の分別ができていない
「火炎による着火の危険性 | 小ガス炎着火試験」は類別2だが、類別1として認識
過去問: OK
過去問
認識は問題ないが、「図」という不要な行が生成
過去問: OK
過去問の解答
化合物名の読み取りミス
過酸化ペンソイル → 過酸化ベンゾイル
読み取り結果のまとめ
化合物名の滅茶苦茶な読み取りミス、表の読み取りミスはたまに生じるようですが、95点くらいのクオリティで読み取れることがわかりました。
表については、解答に致命的な影響を与える可能性はあるので、手作業でのチェックは必要かもしれません。
OCRデータを加工整形する
必要な作業
自炊した書籍には、想定問題と回答が多量に網羅されています。
一方、書籍という形式上、問題文と回答が異なるページに存在するケースが多いです。
雑多なテキストデータのままだと扱いにくいので、関連するテキストをもとに、問題ー回答のペアjsonとして構造化してみます。
テキスト抽出
この書籍では常に、「過去問題」という見出しのあとに問題文が続き、「正解」という見出しの後に、回答が続く構成になっています。
なので、まずは「問題」ー「正解」のペアとなるテキストをざっくりと抽出するスクリプトを組みました。
import os
# フォルダのパスを指定
folder_path = 'txt_from_png'
# フォルダ内のすべてのテキストファイルをリストで読み込む
file_list = os.listdir(folder_path)
file_list.sort(key=lambda x: int(x.split('_')[1].split('.')[0])) #これでpage_1.txt, page_2.txt, ... となる
text_files = []
for filename in file_list:
if filename.endswith('.txt'):
with open(os.path.join(folder_path, filename), 'r', encoding='utf-8') as file:
text_files.append(file.read())
lines=[]
for text in text_files[3:]:
lines.extend(text.splitlines())
buffer_lines=100 #正解の見出しの後、適当に100行程度を抜きとっておく。
prob_text=""
flag=False
cnt=0
for i,line in enumerate(lines):
if line.find("過去問題")!=-1:
flag=True
if flag:
prob_text+=line
prob_text+="\n"
if flag and line.find("正解")!=-1:
flag=False
add_lines="\n".join(lines[i+1:i+buffer_lines])
with open("problems/prob_"+str(cnt)+".txt","w") as f:
f.write(prob_text+add_lines)
prob_text=""
flag=False
cnt+=1
json化
openaiのapiでjson化します。
全90ファイルを40minくらいで処理できました。
from openai import OpenAI
from tqdm import tqdm
import json
with open("key.txt") as f:
key = f.read().strip()
client = OpenAI(api_key=key)
system_prompt="""次の文章からproblem, option, answer, explanationを抽出し、リスト形式のjsonで返すこと。
- json以外は何も出力しないこと
- answerは整数値で返すこと。
- 問題文が抜けないようにすること
- 「次のA~Eのうちいくつあるか」という質問では、その選択肢を、絶対に絶対にproblemに含めること
"""
for i in tqdm(range(0,90)):
#問題文の読み込み
input_path=f"problems/prob_{i}.txt"
with open(input_path) as f:
text = f.read()
text=text.split("過去問題")[1].strip()
#jsonに変換
completion = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{
"role": "user",
"content":text
}
]
)
#書き出し
json_data=(completion.choices[0].message.content)
with open(f"json_problems_{i}.json", "w") as f:
f.write(json_data)
試行錯誤
プロンプトは試行錯誤しました。選択肢を勝手に省略してしまうケースが多かったので、サボらないように、以下の指示を加えたのがポイントです。
- 問題文が抜けないようにすること
- 「次のA~Eのうちいくつあるか」という質問では、その選択肢を、絶対に絶対にproblemに含めること
はじめは、この指示を入れずに生成したのですが、その場合は、おかしな問題文がちょくちょく生成されてしまいました。
うまくいったケース
{
"problem": "法令上、次の文の( )内に当てはまる語句として、正しいものはどれか。 (本記事では略)",
"option": [
"第1類",
"第2類",
"第3類",
"第5類",
"第6類"
],
"answer": 2,
"explanation": "(本記事では省略)"
},
問題文がすべて抜けているケース (対応済み)
{
"problem": "問1",
"option": [
"(A)",
"(B)",
"(C)",
"(D)",
"(E)"
],
"answer": 3,
"explanation": "(本記事では省略)"
},
問題文の選択肢が抜けているケース (対応済み)
{
"problem": "消火剤に関する次のA〜Eの記述のうち、誤っているものはいくつあるか。",
"option": [
"1つ",
"2つ",
"3つ",
"4つ",
"5つ"
],
"answer": 2,
"explanation": "(本記事では省略)"
},
上述の追加指示を加えることで、問題文や選択肢が省略されるケースが激減しました。
(ただしゼロではない)
jsonファイルの統合
jsonファイルをセクションごとに個別生成したので、最後にまとめました。
import json
import glob
load_dir="json_problems"
json_path_list=glob.glob(load_dir+"/*.json")
problem_list=[]
for json_path in json_path_list:
with open(json_path) as f:
txt=f.read()
#ファイル内のヘッダーやフッターの削除
txt=txt.replace('```',"")
txt=txt.replace("json","").strip()
temp_problem_list=json.loads(txt)
problem_list.extend(temp_problem_list)
with open("problems.json","w") as f:
json.dump(problem_list,f,ensure_ascii=False,indent=4)
一連の処理の結果、全部で606件の問題&正答データをjsonで収集できました。

ついでに、書籍データも保存します。
txt_path_list=glob.glob("txt_from_png/*.txt")
txt_path_list.sort(key=lambda x: int(x.split('page_')[1].split('.')[0])) #これでpage_1.txt, page_2.txt, ... となる
txt_list=[]
for path in txt_path_list:
with open(path) as f:
txt=f.read()
txt=txt.replace('```',"")
txt=txt.replace("markdown","").strip()
#txtのはじめがSureの時は、二行目からとる
if txt.startswith("Sure"):
txt = txt.split('\n', 1)[1]
if txt.startswith("Here"):
txt = txt.split('\n', 1)[1]
#一行目に「テキスト」という文字が含まれるときは、二行目からとる
if "テキスト" in txt[:20]:
txt = txt.split('\n', 1)[1]
txt=txt.strip()
txt_list.append(txt)
with open("book.json","w") as f:
json.dump(txt_list,f,ensure_ascii=False,indent=4)
500行ほどのjsonデータを作れました。
書籍は、ページごとに情報が簡潔しているケースが多いので、チャンク処理で悩むことも少なそうです。

試験対策本をRAGで参照しながら回答させる
手法A: 過去の類題をRAGで引っ張って回答させる
過去の類題をjson形式の文字列にした上で、embedding vectorにして検索させます。解きたい問題に対して、とりあえず10問の類題をプロンプトに乗せて回答させてみました。
メモ書き気味ですが、以下のコードで推論しました。
手法B: 過去の類題+本をRAGで引っ張って回答させる
上記に加え、本の各ページもRAGで引っ張ってきて回答させる検討もしました。
以前に試した手法
・モデル単体の性能を調べる。
・法令文書を巡回するエージェントを走らせる
というアプローチも検証しました。
結果
当該試験の「過去に出題された問題」を各アプローチで解かせました。
試験は、法令関連15問、化学知識10問、安全関連20問の45問で構成されています。
試験時間は2.5時間とのことですが、Claude-3.5-sonnetはRAG有りでも5分程度で回答完了しました。
総合結果は以下の通り。
注: 化学知識を問う問題で、問題側の(OCRでの読み取り)ミスがありました。以下、1問分が、誤って誤答としてカウントされています (詳細は後述)

類題のRAGを行ったモデルで、最高の正答率93%が得られました。
(全体では3問ミス)
興味深いのは、類題に加えて、本全体の検索も行うと、精度が下がった点です。質問とは関係性の低い、ノイジーな情報が交じることによって、精度が下がった可能性が高いです。
各問題の正否は以下の通り。

解けなかった問題の確認
問題番号5、12、21は類題をRAGで検索しても解けませんでした。
理由を探っていきます。
問題5 消火器の問題 → 表を正しく参照をすれば正答可能
問題5は、消火器の問題でした。
#https://www.shoubo-shiken.or.jp/kikenbutsu/exercise.htmlより生成
{
"number": 5,
"text": "法令上、危険物とその消火に適応する消火器との組合せについて、次のうち誤っているものはどれか。",
"options": [
"二酸化炭素(第4類危険物に適応)",
"強化液(霧状)(第4類、第5類、第6類危険物に適応)",
"水(棒状)(第5類、第6類危険物に適応)",
"泡(第4類、第5類、第6類危険物に適応)",
"粉末(りん酸塩類等)(第4類、第5類危険物に適応)"
]
},
実は、試験対策本にも、これとそっくりな問題が出題されてました。
{
"problem": "危険物とその火災に適応する消火器の組合せとして、次のうち正しいものはどれか。",
"option": [
"第1類 消火器",
"第2類 霧状の強化液を放射する消火器",
...
]
}
この問題を正しく解くには、「危険物の規制に関する政令」の表を丸暗記する必要があります。

過去問を真面目に勉強されている方は、表を丸暗記されるはずですが、RAG用データには、この表が含まれていなかったので、うまく回答できなかったようです。
この問題については、適切に表を参照できるようにすれば、モデルが正答可能なことが分かっています。
問題12 移動タンク貯蔵所に必要な書類 →対策本を参照すれば回答可能
移動タンク貯蔵所に必要な書類を問う問題でした。
類題集には、ダイレクトにこの問題に関連する質問が含まれていなかった雰囲気なので、誤答したのは、仕方がなさそうです。
ただし、試験対策本の本文には説明がありました(p125)。実際、本文も含めてRAGを行ったケースでは、正解できていました。
問題21 混合物の数を数える問題 → OCR時の読み取りエラーに伴う出題ミス
これは問題側のミスでした。
OCRで問題文、選択肢、回答を自動抽出したのですが、問題文の一部が誤って選択肢に含まれていたようです。
"options": [
"硫黄、ベンゼン、固形アルコール、鉄、エタノール、ラッカー用シンナー、リン、水、ガソリン、ナトリウム、塩素酸カリウム、動植物油",
"1つ",
"2つ",
"3つ",
"4つ",
"5つ"
]
# 硫黄、ベンゼン、 ...の記述は、選択肢(options)ではなく、問題文に含まれている必要があった
モデルの回答自体は合っていたのですが、選択肢が余分に増えていたため、自動採点プログラムの都合上、誤った回答として誤認識されていたようです。
まとめ
本記事のまとめは以下の通りです。
危険物取扱者試験(甲種試験)を解かせるために、試験対策本から、過去問の類題600件程度を自動抽出しました。
通常のOCRとGPT-4oのマルチモーダル認識機能を併用して文章を抽出しました
一部、抽出ミスはありましたが、RAGのソースとして使う分には、十分なレベルの文字抽出ができました
類題をembedding vectorの類似度検索で10問検索し、プロンプトに乗せながらClaude-3.5-sonnetに回答させた結果、45問中、43問で正解しました。
回答時間は5分 (試験時間は2.5時間)
合格ラインの60%は余裕で超えました
解けなかった問題2問についても、もう少し丁寧にRAG用のデータベースを作り込めば、正答できる手応えでした
これまで、色々なアプローチで危険物取扱者試験に取り組んできましたが、原理的には、100%の正答率を出せそうな手応えを得ることができました。
以下、細々とした補足です。
補足1: これまでに試した精度向上のアプローチまとめ
実際に解きたい問題の類題を検索(本記事)
これが最強でした
試験対策本を丸ごと検索(本記事)
ノイズ情報に惑わされて精度が下がるケースが観測されました
精度は殆ど上がりませんでした
モデルが想定して作った「類題」の「ヤマ」が外れたようです
類題を自動生成するのは容易ではなく、それなりのドメイン知識が必要ということが分かりました
一定の精度改善の効果がありました
一方で、法令文書を読むだけでは回答が難しい問題がいくつかありました
補足2: RAGによる幻覚抑制プロセスの再考
今までの実施経験をもとに、RAGに必要そうな要素を整理してみました。
上手くいくケース
RAGによって正しい回答が得られるためには、以下のプロセスを踏む必要がありそうです。
LLMが単体で「それなりに正しい文章」(≒命題の集合)を作れる
システムが「理解が怪しい箇所」について自覚し、適切な検索クエリを生成できる
「理解が怪しい箇所」について正確な記述を行った文献がヒットする
ヒットした文献を読解し、LLMが自身の信念を修正できる

これ以外のケースでは、基本的に回答に失敗します。
以下に失敗例を示します。
失敗例1: 検索の着眼点が間違っている
LLMが自身の信念の間違いや、理解が怪しい箇所に気づくのは、容易ではありません(人間も同様)。
例えば、理解が正しい箇所についての事実検証のみを行い、誤った箇所のチェックを怠った場合は、幻覚を修正できません。

通常、文章には多数の命題が含まれており、すべてを逐一検証するのはかなりコストがかかる*ため、「検証すべき項目」を適切にLLMに選ばせる仕組みが必要になります。
*モデルが高性能化すれば、より多くの命題をエージェント的に検証できるようになります。ただし、検証数やコンテキスト長が増えると判断ミスの確率が指数関数的に上がります。筆者の手応え的には、2-3箇所くらいの確認が、現状のベストであるような気がしています。
失敗例2: 検索クエリが間違っている/検索精度が低い
RAGで成功するには、調べたい命題について、データベースから適切に情報を引っ張ってくる必要があります。この時、検索クエリが不適切であったり、検索システムの精度が低い場合、目的の命題にたどり着けないことが多々あります。

検索のヒット数を増やすことでこの問題は一部、緩和できますが、その分、モデルの読解力を高める必要があり、万能ではありません。
(今回、うまくいった「類題検索」では、文章をまるごと検索するアプローチを取りました。解きたい問題とデータベースのドメインがほぼ一致しているので、適切な回答を得られやすかったようです)
失敗例3: データベースの情報が適切に構造化・整理されていない
RAGには、データベースの品質も重要です。
例えば、「GはBである」という命題にたどり着くために、複数の文献を収集する必要がある場合、エージェントにとっての検索負担と難易度が激増します。
以下の例では、「GはBである」という真の命題を得るために、
「Gはα」、「αはβ」、「βはγ」、「γはB」という4つの命題に関わる文献をたどる必要があるケースです。

十分に賢いエージェント、検索システムであれば、この作業は可能ですが、現実問題としては、どこかのタイミングでミスが生じることが多い印象です。
そのため、検索すべき事象をコンパクトにまとめたデータベースを作る必要があります。
(今回、うまくいった試験対策本・類題のアプローチでは、欲しい情報が一つのセクション内にきれいに纏められているので、この問題が発生しにくいです)
失敗例4: モデルが自身の信念を修正できない
適切なRAG用の文章を与えたにも関わらず、正答を導けないケースが多々ありました。特に、RAG用の文章が長すぎて、不必要な情報が多く含まれる(ノイジーな)ケースで誤答が起きやすいです。
RAG用の文章をきちんと読解して、自身の信念を修正するためには、高い推論性能が求められます。Claude-3.5-sonnetと比べ、GPT-4o、Openai-o1は、読解力がやや劣るかもしれないという示唆が得られています。
以上