🐥

【追加検証】Cloud VisionによるOCR + RAGによる特定分野特化型LLMの構築

2024/10/04に公開

1. はじめに

【簡単に実装!】RAGによる特定分野特化型LLMの構築(基本編)では、RAGを初めて学ぶ方に向けてRAGの原理から簡単な実装を行いました。
しかしながら、文章形式のデータは、参照するべき情報が十分に反映されておらず、表構造のデータは項目と数値の対応が取れていませんでした。

そこで本記事では、1.Google Cloud Vision API を用いたOCRでテキスト処理+LLMで生成する2段階の手法と、2.LLMのみで画像から直接情報抽出する1段階の手法を比較し、文章形式のデータと表構造データの精度が高いLLMシステムの設計・実装を行います。
また、Gradioを使用してWebインターフェースも構築します。

以下は、GradioのUIの実行状況です。後ほどこれを実装していきます。
2024-10-0313.39.55-ezgif.com-video-to-gif-converter.gif

今回OCRにGoogle Cloud Visionを使用した理由は、2つあります。
第1は、Google Cloudを登録すると初期に1000ユニットまで無料で利用できるためです。
多くの方に実装を試していただきたいので、なるべくAPIコストのかからないものを選択しました。
第2に、日本語の読み取り性能が高いという記事が数多く出ていたからです。

参考元1 : https://data.gunosy.io/entry/ocr-tesseract-deeplearning
参考元2 : https://dev.classmethod.jp/articles/using-document-ai-and-google-cloud-vision-api-for-ocr/

2. 設計フロー

設計フローはほとんど同じで、画像からのテキスト化をGoogle Cloud Visionを使用するか、GPT-4o-miniを使用するかの違いです。

【OCR+LLMの設計フロー】
image.png

【LLMのみの設計フロー】
image.png

3. この記事の対象者

  • PDFドキュメントを活用したQ&Aシステムの開発に興味がある方
  • LangChainやGradioなどのライブラリに関心がある方
  • Pythonを用いて何かアプリケーションを作ってみたい方

4. 動作環境・使用するツールや言語・使用するPDF文書

  • OSバージョン

    • Mac(M2)、Windows11
  • 開発環境

    • Google Colablatory
  • 言語

    • Python 3.10.12
  • 主要ライブラリ

    • Google Cloud Vision API
      • 画像からテキストを抽出する(OCR)ために使用しています。
    • LangChain
    • OpenAI API
    • Gradio
      • 主に機械学習を行う際に使用するGoogle Colaboから直接開けるウェブインターフェースとして使用されています。クリックボタンの設置やLLMによる解答欄などが数行で作れるのでこちらを採用しています。
    • pdf2image
      • PDFを画像に変換するモジュール
    • ChromaDB
      • 基本編と同様で、無料で作れる&参考文献が豊富なので採用しています。
  • PDF文書
    実際に企業特化LLMシステムを作る上で、50ページを超えるようなPDFを扱う場合を想定して、76ページのPDFを採用しています。

5. 実装(OCR+LLM)

2.設計フローをもとにコードを肉付けしていきます。

5.1 必要なライブラリのインポート

import os
import io
import gradio as gr
from google.cloud import vision
from google.oauth2 import service_account
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain.vectorstores import Chroma
from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferMemory
from langchain.schema.runnable import RunnablePassthrough
from langchain.prompts import PromptTemplate
from langchain.retrievers import BM25Retriever, EnsembleRetriever
from pdf2image import convert_from_path

【コードの説明】

  • service_account : Google Cloud サービスの認証に必要なクレデンシャルを管理
  • RecursiveCharacterTextSplitter : 長いテキストを小さなチャンクに分割
  • ConversationalRetrievalChain : 会話型の検索と質問応答を組み合わせたチェーン
  • ConversationBufferMemory : 会話の履歴を保持するためのメモリ
  • RunnablePassthrough : LangChainの実行可能なコンポーネントを作成
  • PromptTemplate : LLMへの入力プロンプトを定義するためのテンプレート
  • BM25Retriever, EnsembleRetriever : BM25アルゴリズムを使用したキーワード検索と、複数の検索手法を組み合わせたアンサンブル検索(=ハイブリッド検索)
  • convert_from_path : PDFファイルを画像に変換するためのライブラリ


5.2 Google Cloud Vision クライアントの作成

Google Cloud Vision APIにアクセスするためのクライアントを作成します。認証情報のパスを受け取り、環境変数を設定した後、認証済みのクライアントを返します。

def create_vision_client(credentials_path):
    os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = credentials_path
    return vision.ImageAnnotatorClient(credentials=service_account.Credentials.from_service_account_file(credentials_path))

【コードの説明】

  • os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = credentials_path
    • これはGoogle Cloud SDKが認証情報を見つけるために使用する環境変数を設定しています。credentials_pathには、サービスアカウントキーのJSONファイルへのパスを指定します。
    • Open AIではAPIkeyがアカウントキーでしたが、Google cloud apiはjsonファイル中にKeyが入っています。jsonファイルを取得する方法は以下のウェブサイトが非常にわかりやすいです。
  • credentials = service_account.Credentials.from_service_account_file(credentials_path)
    • 指定されたJSONファイルから認証情報を読み込みます
      • jsonファイルを確認してみると、プロジェクトID、クライアントID、秘密鍵など、APIにアクセスするために必要な全ての情報が辞書形式で書いてあります。
  • return vision.ImageAnnotatorClient()
    • ImageAnnotatorClientのインスタンスを作成&返すことで、OCRのVison APIの機能にアクセスできるようになります。


5.3 PDFファイルからテキストを抽出

PDFファイルからテキストを抽出して、検証用にPDFのページ番号もテキストに追加できる関数を作成します。

