Prompt Flow in Azure AI StudioでマルチモーダルRAGを構築してみた

本記事では、マルチモーダルRAGを理解するために調べてみた結果、AzureのPrompt Flow、Azure OpenAI Service、Azure AI Searchを利用したマルチモーダルRAGを構築してみた内容について投稿します。

# はじめに
近年、AI技術の進化により、情報検索と生成の融合であるRAG（Retrieval-Augmented Generation）が注目を集めています。特に、Azureの提供するサービスを活用することで、より高度なマルチモーダルRAGの構築が可能となりました。

本記事では、Azure Prompt Flowを用いて、テキストと画像の両方を扱うマルチモーダルRAGを構築する方法について解説します。具体的には、LangChainから紹介されているマルチモーダルRAGのパターンについて紹介し、その後、Azure OpenAI Service（GPT-4o）、Azure AI Search、AzureのPrompt Flowを組み合わせて、簡単にRAGを構築してみた内容について記載します。

この記事を通じて、読者の皆様がAzureの各種サービスを活用し、実際のプロジェクトでマルチモーダルRAGを構築する際の参考になれば幸いです。

# マルチモーダルRAGとは
マルチモーダルRAG（Retrieval-Augmented Generation）は、テキストだけでなく、画像や音声など複数のデータ形式（モダリティ）を統合して情報を検索・生成する技術です。従来のRAGは主にテキストデータに依存していましたが、マルチモーダルRAGはこれを拡張し、異なるモダリティ間の情報を一貫して処理・統合することができます。

以下のようなメリットがあると言われています。

1. 多様なデータの統合：テキスト、画像、音声など、異なる形式のデータを統合して処理することで、より豊富な情報を提供できる
2. 精度の向上：複数のモダリティから得られる情報を組み合わせることで、より正確で包括的な応答が可能になる
3. 柔軟な応用：視覚質問応答（VQA）や画像キャプション生成など、さまざまな応用が考えられる

例えば、企業内のドキュメント検索において、テキストだけでなく、関連する画像やグラフも含めて検索結果を提供することで、ユーザーはより直感的に情報を理解することができます。また、医療分野では、患者の診断データ（テキスト）と医療画像を統合して解析することで、診断の精度を向上させることが期待されます。

マルチモーダルRAGの実装には、各モダリティを同じベクトル空間に埋め込む技術や、異なるモダリティ間の情報を効果的に統合するためのフレームワークが必要です。これにより、異なる形式のデータを一貫して処理し、ユーザーに対して最適な情報を提供することが可能になります。

# マルチモーダルRAGの実装パターン
以下のLLMアプリケーション開発フレームワークであるLangChainのブログでは、マルチモーダルRAGの実装パターンとして3種類紹介されています。

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

## パターン①：直接マルチモーダル検索パターン
* マルチモーダルEmbeddingモデルを使用して画像とテキストを同一空間にベクトル化して格納する
* 質問に対する類似検索により画像とテキストのどちらかを取得するが、画像の場合は別途格納している画像のリンクを提供する
* 類似検索された元の画像またはテキスト（チャンク）をLLMに渡して回答を生成する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/6dd595a7-b78b-4450-635a-22bc37abe7c3.png)

## パターン②：要約中心マルチモーダル検索パターン
* マルチモーダルLLMを使用してテキストと画像から要約（テキスト）を生成する
* テキストEmbeddingモデルを使用して画像の要約とテキストの要約をベクトル化して格納する
* 質問に対する類似検索により、画像の要約とテキストの要約のどちらかを取得する
* 類似検索された要約が画像のものである場合は要約（テキスト）、テキストのものである場合は元テキストをLLMに渡して回答を生成する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/bc3bac57-cbdc-b28a-22b3-c0201f7ef64e.png)

