🗂

日本語OCR「YomiToku」を活用したRAG構築とAdvanced RAGを用いて性能比較

に公開

はじめに

近年、生成AIの活用が広がるなかで、社内文書やFAQなどの独自の情報を活用したRAG(Retrieval-Augmented Generation) の需要が高まっています。RAGは、大規模言語モデル(LLM) が持つ汎用的な知識に加え、最新情報や社内データといったLLMが学習していない外部知識を検索対象として組み合わせることで、より正確かつ最新の情報に基づいた回答生成を可能にします。

私は以前から生成AIやLLM、RAGといった技術に強い関心を持っていました。そこで今回は、その中でも特に注目を集めている「RAG」に焦点を当てました。本検証では、RAGの発展的な手法であるHyDEハイブリッド検索を導入し、通常のRAG手法との比較を行っています。また、以前の検証でOCR技術に興味を持ったことから、ドキュメントの読み込みには日本語文書画像解析に特化したPythonパッケージである YomiToku を用いています。本検証で使用するドキュメントは「令和6年分 年末調整のしかた」というPDFです。https://www.nta.go.jp/publication/pamph/gensen/nencho2024/pdf/nencho_all.pdf

本記事では、以下の流れで検証を行っています。

  • 日本語OCRライブラリ 「YomiToku」 を用いて、PDFからテキスト情報を抽出
  • そのテキスト情報を活用し、RAGシステムを構築
  • HyDEハイブリッド検索と通常RAGの性能比較
  • 最後に補足として、RAGの性能をRagasを用いて定量的に評価

本記事の対象は以下の通りです。

  • Python初学者~中級者
  • RAGに興味のある方

実装と結果

1. 日本語OCR「YomiToku」を用いてPDFからテキスト情報を抽出

本検証の実行環境はGoogle Colabとしています。YomiTokuを用いたOCR処理には、GPU環境が推奨されていますが、無料版のGoogle ColabではGPUの使用上限が設けられているため、本検証では、OCRの実行とRAGの実装を分けて実施しています。

また、今回の検証では、LangChainというフレームワークを使用しているのですが、そのLangChainの機能の一つにDocument loaderというものがあります。これはデータソースからドキュメントを読み込むためのものです。今回、YomiTokuを試す前にLangchainのDocument Loaderを使用しました。その結果、RAG の性能自体は YomiToku.ver と大きな差は見られませんでしたが、PDFからテキスト情報をロードする際に、テキストが上から順に正しく読み込まれないという現象が発生しました。今回のケースだとあまり問題はなかったのですが、もしテキストのロードが上手くいっていないことが原因でRAGの性能が伸び悩んでいるならYomiTokuは救世主になるかもしれません。

https://colab.research.google.com/
https://github.com/kotaro-kinoshita/yomitoku

初めに、デフォルトのCPU環境からGPU環境に切り替えます。Google Colabのメニュー欄の「ランタイム」を押すと、「ランタイムのタイプを変更」というボタンが出てきます。そして、そのボタンを押し、「T4 GPU」を選択して保存します。GPU環境に切り替えれたら、以下のライブラリのインストールを行います。

!pip install -q yomitoku

本検証で利用するPDFを以下のコードでGoogle Colab上に用意します。

import requests
import os

url = 'https://www.nta.go.jp/publication/pamph/gensen/nencho2024/pdf/nencho_all.pdf'
filename = '年末調整のしかた.pdf'

if not os.path.exists(filename):
    with open(filename, 'wb') as file:
        file.write(requests.get(url).content)

そして、以下のコードでOCRを実施します。

!yomitoku 年末調整のしかた.pdf -f md -o "/content/drive/MyDrive/results" -v --figure

GPU環境だと本PDFの読み込みに約7分、CPU環境だと3時間以上かかりました。実行が完了すると、Google Drive のマイドライブに新しく results フォルダができていると思います。そのフォルダ内にOCRによって取得できたファイルや画像が格納されています。以下はその一例です。


ファイルや画像はページ分作成されます。今回のPDFは64ページあるので、64×3=192個のファイル・画像が作成されました。今回の検証では、OCRによって取得できた64個分のmdファイルを使用します。このまま検証を進めてもいいのですが、先述したようにGPU環境には使用上限があるので、ここでGPU環境からCPU環境に戻すことをおすすめします。戻す際は、先ほどと同じようにランタイムボタンから切り替えることができます。

2. RAG実装のためのライブラリ準備やAPIキーの設定

RAGの実装を行うために、本検証では様々なライブラリやAPIを活用しています。なお、今回使用するOpenAIのAPIキーは事前にOpenAIの公式サイトから発行する必要があります。また、今回のRAG実装に関して、LangChainとLangGraphによるRAG・AIエージェント[実践]入門 という書籍を参考にしました。