def extract_text_from_pdf(vision_client, pdf_path):
    text = ""
    for page_num, image in enumerate(convert_from_path(pdf_path), start=1):
        image_bytes = io.BytesIO()
        image.save(image_bytes, format='PNG')
        try:
            response = vision_client.document_text_detection(image=vision.Image(content=image_bytes.getvalue()))
            text += f"--- ページ {page_num} ---\n{response.full_text_annotation.text}\n\n"
        except Exception as e:
            print(f"OCRプロセスでエラーが発生しました (ページ {page_num}): {str(e)}")
    return text

【コードの説明】

  • 関数の定義を行います。
    • def extract_text_from_pdf(vision_client, pdf_path)は、Google Cloud Vison APIのクライアントオブジェクトであるvision_clientとPDFファイルへのパスpdf_pathを2つの引数とします。
  • 変数の初期化を行います。
    • text = ""で空の文字列textを初期化して、ここの各ページのテキストを追加していきます。
  • PDFの各ページを処理するループを作ります。
    • convert_from_path(pdf_path)でPDFの各ページを画像に変換してから、ページ番号が1から始まるので、画像とページ数が対応するように、start = 1とします。
  • 画像をバイトストリームに変換します。
    • 'io.BytesIO()'でメモリ内にバイトストリームを作成して、image.save()メソッドで画像をPNG形式でバイトストリームに保存します。
  • OCRの実行を行います。
    • try-exceptブロックでエラーハンドリングを行いつつ、vision_client.document_text_detection()メソッドで画像からテキストを抽出します。
    • 検証の際にPDFのページに戻れるように、抽出されたテキストをページ番号とともにtextに追加します。


5.4 QAシステムの初期化

PDFファイルからテキストを抽出し、質問応答(QA)システムを初期化する関数を作ります。

def initialize_qa_system(pdf_path, vision_client):
    text = extract_text_from_pdf(vision_client, pdf_path)
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=250, separators=["\n\n", "\n", "| ", " | ", "-+-", " ", ""])
    split_docs = text_splitter.create_documents([text])
    
    embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
    vectorstore = Chroma.from_documents(split_docs, embedding=embeddings, persist_directory=".")
    
    bm25_retriever = BM25Retriever.from_documents(split_docs)
    bm25_retriever.k = 3
    ensemble_retriever = EnsembleRetriever(retrievers=[vectorstore.as_retriever(), bm25_retriever], weights=[0.8, 0.2])
    
    llm = RunnablePassthrough() | ChatOpenAI(model_name="gpt-4o-mini", temperature=0)
    memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True, output_key="answer")
    
    prompt = PromptTemplate(
        template="""あなたは与えられた文書の内容に基づいて質問に答える専門家です。
        以下の文脈情報と質問に対して、文書の情報のみを使用して詳細かつ正確に回答してください。
        文書に関連する情報がない場合は、「申し訳ありませんが、この文書にはその情報が含まれていません」と答えてください。
        表や図表の情報が含まれている場合は、その構造や関係性を考慮して回答してください。

        文脈情報:
        {context}

        質問: {question}

        回答:""",
        input_variables=["context", "question"]
    )
    
    return ensemble_retriever, llm, memory, prompt, vectorstore

【コードの説明】

  • テキストの抽出とチャンクの分割
    • extract_text_from_pdf() 関数でPDFからテキストを抽出します。
    • RecursiveCharacterTextSplitter クラスを使用してテキストを小さなチャンクに分割します。
      • 基本編では、Azureがchunk_size = 512, chunk_over = 128(割合4:1)で行っていましたが、PDFの境目で情報の分断が起きるのを防ぎたくて、chunk_sizeを広げました。chunk_overをそれに伴い増やしています。
      • separatorsは、自分が読み込むPDFが何の記号を用いてテキストの意味を分断しているのかを考えて指定すると良いと思います。
    • create_documents()メソッドで分割されたテキストからドキュメントのリストを作成します。
  • テキストの埋め込みとベクトルストアの作成
    • OpenAIEmbedingクラスでモデルを初期化する
    • チャンクをベクトル化するために、Chroma.from_documents()メソッドでChromaベクトルストアに保存する
      • 今回は、Google Colabでベクトルストアが生成されたのをすぐ確認するため、カレントディレクトに保存しています。
  • 検索システムの設定
    • 今回は化学系で、特定分野のキーワードが出てくると思ったので、ハイブリッド検索を作りました。
    • BM25Retrieverを使用してキーワード検索システムを初期化する
    • bm25_retriever.k = 3でBM25検索をするときは、上位3件の結果を取得するように設定する
    • EnsembleRetrieverを使用して、ベクトル検索とBM25検索(キーワード検索)を組み合わせたハイブリッド検索システムを作成
      • weights=[0.8, 0.2] : ベクトル検索に80%、BM25検索に20%の重みを付与。以下の参照元より、この割合が最も精度が高いようなので採用した。

        引用元:ハイブリッド検索の融合方法の検討と Azure AI Search への実装
        https://qiita.com/nohanaga/items/91cc6fb0eff06af94a19

    • 言語モデルとメモリの設定
      • RunnablePassthrough()ChatOpenAI を組み合わせて言語モデルを設定。
      • ConversationBufferMemory を使用して会話履歴を保持するメモリを初期化。


5.5 質問に対する回答を生成

def answer_question(question, k_val, retriever, llm, memory, prompt, vectorstore):
    try:
        retriever.retrievers[0].search_kwargs["k"] = k_val
        retriever.retrievers[1].k = k_val

        qa = ConversationalRetrievalChain.from_llm(
            llm=llm, retriever=retriever, memory=memory, return_source_documents=True,
            verbose=True, combine_docs_chain_kwargs={"prompt": prompt}, chain_type="stuff",
            return_generated_question=False, output_key="answer"
        )

        result = qa.invoke({"question": question, "chat_history": []})
        vector_results = vectorstore.similarity_search_with_relevance_scores(question, k=len(result["source_documents"]))
        doc_score_map = {doc.page_content: score for doc, score in vector_results}

        sources = [f"ページ: {doc.metadata.get('page_number', 'N/A')}\n類似度: {doc_score_map.get(doc.page_content, 0):.4f}\nテキスト:\n{doc.page_content}\n\n" 
                   for doc in result["source_documents"][:k_val]]

        return result["answer"], "\n".join(sources)
    except Exception as e:
        return f"エラーが発生しました: {str(e)}", ""