## パターン③：原文回帰マルチモーダル検索パターン
* マルチモーダルLLMを使⽤してテキストと画像から要約（テキスト）を⽣成する
* テキストEmbeddingモデルを使⽤して画像の要約とテキストの要約をベクトル化して格納する
* 質問に対する類似検索により、画像の要約とテキストの要約のどちらかを取得する
* 類似検索された画像の要約（テキストの要約）に対応する元画像（元テキスト）をLLMに渡して回答を生成する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/3acd5b44-6a1e-1ef9-e01e-57ed2461f2d6.png)

# マルチモーダルRAGの構築
いよいよマルチモーダルRAGを構築していきます。
今回は、[Microsoftの公式ドキュメント](https://techcommunity.microsoft.com/t5/ai-azure-ai-services-blog/azure-ai-search-now-supports-ai-vision-multimodal-and-ai-studio/ba-p/4136743)に記載されている手順を参考に、マルチモーダルRAGの実装パターンの中で最もシンプルな「パターン①」と「パターン②」のミックスに近いアーキテクチャを構築します。
また、今回入力するスライドにテキストと画像が使われているので、これらをもとにしたマルチモーダルRAGとします。

RAGの各構成要素として以下のサービスを利用します。
* RAGアプリケーション：Prompt Flow
* 元画像ストレージ：Blob Storage
* LLM：Azure OpenAI Service（GPT-4o）
* ナレッジベース：Azure AI Search

また、RAGの検索対象データとして[三菱電機のDX戦略資料](https://www.mitsubishielectric.co.jp/news/2024/pdf/0529-a2.pdf)を使用します。

## アーキテクチャ
今回構築するRAGの簡単なアーキテクチャを示します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/ec394f71-4651-4361-749d-40b3ca175696.png)

## 構築手順

### 1. Azure AI services multi-service accountの作成
AzureポータルからAzure AI services multi-service accountのリソースを作成します。
サブスクリプション、リソースグループ、名前は任意の値を入力します。リージョンは、利用するAI Studioと同じリージョンを選択し、必要な情報を入力してリソースを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/c943d5a4-8160-cebd-1cdd-dc104b4be1d6.png)


### 2. Blobコンテナの作成
RAGで参照するデータを格納するBlobコンテナを作成していきます。

:::note warn
ストレージアカウントは事前に作成しておきます
:::

① Blobコンテナを作成

[コンテナー]からBlob コンテナーを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/200c3b45-5dce-1f90-15c9-aea20109f7b4.png)

② 作成したBlobコンテナにRAGの検索対象データをアップロード

[アップロード]を押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/c3d25255-5e01-494e-ca24-261742e6f7e0.png)

点線内に検索対象データをドラッグ＆ドロップし、[アップロード]を押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/25ce78d2-0005-383e-7aa3-3719b3e68923.png)

### 3. Azure AI Search インデックスの作成
[データのインポートとベクター化]からデータをインポートし、インデックスを作成していきます。

:::note warn
Azure AI Searchリソースは事前に作成しておきます
:::

[データのインポートとベクター化]を押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/21e22bd0-0d6a-8c27-db1a-8352af7ccc6b.png)

[Azure Blob Storage]を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/d0f6ebd3-32ff-0fb6-02ae-1dee695be76f.png)

Kindは[AI Visionのベクトル化]を選択し、先ほど作成したAzure AI services multi-service accountを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/b9f98c8e-1734-2b45-9d4a-484f98c6c453.png)

各チェックボックスを有効化し、Kindには[AI Visionのベクトル化]を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/9309cd11-3508-9045-329e-e2f7fbdf8ebd.png)

デフォルトのまま次に進みます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/ebc8209b-adf9-86c3-9281-5d34c387bf35.png)

[オブジェクト名のプレフィックス]に任意の文字列を入力し、[作成]を押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/1c1d4344-6227-5ee9-44a0-2792379bd77d.png)

以上により、BlobコンテナにアップロードしたデータをAzure AI Searchにベクトル化して取り込みます。

