🔍

画像付きのHTMLをマークダウンに変換してRAGを行う(Reader-LM + Qwen2-VL)

2024/09/30に公開

はじめに

今回は、WEBページ(画像付きのHTML)の内容からRAGを行ってみます。
HTMLは階層構造を持ち、タグや属性の入れ子が複雑になためRAGのコンテキストとしてそのままでは扱うのが難しいです。また、画像が含まれている場合もあるためテキストだけ抽出してもWEBページの全容を把握できません。

そのため一度、HTML内の画像をキャプションに変換して更新 → 更新したHTMLをマークダウンに変換して(RAGに使う)コンテキストにします

具体的には以下の手順で処理します。

  1. 指定したurl先のHTMLを取得
  2. HTMLの画像のalt属性にキャプションを追加して更新(「Qwen2-VL-2B-Instruct」を使用)
  3. 更新されたHTMLをマークダウンに変換(「reader-lm-0.5b」を使用)
  4. マークダウンからインデックスを作成(「multilingual-e5-large」を使用)
  5. インデックス検索してRAGを実行(「EZO-Common-T2-2B-gemma-2-it」を使用)

作業環境

OS: WSL2 Ubuntu22.04
GPU: GeForce RTX 2080 SUPER(8GB)
CPU: Corei9-9900KF

使用するllmモデルについて

Reader-LM-0.5b

Reader-LMはJina AI社がリリースした、HTMLからマークダウンへの変換に特化したモデルです。
https://huggingface.co/jinaai/reader-lm-0.5b

モデルサイズは以下が選択できます。

  • 0.5b
  • 1.5b

今回はreader-lm-0.5bを使用しました。

Qwen2-VL-2B-Instruct

Qwen2-VLは「Alibaba Cloud」がリリースした視覚言語モデルです。多言語をサポートしており日本語にも対応しています。今回は画像のキャプション生成に使用します。
https://huggingface.co/Qwen/Qwen2-VL-2B-Instruct

モデルサイズは以下が選択できます。

  • 2B(2B-Instruct)
  • 7B(7B-Instruct)
  • 72B(72B-Instruct)

今回はQwen2-VL-2B-Instructを使用しました。

EZO-Common-T2-2B-gemma-2-it

EZO-Common-T2-2B-gemma-2-itはGemma-2-2B-itに対してAxcxept社がチューニングを行ったモデルです。今回はRAGの質疑応答に使用します。
https://huggingface.co/AXCXEPT/EZO-Common-T2-2B-gemma-2-it

準備

ライブラリの準備

$ pip install flask
$ pip install requests
$ pip install beautifulsoup4
$ pip install torch 
$ pip install git+https://github.com/huggingface/transformers
$ pip install sentence-transformers
$ pip install flash_attn 
$ pip install langchain-core
$ pip install langchain-community
$ pip install langchain-text-splitters
$ pip install langchain-huggingface

htmlと画像ファイル + ローカルサーバーの準備

今回はurl指定先の画像とhtmlを取得しますが、ネット上に存在するページにアクセスしてスクレイピングするのではなく、Flaskでローカルサーバーを立てて実験します。

以下でhtmlと画像ファイル、ローカルサーバーの準備をします。

※ 自前でhtml、画像ファイルを用意する場合、次の手順は必要ありません。

htmlと画像ファイルの準備