【コードの説明】

  • リトリーバーの設定

    • アンサンブルリトリーバーの各コンポーネント(ベクトル検索とBM25検索)の
      k値(取得するドキュメント数)を設定します。
  • 会話型検索チェーンの作成

    • ConversationalRetrievalChain.from_llm() メソッドを使用して、会話型の
      検索と質問応答を組み合わせたチェーンを作成する。
      • 関連ドキュメントを返して欲しいので、return_source_documents = True
        に設定
      • すべての関連ドキュメントを一度にプロンプトに「詰め込む」方式
        chain_type="stuff" を採用
  • 質問応答の実行

    • qa.invoke() メソッドを呼び起こして、質問に対する回答を生成します。
    • 今回は各質問を独立したものとするため、空のchat_historyを使用しています。
  • 関連ドキュメントのスコア計算

    • similarity_search_with_relevance_scores() メソッドを使用して、質問に
      関連するドキュメントとその類似度スコアを取得します。
    • 取得したスコアを辞書形式で保存しています。
  • 情報源の整形

    • リスト内包表記を使用して、各関連ドキュメントの情報(ページ番号、類似度ス
      コア、テキスト内容)を整形します。
      • :.4f フォーマット指定子を使用することで、類似度スコアを小数点以下4桁
        までの表示にします。

5.6 Gradioを使用してユーザーインターフェースを作成

Gradioライブラリを使用して、質問応答システムのためのウェブベースのユーザーインターフェースを実行可能な関数を作成します。

def create_gradio_interface(qa_system):
    with gr.Blocks(title="化学薬品等取扱いの手引き Q&Aシステム") as demo:
        gr.Markdown("# 化学薬品等取扱いの手引き Q&Aシステム")
        gr.Markdown("化学薬品等取扱いの手引きに関する質問に答えます。")

        chatbot = gr.Chatbot(label="会話履歴")
        msg = gr.Textbox(label="質問", placeholder="ここに質問を入力してください...", lines=3)
        k_val_slider = gr.Slider(minimum=1, maximum=10, value=3, step=1, label="抽出数")

        with gr.Row():
            submit_button = gr.Button("送信")
            clear_button = gr.Button("クリア")

        sources_output = gr.Textbox(label="参照元ドキュメント", lines=20, visible=True)

        def user(user_message, chat_history, k_val):
            return "", chat_history + [[user_message, None]]

        def bot(chat_history, k_val):
            user_message = chat_history[-1][0]
            answer, sources = qa_system(user_message, k_val)
            chat_history[-1][1] = answer
            return chat_history, sources

        def clear_chat():
            return None, 3, ""

        submit_button.click(
            user,
            inputs=[msg, chatbot, k_val_slider],
            outputs=[msg, chatbot],
            queue=False
        ).then(
            bot,
            inputs=[chatbot, k_val_slider],
            outputs=[chatbot, sources_output]
        )

        clear_button.click(
            clear_chat,
            outputs=[chatbot, k_val_slider, sources_output]
        )

    return demo

【コードの説明】

  • Gradioの基本構造

    • gr.Blocks() を使用すると、Gradio UIの骨組みを作成することができます。
    • 'title'でWebページのタイトルを、'with'ステートメントを使うと、このブロック内のすべてのコードが demo オブジェクトと同期できます。
  • 入力要素、ボタン、出力要素の配置

    • gr.Row() で要素を横並びに配置できます。
    • gr.Textbox() で質問入力用のテキストボックスを作成します
    • 検索による抽出数は、gr.Slider() で調整可能です。
    • gr.button() でボタンを作成できます。簡単すぎて笑ってしまいました。UIのコーディングを一度もしたことないので、よくわかりませんが、このGradioは簡単すぎて良いですね。笑
    • 同様にして、gr.Textbox で回答と参照元ドキュメントのテキストボックスが表示できます。
  • 送信ボタンとクリアボタンの動作定義

    • submit_button.click() で送信ボタンをクリックしたときの動作が定義できます。qa_system 関数を呼び出して、入力と出力を指定できます。
    • 同様にして clear_button.click() でクリアボタンがクリックしたとき、入力欄や表示欄がクリアされ、スライダーが初期位置に戻る動作が定義できます。多少ややこしいのでコードを抜き出して解説します。


5.7 アプリケーションのメイン処理を行う

最後に、アプリケーション全体のフローを制御/実行するメイン関数を定義します。

def main():
    credentials_path = "/content/drive/MyDrive/third-strategy-435210-h5-192ddf08752c.json"
    pdf_path = "/content/drive/MyDrive/化学薬品等取扱いの手引き.pdf"

    vision_client = create_vision_client(credentials_path)
    retriever, llm, memory, prompt, vectorstore = initialize_qa_system(pdf_path, vision_client)

    qa_system = lambda question, k_val: answer_question(question, k_val, retriever, llm, memory, prompt, vectorstore)

    demo = create_gradio_interface(qa_system)
    demo.launch()

【コードの説明】

  • 必要なファイルパスの設定

    • credentials_pathで、Google Cloud Vision APIの認証情報(サービスアカウントキー)のJSONファイルへのパスを指定します。
    • pdf_pathで質問応答の対象となるPDFファイル(今回は「化学薬品等取扱いの手引き」)へのパスを指定します。
  • Vision APIクライアントの作成

    • create_vision_client()関数を呼び出して、Google Cloud Vision APIのクライアントを作成する。これは、PDFファイルからテキストを抽出するときに使います。
  • QAシステムの初期化

    • initialize_qa_system()関数を呼び出して、QAシステムの主要コンポーネントを初期化します。
  • 質問応答関数の定義

    • lambda関数を使って、qa_system関数を定義します。この関数は質問(question)と抽出数(k_val)を引数として受け取って、answer_question()関数を呼び出します。
  • Gradioインターフェースの作成

    • create_gradio_interface()関数を呼び出して、ウェブベースのユーザーインターフェースを作成し、demo変数に格納します。
  • アプリケーションの起動

    • launch()メソッドでGradioアプリケーションを起動する。