以下のコードでライブラリのバージョンを固定します。

!pip install numpy==1.26.4
# 【注意】
# 上記の `!pip install numpy==1.26.4` を実行したあと、
# Google Colab 上部のメニューから「ランタイム」の「セッションを再起動する」を実行してください。
# その後このセルを実行して `1.26.4` と表示されることを確認してください。

import numpy as np

print(np.__version__)
assert np.__version__ == "1.26.4"
!pip install langchain-core==0.3.0 langchain-openai==0.2.0 \
    langchain-community==0.3.0 GitPython==3.1.43 \
    langchain-chroma==0.1.4 pydantic==2.10.6

上記のライブラリをインストールしてる間に画面左の鍵マークを押し、キーの名前と値を設定しておきます。設定が終わって以下のコードを実行すると、APIキーの設定が完了します。

import os
from google.colab import userdata

os.environ["OPENAI_API_KEY"] = userdata.get("OPENAI_API_KEY")

3. ドキュメントのロード

ドライブの results フォルダ内にあるmdファイルをドキュメントとしてロードします。

documents = []

for i in range(1, 65):   # 1〜64ページ
    file_path = f"/content/drive/MyDrive/results/_年末調整のしかた_p{i}.md"
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            documents.append(f.read())
    except FileNotFoundError:
        print(f"{file_path} が見つかりません")
from IPython.display import Markdown
Markdown(documents[0])

例えば、1ページ目は以下の通りです。

令和6年分

年末調整のしかた
本年の年末調整においては、
定額減税に関する事務を行う必要があります!
「年末調整がよくわかるページ」をご覧ください!
国税庁ホームページには、「年末調整がよくわかるページ」を掲載しています。

このページには、本年の定額減税を含めた年末調整の手順等を解説した動画やパンフレット、
扶養控除等申告書など各種申告書、従業員向けの説明用リーフレットや各種申告書の記載例な
ど年末調整の際に役立つ情報を掲載していますので、ご活用ください。

なお、動画による説明は、YouTube にも掲載していますので、ご活用ください。

※ 令和6年分の各種情報については、令和6年10月頃に掲載いた
します。


税務職員ふたば

年末調整がよくわかる



年末調整でお困りのときは“ふたば”にご相談ください。
年末調整に関する相談は、国税庁ホームページからチャットボットの「税務職
員ふたば」をお気軽にご利用ください。

年末調整の各種申告書の書き方や添付書類に関することなどについて、A Iが
自動で回答します。

※ 公開期間は令和6年10月頃から令和7年1月下旬
までの予定です。

国税庁 ふたば

年末調整手続の電子化で業務の効率化!
年末調整手続の電子化を行うと、給与の支払者(勤務先)及び給与所得者(従業員)それぞ
れにおいて、書類の作成や確認、保管などの業務全般が大幅に効率化されるなど、双方に大き
なメリットがあります。

また、国税庁では「年末調整控除申告書作成用ソフトウェア」(年調ソフト)を無償で提供
しております。

年末調整手続の電子化や年調ソフトについて、詳しくは国税庁ホームページをご覧ください。

年末調整に係る源泉徴収をした所得税及び復興特別所得税の納期限は、

(よくわかるページ)


(YouTube)

(チャットボット)

(年末調整手続の電子化
に向けた取組について)

令和7年1月10日(金)(納期の特例の承認を受けている場合は、令和7年1月20日(月))です。

※ その他、給料や報酬などについて源泉徴収をした所得税及び復興特別所得税の納期限については、2ページを確認してください。


昨年と比べて
(定 額 減 税)

年末調整とは

・末調整のしかた

福袋

年末調整のしかた
・税額の納付
・年末調整再調整

令和7年分の給与
の源泉徴収事務

給与所得控除後の
給与等の金額の表

基出所得税額の

早

見
表示、表示、この商品は、ご注文がある。

次にドキュメントの分割を行います。ドキュメントを適切な大きさのチャンクに分割することで、より正確な回答を得やすくなる場合があります。今回はとりあえず、チャンクサイズを1000に設定しました。

from langchain_core.documents import Document
new_documents = [Document(page_content=t, metadata={"page_label": f"{i+1}"}) for i, t in enumerate(documents)]

# チャンクに分割
from langchain_text_splitters import RecursiveCharacterTextSplitter
python_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=150)
splits = python_splitter.split_documents(new_documents)

print(f'ページ数: {len(documents)}')      # ページ数: 64
print(f'チャンク数: {len(splits)}')   # チャンク数: 207

4. RAGの構築

まずはベクトルの保存を行います。本検証では、ベクトルデータベースとして、ChromaDBを用います。

from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
db = Chroma.from_documents(splits, embeddings)