htmlファイルは「自分が所持している「M5Stack」製品の簡単な紹介ページ(のようなもの)」をサンプルとして用意しました。
Vercel Labsの「v0」(+ 少しだけ手直し)で作成しています。
https://v0.dev/chat

  • index.html
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>自分が使っているM5Stack製品の紹介</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
    <div class="bg-gradient-to-br from-blue-100 to-purple-100 min-h-screen p-8">
        <div class="max-w-7xl mx-auto bg-white rounded-lg shadow-2xl overflow-hidden">
            <div class="p-8">
                <h1 class="text-5xl font-bold text-gray-800 mb-6 text-center">自分が使っているM5Stack製品の紹介</h1>
                <p class="text-xl text-gray-600 mb-12 text-center max-w-3xl mx-auto">
                    M5Stackはカスタマイズがしやすいため、あらゆるIoTやプロジェクトに対応できるデバイスです。
                    <br>このページでは自分が使っているM5Stack製品の一部をご紹介します。
                </p>
                
                <div class="grid grid-cols-1 md:grid-cols-3 gap-12 mb-16">
                    <div class="bg-gradient-to-b from-gray-50 to-gray-100 p-6 rounded-lg shadow-lg transform hover:scale-105 transition-transform duration-300">
                        <img src="{{ url_for('static', filename='images/M5Stack_Core2_Stack-chan.png') }}"
                            alt="M5Stack Core2" class="w-full h-64 object-contain mb-6">
                        <h2 class="text-2xl font-semibold text-gray-800 mb-3">M5Stack Core2</h2>
                        <p class="text-gray-600">高性能なESP32チップを搭載した多目的IoTデバイス。豊富な拡張性と優れたディスプレイを備え、様々なプロジェクトに対応可能です。</p>
                    </div>

                    <div class="bg-gradient-to-b from-gray-50 to-gray-100 p-6 rounded-lg shadow-lg transform hover:scale-105 transition-transform duration-300">
                        <img src="{{ url_for('static', filename='images/M5StickC_Plus2.png') }}"
                            alt="M5StickC Plus2" class="w-full h-64 object-contain mb-6">
                        <h2 class="text-2xl font-semibold text-gray-800 mb-3">M5StickC Plus2</h2>
                        <p class="text-gray-600">コンパクトなIoT開発キット。カラーディスプレイを搭載し、ウェアラブルやポータブルアプリケーションに最適です。様々なセンサーやモジュールと組み合わせ可能。</p>
                    </div>

                    <div class="bg-gradient-to-b from-gray-50 to-gray-100 p-6 rounded-lg shadow-lg transform hover:scale-105 transition-transform duration-300">
                        <img src="{{ url_for('static', filename='images/M5Stack_Cardputer.png') }}"
                            alt="M5Stack CardPuter" class="w-full h-64 object-contain mb-6">
                        <h2 class="text-2xl font-semibold text-gray-800 mb-3">M5Stack CardPuter</h2>
                        <p class="text-gray-600">ポケットサイズのコンピュータ。フルQWERTYキーボードを搭載し、ポータブルコーディングやIoT制御に最適です。拡張性が高く、様々なプロジェクトに対応可能。</p>
                    </div>
                </div>

                <div class="mb-16 bg-gradient-to-r from-blue-50 to-purple-50 p-8 rounded-lg shadow-lg">
                    <h3 class="text-3xl font-semibold text-gray-800 mb-6">M5Stackを選ぶ理由</h3>
                    <ul class="list-disc list-inside text-gray-600 space-y-3 text-lg">
                        <li><strong>高度なカスタマイズ性</strong>:各モデルを自由にアレンジし、独自のデバイスを作成可能</li>
                        <li><strong>豊富な拡張モジュール</strong>:センサー、ディスプレイ、通信モジュールなど、多彩な拡張オプション</li>
                        <li><strong>柔軟なプログラミング</strong>:Arduino IDE、MicroPython、UIFlowなど、多様な開発環境に対応</li>
                    </ul>
                </div>

                <div class="mb-16">
                    <h3 class="text-3xl font-semibold text-gray-800 mb-6">M5Stackデバイス比較(一部)</h3>
                    <div class="overflow-x-auto bg-white rounded-lg shadow-lg">
                        <table class="w-full text-sm text-left text-gray-600">
                            <thead class="text-xs uppercase bg-gradient-to-r from-blue-100 to-purple-100">
                                <tr>
                                    <th scope="col" class="px-6 py-3 text-gray-700">機能</th>
                                    <th scope="col" class="px-6 py-3 text-gray-700">M5Stack Core2</th>
                                    <th scope="col" class="px-6 py-3 text-gray-700">M5StickC Plus2</th>
                                    <th scope="col" class="px-6 py-3 text-gray-700">CardPuter</th>
                                </tr>
                            </thead>
                            <tbody>
                                <tr class="bg-white border-b">
                                    <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap bg-gray-50">入力方法</th>
                                    <td class="px-6 py-4">タッチスクリーン、ボタン</td>
                                    <td class="px-6 py-4">ボタン</td>
                                    <td class="px-6 py-4">フルキーボード</td>
                                </tr>
                                <tr class="bg-white border-b">
                                    <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap bg-gray-50">バッテリー</th>
                                    <td class="px-6 py-4">内蔵(中容量)</td>
                                    <td class="px-6 py-4">内蔵(小型)</td>
                                    <td class="px-6 py-4">内蔵(大容量)</td>
                                </tr>
                                <tr class="bg-white">
                                    <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap bg-gray-50">主な用途</th>
                                    <td class="px-6 py-4">多目的プロトタイピング、IoTデバイス開発</td>
                                    <td class="px-6 py-4">ウェアラブルデバイス、IoTセンサー</td>
                                    <td class="px-6 py-4">ポータブルコーディング、IoT制御</td>
                                </tr>
                            </tbody>
                        </table>
                    </div>
                </div>

                <div class="text-center">
                    <a href="#" class="bg-gradient-to-r from-blue-600 to-purple-600 text-white px-8 py-4 rounded-full font-semibold text-lg hover:from-blue-700 hover:to-purple-700 transition duration-300 inline-block shadow-lg">
                        M5StackでIoT開発を始めよう
                    </a>
                </div>
            </div>
        </div>
    </div>