6. 実装(LLMのみ)

半分くらいはOCR+LLMと内容が同じなので、差分のみ説明します!

6.1 必要なライブラリのインポート

import gradio as gr 
import base64 
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain.vectorstores import Chroma
from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferMemory
from langchain.schema.runnable import RunnablePassthrough
from langchain.prompts import PromptTemplate
from langchain.retrievers import BM25Retriever, EnsembleRetriever
from pdf2image import convert_from_path
import requests
import os
import time
import random


6.2 APIコールの再試行ロジック:安定性と効率性の両立

今回、APIコールで何回も失敗してしまいました。OpenAIのAPIはレート制限を設けているので、短時間に送るリクエストの制限しなければならない or レート制限にかかったら場合待機時間を増やしてレート上限に達しないように設定しなければなりません。

def api_call_with_retry(func, max_retries=5):
    for i in range(max_retries):
        try:
            return func()
        except Exception as e:
            if 'rate_limit_exceeded' in str(e):
                wait_time = (4 ** i) + random.random()  
                print(f"Rate limit exceeded. Waiting for {wait_time:.4f} seconds.")
                time.sleep(wait_time)
            else:
                raise
    raise Exception("Max retries exceeded")

レート制限に達した場合のために以下を設定します。

  • 4**iにより、レート制限エラーが起きた後の最試行回数が増えるごとに、待機時間が指数関数的に増加するように設定することで、連続的なレートエラーを防ぎます。

    引用元 : OpenAI Cookbook examples : API 使用方法 : レート制限の操作 (翻訳/解説)
    https://tensorflow.classcat.com/2023/08/09/openai-cookbook-examples-how-to-handle-rate-limits/

  • 待機時間をコンソールに出力し、time.sleep()で指定時間だけ処理を一時停止させます。


6.3 LLMで画像からテキストを抽出する関数

OCRではなく、画像の内容を理解/解釈してテキスト化できることを期待して、gpt-4o-miniを使用し、画像からテキストを抽出する関数を実装します。

def extract_text_from_image(image_path):
    with open(image_path, "rb") as image_file:
        base64_image = base64.b64encode(image_file.read()).decode('utf-8')

    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"
    }

    payload = {
        "model": "gpt-4o-mini",
        "messages": [
            {
                "role": "user",
                "content": [
                    {
                        "type": "text",
                        "text": "この画像から全てのテキストを抽出してください。以下の点に注意してください:\n"
                               "1. 表形式のデータは、その構造を維持してください。各セルの内容を正確に抽出し、セルの区切りには '|' を使用してください。\n"
                               "2. 図や図表に含まれるテキストは、その配置や関係性を考慮して抽出してください。\n"
                               "3. 段落や文章の構造を維持し、改行や段落の区切りを適切に表現してください。\n"
                               "4. 箇条書きやリストの形式は保持してください。\n"
                               "5. ヘッダー、フッター、ページ番号なども含めて抽出してください。\n"
                               "6. テキストの前後関係や文脈を維持することに注意してください。\n"
                               "出力は純粋なテキスト形式で、マークダウンや特殊な書式は使用しないでください。"
                    },
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": f"data:image/jpeg;base64,{base64_image}"
                        }
                    }
                ]
            }
        ],
        "max_tokens": 8192
    }
    
    def make_api_call():
        response = requests.post("https://api.openai.com/v1/chat/completions", headers=headers, json=payload)
        response_json = response.json()
        print("API Response:", response_json)  # レスポンスの内容を出力
        if 'error' in response_json:
            raise Exception(f"API Error: {response_json['error']}")
        return response_json['choices'][0]['message']['content']

    return api_call_with_retry(make_api_call)
  • 関数の定義

    • with open(image_path, "rb") as image_file: base64_image = base64.b64encode(image_file.read()).decode('utf-8')
      • 画像ファイルをBase64式にエンコードすることで、画像データをAPI要求のJSONpayloadに含有することができるようになります。
  • API呼び出しに必要なHTTPヘッダーの設定

    • headers = { "Content-Type": "application/json", "Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"}
      -content-Type : JSONデータの送信
      -Authorization : OpenAI APIキーを使用した認証
  • OpenAI APIへの具体的な指示を含んだペイロードの構築

    • role : ユーザーからの入力
    • content : テキストと画像を含む配列
      • テキスト部分では、画像からのテキスト抽出に関する具体的な指示を提示してます。今回使用したPDFは表形式のデータが多かったので、表形式のデータ構造や段落構造、図表ないのテキストの関係性等をプロンプトでの指示を入れました。

6.4 QAシステムの初期化関数

PDFファイルからテキストを抽出し、質問応答(QA)システムを初期化する関数を作ります。

