GPT-4VをつかったMulti-modal RAGの実装 (1) 

# はじめに

生成AI系アプリの開発では、LLMが専門的な質問にも回答できるようにするために、RAG（Retrieval Augmented generation）が広く使われています。このRAGのインプットには多様なファイル形式（たとえば、PDF, CSV, TXTなど）がサポートされていますが、最終的にはテキストの形でチャンクに分割し、各々のチャンクをベクトル化してベクトルストアに保存するというのが一般的なやり方となっています。

しかし、実際の文書にはテキストだけでなく、画像やテーブルもたくさんある場合が多く、本来ならこれらの情報もベクトルストアに保存するべきで、RAGの精度を高めていく際に本質的に重要です。

![DALL·E 2023-11-20 17.59.22 - An image illustrating the concept of Multi-modal RAG (Retrieval Augmented Generation). The scene shows a futuristic AI brain, with multiple streams of.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565029/3d19d61e-23f1-7d03-05e4-4b411ced154a.png)
DALL-E 3で作成したMulti-modal RAGのイメージ画

今回の記事では、異なるデータタイプ（画像、テキスト、テーブル）を横断して、ベクトルストアを構成することが可能なMulti-modal RAGの実装について紹介したいと思います。詳細は以下の論文やブログを参照してください。

https://arxiv.org/pdf/2304.08485.pdf

https://blog.langchain.dev/semi-structured-multi-modal-rag/

画像やテーブルをRAGに組み込む方法は複数考えられますが、今回紹介するのはマルチベクトルリトリーバー(Multivector Retriever)を使う方法です。簡単に説明すると以下のような流れになります。
1. 生のテキスト、テーブル、画像をすべてドキュメントストアに保存する
2. ベクトルストアにはこれらの要約(サマリ)のみを保存する。これら要約は、すべてIDキーによって生のテーブルや画像と紐づいている。
3. クエリによる問い合わせがあった際には、まずベクトルストアを検索して、必要な情報のIDキーを特定する
4. 最終的には、特定されたIDキーに対応する生のデータをドキュメントストアから探して、それを返す

ベクトルストアにサマリのみを保存する理由は、ベクトル検索は通常はテキスト（文字列）に対して行われるからです。画像やテーブルをテキストのサマリと変換することで検索が可能になります。

# 1. 事前準備 
このようなMulti-modal RAGを実現するためには、まず文書を、テキスト、テーブル、画像、といったタイプに分割する必要があります。

### 1.1 Unstructuredのインストール
今回は、Unstructuredというツールをつかって各要素（テーブル、画像、テキスト）を抽出していきます。

https://unstructured-io.github.io/unstructured/core/partition.html

```python: 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エンジンを使いました。

https://github.com/UB-Mannheim/tesseract/wiki

日本語の文書を取り扱いたい場合はダウンロードした`Tesseract-OCR/tesseract`以下に`jpn.traineddata`があるか確認してください。日本語がない場合はダウンロードできます：

https://github.com/tesseract-ocr/tessdata

ダウンロードした後は環境変数の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キーの設定を行います。ついでに今回のテストに必要なファイルへのパス変数もここで指定しておきます。
```python:環境変数
import os
import openai

openai.api_key = os.environ["OPENAI_API_KEY"]
path = "/Users/ユーザー名/Desktop/work/Qiita/"
```

### 2.2 PDFからの要素の抽出

今回はPDFファイルからテキストに加え、画像やテーブルといった要素を抽出していきます。

```python: 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`リストに追加されます。

```python:小さいファイルを消去するところ
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`から抽出された画像データにアクセスするために、ディレクトリパスを指定しています。

```python:
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 テーブル要約の作成
続いて今度はテーブルの要約です。

```python:
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`もどのようにテーブルを解釈させたいかに応じて適宜書き換えが必要です。


（前半終わり。次の記事に続く）

https://qiita.com/mashmoeiar11/items/d7ba174c770a0f05356c


最後まで読んでいただきありがとうございます。最近X(Twitter)もはじめたのでこちらもよろしくお願いします！

https://x.com/keisuke_yoshiha

＊本記事はcode spnippetをもとにChatGPTにつくらせています。

# 参考文献
https://blog.langchain.dev/semi-structured-multi-modal-rag/

https://github.com/langchain-ai/langchain/blob/master/cookbook/Semi_structured_multi_modal_RAG_LLaMA2.ipynb?ref=blog.langchain.dev

https://github.com/sudarshan-koirala/youtube-stuffs/blob/main/langchain/LangChain_Multi_modal_RAG.ipynb