### 4. OCR読み取りの日本語有効化
本投稿執筆時点では、3.で説明した方法で作成されたインデックスは日本語文字が文字化けしています。これは、インデックス作成時の適用されるスキルセットに含まれるスキル`#Microsoft.Skills.Vision.OcrSkill`の言語がデフォルトで英語になっている（`defaultLanguageCode`の値が`en`）ためです。
そのため、ここでは日本語をOCRで正しく抽出するため、`defaultLanguageCode`の値を`ja`に修正します。

ブレードメニューの[スキルセット]を押下した後、新たに作成されたスキルセットを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/510b91cd-3879-be19-2445-e9cb796e50b1.png)

`defaultLanguageCode`の値を`ja`に修正します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/fde859d4-a391-ceb6-f8c0-e5c8b2536ac2.png)

その後、インデクサーを再実行し、インデックスの内容を更新します。
更新するインデックスを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/d58a33b5-4221-3539-4ef2-955a4d290cda.png)

リセットを実行し、インデックスを空にします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/633d83a5-3de6-7eb9-58fe-8351eb2eeb8a.png)

インデクサーを再実行し、改めてインデックスにデータを投入します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/c1cd67ef-13b2-be10-0ef1-67c3c5655445.png)

以上により、OCRで日本語を正しく読み取った結果がインデックスに格納されます。

### 5. Prompt FlowによるRAGフローの作成
:::note warn
事前にAzure AI Studioにてプロジェクトを作成しておきます
:::

① フローの作成
ブレードメニューの[プロンプト フロー]を押下し、[作成]を押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/c7c5d750-7f50-e077-fa6a-d61400c39e1f.png)

その後、[チャット フロー]の[作成]を押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/5a6bcd7c-7c24-61ce-9364-3bcf9fcb5b00.png)

任意のフォルダ名を入力し、フロー作成を完了します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/c1dfa39c-3a96-b073-c1ec-06c5b40e178a.png)

② フローのインポート
生ファイルモードのトグルを有効化し、以下のJSONファイルを入力して保存します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/f59a554a-4a02-95f4-dcfd-e1b89069b8a3.png)

```json:フローのJSONデータ
id: template_chat_flow
name: Template Chat Flow
inputs:
  chat_history:
    type: list
    is_chat_input: false
    is_chat_history: true
  question:
    type: string
    is_chat_input: true
outputs:
  answer:
    type: string
    reference: ${chat.output}
    is_chat_output: true
  knowledge_id:
    type: string
    reference: ${search_index.output.title}
    is_chat_output: false
nodes:
- name: contextualize_question
  type: python
  source:
    type: code
    path: contextualize_question.py
  inputs:
    chat_history: ${inputs.chat_history}
    question: ${inputs.question}
  use_variants: false
- name: search_index
  type: python
  source:
    type: code
    path: search_index.py
  inputs:
    query: ${contextualize_question.output}
  use_variants: false
- name: chat
  type: python
  source:
    type: code
    path: chat.py
  inputs:
    chat_history: ${inputs.chat_history}
    image_file_name: ${search_index.output.title}
    query: ${contextualize_question.output}
  use_variants: false
node_variants: {}
environment:
  python_requirements_txt: requirements.txt
```