def initialize_qa_system():
    # PDFをイメージに変換
    images = convert_from_path("/content/drive/MyDrive/化学薬品等取扱いの手引き.pdf")

    # イメージからテキストを抽出
    text = ""
    for i, image in enumerate(images):
        image_path = f"temp_image_{i}.jpg"
        image.save(image_path)
        text += extract_text_from_image(image_path)
        os.remove(image_path)
        time.sleep(2)  # APIコールの間に2秒の待機時間を追加


    # テキストを分割
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=1024,
        chunk_overlap=256,
        length_function=len,
        separators=["\n\n", "\n", " ", ""]
    )
    split_docs = text_splitter.create_documents([text])

    # OpenAIのチャットモデルを初期化
    base_llm = ChatOpenAI(model_name="gpt-4o-mini", temperature=0)
    llm = RunnablePassthrough() | base_llm

    # OpenAIの埋め込みモデルを初期化
    embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

    # Chromaベクトルストアを作成
    vectorstore = Chroma.from_documents(split_docs, embedding=embeddings, persist_directory=".")

    # BM25Retrieverを初期化
    bm25_retriever = BM25Retriever.from_documents(split_docs)
    bm25_retriever.k = 3

    # EnsembleRetrieverを作成
    ensemble_retriever = EnsembleRetriever(
        retrievers=[vectorstore.as_retriever(), bm25_retriever],
        weights=[0.8, 0.2]
    )

    # メモリを初期化
    memory = ConversationBufferMemory(
        memory_key="chat_history",
        return_messages=True,
        output_key="answer"
    )

    # プロンプトテンプレート
    template = """あなたは与えられた文書の内容に基づいて質問に答える専門家です。
    以下の文脈情報と質問に対して、文書の情報のみを使用して詳細かつ正確に回答してください。
    文書に関連する情報がない場合は、「申し訳ありませんが、この文書にはその情報が含まれていません」と答えてください。

    文脈情報:
    {context}

    質問: {question}

    回答:"""

    PROMPT = PromptTemplate(template=template, input_variables=["context", "question"])

    return ensemble_retriever, llm, memory, PROMPT, vectorstore

  • time.sleep(2) : PDFを画像に変換して、テキスト化する際にもAPIのレート制限が数回かかったので、抽出処理の間に2秒の待機時間を設定しています。
  • あとはOCR+LLMと同じです。


6.5 質問応答関数

初期化されたQAシステムを使用して、ユーザーからの質問に対して応答を生成する機能を実装します。

# QAシステムを初期化
retriever, llm, memory, PROMPT, vectorstore = initialize_qa_system()

def answer_question(question, k_val):
    try:
        # 抽出数を動的に設定
        retriever.retrievers[0].search_kwargs["k"] = k_val
        retriever.retrievers[1].k = k_val

        qa = ConversationalRetrievalChain.from_llm(
            llm=llm,
            retriever=retriever,
            memory=memory,
            return_source_documents=True,
            verbose=True,
            combine_docs_chain_kwargs={"prompt": PROMPT},
            chain_type="stuff",
            return_generated_question=False,
            output_key="answer"
        )

        result = qa.invoke({"question": question, "chat_history": []})
        answer = result["answer"]
        source_documents = result["source_documents"]

        # ベクトル検索を使用してスコアを取得
        vector_results = vectorstore.similarity_search_with_relevance_scores(question, k=len(source_documents))

        # ドキュメントとスコアのマッピングを作成
        doc_score_map = {doc.page_content: score for doc, score in vector_results}

        # 参照元ドキュメントのリストを作成
        sources = []
        for i, doc in enumerate(source_documents[:k_val]):
            score = doc_score_map.get(doc.page_content, 0)  # スコアが見つからない場合は0とする
            source_info = f"ページ: {doc.metadata.get('page_number', 'N/A')}\n"
            source_info += f"類似度: {score:.4f}\n"
            source_info += f"チャンク(取得テキスト): {doc.page_content}\n\n"
            sources.append(source_info)

        return answer, "\n".join(sources)
    except Exception as e:
        return f"エラーが発生しました: {str(e)}", ""

OCR+LLMのコード説明を参照してください。


6.6 Gradioインターフェースの作成

with gr.Blocks(title="化学薬品等取扱いの手引き Q&Aシステム") as demo:
    gr.Markdown("# 化学薬品等取扱いの手引き Q&Aシステム")
    gr.Markdown("化学薬品等取扱いの手引きに関する質問に答えます。")

    with gr.Row():
        question_input = gr.Textbox(label="質問", lines=3, placeholder="ここに質問を入力してください...")
        k_val_slider = gr.Slider(minimum=1, maximum=10, value=3, step=1, label="抽出数")

    with gr.Row():
        submit_button = gr.Button("Submit")
        clear_button = gr.Button("Clear")

    answer_output = gr.Textbox(label="回答", lines=10)
    sources_output = gr.Textbox(label="参照元ドキュメント", lines=20)

    def clear_inputs():
        return "", 3, "", ""

    submit_button.click(
        answer_question,
        inputs=[question_input, k_val_slider],
        outputs=[answer_output, sources_output]
    )

    clear_button.click(
        clear_inputs,
        outputs=[question_input, k_val_slider, answer_output, sources_output]
    )

# Gradioアプリの起動
demo.launch()

OCR+LLMのコード説明を参照してください。

【GradioのUI】
Gradioアプリが起動すると、Google colab上で下図のようなUIが表示されます。

  • Google Colab上のQAシステム
    こちらでもQAシステムを使うことができますし、https://~のURLを押していただくと、Public URLの飛んでより広い画面でQAシステムを利用することができます。

image.png

  • Public URL上のQAシステム
    2024-10-0313.39.55-ezgif.com-video-to-gif-converter.gif


7 検索クエリに対する出力の検証

今回は検証として、ユーザーの質問に対しての回答箇所が、「1.文章, 2.特定のキーワード, 3.表データのキーワード, 4.表データの数値」の4つパターン調査して、OCR+LLMとLLMのみの場合の精度(出力の確からしさ)を測っていきたいと思います。

詳細の結果の前に、1~4の結果をまとめておきます。

結論としては、LLMはPDFの表データの構造化はできますが、漢字の誤認識が非常に多かったため、精度はかなり低い結果になりました。
OCR+LLMは表構造データが少し崩れていましたが、文字の認識率が見たところ100%で回答精度が高く、実用性はOCR+LLMに軍配があがりました。

PDFの検索対象 OCR+LLM LLMのみ
1.文章 ⭕️
2.特定のキーワード ⭕️
3.表データのキーワード ⭕️
4.表データの数値 ⭕️


7.1 検索クエリの対象が文章であるとき

■ 検索クエリ
劇薬や毒物を扱うときは、どのように保存すればいいですか