そして、以下のコードでプロンプト、LLMモデル、そしてベクトル検索のリトリーバを組み合わせ、質問応答のフローを実装しています。

from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import ChatOpenAI

prompt = ChatPromptTemplate.from_template('''\
以下の文脈だけを踏まえて質問に回答してください。

文脈: """
{context}
"""

質問: {question}
''')

model = ChatOpenAI(model='gpt-4o', temperature=0)

retriever = vectorstore.as_retriever()

chain = {
    "question": RunnablePassthrough(),
    "context": retriever,
} | prompt | model | StrOutputParser()

answer_1 = chain.invoke("源泉徴収をした所得税及び復興特別所得税の納期限はいつですか")
Markdown(answer_1)

上記のコードを実行すると、以下のような回答結果が返ってきます。

ERROR:chromadb.telemetry.product.posthog:Failed to send telemetry event CollectionQueryEvent: capture() takes 1 positional argument but 3 were given
源泉徴収をした所得税及び復興特別所得税の納期限は、以下の通りです。

納期の特例の承認を受けていない場合: 給料や報酬などを支払った月の翌月10日
納期の特例の承認を受けている場合(給与など特定の所得に限ります):
1月から6月までの分: 7月10日
7月から12月までの分: 翌年の1月20日
なお、納期限が日曜日、祝日などの休日や土曜日に当たる場合には、その休日明けの日が納期限となります。

以下の画像を確認すると、正しい回答結果が返ってきていることが分かります。

本検証では、次節で紹介する Advanced RAG 手法と性能比較を行うために、質問と回答のペアを10個ずつ用意しました。それぞれの手法の結果は「各RAG手法の結果について」という節でまとめています。
※6問目から10問目については、ChatGPTを用いてデータを生成しています。内容の確認は一通り行いましたが、質問と回答の組み合わせに誤りが含まれている可能性もありますので、ご注意ください。

5. HyDEやHybrid検索の実装

本検証では、RAGの出力を改善するための手法(Advanced RAG)を2つほど紹介します。1つ目はHyDE(Hypothetical Document Embeddings)です。HyDEは、ユーザーの質問に対してLLMに仮の回答を推論させ、その回答を類似度検索に利用する手法です。

hypothetical_prompt = ChatPromptTemplate.from_template("""\
次の質問に回答する一文を書いてください。

質問: {question}
""")

hypothetical_chain = hypothetical_prompt | model | StrOutputParser()   # 仮説的な回答を生成する Chain
hyde_rag_chain = {
    "question": RunnablePassthrough(),
    "context": hypothetical_chain | retriever,   # 仮説的な回答を生成する Chain の出力を retriever に渡す
} | prompt | model | StrOutputParser()

hyde_answer_1 = hyde_rag_chain.invoke("源泉徴収をした所得税及び復興特別所得税の納期限はいつですか?")
Markdown(hyde_answer_1)

つぎにハイブリッド検索という手法を紹介します。ハイブリッド検索では、複数の Retriever の検索結果を組み合わて使う手法です。今回の検証では、先ほどと同様の埋め込みベクトルの類似度検索とBM25という自然言語処理手法を使用します。

!pip install rank-bm25==0.2.2
from langchain_core.documents import Document

def reciprocal_rank_fusion(
    retriever_outputs: list[list[Document]],
    k: int = 60,
) -> list[str]:
    # 各ドキュメントのコンテンツ (文字列) とそのスコアの対応を保持する辞書を準備
    content_score_mapping = {}

    # 検索クエリごとにループ
    for docs in retriever_outputs:
        # 検索結果のドキュメントごとにループ
        for rank, doc in enumerate(docs):
            content = doc.page_content

            # 初めて登場したコンテンツの場合はスコアを0で初期化
            if content not in content_score_mapping:
                content_score_mapping[content] = 0

            # (1 / (順位 + k)) のスコアを加算
            content_score_mapping[content] += 1 / (rank + k)

    # スコアの大きい順にソート
    ranked = sorted(content_score_mapping.items(), key=lambda x: x[1], reverse=True)
    return [content for content, _ in ranked]
from langchain_community.retrievers import BM25Retriever

chroma_retriever = retriever.with_config(
    {"run_name": "chroma_retriever"}
)

bm25_retriever = BM25Retriever.from_documents(splits).with_config(
    {"run_name": "bm25_retriever"}
)
from langchain_core.runnables import RunnableParallel

hybrid_retriever = (
    RunnableParallel({
        "chroma_documents": chroma_retriever,
        "bm25_documents": bm25_retriever,
    })
    | (lambda x: [x["chroma_documents"], x["bm25_documents"]])
    | reciprocal_rank_fusion
)
hybrid_rag_chain = (
    {
        "question": RunnablePassthrough(),
        "context": hybrid_retriever,
    }
    | prompt | model | StrOutputParser()
)
hybrid_answer_3 = hybrid_rag_chain.invoke("ひとり親の控除額はいくらですか")
Markdown(hybrid_answer_3)
ひとり親控除額は350,000円です。

