Open12

ローカルLLMでRAGをやってみる

kun432kun432

ざっくり方針。

  • LLMは elyza/ELYZA-japanese-Llama-2-7b-instruct を使う
  • Embeddingは intfloat/multilingual-e5-large を使う
  • ベクトルDBはQdrantを(組み込み型で)使う
  • フレームワークはLlamaIndexを使う

環境

$ mkdir local-rag && cd local-rag
$ pyenv virtualenv 3.10.13 local-rag
$ pyenv local  local-rag

まずローカルLLMが単独で動くところまで一旦確認。

$ pip install --upgrade pip
$ pip install jupyterlab ipywidgets
$ jupyter-lab --ip='0.0.0.0' --NotebookApp.token=''

以後はJupyterで。

!pip install transformers accelerate bitsandbytes

elyza/ELYZA-japanese-Llama-2-7b-instructは既にダウンロード済み。

from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

model_path = "/SOMEWHERE/ELYZA-japanese-Llama-2-7b-instruct"

tokenizer = AutoTokenizer.from_pretrained(model_path)

model = AutoModelForCausalLM.from_pretrained(
    model_path,
    torch_dtype="auto",
    device_map="auto"
)

推論

B_INST, E_INST = "[INST]", "[/INST]"
B_SYS, E_SYS = "<<SYS>>\n", "\n<</SYS>>\n\n"
DEFAULT_SYSTEM_PROMPT = "あなたは誠実で優秀な日本人のアシスタントです。"
text = "クマが海辺に行ってアザラシと友達になり、最終的には家に帰るというプロットの短編小説を書いてください。"

prompt = "{bos_token}{b_inst} {system}{prompt} {e_inst} ".format(
    bos_token=tokenizer.bos_token,
    b_inst=B_INST,
    system=f"{B_SYS}{DEFAULT_SYSTEM_PROMPT}{E_SYS}",
    prompt=text,
    e_inst=E_INST,
)

with torch.no_grad():
    token_ids = tokenizer.encode(prompt, add_special_tokens=False, return_tensors="pt")
    output_ids = model.generate(
        token_ids.to(model.device),
        max_new_tokens=256,
        pad_token_id=tokenizer.pad_token_id,
        eos_token_id=tokenizer.eos_token_id,
    )
output = tokenizer.decode(output_ids.tolist()[0][token_ids.size(1) :], skip_special_tokens=True)
print(output)
承知しました。以下にクマが海辺に行ってアザラシと友達になり、最終的には家に帰るというプロットの短編小説を記述します。

クマは山の中でゆっくりと眠っていた。
その眠りに落ちたクマは、夢の中で海辺を歩いていた。
そこにはアザラシがいた。
クマはアザラシに話しかける。

「おはよう」とクマが言うと、アザラシは驚いたように顔を上げた。
「あ、おはよう」アザラシは少し緊張した様子だった。
クマはアザラシと友達になりたいと思う。

「私はクマと��

VRAMは14GBぐらい使ってる。EmbeddingsもGPU使うつもりなので量子化するかも。

Fri Dec  8 03:59:27 2023
+---------------------------------------------------------------------------------------+
| NVIDIA-SMI 530.30.02              Driver Version: 530.30.02    CUDA Version: 12.1     |
|-----------------------------------------+----------------------+----------------------+
| GPU  Name                  Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf            Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|                                         |                      |               MIG M. |
|=========================================+======================+======================|
|   0  NVIDIA GeForce RTX 4090         On | 00000000:01:00.0 Off |                  Off |
|  0%   46C    P8               15W / 450W|  14695MiB / 24564MiB |      0%      Default |
|                                         |                      |                  N/A |
+-----------------------------------------+----------------------+----------------------+

次はベクトルDBとEmbedding

kun432kun432

んー、ちょっとcalm2に変えようかなぁ、使ってみた個人的な印象だと一番自然な会話になってる、精度とかどこまでロジカルに答えれるか?とかまでは見てないけども。自分のやり方が悪いのかもだけど、Elyza変な応答返すときがそれなりにある。

kun432kun432

LlamaIndexでローカルRAGの記事をいくつか見つけた。

Recursive Retriever(Node Reference)+Node Sentence Windowを使ってるというまさにピンポイントな。記事。ただしLLMはzephyr-7b-beta。

https://note.com/pescafe/n/nd55b377f1518

こちらはElyza+e5-multilingualという、こちらもまたピンポイントな記事。

https://tech.dentsusoken.com/entry/2024/01/22/LlamaIndexを使ってローカル環境でRAGを実行する方法