</body>
</html>

画像ファイルは手元にあるM5Stack製品の写真を使いました。

  • M5Stack_Core2_Stack-chan.png

  • M5StickC_Plus2.png

  • M5Stack_Cardputer.png

Flaskでローカルサーバーを立ち上げる

プロジェクトフォルダを以下のように作成し、上にある3枚の画像(右クリックして保存)とindex.htmlを配置します。

.
└── flask_app/
    ├── static/
    │   └── images/
    │       └── M5Stack_Stack-chan.png
    │       └── M5StickC_Plus2.png
    │       └── M5Stack_Cardputer.png
    └── templates/
        └── index.html

その後、以下の「app.py」をflask_app直下に配置します。

app.py
from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def home():
    return render_template('index.html')

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0')

「app.py」を実行してhttp://localhost:5000にアクセスすると以下のようなページが表示されるようになります(ガワだけなのでボタンをクリックしても動作はしません)。

以上で準備が完了しました。

コーディング(全体)

全体のコードは以下となります(長いため折りたたんでいます)。

コーディング(全体)
html_markdown_rag.py
import torch
import requests
from PIL import Image
from io import BytesIO
from bs4 import BeautifulSoup
from urllib.parse import urljoin
from transformers import (
    Qwen2VLForConditionalGeneration, 
    AutoModelForCausalLM, 
    AutoTokenizer, 
    AutoProcessor
)
from langchain_community.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document


# デバイス
device = "cuda"

# URLを指定
url = "http://localhost:5000"

# 質問文
question = "キーボードを搭載しているのはどの製品でしょうか"


def main():
    
    # GETリクエストを送信
    response = requests.get(url)
    
    # htmlを取得
    html = response.text
    
    # Qwen2-VL-2B-Instructのモデルとプロセッサを取得
    qwen_vl_model, qwen_vl_processor = load_qwen_vl()
    
    # 画像の説明をalt属性に追加
    updated_html = update_html(html, qwen_vl_model, qwen_vl_processor)

    # Qwen2-VL-2B-Instructモデルとプロセッサをメモリから削除
    del qwen_vl_model
    del qwen_vl_processor
    torch.cuda.empty_cache()

    # reader-lm-0.5bのモデルとトークナイザーを取得
    reader_model, reader_tokenizer = load_reader()
    
    # htmlをマークダウンに変換
    markdown = generate_reader(reader_model, reader_tokenizer, updated_html)

    # マークダウンの内容を出力
    print(f"\n{markdown}")

    # reader-lm-0.5bモデルとトークナイザーをメモリから削除
    del reader_model
    del reader_tokenizer
    torch.cuda.empty_cache()

    # マークダウンからインデックスを作成
    index = create_inex(markdown)
    
    # 質問文を使ってインデックスを検索
    documents = index.similarity_search(question, k=5)
    
    # 検索したチャンクを一つのテキストにまとめる
    documents_context = "\n\n".join([doc.page_content for doc in documents])

    # プロンプトを作成
    prompt = """あなたは質問の意図を汲み取り、正確に回答する優秀なアシスタントです。
    ## コンテキスト
    {context}

    ## 質問
    {question}
    """.format(context=documents_context, question=question)

    # EZO-Common-T2-2B-gemma-2-itのモデルとトークナイザーを取得
    ezo_gemma_model, ezo_gemma_tokenizer = load_ezo_gemma()
    
    # RAGの回答を取得
    qwen_response = generate_ezo_gemma_rag(ezo_gemma_model, ezo_gemma_tokenizer, prompt)
    
    # 質問文を出力
    print(f"\n質問: {question}")
    
    # 最終的な回答を出力
    print(f"回答: {qwen_response}")