上記手順で作成される各ノードの説明は以下の通りです。
|ノード名|説明|
|:---|:---|
|contextualize_question|LLMを利用し、ユーザの質問を履歴を考慮した質問に変換する。これにより、コンテキストを考慮して関連データを取得することができるようになる（[参考](https://python.langchain.com/v0.1/docs/use_cases/question_answering/chat_history/)）。|
|search_index|Azure AI Searchから関連データを取得する。|
|chat|LLMを利用して回答を生成する。|

③ Pythonスクリプトの入力
各ノードにPythonスクリプトを入力する

```python:ノード「contextualize_question」に入力するスクリプト
from promptflow import tool
from openai import AzureOpenAI

def contextualize_question(question, chat_history):
	client = AzureOpenAI(
		azure_endpoint = "<Azure OpenAI Serviceリソースのエンドポイント>", 
		api_key="<Azure OpenAI ServiceリソースのAPIキー>",  
		api_version="2024-02-01"
    )

    # ref. https://python.langchain.com/v0.1/docs/use_cases/question_answering/chat_history/
	contextualize_q_system_prompt = """チャット履歴と、チャット履歴のコンテキストを参照している \
	可能性のある最新のユーザー質問が与えられます。チャット履歴が無くても意味が十分に理解できるような \
	単一の質問文に言い換えてください。注意点として、質問には回答せず、必要に応じて質問を言い換えて \
	回答してください。
	"""

	chat_history_ = [  
		{"role": "user", "content": history["inputs"]["question"]} if i % 2 == 0 else {"role": "assistant", "content": history["outputs"]["answer"]}  
		for i, history in enumerate(chat_history)  
	]  

	message_text = [{"role": "system", "content": contextualize_q_system_prompt}]  
	message_text.extend(chat_history_)  
	message_text.append({"role":"user", "content": question})
	print(f"DEBUG message_text: {message_text}")
    
	completion = client.chat.completions.create(
		model="gpt4o-deploy",
		messages = message_text,
		temperature=0,
		max_tokens=4096,
		top_p=0.95,
		frequency_penalty=0,
		presence_penalty=0,
		stop=None
		)
	contextual_q = completion.choices[0].message.content
	print(f"DEBUG contextual_q: {contextual_q}")

	return contextual_q


@tool
def my_python_tool(question: str, chat_history: list) -> str:
    return contextualize_question(question, chat_history)
```

```python:ノード「search_index」に入力するスクリプト
from azure.core.credentials import AzureKeyCredential
from azure.search.documents import SearchClient
from azure.search.documents.models import VectorizableTextQuery
from promptflow import tool


def retreive_from_search_index(query):
    search_client = SearchClient(
        "<Azure AI Searchのエンドポイント>",
        index_name="<インデックス名>",
        credential=AzureKeyCredential("<アクセスキー>"),
    )

    vector_query = VectorizableTextQuery(
        text=query,
        k_nearest_neighbors=3,
        fields="text_vector, image_vector",
    )

    result = search_client.search(
        search_text=query,
        query_type="semantic",
        semantic_configuration_name="<セマンティック構成名>",
        vector_queries=[vector_query],
        top=1
    )

    return next(result)


@tool
def my_python_tool(query: str) -> object:
    return retreive_from_search_index(query)
```

```python:ノード「chat」に入力するスクリプト
import datetime
import json
import os
import urllib.parse

from azure.identity import DefaultAzureCredential
from azure.storage.blob import (BlobSasPermissions, BlobServiceClient,
                                generate_blob_sas)
from openai import AzureOpenAI
from promptflow import tool


def generate_blob_sas_url(image_file_name):
	account_name = "<ストレージアカウント名>"
	account_key = "<アクセスキー>"
	container_name = "<Blobコンテナ名>"

	blob_name = image_file_name

	start_time = datetime.datetime.now(datetime.timezone.utc)
	expiry_time = start_time + datetime.timedelta(days=1)

	sas_token = generate_blob_sas(
		account_name=account_name,
		container_name=container_name,
		blob_name=blob_name,
		account_key=account_key,
		permission=BlobSasPermissions(read=True),
		expiry=expiry_time,
		start=start_time
	)

	# 完全なURLを構築
	blob_url = f"https://{account_name}.blob.core.windows.net/{container_name}/{blob_name}"
	sas_url = f"{blob_url}?{sas_token}"
	print(f"DEBUG SAS URL: {sas_url}")

	return sas_url


def chatcompletion(query, chat_history, image_file_name):
	client = AzureOpenAI(
		azure_endpoint = "<Azure OpenAI Serviceリソースのエンドポイント>", 
		api_key="<Azure OpenAI ServiceリソースのAPIキー>",  
		api_version="2024-02-01"
		)
	url = generate_blob_sas_url(image_file_name)

	system_message = """
    アシスタントが顧客の質問をサポートします。答えは簡潔にしてください。
	以下の情報源のリストに記載されている事実のみを回答してください。以下に十分な情報がない場合は、わからないと言ってください。以下のソースを使用しない回答を生成しないでください。ユーザーに明確な質問をすることが役立つ場合は、質問してください。
	表形式の情報の場合は、HTML テーブルとして返します。マークダウン形式を返しません。質問が英語でない場合は、質問で使用されている言語で回答してください。 	
     """

	chat_history_ = [  
		{"role": "user", "content": history["inputs"]["question"]} if i % 2 == 0 else {"role": "assistant", "content": history["outputs"]["answer"]}  
		for i, history in enumerate(chat_history)  
	]  

	message_text = [{"role": "system", "content": system_message}]  
	message_text.extend(chat_history_)  
	message_text.append({  
		"role": "user",  
		"content": [  
			{"type": "text", "text": query},  
			{"type": "image_url", "image_url": {"url": url}}  
		]  
	})

	completion = client.chat.completions.create(
		model="gpt-4o",
		messages = message_text,
		temperature=0,
		max_tokens=4096,
		top_p=0.95,
		frequency_penalty=0,
		presence_penalty=0,
		stop=None
    )

	return completion.choices[0].message.content

@tool
def my_python_tool(query: str, chat_history: list, image_file_name: str) -> str:
	return chatcompletion(query, chat_history, image_file_name)
```

④ コンピューティングセッションの開始
[コンピューティングセッションの開始]を押下し、コンピューティングセッションを開始します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/12a2c03e-9575-8ede-2966-fad701373635.png)