後者はLlamaIndexのllama.cpp連携を使っているようだけど、

https://docs.llamaindex.ai/en/stable/examples/llm/llama_2_llama_cpp.html

自分もちょっと試してみた限り、

  • LlamaIndexのLlamaCPP modelは、llama-cpp-pythonが必要になる。
  • llama.cpp単独で実行した場合に比べて、llama-cpp-pythonを介するとなぜか遅くなる時がある。

ということがあったので、OpenAI互換のAPIを立ててアクセスしたほうが良いと思う。なので、OpenAI互換のAPIを立てるというのも考えてみても良いかもしれない。LlamaIndexにもOpenAI互換API用のmodelがある。

https://docs.llamaindex.ai/en/stable/examples/llm/localai.html

なので、FastChatなり、text-generation-webuiなり、llama.cppにもapi_like_OAI.pyってのがあったはずなので、そういうものを使うのが良いと思う。他にも色々あるので、量子化したいとか色々ニーズに合わせてやればいいと思う。

https://qiita.com/takaaki_inada/items/a918ca6984e832bc9741

例えば、ELYZA-japanese-Llama-2-7b-fast-instructをFastChatでOpenAI API互換で立ち上げる場合。

$ python -m fastchat.serve.controller &
$ python -m fastchat.serve.model_worker --model-path /SOMEWHERE/ELYZA-japanese-Llama-2-7b-fast-instruct  &
$ python -m fastchat.serve.openai_api_server --host localhost --port 8080  &     # 8080にしておくとLlamaIndex側で設定不要

LlamaIndexからはこんな感じで使えばよい。

from llama_index import ServiceContext
from llama_index.embeddings import HuggingFaceEmbedding
from llama_index.llms import LOCALAI_DEFAULTS, ChatMessage, OpenAILike

llm = OpenAILike(
    **LOCALAI_DEFAULTS,
    model="ELYZA-japanese-Llama-2-7b-fast-instruct",
    is_chat_model=True,
)

embed_model = HuggingFaceEmbedding(
    model_name="/SOMEWHERE/multilingual-e5-large",
    device="cpu",
    max_length=512,
)

service_context = ServiceContext.from_defaults(
    llm=llm,
    embed_model=embed_model,
)

llama-cpp-python遅い問題はまだ解決されなさそう。。。

llama-cpp-python遅い問題ってのは確かにあるにはあるみたいだけど、自分が確認したのはllama-cpp-pythonそのものの問題ではないかもしれない。

kun432kun432

LlamaIndex使って動作するところまで来た。あとは、

  • インデックスの工夫。Recursive Retriever(Node Reference)使うつもり。
  • Streamlit/Chainlitあたりでフロント作る。

あたり。

いろいろTool用意してAgentにしたいという思いはあるけれども、ちょっとローカルLLMだと難しいかなぁ・・・

kun432kun432

自分もちょっと試してみた限り、

  • LlamaIndexのLlamaCPP modelは、llama-cpp-pythonが必要になる。
  • llama.cpp単独で実行した場合に比べて、llama-cpp-pythonを介するとなぜか遅くなる時がある。

ということがあったので、OpenAI互換のAPIを立ててアクセスしたほうが良いと思う。LlamaIndexにもOpenAI互換API用のmodelがある。

これはJupyterlab+LlamaIndexで起きていたのだけど、そういえばllama-cpp-pythonを素で試したことなかったので、ほんとに遅いのかな?と思って試してみた。

llama-cpp-python

$ CMAKE_ARGS="-DLLAMA_CUBLAS=on" pip install llama-cpp-python
$ python -m llama_cpp.server --model ./ELYZA-japanese-Llama-2-13b-fast-instruct-q5_K_M.gguf --n_gpu_layers 41 --port 8080
$ curl http://localhost:8080/v1/chat/completions -H 'Content-Type: application/json' -d '{"model": "gpt-3.5-turbo","messages": [{"role":"system","content":"あなたは誠実で優秀な日本人のアシスタントです。"},{"role": "user", "content": "クマが海辺に行ってアザラシと友達になり、最終的には家に帰るというプロットの短編小説を書いてください。"}]}'
llama_print_timings:        load time =     109.10 ms
llama_print_timings:      sample time =      44.24 ms /   256 runs   (    0.17 ms per token,  5786.88 tokens per second)
llama_print_timings: prompt eval time =     124.01 ms /    30 tokens (    4.13 ms per token,   241.91 tokens per second)
llama_print_timings:        eval time =    3319.18 ms /   255 runs   (   13.02 ms per token,    76.83 tokens per second)
llama_print_timings:       total time =    3871.40 ms /   285 tokens