# Qwen2-VL-2B-Instructを読み込む関数
def load_qwen_vl():
    repo_id = "Qwen/Qwen2-VL-2B-Instruct"
    
    # モデルの読み込み
    model = Qwen2VLForConditionalGeneration.from_pretrained(
        repo_id, 
        torch_dtype=torch.float16,
        device_map=device,
    )
    
    # プロセッサの読み込み
    processor = AutoProcessor.from_pretrained(
        repo_id,
        min_pixels=256 * 28 * 28, 
        max_pixels=1280 * 28 * 28,
    )
    return model, processor


# 画像のキャプションを生成する関数
def qwen_vl(model, processor, image):    
    text = "どのような画像であるか簡潔に説明してください。"
    
    # メッセージの準備
    messages = [
        {
            "role": "user",
            "content": [
                {
                    "type": "image",
                },
                {"type": "text", "text": text},
            ],
        }
    ]

    # 入力の準備
    text_prompt = processor.apply_chat_template(messages, add_generation_prompt=True)
    inputs = processor(
        text=[text_prompt], 
        images=[image], 
        padding=True, 
        return_tensors="pt"
    ).to(device)

    # 推論の実行
    output_ids = model.generate(
        **inputs, 
        max_new_tokens=1024
    )
    
    # 生成された出力から回答を抽出
    generated_ids = [
        output_ids[len(input_ids) :]
        for input_ids, output_ids in zip(inputs.input_ids, output_ids)
    ]
    
    # 出力テンソルをテキストにデコード
    response = processor.batch_decode(
        generated_ids, 
        skip_special_tokens=True, 
        clean_up_tokenization_spaces=True
    )[0]
    return response
    
    
# 画像のalt属性に説明文を追加する関数
def update_html(html, qwen_vl_model, qwen_vl_processor):
    
    # BeautifulSoupを使用してHTMLを解析
    soup = BeautifulSoup(html, "html.parser")

    # すべての<img>タグを見つける
    img_tags = soup.find_all("img")
    
    for img in img_tags:
        # src属性から画像URLを取得
        img_url = img.get("src")
        if img_url:
            # 画像のパスを取得
            full_url = urljoin(url, img_url)
            
            # 画像データを取得
            img_data = requests.get(full_url).content
            
            # BytesIOオブジェクトを作成
            img_file = BytesIO(img_data)
            
            # Imageオブジェクトを作成
            image = Image.open(img_file)
            
            # 画像の説明文を取得
            caption = qwen_vl(qwen_vl_model, qwen_vl_processor, image)

            # 画像のalt属性を取得
            alt = img["alt"]
                        
            # 画像のalt属性に説明文を追加
            img["alt"] = f"**{alt}** {caption}"
            
    # 更新したhtmlを取得
    updated_html = soup.prettify()
    return updated_html


# reader-lm-0.5bを読み込む関数
def load_reader():
    repo_id = "jinaai/reader-lm-0.5b"
    
    # モデルの読み込み
    model = AutoModelForCausalLM.from_pretrained(
        pretrained_model_name_or_path=repo_id,
        torch_dtype=torch.float16, 
        device_map=device
    )

    # トークナイザーの読み込み
    tokenizer = AutoTokenizer.from_pretrained(
        pretrained_model_name_or_path=repo_id
    )
    return model, tokenizer


# htmlをマークダウンに変換する関数
def generate_reader(model, tokenizer, content, max_new_tokens=8192):
    
    # メッセージの作成
    messages = [
        {"role": "user", "content": content}
    ]
    
    # トークナイザーのチャットテンプレートを適用
    prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True
    )
    
    # プロンプトをトークン化してテンソルに変換
    inputs = tokenizer([prompt], return_tensors="pt").to(device)
    
    # 入力テンソルから出力を生成
    generated_ids = model.generate(
        inputs.input_ids,
        attention_mask=inputs.attention_mask,
        max_new_tokens=max_new_tokens
    )
    
    # 生成された出力から回答を抽出
    generated_ids = [
        output_ids[len(input_ids):]
        for input_ids, output_ids in zip(inputs.input_ids, generated_ids)
    ]

    # トークンIDを文字列に変換
    response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]
    return response


