noriho137’s diary

機械学習, 時々Web

gpt-oss-20bをColaboratoryのT4で動かす

OpenAIからgpt-ossが公開されたので、Google ColaboratoryのT4 (VRAM 15GB) で動かしてみました。

HuggingFaceのopenai/gpt-oss-20b には transformerspipeline を使用したサンプルコードが記載されています。 AutoTokenizerAutoModelForCausalLM を使用して generate する方法を知りたくて詳しく読んでいたところ、「Learn more about how to use gpt-oss with Transformers」とあり、リンク先にサンプルコード(下記)が記載されていました。

from transformers import AutoModelForCausalLM, AutoTokenizer
 
model_name = "openai/gpt-oss-20b"
 
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    device_map="auto",
    torch_dtype="auto",
)
 
messages = [
    {"role": "system", "content": "Always respond in riddles"},
    {"role": "user", "content": "What is the weather like in Madrid?"},
]
 
inputs = tokenizer.apply_chat_template(
    messages,
    add_generation_prompt=True,
    return_tensors="pt",
    return_dict=True,
).to(model.device)
 
generated = model.generate(**inputs, max_new_tokens=100)
print(tokenizer.decode(generated[0][inputs["input_ids"].shape[-1] :]))

これをColaboratory (GPUはT4を使用) で動かしてみます。

まず、Quick setup

pip install -U transformers accelerate torch triton==3.4 kernels

とあるので、その通りに実行した上で上記のサンプルコードを動かすと、次のようなエラーになってしまいました。

RuntimeError                              Traceback (most recent call last)
/usr/local/lib/python3.11/dist-packages/transformers/utils/import_utils.py in __getattr__(self, name)
   2291             try:
-> 2292                 module = self._get_module(self._class_to_module[name])
   2293                 value = getattr(module, name)

24 frames
RuntimeError: operator torchvision::nms does not exist

The above exception was the direct cause of the following exception:

ModuleNotFoundError                       Traceback (most recent call last)
/usr/local/lib/python3.11/dist-packages/transformers/utils/import_utils.py in __getattr__(self, name)
   2293                 value = getattr(module, name)
   2294             except (ModuleNotFoundError, RuntimeError) as e:
-> 2295                 raise ModuleNotFoundError(
   2296                     f"Could not import module '{name}'. Are this object's requirements defined correctly?"
   2297                 ) from e

ModuleNotFoundError: Could not import module 'GptOssForCausalLM'. Are this object's requirements defined correctly?

色々調べていると、torchvisiontorchaudio を予めアンインストールしておくと良いという情報を見つけました。

つまりこういうことです。

pip uninstall torchvision torchaudio
pip install -U transformers accelerate torch triton==3.4 kernels

torch をアップデートとしたことによってバージョンが2.8.0に上がっており、torchvisiontorchaudio のバージョンとの整合性がなくなってしまったようです。

これで無事、実行できるようになりました。 この時の環境は下記です。

accelerate==1.10.0
kernels==0.9.0
torch==2.8.0
transformers==4.55.2
triton==3.4.0

出力結果は

<|channel|>analysis<|message|>We have to comply with dev instruction: "Always respond in riddles". So answer as a riddle about the weather in Madrid. The prompt: "What is the weather like in Madrid?" We need to respond as a riddle. Could phrase something like: "When the sun rises in the capital, clouds either dance or stay aside..." Provide a riddle that hints at typical weather. Madrid has a Mediterranean climate: hot summers, mild winters, dry. Could involve "the

となりました。 なんだか微妙な回答ですが、システムプロンプトで「常に謎かけで答える」ように指示しているので、こんなものなのかもしれません。

日本語も試してみました。 elyza/Llama-3-ELYZA-JP-8B のようなメッセージ

messages = [
    {"role": "system", "content": "あなたは誠実で優秀な日本人のアシスタントです。特に指示が無い場合は、常に日本語で回答してください。"},
    {"role": "user", "content": "仕事の熱意を取り戻すためのアイデアを5つ挙げてください。"},
]

を設定して、max_new_tokens=1000で出力してみたところ、出力結果は

<|channel|>analysis<|message|>We need to respond in Japanese because of developer instruction. Provide 5 ideas. Should be thoughtful. Let's craft.<|end|><|start|>assistant<|channel|>final<|message|>仕事の熱意を取り戻すためのアイデアを5つご紹介します。  

| No. | アイデア | 具体的なアクション例 | 期待できる効果 |
|-----|-----------|-----------------------|----------------|
| 1 | **目標の再設定と可視化** | - 短期(1週間)・中期(3〜6か月)・長期(1年)で「達成したいこと」をリスト化。<br>- 毎朝紙に書き出す、またはデジタルボードに貼る。 | 具体的なゴールが明確になることで、日々のタスクに意味を見出しやすくなる。 |
| 2 | **スキルアップの小さな挑戦** | - 毎週1時間、自分の業務に関連する新しい技術や知識を学習。(例:ワークショップ、Web セミナー)<br>- 学んだことを社内の簡易プレゼンで共有。 | 新しい知識に対する興奮が再び職場への好奇心を生む。 |
| 3 | **ポジティブな同僚との交流** | - 毎週末に「感謝の言葉」を合言葉にしたランチ会を企画。<br>- 仕事外で共有できる趣味の時間(スポーツ、読書会)を取り入れる。 | チーム内での感謝や共感が、仕事への帰属意識を高める。 |
| 4 | **業務プロセスの見直しと改善** | - 仕事の「ムダ」を洗い出し、改善案を作成。<br>- 上司やチームに提案し、承認後に実行。 | 作業効率が上がり、成果を感じやすい環境ができる。 |
| 5 | **自己肯定感を高める習慣** | - 毎晩寝る前に「今日の成果3点」を思い出す。<br>- 自己評価を「改善点」ではなく「達成」として書き出す。 | 自己肯定感が上がり、挫折しやすい瞬間に自信を持って対処できる。 |