■ 回答クエリ(OCR+LLM)

劇薬や毒物を扱う際の保存方法は以下の通りです。

  1. 保管場所 : 劇薬や毒物は専用の金属製保管庫で保管し、常時施錠する必要があります。

  2. 要管理物質 : 要管理物質は施錠可能な保管場所で保管します。

  3. 保管庫の固定 : 保管庫は床等に固定し、安定した状態で保管します。

  4. 表示 : 保管庫には「医薬用外毒物」や「医薬用外劇物」の表示を行います。

  5. 保管庫の点検 : 保管庫に腐食、亀裂、破損等がないかを定期的に確認します。


■ 回答クエリ(LLMのみ)
劇薬や毒物を扱う際の保存方法は以下の通りです。

  • 毒物は専用の冷蔵庫で、常時施錠し保管すること。
  • 要管理物質は施錠可能な保管場所で保管すること。
  • 保管場所は常に定めておくこと。
  • 薬品棚には管理者の指示を記載すること。
  • 保管庫に棚番や電源、破損等がないか定期的に確認すること。


■ LLMが参考にした文脈情報(OCR+LLM)

3-5.毒物・劇物・要管理物質の取扱い
毒物及び劇物取締法により毒物又は劇物として指定されている化学物質 (p.58)については、以
下のように適切な取扱いをしてください。また、特定毒物については、 特定毒物研究者の許可を受け
た者でなければ使用できません。要管理物質(p.59) は、毒劇物に準じた管理をお願いします。
■ 容器について
• 環境安全管理室発行のバーコードラベルを貼る
• 飲食物用に使用される容器に移し替えてはならない
別な容器に移し替えた場合はその容器にも 医薬用外毒物」(赤地に白字で)、「医薬用外
劇物」(白地に赤字で)と表示する
■ 保管について
毒劇物は専用金属製保管庫で、常時施錠し保管する
要管理物質は施錠可能な保管場所での保管する
保管庫は、床等に固定する
保管庫に「医薬用外毒物 」「 医薬用外劇物」の表示をする
薬品棚には転落防止用の措置を講じる
保管庫に腐食・亀裂・破損等がないか定期的に確認する

■ LLMが参考にした文脈情報(LLMのみ)

3-5. 毒物・劇物・要管理物質の取扱い

毒物及び劇物取締法による毒物は劇物として指定されている化学物質 (p.58) については、以下のように適切に取扱ってください。また、特定毒物については、特定毒物研究者の許可を受けた者でなければ使用できません。要管理物質 (p.59) は、毒劇物に準じて管理をおこないます。

■ 容器について
- 環境安全管理室発行のバーコードラベルを貼る
- 飲食物用に使用される容器に移し替えてはならない
- 別な容器に移し替えた場合はその容器にも「医薬用外毒物」(赤地に白字で)、「劇物」と表示する

■ 保管について
- 毒物は専用の痛床及び管理室で、常時施錠し保管する
- 要管理物質は施錠可能な保管場所での保管する
- 保管場所は、常に定定する
- 薬品棚には管理者の指示を記し
- 保管庫に棚番・電源・破損等がないか定期的に確認する 

■ PDF内の該当箇所
image.png

■ 結果と考察
結果としては、OCR+LLMが正解、LLMのみが50%程度正解になります。
ここの50%の差は、表示 : 保管庫には「医薬用外毒物」や「医薬用外劇物」の表示があるかないかに加え、PDF内の文字をそのままテキスト化できているかどうかです。
PDF内の該当箇所には、容器/保管についての2箇所で赤文字で「<font color="#FF0000">"医薬用外毒物"と”医薬用外劇物”</font>の表示が必要」と書かれているので、これを書いていないのは割と致命的だと思うので、評価を下げました。
また、LLMの参考箇所とPDF内の該当箇所を比較して見てみると、OCR+LLMでは完全にテキスト化できている一方で、LLMのみでは、保管のところで1行テキスト化できていない部分があったり、誤字がかなり目立ちます。

【誤字の具体例】

PDFの該当箇所 LLMが参考にした文脈情報(OCR+LLM) LLMが参考にした文脈情報(LLMのみ)
保管庫は、床等に固定する 保管庫は、床等に固定する 保管場所は、常に<font color="red">定定</font>する
保管庫に「医薬用外毒物」「医薬用外劇物」の表示をする 保管庫に「医薬用外毒物」「医薬用外劇物」の表示をする <font color="red">記載なし</font>
保管庫に腐食・亀裂・破損等がないか定期的に確認する 保管庫に腐食・亀裂・破損等がないか定期的に確認する 保管庫に<font color="red">棚番・電源・破損等</font>がないか定期的に確認する

上記の結果を踏まえると、テキストを文字通りに書き起こせるOCR+LLMの方が優秀であると判断できます。おそらく、gpt-4o-miniで画像処理を行うと、認識した文字を文脈に沿って最適だと判断した文字に直してしまうのかな?と思います。


7.2 検索クエリの対象が特定のキーワードであるとき

7.1の実験は、頻出するワードに対して検索をかけました。この7.2ではこの文章でおそらく1回しか出現しない"エネルギー分散型蛍光X線分析装置"について検索してみたいと思います。

■ 検索クエリ
エネルギー分散型蛍光X線分析装置について知ってることを教えてください



■ 回答クエリ(OCR+LLMver)

エネルギー分散型蛍光X線分析装置(島津製作所: EDX-8000)の用途は、固体・液体試料中の元素の定性及び半定量分析です。以下はその仕様です:

  • 検出可能元素 : 6C~92U
  • X線管球 : Rhターゲット(4~50kV、1~1000 μA)
  • 照射面積 : 1, 3, 5, 10 mmΦ
  • 検出器 : SDD(シリコンドリフト)検出器
  • 検出下限 : 数百 ppm~数%(測定元素、分析条件により異なる)
  • 定量範囲 : 数%~(測定元素、分析条件により異なる)