今回は上記のような結果が返ってきました。この出力は正しい回答になっています。

各RAG手法の結果について

本検証では、通常のRAG、HyDE、ハイブリッド検索の実装をそれぞれ行いました。また、質問と回答のペアを10個準備し、各手法の性能比較を実施して、その結果を以下にまとめています。本検証では、真偽判定の結果について、出力の正確性に応じて次のように評価を行いました。完全に正しい出力が得られた場合を「◎」、多少の不備はあるが概ね正しい出力の場合を「〇」、誤りではないものの望ましい出力から離れている場合を「△」、回答が得られなかった、または誤った内容が含まれている場合を「✕」としました。

まずは通常のRAGについてです。

10問中8問は正しい結果が返ってきましたが、残りの2つはハルシネーションが含まれていたり、望ましい回答から程遠い結果が返ってきました。

次にHyDEです。

HyDE手法を用いることで通常のRAGより出力結果が良くなるのかなと思っていたのですが、全体的に見ると、そうでもなかったようです。HyDEは、LLMが仮説的な回答を推論しにくいケースだとあまり良い結果は返ってこないようです。しかし、通常RAGやハイブリッド検索では完璧に答えることができなかった4つ目の質問に対しては、HyDEを用いることで適切な出力を得ることができました。HyDEは具体的な質問よりも抽象的な質問に強い傾向があるらしいので、今回の質問・回答のペア(比較的具体的な質問が多かった)だと相性が良くなかったのかもしれません。

そして最後にハイブリッド検索です。

本検証では、ハイブリッド検索による出力が一番良い結果となりました。ハイブリッド検索は基本、固有名詞や専門用語を含む質問に強いらしいので、専門用語が頻出するドキュメントを用いていたら、もっと面白い結果になっていたかもしれません。

まとめ

本検証では、日本語OCR「YomiToku」を用いて、RAGの実装を行いました。また、RAGの性能を向上させるために HyDEやハイブリッド検索といったAdvanced RAG手法を導入し、通常RAGとの比較も実施しました。先述のように今回の検証では、ハイブリッド検索を用いた出力が一番良い結果となりました。Advanced RAGには、紹介した手法以外にもいくつかあるので、興味のある方は調べてみてください。再度、自身の方でもRAGシステムを構築する機会があれば他の手法を試してみたいと思います。

課題としては補足でも紹介しますが、RagasというRAGの性能評価を行うフレームワークを用いたのですが、適切に実装・評価・解釈を行えませんでした。もう少し自身に馴染みのあるドキュメントを用いていたら、評価や解釈を適切に行えたかもしれないので、その点は反省点です。再度、RAGの実装の機会があればRagasについてしっかり調べて報告したいと思います。

補足

本検証では、あまり上手くいきませんでしたが、RagasというRAGの性能を評価するフレームワークに関しても実装を行ったので、補足で紹介いたします。本節でも「LangChain と LangGraph による RAG・AI エージェント[実践]入門」を元に実装を行っています。コードなど詳しくは本書や本書のGitHubリポジトリを参考にしていただきたいです。
https://github.com/GenerativeAgents/agent-book

まずは合成テストデータの生成を行います。合成テストデータとは、LLMを使用して生成した質問や回答、文脈などが含まれたデータセットです。以下がその例です。

ただし、上記のように質問や回答が英語になってしまいます。日本語で出力する方法もあるらしいですが、上手く実装を行えず、今回は日本語での出力は断念しました。

次にLangSmithのDatasetの作成を行います。というのも、評価に使用するデータセットは、適切に保存して管理する必要があります。LangSmithには、評価用の「Dataset」を管理する機能を持っています。

Ragasで生成した合成テストデータがLangSmith上に保存されたことが、以下から分かります。

そして最後に評価の実装を行います。LangSmithのクライアントが提供する「evaluate」関数を使用します。また、Ragasにはいくつかの評価メトリクスがあるのですが、今回はContext precisionAnswer relevancyを用いました。

本検証の結果だと以下のようになりました。

しかし、質問や回答が英語で出力されるということや、扱ったドキュメントの内容をあまり理解できなかったことから、Ragasによる評価の解釈が難しく記事の本編で紹介することができませんでした。先ほどの繰り返しになりますが、次回以降Ragasを扱う際はこの点に注意して取り組みたいと思います。

宣伝

弊社ではデータ基盤やLLMのご相談や構築も可能ですので、お気軽にお問合せください。
https://solution.rounda.co.jp/

また、中途採用やインターンなど随時募集中です!

https://www.wantedly.com/companies/company_5576351/projects

Discussion