---

### 使い方のヒント
1. **スケジュールに組み込む**  
   毎朝や毎週の特定時間に「目標再設定」や「スキルアップ」を行うリマインダーを設定すると、習慣化しやすいです。  
2. **小さくても継続する**  
   規模が大きいと挫折しやすいので、最初は15〜20 分程度でスタートし、慣れたら徐々に増やしましょう。  
3. **成果を可視化**  
   仕事の進捗やスキル取得の証拠(スクリーンショット、メモ)を共有すると、自己満足が高まります。  

これらを日常に取り入れながら、少しずつ仕事への情熱が再燃していくはずです。もしさらに具体的な実行計画が必要であれば、遠慮なくご相談ください!<|return|>

となりました。 中々良さそうです。


追記

torchvisiontorchaudio をアンインストールする必要があることについては OpenAI の Cookbook に書いてありました。

We also need to install transformers from source, and we uninstall torchvision and torchaudio to remove dependency conflicts.

だそうです。

cookbook.openai.com

Chainlitで大規模言語モデルを非同期にロードしてキャッシュする

モデルをどこでロードするのか問題

Chanlitで大規模言語モデル(LLM)をロードする場合、下記のどこで行うのが適切でしょうか?

  1. グローバルスコープ
  2. デコレータ@cl.on_chat_startを付与した関数内
  3. デコレータ@cl.on_messageを付与した関数内

結論から言うと、デコレータ@cl.on_chat_startを付与した関数内で行うのが適切だと思います。

各方法のメリットやデメリットは下表のとおりです。

LLMをロードする場所 メリット デメリット
グローバルスコープ アプリ起動時に1度だけモデルをロードするので効率的
  • アプリの起動に時間がかかる
  • ユーザによるモデルの選択ができない
デコレータ@cl.on_chat_startを付与した関数内
  • ユーザごとに独立したモデルを使用可能
  • アプリの起動時間を短縮できる
チャットを開始する度にモデルがロードされるので、最初のメッセージ送信まで待たされる
デコレータ@cl.on_messageを付与した関数内 本当に必要になった時にモデルがロードされる メッセージ送信の度にモデルがロードされるので、非常に効率が悪い

デコレータ@cl.on_chat_startを付与した関数内(長いので以下「@cl.on_chat_start内」と略記)でLLMをロードする場合、チャットを開始する度にLLMをロードする点がネックになりますが、これは次の対応を行うことで回避できます(それでも最初のロード時は待つしかありませんが…)。

  • モデルのロード処理を非同期にする
  • キャッシュを利用する
  • セッションを利用する

非同期化

まず、モデルのロード処理の非同期化について。

Hugging Faceで公開されているモデルをロードする場合、transformersAutoModelForCausalLM.from_pretrainedなどを使用すると思いますが、これは同期処理です。 同期処理なので、モデルのダウンロードや読み込みが完了するまで他の処理を実行できません。 モデルのロードはI/O待ちが長いため、単純に@cl.on_chat_start内でAutoModelForCausalLM.from_pretrainedを実行すると、しばらくして、チャットの画面上にサーバに接続できませんでしたと表示されてしまいます。 これではユーザは困ってしまいます。

同期処理でモデルをロードするとフリーズ

そこで、asyncio.to_threadを用いてモデルのロード処理を非同期で実行するようにします。

import aysncio
import chainlit as cl
from transformers import AutoModelForCausalLM

@cl.on_chat_start
async def on_chat_start():
(略)
    model = await asyncio.to_thread(AutoModelForCausalLM.from_pretrained,
                                    model_name_or_path)

非同期処理にすることでチャットがフリーズせずに済みます。

キャッシュとセッション

ところで、非同期化してもチャットを開始する度にモデルのロードが行われること自体は変わらないので、チャットが可能になるまでその都度待たされてしまう点は同じです。 その解決策としてキャッシュとセッションを使用します。

ロードしたモデルをキャッシュすることで、モデルのロードを繰り返さないようにすることができます。 モデルをキャッシュするには、モデルをロードして返却する関数を定義し、その関数にデコレータ@cl.cacheを付与すればOKです。

また、ロードしたモデルをセッションに保存しておくことで、同一セッション内では、セッションからモデルを取り出して使うことができます。

  • セッションへの保存:cl.user_session.set(セッション名, 保存したいオブジェクト)
  • セッションからの取得:cl.user_session.get(セッション名)

まとめると以下のような実装イメージになります。

import aysncio
import chainlit as cl
from transformers import AutoModelForCausalLM