# インデックスを作成する関数
def create_inex(text):
    repo_id = "intfloat/multilingual-e5-large"
    # 埋め込みモデルの読み込み
    embedding_model = HuggingFaceEmbeddings(
        model_name=repo_id
    )

    # テキストをチャンクに分割
    text_splitter=RecursiveCharacterTextSplitter(
        chunk_size=1024,
        chunk_overlap=256
    )

    # テキストを分割
    documents = [Document(page_content=text)]
    split_texts = text_splitter.split_documents(documents)

    # インデックスの作成
    index = FAISS.from_documents(
        documents=split_texts,
        embedding=embedding_model,
    )
    return index


# EZO-Common-T2-2B-gemma-2-itを読み込む関数
def load_ezo_gemma():
    repo_id = "HODACHI/EZO-Common-T2-2B-gemma-2-it"
    
    # モデルの読み込み
    model = AutoModelForCausalLM.from_pretrained(
        pretrained_model_name_or_path=repo_id,
        torch_dtype=torch.float16, 
        device_map=device
    )

    # トークナイザーの読み込み
    tokenizer = AutoTokenizer.from_pretrained(
        pretrained_model_name_or_path=repo_id
    )
    return model, tokenizer


# RAGを実行する関数
def generate_ezo_gemma_rag(model, tokenizer, prompt, max_new_tokens=8192):
    
    # メッセージの作成
    messages = [
        {"role": "user", "content": prompt}
    ]
    
    # トークナイザーのチャットテンプレートを適用
    prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True
    )
    
    # プロンプトをトークン化してテンソルに変換
    inputs = tokenizer([prompt], return_tensors="pt").to(device)
    
    # 入力テンソルから出力を生成
    generated_ids = model.generate(
        inputs.input_ids,
        attention_mask=inputs.attention_mask,
        max_new_tokens=max_new_tokens
    )
    
    # 生成された出力から回答を抽出
    generated_ids = [
        output_ids[len(input_ids):]
        for input_ids, output_ids in zip(inputs.input_ids, generated_ids)
    ]

    # 出力テンソルをテキストにデコード
    response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]
    return response


if __name__ == "__main__":
    main()

グローバル変数で設定している以下のパラメータを適宜変更すれば動作(するはず・・・)します。

  • device: モデルを配置するデバイス
  • url: html取得元のURL
  • question: 質問文

以下で主な機能(main関数以外の関数部分)を確認します。

コーディング(関数部分)

Qwen2-VL-2B-Instructを読み込む関数

html_markdown_rag.py
# Qwen2-VL-2B-Instructを読み込む関数
def load_qwen_vl():
    repo_id = "Qwen/Qwen2-VL-2B-Instruct"
    
    # モデルの読み込み
    model = Qwen2VLForConditionalGeneration.from_pretrained(
        repo_id, 
        torch_dtype=torch.float16,
        device_map=device,
    )
    
    # プロセッサの読み込み
    processor = AutoProcessor.from_pretrained(
        repo_id,
        min_pixels=256 * 28 * 28, 
        max_pixels=1280 * 28 * 28,
    )
    return model, processor

Qwen2-VL-2B-Instructのモデルとプロセッサの読み込みを行う関数です。
Qwen2-VL-2B-Instructは、画像からの説明文(キャプション)の生成に使用します。

画像のキャプションを生成する関数

html_markdown_rag.py
# 画像のキャプションを生成する関数
def qwen_vl(model, processor, image):    
    text = "どのような画像であるか簡潔に説明してください。"
    
    # メッセージの準備
    messages = [
        {
            "role": "user",
            "content": [
                {
                    "type": "image",
                },
                {"type": "text", "text": text},
            ],
        }
    ]

    # 入力の準備
    text_prompt = processor.apply_chat_template(messages, add_generation_prompt=True)
    inputs = processor(
        text=[text_prompt], 
        images=[image], 
        padding=True, 
        return_tensors="pt"
    ).to(device)

    # 推論の実行
    output_ids = model.generate(
        **inputs, 
        max_new_tokens=1024
    )
    
    # 生成された出力から回答を抽出
    generated_ids = [
        output_ids[len(input_ids) :]
        for input_ids, output_ids in zip(inputs.input_ids, output_ids)
    ]
    
    # 出力テンソルをテキストにデコード
    response = processor.batch_decode(
        generated_ids, 
        skip_special_tokens=True, 
        clean_up_tokenization_spaces=True
    )[0]
    return response

