はじめに
生成AI系アプリの開発では、LLMが専門的な質問にも回答できるようにするために、RAG(Retrieval Augmented generation)が広く使われています。このRAGのインプットには多様なファイル形式(たとえば、PDF, CSV, TXTなど)がサポートされていますが、最終的にはテキストの形でチャンクに分割し、各々のチャンクをベクトル化してベクトルストアに保存するというのが一般的なやり方となっています。
しかし、実際の文書にはテキストだけでなく、画像やテーブルもたくさんある場合が多く、本来ならこれらの情報もベクトルストアに保存するべきで、RAGの精度を高めていく際に本質的に重要です。
DALL-E 3で作成したMulti-modal RAGのイメージ画
今回の記事では、異なるデータタイプ(画像、テキスト、テーブル)を横断して、ベクトルストアを構成することが可能なMulti-modal RAGの実装について紹介したいと思います。詳細は以下の論文やブログを参照してください。
画像やテーブルをRAGに組み込む方法は複数考えられますが、今回紹介するのはマルチベクトルリトリーバー(Multivector Retriever)を使う方法です。簡単に説明すると以下のような流れになります。
- 生のテキスト、テーブル、画像をすべてドキュメントストアに保存する
- ベクトルストアにはこれらの要約(サマリ)のみを保存する。これら要約は、すべてIDキーによって生のテーブルや画像と紐づいている。
- クエリによる問い合わせがあった際には、まずベクトルストアを検索して、必要な情報のIDキーを特定する
- 最終的には、特定されたIDキーに対応する生のデータをドキュメントストアから探して、それを返す
ベクトルストアにサマリのみを保存する理由は、ベクトル検索は通常はテキスト(文字列)に対して行われるからです。画像やテーブルをテキストのサマリと変換することで検索が可能になります。
1. 事前準備
このようなMulti-modal RAGを実現するためには、まず文書を、テキスト、テーブル、画像、といったタイプに分割する必要があります。
1.1 Unstructuredのインストール
今回は、Unstructuredというツールをつかって各要素(テーブル、画像、テキスト)を抽出していきます。
pip install unstructured[all-docs]
ここでは以下のバージョンを使いました。
pip install unstructured --upgrade
Name: unstructured
Version: 0.10.30
1.2 Tesseractのインストール
画像からの文字の読み取りにはOCR(Optical Character Recognition)を使用します。 今回はTesseractというオープンソースのOCRエンジンを使いました。
日本語の文書を取り扱いたい場合はダウンロードしたTesseract-OCR/tesseract
以下にjpn.traineddata
があるか確認してください。日本語がない場合はダウンロードできます:
ダウンロードした後は環境変数のPathに追加します。
(base) C:\Users\ユーザーネーム\AppData\Local\Programs\Tesseract-OCR>tesseract --version
または
setx TESSDATA_PREFIX "C:\Users\ユーザーネーム\AppData\Local\Programs\Tesseract-OCR\tessdata"
これで要素抽出の準備は完了です。
2. 実装
2.1 環境設定とライブラリのインポート
毎度のことですが、OpenAIのサービスを使用できるようにするために、まずは環境変数からOpenAI APIキーの設定を行います。ついでに今回のテストに必要なファイルへのパス変数もここで指定しておきます。
import os
import openai
openai.api_key = os.environ["OPENAI_API_KEY"]
path = "/Users/ユーザー名/Desktop/work/Qiita/"
2.2 PDFからの要素の抽出
今回はPDFファイルからテキストに加え、画像やテーブルといった要素を抽出していきます。
from unstructured.partition.pdf import partition_pdf
def process_pdf(file):
# Get Elements
raw_pdf_elements = partition_pdf(
filename=path + file,
languages=['jpn', 'eng'],
extract_images_in_pdf=True,
infer_table_structure=True,
chunking_strategy="by_title",
max_characters=4000,
new_after_n_chars=3800,
combine_text_under_n_chars=2000,
image_output_dir_path=path + "output/",
)
# delete small size files
delete_small_files(path + "output/")
# Get tables and texts
tables = []
texts = []
for element in raw_pdf_elements:
if "unstructured.documents.elements.Table" in str(type(element)):
tables.append(str(element))
elif "unstructured.documents.elements.CompositeElement" in str(type(element)):
texts.append(str(element))
return tables, texts
ここではまずpartition_pdf
関数を呼び出し、PDFをテキスト、画像、テーブルなどの各要素に分解しています。入力変数の詳細は上記のunstructuredのリンクを確認してください。変数名からある程度想像できるかとは思うのですが、tesseract-OCRで使用する言語の指定、画像抽出をするか、テーブル構造を推定するか、チャンクの最大文字数、など様々な変数が指定されています。
このpartition_pdf
のあとにjpg形式で画像ファイルが大量に生成され、指定したディレクトリへ保存されます。ファイルサイズが異常に小さいもの(テーブルの一部やタイトルなどを画像として認識)が紛れたりすることがあるので、このような小さいファイルはdelete_small_files
でまとめて消しています。参考までにdelete_small_files
は下記のような感じです。そのあとは各要素を分類していきます。画像はすでに別フォルダに保存されたので、ここではテーブルとテキストのみ分類してます。テーブルは unstructured.documents.elements.Table
タイプとして識別され、tables
リストに追加されます。テキストは unstructured.documents.elements.CompositeElement
タイプとして識別され、texts
リストに追加されます。
def delete_small_files(directory_path, max_size_kb=20):
max_size_bytes = max_size_kb * 1024
for filename in os.listdir(directory_path):
file_path = os.path.join(directory_path, filename)
if os.path.isfile(file_path):
file_size = os.path.getsize(file_path)
if file_size <= max_size_bytes:
os.remove(file_path)
print(f"Deleted: {file_path}")
2.3 画像の処理と要約の作成
次に、summarize_images
を使ってPDFから抽出された画像を処理し、それらの画像についての要約を生成します。この関数には、特に引数はないですが、先ほどのpartition_pdf
から抽出された画像データにアクセスするために、ディレクトリパスを指定しています。
import os
import base64
from langchain.chat_models import ChatOpenAI
from langchain.schema.messages import HumanMessage
def summarize_images():
img_base64_list = []
image_summaries = []
img_prompt = "画像を日本語で詳細に説明してください。"
for img_file in sorted(os.listdir(path + "output/")):
if img_file.endswith('.jpg'):
img_path = os.path.join(path + "output/", img_file)
base64_image = encode_image(img_path)
img_base64_list.append(base64_image)
image_summaries.append(image_summarize(base64_image, img_prompt))
return img_base64_list, image_summaries
def encode_image(image_path):
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
def image_summarize(img_base64, prompt):
chat = ChatOpenAI(model="gpt-4-vision-preview", max_tokens=1024)
msg = chat.invoke([
HumanMessage(content=[
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_base64}"}}
])
])
return msg.content
今回Multi-modal RAGの実装で画像を扱えるようにするために、バイナリである画像データをBase64
エンコードを用いてテキスト形式に変換します。その変換処理をしているのがencode_image
関数です。そのあとで、エンコードされた画像はimg_base64_list
リストに追加されます。一方で、エンコードされた画像はimage_summarize
関数内で、Open AIのGPT-4V (gpt-4-vision-preview
)を使って解釈し、サマリを生成し、image_summaries
リストに追加されます。今回、このモデルがAPI使用できるようになったため、このような画像解釈が可能になりました!Open AIありがとうございます!もちろん、LLaVA (https://llava.hliu.cc/
)など、画像解釈が可能な他のLLMを使用することもできますが、すべてのLLMをOpenAIのものに統一できるという意味で、gpt-4-vision-preview
の登場は大変ありがたいです!
2.4 テーブル要約の作成
続いて今度はテーブルの要約です。
from langchain.chat_models import ChatOpenAI
from langchain.schema.output_parser import StrOutputParser
from langchain.prompts import ChatPromptTemplate
def summarize_tables(tables):
table_prompt = """あなたはテーブルの内容を説明する役割をもっています。
(1) 何に関してのテーブルなのか、
(2) テーブルの詳細内容と考察
に関して日本語で説明してください。
テーブル(テキスト):
{element}
"""
prompt = ChatPromptTemplate.from_template(table_prompt)
model = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
summarize_chain = {"element": lambda x: x} | prompt | model | StrOutputParser()
table_summaries = summarize_chain.batch(tables, {"max_concurrency": 5})
assert len(table_summaries) == len(tables), "Summary tables and original tables count must be equal"
return table_summaries
ここでは、先ほどのpartition_pdf
で抽出されたテーブルデータ(文字列)tables
を入力引数にとり、LLMを用いてテーブルのサマリを作成しています。gpt-3.5-turbo
を用いていますが、値段以外の理由は特にないです。gpt-4-32k
のような上位モデルを使っていただいても大丈夫です。
summarize_chain
の行は、テーブル要約のプロセスをチェーン化しています。ラムダ関数をつかって入力データ(ここではテーブルデータ)をそのまま次のステップに渡しています。そしてパイプ演算子によって、prompt
, model
, StrOutputParser()
と、複数の処理を順につなげています。StrOutputParser()
は、最終的なモデル出力を文字列として解析します。table_prompt
もどのようにテーブルを解釈させたいかに応じて適宜書き換えが必要です。
(前半終わり。次の記事に続く)
最後まで読んでいただきありがとうございます。最近X(Twitter)もはじめたのでこちらもよろしくお願いします!
*本記事はcode spnippetをもとにChatGPTにつくらせています。
参考文献