@cl.cache
def load_model(model_name_or_path):
    model = AutoModelForCausalLM.from_pretrained(model_name_or_path)
    return model


@cl.on_chat_start
async def on_chat_start():
    # model_name_or_pathにはモデル名またはパスを指定
    model = await asyncio.to_thread(load_model,
                                    model_name_or_path)

    # 名前を指定してセッションにモデルを保存
    cl.user_session.set('model', model)


@cl.on_message
async def on_message(message):
    # 保存したときと同じ名前を指定してセッションからモデルを取得
    model = cl.user_session.get('model')

処理状況をユーザに通知

これまでの対応で処理としては問題ありませんが、ユーザにはモデルがロード中なのか終わったのかが分かりません。 そのようなときはcl.Stepを使用すると便利です。 下記のようにwith cl.Step節内でモデルのロードを行うことで、チャット上ではモデルをロード中である旨が表示されるようになります。

import aysncio
import chainlit as cl
from transformers import AutoModelForCausalLM


@cl.cache
def load_model(model_name_or_path):
    model = AutoModelForCausalLM.from_pretrained(model_name_or_path)
    return model


@cl.on_chat_start
async def on_chat_start():
    message = cl.Message(content='モデルをロード中です。しばらくお待ちください。')
    await message.send()

    with cl.Step(name='モデルをロード', type='llm'):
        # model_name_or_pathにはモデル名またはパスを指定
        model = await asyncio.to_thread(load_model,
                                        model_name_or_path)

    # 名前を指定してセッションにモデルを保存
    cl.user_session.set('model', model)

    message.content = 'モデルのロードが完了しました。'
    await message.update()

    await cl.Message(content='ようこそ!ご用件は何でしょうか?').send()

モデルのロード中は使用中 モデルをロードと表示され、モデルのロード処理が継続していることが分かります。

モデルのロード中

モデルのロードが完了すると表示が使用済み モデルをロードに変わります。

モデルのロード完了後

まとめ

ChainlitでLLMを使用する場合は下記対応を行うと良いでしょう。

  • モデルのロードはデコレータ@cl.on_chat_startを付与した関数内で行う
  • モデルのロードはI/O待ちが長いのでasyncio.to_threadで非同期実行する
  • キャッシュを利用する
  • セッションを利用する
  • cl.Stepでユーザに進捗を通知する

Chainlitチャットアプリにngrokで外部からアクセス

ローカルLLMを使ってチャットアプリを構築したいなと思い調べていたところ、Chainlitがお手軽そうでした。 ごく単純なものであれば、コードを数行書くだけでチャットアプリを作れます。 ただ、自前のサーバ環境がなく、ローカルPCやGoogle Colaboratory上で起動しても外部からはアクセスできないため、何とかならないものかと調べていたところ、トンネリングツールなるものがあることを知りました。 そこで今回はChainlitのサンプルコードの実行とトンネリングツールの一つであるngrokによる外部公開を試してみます。

Chainlit

インストール

ChainlitPythonパッケージなので、pipでインストールできます。

pip install chainlit

サンプルコードの実行

公式サイトのIn Pure Pythonのページにあるサンプルコードを動かしてみます。 こちらはユーザが入力した文字列をオウム返しするようなチャットアプリです。

import chainlit as cl


@cl.on_message
async def main(message: cl.Message):
    # Your custom logic goes here...

    # Send a response back to the user
    await cl.Message(
        content=f"Received: {message.content}",
    ).send()

このコードをapp.pyという名前で保存します。

起動するには次のコマンドを実行します。

chainlit run app.py

ブラウザでhttp://localhost:8000にアクセスするとチャットアプリが利用できます。

Chainlit画面イメージ

また、ホスト名やポート番号を変えたい場合は起動時に次のように指定すればOKです。

chainlit run app.py --host {ホスト名} --port {ポート番号}

ngrok

さて上記で作成したチャットアプリを公開してみましょう。 そのためにまず、ngrokのアカウントを作成して、認証トークンを発行します。 とりあえず試すだけなら無料プランで十分です。

認証トークン発行

アカウント作成してログイン後、左側のメニューにあるGetting Started配下のYour Authtokenで認証トークンの発行や管理ができます。

ngrok認証トークン設定画面

以降では認証トークンを発行済みの前提で進めます。

pyngrokインストール

今回はPythonからngrokを操作するため、ngrokのPythonラッパーであるpyngrokをインストールします。

pip install pyngrok

こちらをインストールするとngrokが使えるようになります。

なお、ngrokを利用するためにはPythonラッパーが必須という訳ではありません。 公式サイトのQuickstartにはngrokエージェントのインストール方法やコマンドラインでの実行方法が記載されていますので、そちらも参考にしてください。

ngrokによるWebアプリの公開

ngrokでローカルのWebアプリを公開するためのコードは下記です。

from pyngrok import ngrok

ngrok.set_auth_token('YOUR_AUTHTOKEN')
ngrok_tunnel = ngrok.connect(8000)
print('Public URL:', ngrok_tunnel.public_url)

set_auth_tokenの引数に認証トークンの値を設定し、connectの引数にはChainlitのアプリで使用しているポート番号と同じ値を指定するようにします。

Chainlitのアプリを実行しているマシンと同じマシン上で上記コードを実行すると、https://{ランダムな文字列}.ngrok-free.appのような外部からアクセス可能なURLが発行されます。 ブラウザでそのURLにアクセスすると、確認画面が表示されます。