■ 回答クエリ(LLMのみver)
エネルギー分散型紫外線X線分析装置(島津製作所:EDX-8000)
用途:固体・液体試料中の元素の定性及び定量分析
【仕様】
検出器性能:6C~92U
X線高電圧:Rhターゲット(4~50 kV、1~1000 μA)
照射時間:1.3,5,10 mm φ
検出器:SDD(シリコンディスリフト)検出器
検出限度:数百 ppm ~ %(測定元素、分析条件により異なる)
定量限度:数%(測定元素、分析条件により異なる)

■ LLMが参考にした文脈情報(OCR+LLM ver)

・エネルギー分散型蛍光X線分析装置 (島津製作所:EDX-8000)
用途: 固体・液体試料中の元素の定性及び半定量分析
[仕様]
検出可能元素:6C~92U
X線管球 : Rh ターゲット(4~50kV、 1~1000 μA)
照射面積:1,3,5,10mmΦ
検出器 SDD(シリコンドリフト)検出器
検出下限:数百 ppm~数%(測定元素、分析条件により異なる)
定量範囲:数%~ (測定元素、 分析条件により異なる)

■ LLMが参考にした文脈情報(LLM only)

- エネルギー分散型紫外線X線分析装置(島津製作所:EDX-8000)
  用途:固体・液体試料中の元素の定性及び定量分析
  【仕様】
  検出器性能:6C~92U
  X線高電圧:Rhターゲット(4~50 kV、1~1000 μA)
  照射時間:1.3,5,10 mm φ
  検出器:SDD(シリコンディスリフト)検出器
  検出限度:数百 ppm ~ %(測定元素、分析条件により異なる)
  定量限度:数%(測定元素、分析条件により異なる)

■ PDF内の該当箇所
スクリーンショット 2024-09-20 23.08.30.png

■ 結果と考察
OCR+LLMの方が圧倒的に正確であるという結果になりました。どちらにも数値ミスはほとんどありませんでしたが、LLMのみの方にやはり説明が足りない部分と誤字が沢山ありました。1行の文章に1ミスはあるので、結構実務利用には辛いですね。ここまでで、社内文書や契約書、名刺など誤字が絶対にあってはいけないものにはOCRが必須であることがわかりました。

【誤字の部分】

PDFの該当箇所 LLMが参考にした文脈情報(OCR+LLM) LLMが参考にした文脈情報(LLMのみ)
エネルギー分散型蛍光X線分析装置(島津製作所: EDX-8000)用途 : 固体・液体試料中の元素の定性及び半定量分析 エネルギー分散型蛍光X線分析装置(島津製作所: EDX-8000)の用途は、固体・液体試料中の元素の定性及び半定量分析 エネルギー分散型<font color="red">紫外線</font>X線分析装置(島津製作所:EDX-8000)用途:固体・液体試料中の元素の定性及び<font color="red">定量</font>分析
検出可能元素:6C~92U 検出可能元素 : 6C~92U 検出<font color="red">器性能</font>:6C~92U
X線管球 : Rhターゲット X線管球 : Rhターゲット X線<font color="red">高電圧</font>:Rhターゲット
照射面積:1,3,5,10mmφ 照射面積:1,3,5,10mmΦ 照射<font color="red">時間</font>:1.3,5,10 mm φ
検出器 SDD(シリコンドリフト)検出器 検出器 SDD(シリコンドリフト)検出器 検出器:SDD<font color="red">(シリコンディスリフト)</font>検出器
検出下限:数百 ppm~数%(測定元素、分析条件により異なる) 検出下限:数百 ppm~数%(測定元素、分析条件により異なる) 検出<font color="red">限度</font>:数百 ppm ~ <font color="red">%</font>(測定元素、分析条件により異なる)
定量範囲:数%~(測定元素、分析条件により異なる) 定量範囲:数%~(測定元素、分析条件により異なる) 定量<font color="red">限度</font>:数%(測定元素、分析条件により異なる)


7.3 検索クエリの対象が表データのキーワードであるとき

■ 検索クエリ
重金属系の廃液を分類するときの注意点について教えてください


■ 回答クエリ(OCR+LLM)
重金属系の廃液を分類する際の注意点は以下の通りです:

  1. 含有する重金属の種類: 重金属系廃液には、鉄、ニッケル、銅、亜鉛、マンガン、鉛、カドミウムなどの重金属類が含まれます。

  2. 有害元素の取り扱い: ベリリウム、オスミウム、タリウム、ヒ素、セレンなどの有害元素を含む廃液は、別途回収する必要があります。

  3. 混入防止: 無機廃液には極力有機物が混入しないように注意することが求められています。


■ 回答クエリ(LLMのみ)
重金属系の廃液を分類する際の注意点は以下の通りです:

  • 重金属系廃液には、鉄、ニッケル、銅、亜鉛、マンガン、鉛、カドミウムなどの重金属類が含まれます。
  • ベリリウム、オスミウム、タリウム、ヒ素、セレンなどの有害元素を含む場合は、別途回収する必要があります。
  • 他の成分が混入しないように注意してください。


■ LLMが参考にした文脈情報(OCR+LLM)

4-2. 実験廃液の分類
当機構では、実験廃液を以下のように分類しています。次ページの早見表も参考にしてください。
(手続きについては p.9 参照)
区分
できるだけ重金属類など他の成分が混入しないようにしてく
ださい。
無機水銀を含む水溶液 (金属水銀は除く。)
水銀系
廃液
クロム系
クロムを含む水溶液
重金属系
鉄、ニッケル、銅、亜鉛、マンガン、鉛、カドミウムなどの重
金属類を含む水溶液
※ベリリウム、オスミウム、 タリウム、ヒ素、セレンなどの有
害元素を含むものは、 別途回収してください。

表になった途端に、構造が壊れてしまうので、単語や数字の前後関係が崩れてしまい、ハルシネーションが起きる原因となりそうです。