Qwen2-VL-2B-Instructを使用して画像から説明文を生成する関数です(update_html関数内で使用)。

画像のalt属性に説明文を追加する関数

html_markdown_rag.py
# 画像のalt属性に説明文を追加する関数
def update_html(html, qwen_vl_model, qwen_vl_processor):
    
    # BeautifulSoupを使用してHTMLを解析
    soup = BeautifulSoup(html, "html.parser")

    # すべての<img>タグを見つける
    img_tags = soup.find_all("img")
    
    for img in img_tags:
        # src属性から画像URLを取得
        img_url = img.get("src")
        if img_url:
            # 画像のパスを取得
            full_url = urljoin(url, img_url)
            
            # 画像データを取得
            img_data = requests.get(full_url).content
            
            # BytesIOオブジェクトを作成
            img_file = BytesIO(img_data)
            
            # Imageオブジェクトを作成
            image = Image.open(img_file)
            
            # 画像の説明文を取得
            caption = qwen_vl(qwen_vl_model, qwen_vl_processor, image)

            # 画像のalt属性を取得
            alt = img["alt"]
                     
            # 画像のalt属性に説明文を追加
            img["alt"] = f"**{alt}** {caption}"
            
    # 更新したhtmlを取得
    updated_html = soup.prettify()
    return updated_html

htmlの画像を説明文(キャプション)に変換して<img>タグのalt属性に追加する関数です。

具体的には以下のような流れです。

  1. <img>タグのsrc属性からurlを取得
  2. url先の画像を取得
  3. Qwen2-VL-2B-Instructを使用して画像から説明文を生成
  4. 説明文を<img>タグのalt属性に追加

上記をhtml内の全画像(すべての<img>タグ)に対して行なっています。

reader-lm-0.5bを読み込む関数

html_markdown_rag.py
# reader-lm-0.5bを読み込む関数
def load_reader():
    repo_id = "jinaai/reader-lm-0.5b"
    
    # モデルの読み込み
    model = AutoModelForCausalLM.from_pretrained(
        pretrained_model_name_or_path=repo_id,
        torch_dtype=torch.float16, 
        device_map=device
    )

    # トークナイザーの読み込み
    tokenizer = AutoTokenizer.from_pretrained(
        pretrained_model_name_or_path=repo_id
    )
    return model, tokenizer

reader-lm-0.5bのモデルとトークナイザーの読み込みを行う関数です。
reader-lm-0.5bは、htmlからマークダウンへの変換に使用します。

htmlをマークダウンに変換する関数

html_markdown_rag.py
# htmlをマークダウンに変換する関数
def generate_reader(model, tokenizer, content, max_new_tokens=8192):
    
    # メッセージの作成
    messages = [
        {"role": "user", "content": content}
    ]
    
    # トークナイザーのチャットテンプレートを適用
    prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True
    )
    
    # プロンプトをトークン化してテンソルに変換
    inputs = tokenizer([prompt], return_tensors="pt").to(device)
    
    # 入力テンソルから出力を生成
    generated_ids = model.generate(
        inputs.input_ids,
        attention_mask=inputs.attention_mask,
        max_new_tokens=max_new_tokens
    )
    
    # 生成された出力から回答を抽出
    generated_ids = [
        output_ids[len(input_ids):]
        for input_ids, output_ids in zip(inputs.input_ids, generated_ids)
    ]

    # トークンIDを文字列に変換
    response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]
    return response

update_html関数で更新(画像の説明文を追加)したhtmlをマークダウンに変換する関数です。

インデックスを作成する関数