ngrokアクセス確認画面

Visit Siteボタンをクリックすると公開したWebアプリにアクセスできます。

外部公開を終了するには次のコードを実行すればOKです。

ngrok.kill()

明示的にkillするか、Pythonプロセスが終了するまで公開され続けるようなのでご注意ください。

なお、connectの引数authを指定することでBasic認証を行うこともできます。 文字列で{ユーザ名}:{パスワード}という形式で指定します。

from pyngrok import ngrok

ngrok.set_auth_token('YOUR_AUTH_TOKEN')
ngrok_tunnel = ngrok.connect(8000, auth='username:password')
print('Public URL:', ngrok_tunnel.public_url)

まとめ

Chainlitを使えば、LLMを用いたチャットアプリも簡単に作れそうです。 また、ngrokのようなサービスを利用することでローカルPC上のWebアプリを簡単に外部公開できるため、Google Colaboratoryのような環境上で起動したチャットアプリに自分のPCからアクセスすることも実現可能です。 ただし、ngrokで外部公開する際はセキュリティが心配です。 外部公開する際はリスクがあることを承知した上で、適切な対応をとりつつ試すようにしましょう。

量子化した大規模言語モデルによるRAG

今回は量子化した大規模言語モデルを用いてRAGを実装してみます。

大規模言語モデルをストレスなく動かそうとするとそれなりのスペックのGPUが必要になるため、個人では気軽に試すことが難しいです。 例えばパラメータ数が13B (=130億) のモデルだと、float16で扱っても約26GBのVRAMが必要になります。 しかしながら、量子化と呼ばれる方法を適用すると、パラメータを8bitや4bitといったより小さなビット数で表現するため、より少量のVRAMでも動かせるようになります。 量子化手法にはいくつかあるようですが、今回は比較的手軽なBitsandbytesを使用してみます。

今回の実装はこちらです。

github.com

準備

Google Colaboratoryを使用する前提で、追加で必要なPythonパッケージをインストールします。

まず、量子化を行うためにbitsandbytesを使用します。

pip install bitsandbytes

Langchain自体はColaboratoryにデフォルトでインストール済みですが、追加でlangchain-communitylangchain-huggingfaceも使用します。

pip install langchain-community langchain-huggingface

RAGで参照させるPDF文書を取り込むためにpdfminer.sixを使用します。

pip install pdfminer.six

ベクトルDBとしてChromaを使用します。 ちなみに特にChromaである必要性はありません。 サクッと実装するのにお手軽なので使っています。 もちろん、FAISSなどでも構いません。

pip install chromadb

ベクトルDBの構築

ベクトルDB用のクラスです。 このクラスでベクトルDBの作成と検索を行うことができます。 ベクトルDBに登録する文書がPDFだけの前提で実装したため、ファイル依存の部分が含まれています。 将来的にはファイル依存部分の実装を分離したいところです。

埋め込みモデル

埋め込みベクトルを作成する際のモデルは多言語対応しているintfloat/multilingual-e5-largeを使用しました。 HuggingFaceEmbeddingsにモデル名を指定してインスタンスを作成します。

embedding_model_name = 'intfloat/multilingual-e5-large'
self.embeddings = HuggingFaceEmbeddings(model_name=embedding_model_name)

次のようにHuggingFaceEmbeddings.embed_documentsにテキストデータを入力することで、テキスを埋め込みベクトルに変換することができます。

embedded_docs = self.embeddings.embed_documents([chunk])

ベクトルDB

ベクトルDBの実装にはDhromaを使用しています。 Chromaはインメモリでも使用できますが、DBをストレージに保存しておくこともできます。 ストレージに保存する際はPersistentClientを使用し、引数のpathに保存先のパスを指定します。

self.client = chromadb.PersistentClient(path=self.db_path)

RAGで参照する文書の用意

RAGで参照させる文書として、今回もIPAが公開している「アジャイルソフトウェア開発宣言の読みとき方」を使用します。

wget https://www.ipa.go.jp/jinzai/skill-standard/plus-it-ui/itssplus/ps6vr70000001i7c-att/000065601.pdf

ダウンロードしたPDFのパスをtarget_filesに指定しています。

target_files = [
    '000065601.pdf',
]

メソッドVectorStore.add_collectionで一定サイズのチャンクに分割してから埋め込みベクトルに変換します。

for i, target_file in enumerate(target_files):
    pages = self.extract_text(target_file)
    print(f'{target_file}: # of pages={len(pages)}')

    for j, page in enumerate(pages, start=1):
        text = page.replace('\n', '')
        if text == '':
            continue

        # Split text into chunks
        chunks = [page[idx:(idx + self.chunk_size)].replace('\n', '')
                  for idx in range(0, len(page), self.chunk_size)]

        # Convert chunk to embedding vector, and add it to vector DB
        for k, chunk in enumerate(chunks):
            embedded_docs = self.embeddings.embed_documents([chunk])
            self.collection.add(
                embeddings=embedded_docs,
                documents=[chunk],
                metadatas=[{'source': target_file, 'page': j, 'chunk': k}],
                ids=[f'F{i + 1:03}-P{j + 1:03}-C{k + 1:03}']
            )