llama.cpp HTTP server

$ ./server -m ./ELYZA-japanese-Llama-2-13b-fast-instruct-q5_K_M.gguf -ngl 41
$ curl -X POST http://localhost:8080/completion -H "content-Type: application/json" -d '{"prompt": "<s>[INST]<<SYS>>あなたは誠実で優秀な日本人のアシスタントです。<<SYS>>クマが海辺に行ってアザラシと友達になり、最終的には家に 帰るというプロットの短編小説を書いてください。 [/INST] "}'
print_timings: prompt eval time =      77.00 ms /    54 tokens (    1.43 ms per token,   701.31 tokens per second)
print_timings:        eval time =    8197.98 ms /   608 runs   (   13.48 ms per token,    74.16 tokens per second)
print_timings:       total time =    8274.97 ms

llama.cpp コマンドライン

$ ./main -m ./ELYZA-japanese-Llama-2-13b-fast-instruct-q5_K_M.gguf -ngl 41 -p "[INST]]<<SYS>>あなたは誠実で優秀な日本人のアシスタントです。<</SYS>>日本語で回答し て ください。富士山の高さは?[/INST]"
llama_print_timings:        load time =     931.60 ms
llama_print_timings:      sample time =      12.71 ms /    74 runs   (    0.17 ms per token,  5823.56 tokens per second)
llama_print_timings: prompt eval time =      72.50 ms /    39 tokens (    1.86 ms per token,   537.94 tokens per second)
llama_print_timings:        eval time =     947.66 ms /    73 runs   (   12.98 ms per token,    77.03 tokens per second)
llama_print_timings:       total time =    1048.27 ms /   112 tokens

んー、それほど差は無いなぁ・・・

LlamaIndexで試してたときは、ストリーミングも有効にしてたのだけど、レスポンスが始まるまでに時間がかかる場合が結構あったのよな。

LlamaIndexのLlamaCPPの実装なのか、もしくは環境なのか、って感じ。

kun432kun432

試してみてわかることはあるね、大事。

とりあえず自分の場合はやっぱりOpenAI互換API立てる方にすると思う。LlamaIndexとLLMを疎にできるというのもあるし。

ただ、

  • llama.cpp HTTP Server+api_like_OAI.pyは、Completionのみで、ChatCompletionに対応していない。
  • FastChatだと設定も量子化もちょっと面倒。LLama系列以外のモデルも使えるってのはあるけれども。

あたりを考えると、13bの量子化も簡単にできて、ChatCompletionもちゃんと動いてくれるllama-cpp-pythonを、LlamaIndexと分けて立てることで遅いという事象は再現しなかったので、これでいいかなと思った。

kun432kun432

https://note.com/eurekachan/n/nfcf35803534f

Llama.cppのserverは、実は http://localhost:8080/v1/chat/completions というエンドポイントでもリクエストを受け付けています。これが、OpenAI互換モードです。入力と出力がOpenAIのgpt-3.5-turboやgpt-4と同じになるので、OpenAIのライブラリをそのまま使うことができるようになります。

まじで?

https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md

POST /v1/chat/completions: OpenAI-compatible Chat Completions API. Given a ChatML-formatted json description in messages, it returns the predicted completion. Both synchronous and streaming mode are supported, so scripted and interactive applications work fine. While no strong claims of compatibility with OpenAI API spec is being made, in our experience it suffices to support many apps. Only ChatML-tuned models, such as Dolphin, OpenOrca, OpenHermes, OpenChat-3.5, etc can be used with this endpoint. Compared to api_like_OAI.py this API implementation does not require a wrapper to be served.

ほんとだー

$ curl http://localhost:8080/v1/chat/completions -H 'Content-Type: application/json' -d '{"model": "gpt-3.5-turbo","messages": [{"role":"system","content":"あなたは誠実で優秀な日本人のアシスタントです。"},{"role": "user", "content": "クマが海辺に行ってアザラシと友達になり、最終的には家に帰るというプロットの短編小説を書いてください。"}]}'
{
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "message": {
        "content": "クマが海辺に行ってアザラシと友達になり、最終的には家に帰るというプロットの短編小説を作成します。\n\nタイトル: 「海の家」\n\n1章: 「海辺で偶然出会ったクマとアザラシ」\n    - 2人が仲良くなるまでの話\n\n2章: 「クマの家でパーティー」\n    - クマとアザラシの友達関係\n\n3章: 「クマが迷子になって大騒動」\n    - クマが迷子になるところから、家に帰るまで\n\n4章: 「クマが海辺にやって来た理由」\n    - クマの家のペットの話\n\n5章: 「エピローグ」\n    - 2人の今後の話",
        "role": "assistant"
      }
    }
  ],
  "created": 1706147807,
  "id": "chatcmpl-RcBjb3EtcckmFV50HdDoHaRoqjTg4Wfg",
  "model": "gpt-3.5-turbo",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 172,
    "prompt_tokens": 81,
    "total_tokens": 253
  }
}