html_markdown_rag.py
# インデックスを作成する関数
def create_inex(text):
    repo_id = "intfloat/multilingual-e5-large"
    # 埋め込みモデルの読み込み
    embedding_model = HuggingFaceEmbeddings(
        model_name=repo_id
    )

    # テキストをチャンクに分割
    text_splitter=RecursiveCharacterTextSplitter(
        chunk_size=1024,
        chunk_overlap=256
    )

    # テキストを分割
    documents = [Document(page_content=text)]
    split_texts = text_splitter.split_documents(documents)

    # インデックスの作成
    index = FAISS.from_documents(
        documents=split_texts,
        embedding=embedding_model,
    )
    return index

generate_reader関数で変換されたマークダウンからインデックスを作成する関数です。
埋め込みモデルには「multilingual-e5-large」を使用しました。

EZO-Common-T2-2B-gemma-2-itを読み込む関数

html_markdown_rag.py
# EZO-Common-T2-2B-gemma-2-itを読み込む関数
def load_ezo_gemma():
    repo_id = "HODACHI/EZO-Common-T2-2B-gemma-2-it"
    
    # モデルの読み込み
    model = AutoModelForCausalLM.from_pretrained(
        pretrained_model_name_or_path=repo_id,
        torch_dtype=torch.float16, 
        device_map=device
    )

    # トークナイザーの読み込み
    tokenizer = AutoTokenizer.from_pretrained(
        pretrained_model_name_or_path=repo_id
    )
    return model, tokenizer

EZO-Common-T2-2B-gemma-2-itのモデルとトークナイザーの読み込みを行う関数です。
EZO-Common-T2-2B-gemma-2-itは、RAGによる質疑応答に使用します。

RAGを実行する関数

html_markdown_rag.py
# RAGを実行する関数
def generate_ezo_gemma_rag(model, tokenizer, prompt, max_new_tokens=8192):
    
    # メッセージの作成
    messages = [
        {"role": "user", "content": prompt}
    ]
    
    # トークナイザーのチャットテンプレートを適用
    prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True
    )
    
    # プロンプトをトークン化してテンソルに変換
    inputs = tokenizer([prompt], return_tensors="pt").to(device)
    
    # 入力テンソルから出力を生成
    generated_ids = model.generate(
        inputs.input_ids,
        attention_mask=inputs.attention_mask,
        max_new_tokens=max_new_tokens
    )
    
    # 生成された出力から回答を抽出
    generated_ids = [
        output_ids[len(input_ids):]
        for input_ids, output_ids in zip(inputs.input_ids, generated_ids)
    ]

    # 出力テンソルをテキストにデコード
    response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]
    return response

プロンプト(質問文 + インデックスの検索結果)をEZO-Common-T2-2B-gemma-2-itに渡して最終的な回答を返却する関数です。

実行結果_1(出力)

マークダウン

自分が使っているM5Stack製品の紹介
---------------------

M5Stackはカスタマイズがしやすいため、あらゆるIoTやプロジェクトに対応できるデバイスです。  
このページでは自分が使っているM5Stack製品の一部をご紹介します。