ここで、メソッドextract_textでPDFファイルからページ単位でテキストを抽出しています。

loader = PDFMinerLoader(target_file)
text = loader.load()
pages = text[0].page_content.split('\x0c')

ベクトルDB構築と永続化

以下のようにDBの保存先のパスdb_path、チャンクサイズchunk_size、永続化の有無is_persist、コレクション名collection_nameを指定します。

db_path = './db'
chunk_size = 256
is_persist = True
collection_name = 'my_collection'

ちなみに今回はチャンクサイズを256文字に設定しているので、文の途中で区切られてしまう場合もあります。 分割方法には工夫の余地があります。

上記のクラスを使用してベクトルDBを構築します。

vector_store = VectorStore(embedding_model_name=embedding_model_name,
                           db_path=db_path,
                           chunk_size=chunk_size,
                           is_persist=is_persist)
vector_store.create_collection(collection_name=collection_name)
vector_store.add_collection(target_files=target_files)

登録する文書のサイズにもよりますが、add_collectionには少々時間がかかります。 実行完了後、カレントディレクトリにdbというディレクトリが作成され、その配下にデータが格納されています。

テキスト生成モデル

テキスト生成のクラスは次のとおりです。

モデル

今回はelyza/ELYZA-japanese-Llama-2-13b-fast-instructを使用します。 本モデルの概要は次の通りです。 詳細はこちらのページで解説されています。

  • 130億パラメータのLlama2がベース
  • ELYZA社がLlama2に日本語の語彙を追加し、日本語データで追加事前学習を実施
  • ELYZA-japanese-Llama-2-13bに対して事後学習を実施 (Instruction Learning)
  • トークナイザーが効率化されており、推論速度が向上

量子化

量子化の設定はとても簡単です。 まず、BitsAndBytesConfigで諸条件を指定します。 今回は4bit量子化を行ってみます。

quantization_config = BitsAndBytesConfig(load_in_4bit=True,
                                         bnb_4bit_use_double_quant=True,
                                         bnb_4bit_quant_type='nf4',
                                         bnb_4bit_compute_dtype=torch.bfloat16)

ここで各引数の意味は次のとおりです。

  • load_in_4bit: 4bit量子化を有効化
  • bnb_4bit_use_double_quant: ネストされた量子化を有効化
  • bnb_4bit_quant_type: 量子化データ型をnf4に設定
  • bnb_4bit_compute_dtype: 計算高速化のため計算タイプをtorch.bfloat16に指定

上記で指定したBitsAndBytesConfigをモデルのロード時に引数quantization_configに渡します。

self.model = AutoModelForCausalLM.from_pretrained(model_name_or_path,
                                                  torch_dtype='auto',
                                                  quantization_config=quantization_config)

たったのこれだけです。 あとは量子化なしの場合と全く同じ実装で構いません。

プロンプト

プロンプトの作成はメソッドmake_promptで行っています。 オリジナルのプロンプトを参考にして、参考情報を付加できるようにフォーマットを作成しました。

B_INST = '[INST]'
E_INST = '[/INST]'
B_SYS = '<<SYS>>\n'
E_SYS = '\n<</SYS>>\n\n'
DEFAULT_SYSTEM_PROMPT = 'あなたは誠実で優秀な日本人のアシスタントです。質問にできるだけ正確に答えてください。'
DEFAULT_SYSTEM_RAG_PROMPT = 'あなたは誠実で優秀な日本人のアシスタントです。参考情報を元にして質問にできるだけ正確に答えてください。'
PROMPT = '## 質問:\n{question}'
RAG_PROMPT = '## 参考情報:\n{context}\n\n## 質問:\n{question}'

参考情報の有無によって、オリジナルのプロンプトにするか参考情報を付加したプロンプトにするかを切り替えています。

if context is None:
    # Without reference information
    _system_prompt = DEFAULT_SYSTEM_PROMPT
    _prompt = PROMPT.format(question=query)
else:
    # With reference information (RAG)
    _system_prompt = DEFAULT_SYSTEM_RAG_PROMPT
    _prompt = RAG_PROMPT.format(context=context, question=query)

prompt = '{bos_token}{b_inst} {system}{prompt} {e_inst} '.format(
    bos_token=self.tokenizer.bos_token,
    b_inst=B_INST,
    system=f'{B_SYS}{_system_prompt}{E_SYS}',
    prompt=_prompt,
    e_inst=E_INST,
)

テキスト生成

メソッドrunの実行時に質問queryとコンテキスト情報contextを渡すことで回答を生成します。 このメソッドの内部では、まず、queryに関連する内容をベクトルDBから取得しています(ベクトル検索)。 その結果をメソッドmake_contextで整形して、プロンプトに与える形式(ここでは箇条書き)にしています。 そして、質問queryと整形した参考情報contextをElyzaのプロンプト形式に埋め込んだ上で、モデルに入力して回答を生成します。

通常のテキスト生成

参考情報なしの通常のテキスト生成を試してみます。

model_name = 'elyza/ELYZA-japanese-Llama-2-13b-fast-instruct'
quantization_method='bitsandbytes'
generator = TextGenerator(model_name_or_path=model_name, quantization_method=quantization_method)
query = 'アジャイルソフトウェア開発宣言で、プロセスやツールよりも重視していることは?'
answer = generator.run(query=query, context=None)