これならllama-cpp-pythonすらいらないかもと思ったりしたんだけども、これllama2スタンダードじゃないプロンプトテンプレートで学習されているモデルでもちゃんと動くのかな?ちなみにllama-cpp-pythonだとこの辺でプロンプトテンプレートが定義してあるっぽい。

https://github.com/abetlen/llama-cpp-python/blob/main/llama_cpp/llama_chat_format.py#L579-L927

自分でプロンプト組み立ててcompletion API叩くってのもありなんだけど、llama.cppのcompletion APIはOpenAI互換ではなさそうだし、プロンプトも含めてコード側で吸収しないといけなくなるのではないか。
自分のコードではモデルやAPIごとに書き方を変えるようなことはしたくないので、そういう意味でllama-cpp-pythonなりFastChatなりを使うのは意味がある。

kun432kun432

ちょっと気になったのだけど、llama-cpp-python経由とllama.cpp HTTP server経由でちょっと回答のクオリティがぜんぜん違う。

LlamaIndexから送ってるプロンプトは以下。LlamaIndexのsimple handlerでの出力なので、実際にはちゃんとsystemとuserを分けてmessageオブジェクトを作って送信していることをデバッグで確認している。

system: あなたは競馬の知識に優れたアシスタントです。ユーザーからの質問に可能な限り丁寧かつ正確に回答します。 
user: 「コンテキスト情報」は以下です。
---------------------
filename: ドウデュース.md
section: ドウデュース

ドウデュース(欧字名:Do Deuce、2019年5月7日 - )は、日本の競走馬。主な勝ち鞍は2021年の朝日杯フューチュリティステークス、2022年の東京優駿、2023年の有馬記念。
馬名の意味は「する+テニス用語(勝利目前の意味)」。2021年のJRA賞最優秀2歳牡馬である。

filename: ドウデュース.md
section: ドウデュース > 血統表

母ダストアンドダイヤモンズはアメリカで重賞2勝を挙げ、2012年のGI・ブリーダーズカップ・フィリー&メアスプリント2着の実績を持つ。引退後の2016年にキーンランドノーベンバーセールでノーザンファーム代表の吉田勝己が100万ドルで落札して輸入された。
曾祖母Darling Dameは1986年凱旋門賞馬ダンシングブレーヴのいとこにあたる。

filename: ドウデュース.md
section: ドウデュース > 競走成績

以下の内容はJBISサーチ、netkeiba.com、France Galopおよびエミレーツ競馬協会の情報に基づく。

フランスのオッズ・人気は現地主催者発表のもの(日本式のオッズ表記とした)
競走成績は2023年12月24日現在

filename: ドウデュース.md
section: ドウデュース > 戦績 > 2歳(2021年)

9月5日に小倉競馬場で行われた2歳新馬戦(芝1800メートル)に武豊鞍上で出走。1番人気に推されると、レースは直線でガイアフォースとの追い比べをクビ差制してデビュー勝ちを果たした。
次走はリステッド競走のアイビーステークスを選択。2番人気に推され、レースでは追い比べから抜け出すと、最後は追い込んできたグランシエロをクビ差凌いで優勝、デビュー2連勝とした。
続いて朝日杯フューチュリティステークスに出走。重賞勝ち馬セリフォスやジオグリフをはじめとした自身と同じ無敗馬が多く顔を揃える中、3番人気に支持される。レースでは直線で外に出すと、先に抜け出していたセリフォスを半馬身差で差し切り優勝、無傷3連勝でGI初制覇を果たした。鞍上の武豊はこの競走22回目の挑戦で初制覇となり、日本の中央競馬 (JRA) の平地GI完全制覇までホープフルステークスを残すのみとした。また馬主である松島及びキーファーズにとっては初の単独所有馬によるGI勝利、並びに国内GI初制覇となった。

filename: ドウデュース.md
section: ドウデュース > 戦績 > 4歳(2023年)