![Image 1: **M5Stack Core2** この画像は、3Dプリンターで作られた機械の一部を示しています。機械の上部には、黒い画面と白い点が表示されています。画面の中央に、白い線が表示されています。この機械は、3Dプリンターで作られた機械の一部を示しています。](https://docs.m5stack.jp/static/images/M5Stack_Core2_Stack-chan.png)

M5Stack Core2
------------

高性能なESP32チップを搭載した多目的IoTデバイス。豊富な拡張性と優れたディスプレイを備え、様々なプロジェクトに対応可能です。

![Image 2: **M5StickC Plus2** この画像は、黄色のボックス型のデジタル時計の写真です。デジタル時計の画面には、現在の時間(19:15:03)、日付(2024年9月23日)、および電源状態(52%)が表示されています。背景は木製の表面です。](https://docs.m5stack.jp/static/images/M5StickC_Plus2.png)

M5StickC Plus2
---------------

コンパクトなIoT開発キット。カラーディスプレイを搭載し、ウェアラブルやポータブルアプリケーションに最適です。様々なセンサーやモジュールと組み合わせ可能。

![Image 3: **M5Stack CardPuter** この画像は、カードコンピューターやM5のデバイスの写真です。デバイスは、タッチパネルとキーボードを備え、さまざまな機能を提供しています。デバイスの上部には、USB、SDカード、microSDカード、およびmicroSDHCカードのスロットが設けられています。また、デバイスの下部には、タッチパネルとキーボードが配置されています。](https://docs.m5stack.jp/static/images/M5Stack_Cardputer.png)

M5Stack CardPuter
------------------

ポケットサイズのコンピュータ。フルQWERTYキーボードを搭載し、ポータブルコーディングやIoT制御に最適です。拡張性が高く、様々なプロジェクトに対応可能。

### M5Stackを選ぶ理由

*   **高度なカスタマイズ性** :各モデルを自由にアレンジし、独自のデバイスを作成可能
*   **豊富な拡張モジュール** :センサー、ディスプレイ、通信モジュールなど、多彩な拡張オプション
*   **柔軟なプログラミング** :Arduino IDE、MicroPython、UIFlowなど、多様な開発環境に対応

### M5Stackデバイス比較(一部)

| 功能 | M5Stack Core2 | M5StickC Plus2 | CardPuter |
| --- | --- | --- | --- |
| 入力方法 | タッチスクリーン、ボタン | ボタン | フルキーボード |
| バッテリー | 内蔵(中容量) | 内蔵(小型) | 内蔵(大容量) |
| 主な用途 | 多目的プロトタイピング、IoTデバイス開発 | ウェアラブルデバイス、IoTセンサー | ポータブルコーディング、IoT制御 |

[M5StackでIoT開発を始めよう](#)

M5Stackデバイス比較(一部)の「機能」が「功能」になってはいますが、その他の文章はhtmlに書かれている通りでした。

今回は「Reader-LM-0.5b」を使用しましたが、htmlが複雑だったり長かったりなど、より精度が必要な場合は「Reader-LM-1.5b」を選択するのもありかと思います。

質問と回答:

質問: キーボードを搭載しているのはどの製品でしょうか
回答: このページでは、**CardPuter** がキーボードを搭載していることが明記されています。 

M5Stack CardPuter は、フルQWERTYキーボードを搭載したポータブルコーディングやIoT制御に最適なデバイスです。 

キーボードを搭載しているのは「M5Stack CardPuter」なので正解です。

次は、質問を変更して実行してみます。

実行結果_2(出力)

質問と回答:

質問: 時計が表示されているのはどの製品でしょうか
回答: このページで時計が表示されているのは **M5StickC Plus2** です。 画像2に示されている黄色のボックス型のデジタル時計がその例です。 

元のhtmlに記載されてない内容、画像から生成した説明文からの質問です。
実際に時計が表示されているのは「M5StickC Plus2」なので正解です。

※ 「画像2」というのは、マークダウン化した際の画像のリンク部分に「Image 2: 」というテキストがalt属性の最初に付けられているからだと思われます。

実行結果_3(出力)

質問と回答:

質問: M5Stack Core2の入力方法は?
回答: M5Stack Core2 は、主に **タッチスクリーン** と **ボタン** を採用した入力方法です。 


また、Core2 は、豊富な拡張性を持つため、必要に応じて外部のキーボードやセンサーなどを接続して、より複雑な入力方法を導入することも可能です。 

(元のhtmlと)マークダウンのテーブル箇所に記載されている内容からの質問です。
M5Stack Core2はタッチスクリーンとボタンの2つを搭載しているので正解です。

おわりに

今回は、以下の3つのモデルを使用しました。

  • Reader-LM-0.5b: htmlからマークダウンへ変換
  • Qwen2-VL-2B-Instruct: 画像の説明文(キャプション)を生成
  • EZO-Common-T2-2B-gemma-2-it: RAGで最終的な回答を生成

上記のモデルを使った今回の実装は、(自分の環境では)実行時間が3分程でした。どれも小さくても高性能なモデルでしたが、PCスペックに余裕がある方はより大きなモデルに変更することでさらに精度を上げられるかと思います。

結果に関しては、url先のhtmlから良い感じにRAGで回答を得られました(ただ、htmlは自分が用意した比較的単純のもののため、より複雑だったり長いものだった場合はまた変わってきそうではありますが・・・)。ご興味あればお試しください・・・。

それでは、また機会があればよろしくお願いします。

参考

https://note.com/hamachi_jp/n/n82fd738041af
https://note.com/npaka/n/n6e2a00a0c0e7
https://zenn.dev/robustonian/articles/qwen2_vl_mac
https://zenn.dev/syoyo/articles/6ac39eed7d3f04
https://prtimes.jp/main/html/rd/p/000000005.000129878.html

Discussion