出力は次のようになりました。

アジャイルソフトウェア開発宣言では、プロセスやツールよりも重視していることが3つあります。

1. 顧客からのフィードバックを重視する
2. 短期的な計画を重視する
3. チームワークを重視する

アジャイルソフトウェア開発宣言は、これらの価値観を重視して開発を行っています。

学習済みの内容にアジャイル開発に関することも含まれていると思われるので、完全に間違いという訳ではないですが、PDFを読んだ上で期待する回答としてはやや微妙な感じです。

RAGによるテキスト生成

query = 'アジャイルソフトウェア開発宣言で、プロセスやツールよりも重視していることは?'
results = vector_store.retrieve(query=query, n_results=5)
context = make_context(results)
answer = generator.run(query, context)
print(f'\n## 回答:\n{answer}')

RAGの場合の回答は次のようになりました。

アジャイルソフトウェア開発宣言では、プロセスやツールよりも個人と対話を、包括的なドキュメントよりも動くソフトウェアを、契約交渉よりも顧客との協調を、価値とすると述べています。

したがって、アジャイルソフトウェア開発宣言で、プロセスやツールよりも重視していることは、個人と対話と、動くソフトウェアです。

こちらの方がよりPDFの内容に基づいた回答になっている気がします。

ちなみにRAGの回答生成(ベクトル検索からテキスト生成まで)にかかった時間はColaboratoryのT4利用で約15秒でした。 用途にもよりますが、これくらいなら待てますね。

Transformers, LangChain & Chromaによるローカルのテキストデータを参照したテキスト生成

前回Hugging FaceTransgormersLangChainを用いたテキスト生成を実装しました。 今回はさらにChromaを用いて、ローカルのDB上のデータを参照して質問応答を行うテキスト生成を実装してみます。

ChromaはいわゆるベクトルDBの一種です。 ベクトルDBは埋め込みベクトルのような高次元のベクトルデータを扱うのに適したDBです。 ベクトルDBの実装には色々とありますが、今回はサーバを構築しなくても簡単に試せるChromaを使ってみました。

このベクトルDBと大規模言語モデル(LLM: Large Language Model)を用いたテキスト生成を組み合わせることで、ユーザからの質問に関連した文章をベクトルDBから取得して、その文章を基に回答を生成する、といったことが可能になります。 全体構成は次の図のようなイメージです。

全体構成の概要

インターネット上に公開していない(あるいは社外秘などで外部には公開できない)ドメイン固有の文書があり、生成AIを使ってそれらの文書をベースに質問応答を行いたい場合は、このような方法が有効になると思います。

実装の流れはこんな感じです。

  1. ベクトルDBの構築
  2. 学習済みモデルをロード
  3. タスクやモデルなどを指定してTransformers Pipelineを構築
  4. PipelineとPromptTemplateを指定してLangChainのLLMChainを構築
  5. 質問文を入力してベクトルDBから類似度の高い文章を検索
  6. 推論の実行(LLMChainに質問文と検索結果を与えて回答を生成する)

なお、今回のコードはこちらです。

github.com

ベクトルDBの構築

今回使用する文書はIPAが公開している「アジャイルソフトウェア開発宣言の読みとき方」です。 PDFファイルをページ単位で埋め込みベクトルに変換してChromaに格納します。

LangChainにはPDFファイルを読み込むPDFMinerLoaderがあるので、それを使用して読み込みます。 PDFファイルを読み込むLoaderは他にもありますが、日本語が文字化けしたり、ページがきちんと認識できなかったりしたので、いくつか試した結果、PDFMinerLoaderを使うことにしました。 ちなみに、名前のとおり、裏ではPDFMiner(正確にはpdfminer.six)が動いています。

from langchain.document_loaders import PDFMinerLoader

file = '000065601.pdf'

loader = PDFMinerLoader(file)
text = loader.load()
pages = text[0].page_content.split('\x0c')

ファイルをページ単位に分割できたら、HuggingFaceのsentence_transformers埋め込みモデルを使って、テキストデータを埋め込みベクトルに変換し、Chromaに格納します。 今回も大規模言語モデルサイバーエージェント社のOpenCALM-1Bを使います。

from langchain.embeddings.huggingface import HuggingFaceEmbeddings

model_name = 'cyberagent/open-calm-1b'
embeddings = HuggingFaceEmbeddings(model_name=model_name)

モデルをロードしたら、テキストデータを入力して埋め込みベクトルに変換します。 Chromaのオリジナルのインターフェースをそのまま使用しても良いですが、LangChainにChromaのラッパーがあるので、それを使うことにします。 langchain.vectorstores.ChromaでChromaのクライアントを生成します。 Chromaの引数embedding_functionに先ほどロードした言語モデルを指定します。 引数persist_directoryを指定すると、ストレージ上の指定した場所にデータが保存されます。 なお、引数persist_directoryを指定しない場合はインメモリとなります。

from langchain.vectorstores import Chroma

vectordb = Chroma(embedding_function=embeddings, persist_directory='./db')

続いて、先ほど読み込んだ文書について、ページ単位でループして、add_textsでDBに登録していきます。 このとき、引数textsに渡したテキストデータが埋め込みベクトルに変換されて登録されます。