2023年2月12日に京都競馬場の改修により、阪神競馬場(芝内回り・2200m)で行われた京都記念に斤量58キロで出走。まずまずのスタートを決めると、道中は馬群を見て最後方グループで待機し、向正面から外へ出て徐々に捲りをかけ、直線に入ると早々と馬群から抜け出して後続を突き放し、3馬身半差の快勝でGIを含む重賞3勝目を挙げた。日本ダービーを勝利した馬が京都記念を勝利するのは、1948年春の京都記念のマツミドリ以来75年ぶりとなった。
次走は3月25日にドバイのメイダン競馬場で行われるドバイターフとし、同月15日(現地時間同月14日)にメイダン競馬場に到着した。しかし出馬投票後の同月24日、調教後に左前肢跛行を発症しドバイターフへの出走を取り消した。友道は「調教後に左腕節に違和感を認め、競馬に向けて進めておりましたが、将来のある馬なのでここでは無理をせず、取り消すことを決断いたしました」と語った。
その後夏は治療と休養にあて、秋初戦として10月29日に東京競馬場で開催される天皇賞(秋)に出走。当日の第5競走で武豊が騎乗馬に右膝を蹴られ負傷したため、急遽戸崎圭太に乗り替わりとなった。レースは中団6番手からチャンスを狙ったものの、最終直線で伸び切れず7着に終わった。
次走のジャパンカップでは騎乗予定だった武豊が上述の負傷の回復が遅く、療養に専念するために引き続き戸崎で出走。急な乗り替わりとなった前走と異なり戸崎が騎乗する想定での調整を行ったものの4着。勝ち馬のイクイノックスは本レースがラストランとなった。
2023年最終戦として有馬記念へ出走。鞍上には武豊が復帰した。
鞍上横山和生と本レースがラストランとなっており、レースを牽引して尚直線粘るタイトルホルダーと、二番手から先に仕掛けていた鞍上クリストフ・ルメールのスターズオンアースを抜き去り、先頭でゴール板を駆け抜けて見事復活の勝利を挙げた。武豊は2017年のキタサンブラック以来、6年ぶりの制覇となった。また、54歳9カ月10日での有馬記念勝利は最年長勝利記録であり、同時に自身が持つJRA・GI最年長勝利記録(54歳19日)を更新。ドリームジャーニーやオルフェーヴル、ブラストワンピースに騎乗し歴代最多の有馬記念4勝を挙げている池添謙一に並ぶ勝利数となった。馬番5番での優勝は、1970年のスピードシンボリ、1972年のイシノヒカルに次いで、51年ぶり3度目となった。さらに、ダービー馬による有馬記念制覇はオルフェーヴル以来10年ぶり9頭目で、三冠馬以外ではハクチカラ、ダイナガリバー、トウカイテイオーに次いで30年ぶり4頭目となったほか、父ハーツクライとの本競走親子制覇を達成した。本競走での親子制覇は史上6回目となった。
優勝後、陣営は翌年のドバイと凱旋門賞に再挑戦する旨を表明した。
---------------------
事前知識を使わずに、与えられたコンテキスト情報だけを使って、次の質問に答えてください。ドウデュースの主な勝ち鞍について教えて

llama-cpp-pythonの場合

assistant:  承知しました。ドウデュースの主な勝ち鞍については、朝日杯フューチュリティステークス、東京優駿、有馬記念です。

llama.cpp HTTP serverの場合

assistant: 
ドウデュースは、2013年の菊花賞と2014年の天皇賞・春に優勝した。

何度か繰り返してもあきらかにllama.cpp HTTP serverの場合の回答精度が悪すぎるというかめちゃくちゃハルシネーションする。LlamaIndexからはOpenAI互換と思って同じリクエストを投げているのにこんだけ違うとなると、llama.cppのOpenAI互換API実装がモデルが期待しているプロンプトとして正しく渡せてないんじゃないかなという気がする。

やっぱりローカルLLMじゃ全然ダメなのかなーとか思っちゃったけど、そっちじゃなかった。ごめんよ、ELYZA。(とはいえ、GPT−4に比べてはいけない)

D̷ELLD̷ELL

初めまして。記事を参考にして頂きありがとうございます。
元記事が某所で論文賞取ってしまった関係で、削除してしまいすみません・・。

本スクラップ、非常に読みごたえがあり楽しめました。
今後とも執筆される記事を拝読させて頂きます。

kun432kun432

ご丁寧にありがとうございます 🙇

もはや4ヶ月も前の記事で、その間いろいろ状況も変わって他にもいろいろモデル出てきているので、そろそろ書き直したいと思いつつ、他で忙殺されていて遅々として進んでいない状況なので、お気になさらずー

時間が空いたらまた進めていきたいと思います!