⑤ 必要なPythonパッケージのインストール
コンピューティングセッションが開始したら、[requirements.txtからパッケージをインストールします]を押下し、以下のrequirements.txtの内容を入力してPythonパッケージをインストールします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/82b02c0d-4ea7-d3b5-1ce2-a55d4feaa3f7.png)

```text:requirements.txt
azure-search-documents==11.6.0b4
openai
python-dotenv
azure-identity
azure-ai-vision-imageanalysis
azure-storage-blob
```

## 動作確認
ここまで構築してきたPrompt Flowを利用したマルチモーダルRAGの動作確認します。

右上の[チャット]を押下してチャットインタフェースを開き、質問を入力すると、[三菱電機のDX戦略資料](https://www.mitsubishielectric.co.jp/news/2024/pdf/0529-a2.pdf)に記載されている内容を基に期待通りの回答が得られている様子が確認できました！

:::note warn
生成AIの出力結果は当社の公表内容および公式見解と一致する訳ではない点にご留意ください
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/31375c1d-1f7b-24eb-42df-7683a6aface0.png)

また、[トレースを表示]からフローの中を覗いてみると、適切なスライドが参照され、それを元に回答を生成していることがわかります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/192d52c9-85bb-1c49-039a-d48c79bbbf9b.png)
-> 続き
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3818297/e643785b-ae0d-8f75-d098-dd8ee1467808.png)

:::note warn
生成AIの出力結果は当社の公表内容および公式見解と一致する訳ではない点にご留意ください
:::

# まとめ
今回は、LangChainで公開されているマルチモーダルRAGのパターンについて紹介した後、AzureのPrompt Flow、Azure OpenAI（GPT-4o）、Azure AI Searchを利用したマルチモーダルRAG環境を構築しました。
RAGのナレッジベースについては、Azure AI Searchの機能を利用することにより、本来、構築に手間のかかる環境を素早く簡単に構築することができました。また、チャットアプリケーションはPrompt Flowによって簡単に構築できる上、各ノードのPythonスクリプトを自由に記述することができるため、プロンプトなどのチューニングも容易にできる印象を受けました。

Prompt Flowで作成したチャットアプリケーションは、Azure App ServiceとしてWebアプリケーションとしてデプロイしたり、Azure Machine Learningの機能を用いてREST APIとしてデプロイすることも可能です。
今後、これらのAzureサービスを利用して、もう少し複雑なフローも組んでいきたいと思います。