for i, page in enumerate(pages):
  if page == '':
    continue
  vectordb.add_texts(texts=[page],
                     metadatas=[{'source': file}],
                     ids=[f'id{i+1}'])

学習済みモデルをロード

学習済みモデルもサイバーエージェント社のOpenCALM-1Bを使います。 なお、埋め込みベクトルを生成した際に使用したモデルと同じものを使う必要があります。

from transformers import AutoModelForCausalLM, AutoTokenizer

model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype=torch.float16).to(device)
tokenizer = AutoTokenizer.from_pretrained(model_name)

Pipelineを構築

続いてTransgormersPipelineを構築します。 テキスト生成の場合はtransformers.pipelineの引数tasktext-generationを指定し、modeltokenizerに先ほどロードした学習済みモデルとトークナイザーを指定します。 kwargsの値はOpenCALM-1BのUsageを参考にしています。

from transformers import pipeline

task='text-generation'

kwargs = {
    'max_new_tokens': 64,
    'do_sample': True,
    'temperature': 0.7,
    'top_p': 0.9,
    'repetition_penalty': 1.05,
    'pad_token_id': tokenizer.pad_token_id
}

pipe = pipeline(
    task=task,
    model=model,
    tokenizer=tokenizer,
    device=device_id,
    torch_dtype=torch.float16,
    **kwargs
)

LLMChainを構築

langchain.llms.HuggingFacePipelineに先ほど構築したPipelineを指定します。

from langchain.llms import HuggingFacePipeline

llm = HuggingFacePipeline(pipeline=pipe)

また、PromptTemplateで大規模言語モデルに指示を出すためのプロンプトのテンプレートを作成します。 テンプレートにはパラメータを含めることができ、{query}のような形式で記述します。 のちほどテキスト生成を実行する際、このパラメータに値を渡します。

from langchain.llms import HuggingFacePipeline
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain

llm = HuggingFacePipeline(pipeline=pipe)

template = """
質問: {query}

回答:
"""
prompt = PromptTemplate(template=template, input_variables=['query'])

LLMChainで大規模言語モデルとプロンプトを統合します。

llm_chain = LLMChain(llm=llm, prompt=prompt)

質問文に関連する文章をベクトルDBから取得

質問文を入力して、ベクトルDB上で質問文と類似度の高いページを検索します。 similarity_searchの引数kで上位何件までを返すのかを指定できます。

query = 'アジャイルソフトウェア開発宣言で、プロセスやツールよりも重視していることは?'
docs = vectordb.similarity_search(query=query, k=5)

ちなみにここでdocsの中身を確認してみると、質問文に関連したページが含まれていることが分かります。

推論

ベクトルDBの検索結果と元の質問文をLLMChainに渡して、最終的な回答を生成します。

llm_chain.run(input_documents=docs, query=query)

出力結果はこんな感じになりました。

'アジャイルは「俊敏な」「型にはまらない」という2つの言葉で表現されますが、「柔軟性・流動性が重要だ。またその柔軟性を保証するための仕組みが重要である。」というのが答えです。「変化への対応は遅いかもしれないけれど,すぐに改善するんだ」。これがチームにとって大切なことです。”スピード”と簡単にいいますが'

この回答だと、ベクトルDBから取得した文章を活用しきれていないように見えますが、大規模言語モデルのパラメータ数や文書を分割してChromaに登録した際の粒度(今回はページ単位)も影響しているかと思います。 モデルを変えてみたり、文書の分割の粒度や分割方法を変えてみたりするなど、工夫の余地はありそうです。

Transformers PipelineとLangChainによるテキスト生成

前回は Hugging FaceのTransgormers Pipelineを用いたテキスト生成を実装しました。 今回はTransgormers Pipelineに加えてLangChainも使用したテキスト生成を実装してみます。

LangChain を利用すると大規模言語モデルを用いたアプリケーション開発が容易になります。 モデルやデータベース、データの取り込みなどが抽象化されており、統一されたインターフェースを用いて実装できます。 このLangChainとHugging Faceで公開されている学習済み大規模言語モデルを使えば、精度はさておき、無料でChatGPTライクなものが実装できるのでは?と思い、調べてみました。

ちなみにLangChainの使用例を検索すると、OpenAIのAPIを使用した実装例(※LangChainにはOpenAIのAPIのラッパーが用意されています)が多数出てきますが、Hugging FaceのTransformers Pipelineを使用した実装例を見つけられなかったので、試行錯誤しながらの実装となりました。

実装の流れはこんな感じです。

  1. 学習済みモデルをロード
  2. タスクやモデルなどを指定してTransformers Pipelineを構築
  3. PipelineとPromptTemplateを指定してLangChainのLLMChainを構築
  4. 推論の実行(LLMChainに入力を与えて出力を得る)

なお、今回のコードはこちらです。

github.com

学習済みモデルをロード

学習済みモデルとして、今回も、サイバーエージェント社が公開している、日本語データセットで学習済みのOpenCALM-1Bを使用します。

from transformers import AutoModelForCausalLM, AutoTokenizer

pretrained_model_name = 'cyberagent/open-calm-1b'