■ LLMが参考にした文脈情報(LLMのみ)

類似度: 0.5132
チャンク(取得テキスト): 区分 | 内容・注意点など
----|----
シアン系 | シアンを含む水溶液 ※必ずアルカリ性で保存し、酸などと絶対に混合しないこと。(酸性にすると猛毒のシアン化水素ガスを発生し、大変危険です。) できるだけ重金属類など他の成分が混入しないようにしてください。
無機系廃液 | 水銀系 | 無機水銀を含む水溶液(金属水銀は除く。)
| フッ素系 | フッ素を含む水溶液
| クロム系 | クロムを含む水溶液
| 重金属系 | 鉄、ニッケル、銅、亜鉛、マンガン、鈴、カドミウムなどの重金属類を含む水溶液 ※ベリリウム、オスミウム、タリウム、セレンなどの有毒元素を含むものは、別途申請してください。

Open AIのAPIは画像処理ができるので、やはり表構造をある程度は保持できています。

■ PDF内の該当箇所
image.png

■ 結果と考察
見事OCR+LLMが完答しており、LLMのみも鉛を鈴と間違えたのみで他は正解していました。今回は回答箇所にベリリウムやオスミウム、タリウムなどカタカナが多かったので、理解しやすかったため、LLMのみも正答率が高いように感じられます。うーん、gptは漢字が苦手なのでしょうか、?

7.4 検索クエリの対象が表データの数値であるとき

再度、異なる表の検証を行います。

■ 検索クエリ
酸化性固体の推定数量は?


■ 回答クエリ(OCR+LLM)

酸化性固体の推定数量は以下の通りです:

  • 第一種酸化性固体:50kg
  • 第二種酸化性固体:300kg
  • 第三種酸化性固体:1,000kg



■ 回答クエリ(LLMのみ)

回答:
酸化性固体の推定数量は、以下の通りです。

  • 第一類酸化性固体:

    • 硫酸塩類: 50 kg
    • 海水酸化物: 300 kg
    • 無機酸化物: 300 kg
    • 鉱酸: 1,000 kg
  • 第二類酸化性固体:

    • よう素類: 1,000 kg
    • 問和酸類: 1,000 kg
    • 重クロム酸類: 1,000 kg
    • その他、食べて定めるもの: 1,000 kg
    • 1-10のいずれかを含有するもの: 1,000 kg

■ LLMが参考にした文脈情報(LLMのみ)

|           |               | 4     | 鉱酸               | 第一種酸化性固体 | 1,000 kg |
|           |               | 5     | よう素類             | 第二種酸化性固体 | 1,000 kg |
|           |               | 6     | 問和酸類               | 第二種酸化性固体 | 1,000 kg |
|           |               | 7     | 重クロム酸類          | 第二種酸化性固体 | 1,000 kg |
|           |               | 8     | その他、食べて定めるもの | 第一種酸化性固体 | 1,000 kg |
|           |               | 9     | 1-10のいずれかを含有するもの | 第一種酸化性固体 | 1,000 kg |

| 種 別  | 性 質      | 名  名       | 性 質                  | 代表的な物質   | 指定数量  |
|-------|-----------|-----------|-------------------|----------------|----------|
| 第一類 | 酸化性固体 | 1     | 硫酸塩類           | 第一種酸化性固体 | 50 kg   |
|           |               | 2     | 海水酸化物           | 第一種酸化性固体 | 300 kg  |
|           |               | 3     | 無機酸化物          | 第一種酸化性固体 | 300 kg  |
|           |               | 4     | 鉱酸               | 第一種酸化性固体 | 1,000 kg |

流石に誤字が酷すぎますね。後ほど比較します。


■ LLMが参考にした文脈情報(OCR+LLM)

危険物(消防法及び危険物の規制に関する政令)
類別 性質
品名
性質
代表的な物質
指定数量
1 塩素酸塩類
2 過塩素酸塩類
3 無機過酸化物
4 亜塩素酸塩類
第一種酸化性固体
50kg
5 臭素酸塩類
第一類 酸化性固体
6 硝酸塩類
第二種酸化性固体
300kg
7
よう素酸塩類
8 過マンガン酸塩類
第三種酸化性固体
1,000 kg

表がすべて縦で配列されていますが、誤字はありません。さすがGoogle Vison。

■ PDF内の該当箇所

スクリーンショット 2024-09-20 23.24.31.png

■ 結果と考察
今回の酸化性固体の推定数量は?とういう質問に対しての期待する回答は、第一種、第二種、第三種の指定数量をただいく回答してもらいたいというものでした。LLMのみでは、誤字&数字ミスが多く見られました。一方でOCR+LLMでは、完答しています。OCRで表構造が崩れても文字:値のセットが近くにあれば、1対1対応をしてくれるため、今回は正解していたと考えられます。


まとめ

1.Google Cloud Vision API を用いたOCRでテキスト処理+LLM で生成する2段階の手法と、2.LLMのみで画像から直接情報抽出する1段階の手法を比較し、最も精度の高いLLMシステムの検討を行いました。
結果としては、OCRでテキスト化による誤字がなかったことから、1.Google Cloud Vision API を用いたOCRでテキスト処理+LLM で生成の方が精度が高いことがわかりました。
今回は、OCRでテキスト化したものをLLMで成形せずにベクトル化しましたが、成形してからベクトル化してみると、OCRで精度高くテキスト化できている状態を活かすことができそうです。
長い記事になりましたが、ここまで読んでいただき誠にありがとうございました!!!

宣伝

株式会社Roundaではデータ基盤運用を検討していきたい方はもちろんのこと
関連サービスの利用を検討している方や、LLMの利用検討を始めたが進め方に懸念がある方に対して
弊社では支援やサポートを行っております。ぜひHPより気軽にご相談ください。

また、採用(インターンも含め)と社員との雑談もよければお待ちしています!

https://en-gage.net/rounda_career/

https://pitta.me/matches/ttoXSnwvlZie

Discussion