model = AutoModelForCausalLM.from_pretrained(pretrained_model_name, torch_dtype=torch.float16).to(device)
tokenizer = AutoTokenizer.from_pretrained(pretrained_model_name)

Pipeline を構築

続いてパイプラインを構築します。 テキスト生成の場合はtransformers.pipelineの引数 task にtext-generationを指定し、modeltokenizerに、先ほどロードした学習済みモデルとトークナイザーを指定します。 kwargsの値はOpenCALM-1BのUsageを参考にしています。

from transformers import pipeline

task='text-generation'

kwargs = {
    'max_new_tokens': 64,
    'do_sample': True,
    'temperature': 0.7,
    'top_p': 0.9,
    'repetition_penalty': 1.05,
    'pad_token_id': tokenizer.pad_token_id
}

pipe = pipeline(
    task=task,
    model=model,
    tokenizer=tokenizer,
    device=device_id,
    torch_dtype=torch.float16,
    **kwargs
)

LangChain

LangChainのHuggingFacePipelineに上で構築したPipelineを指定します。 また、PromptTemplateで大規模言語モデルに指示を出すためのプロンプトのテンプレートを作成します。 テンプレートにはパラメータを含めることができ、{query}のような形式で記述します。 のちほどテキスト生成を実行する際、このパラメータに値を渡します。 LLMChainで大規模言語モデルとプロンプトを統合します。

from langchain.llms import HuggingFacePipeline
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain

llm = HuggingFacePipeline(pipeline=pipe)

template = """
{query}
"""

prompt = PromptTemplate(template=template, input_variables=['query'])

llm_chain = LLMChain(llm=llm, prompt=prompt)

推論

LLMChain.runで推論を実行します。このとき、テンプレートでパラメータになっていたqueryrunの引数で指定します。

llm_chain.run(query='アジャイルソフトウェア開発宣言の内容は')

ちなみに出力結果はこんな感じでした。

'「ソフトウェアは、目的を達成するために手段の1つとして存在しなくてはならない」という原則と、「反復可能な開発によってのみプロジェクトが成功するわけではない。そのプロジェクトは目的に達しないものである」(序文)との両点から成り立っています。「顧客のニーズを満たし(Price of Customer)」を目的に掲げている企業にとってこの宣言'

Transformaers Pipeline によるテキスト生成

Hugging Face の Transformers には推論を簡単に行うための Pipeline という仕組みがあります。 PyTorch や TensorFlow のような面倒なコードを書かずに、わずかなコーディング量で推論することができるので、ちょっとしたことを試すのにはとでも便利です。

Pipeline 自体は画像認識や音声認識など様々なタスクに使用できるようですが、今回は自然言語処理のタスク、特にテキスト生成を試してみます。

特定のモデルで Pipeline を使用する流れはこんな感じです。

  1. 学習済みモデルをロード
  2. タスクやモデルなどを指定して Pipeline を構築
  3. 推論の実行(パイプラインに入力を与えて出力を得る)

ちなみに、最も単純な場合は、明示的に学習済みモデルをロードせずにタスクを指定するだけでも良さそうですが、使用可能な言語が限られるなどの制限がありそうです。 今回は、最近公開された日本語の大規模言語モデルを使用して、日本語テキスト生成を試したかったので、上記のような流れで実装してみます。

なお、今回のコードはこちらです。

github.com

学習済みモデルをロード

学習済みモデルとして、今回は、サイバーエージェント社が公開している日本語データセットで学習済みの OpenCALM-1B を使用してみます。

from transformers import AutoModelForCausalLM, AutoTokenizer

pretrained_model_name = 'cyberagent/open-calm-1b'

model = AutoModelForCausalLM.from_pretrained(pretrained_model_name, torch_dtype=torch.float16).to(device)
tokenizer = AutoTokenizer.from_pretrained(pretrained_model_name)

パイプラインを構築

続いてパイプラインを構築する。 テキスト生成の場合は transformers.pipeline の引数 tasktext-generation を指定し、modeltokenizer に、先ほどロードした学習済みモデルとトークナイザーを指定する。 kwargs の値は OpenCALM-1BUsage を参考にしました。

from transformers import pipeline

task = 'text-generation'

kwargs = {
    'max_new_tokens': 64,
    'do_sample': True,
    'temperature': 0.7,
    'top_p': 0.9,
    'repetition_penalty': 1.05,
    'pad_token_id': tokenizer.pad_token_id
}

generator = pipeline( task=task, model=model, tokenizer=tokenizer, device=device_id, torch_dtype=torch.float16, **kwargs )

推論の実行

引数にテキストを与えると、それに続くテキストが生成されます。 たったのこれだけ。なんて簡単なんだ!

generator('アジャイルソフトウェア開発宣言の内容は')

ちなみに出力結果はこんな感じでした。

[{'generated_text': 'アジャイルソフトウェア開発宣言の内容は、ソフトウェアの開発・運用に関する方針を簡潔にまとめたものです。\nこの文書には、「開発」や「生産」「販売」、「保守サービスなどの各業務プロセスにおいて『顧客満足』を追求していくこと」、そしてそれらを実現するための考え方として、『SMILE(Smile:笑顔で)』と書かれています。『信頼』『誠実さ'}]

使用する学習済みモデルやモデルのパラメータ数によって精度も変わると思います。 様々なモデルで試してみたいですね。