はじめに
Livebook は Elixir のコードをブラウザから実行し、様々なことを自動化できるツールです
前回の記事で通常の生成 AI チャットを構築しました
今回の記事ではそれを踏まえて、事前に登録しておいたドキュメントを検索して回答する RAG (検索拡張生成) チャットを実装します
事前準備
事前準備は一部前回の記事と重複していますが、 扱うモデルが Gemma2 の日本語特化版に変わっています
Ollama のインストール
Ollama の公式サイトからインストーラーをダウンロードし、実行してください
インストールすると、ターミナル( Windows の場合はコマンドプロンプトや PowerShell など)から生成 AI とチャットできるようになります
本記事では日本語特化版の Gemma2 2B モデルを使用します
ターミナルで以下のコマンドを実行してください
ollama run hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf
Gemma2 と日本語でチャットできますが、浦島太郎についてはあまり詳しくないようです
>>> 浦島太郎が助けたのは何ですか?
浦島太郎が助けたものは **日本の歴史と文化** です。
彼は、**日本の神話や伝説** を形作ったり、**日本の精神的な象徴** を表現したりしています。
Livebook のインストール
Livebook の公式サイトからインストーラーをダウンロードして実行してください
インストールしたら Livebook を起動してください(macOS の場合は Launchpad から Livebook のアイコンをクリック)
ブラウザで以下のような画面が開きます
Livebook でのチャット実装
ノートブックの取得
Livebook の画面右上 Open
のボタンをクリックしてください
開いた画面で From URL
タブを選択し、 Notebook URL に https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/ollama/rag_chat.livemd
と入力してください
Import
ボタンをクリックすると、以下のような画面が表示されます
セットアップ
一番上のセットアップセルを実行してください
Mix.install([
{:ollama, "~> 0.8"},
{:nx, "~> 0.9"},
{:hnswlib, "~> 0.1"},
{:kino, "~> 0.15"},
{:req, "~> 0.5"}
])
セットアップセルの実行により、以下のモジュールがインストールされました
- ollama: Ollama の API にアクセスし、生成 AI を呼び出す
- nx: 機械学習に必要な行列演算を実行する
- hnswlib: Hierarchical Navigable Small World (HNSW) = 効率的にベクトル検索を実行する
- kino: ノートブックにリッチな UI を追加する
- req: HTTP リクエストを行う
Ollama クライアントの準備
Ollama の生成 AI を呼び出すためのクライアントを用意します
client = Ollama.init(base_url: "http://localhost:11434/api", receive_timeout: 300_000)
続いて、日本語特化版 Gemma2 モデルを用意します
Ollama.pull_model(client, name: "hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf")
Ollama.preload(client, model: "hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf")
通常のチャット
RAG を使わない通常のチャットを実装してみます
messages = [
%{role: "system", content: "あなたは親切なアシスタントです"},
%{role: "user", content: "浦島太郎が助けたのは何ですか?"}
]
{:ok, %{"message" => %{"content" => content}}} =
Ollama.chat(
client,
model: "hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf",
messages: messages
)
Kino.Markdown.new(content)
実行結果
スケールが大きい!
ドキュメントの登録
事前知識として桃太郎、金太郎、浦島太郎の物語をデータベースに登録しましょう
まず、テキストをベクトルに変換(埋め込み)するためのモデルとして、日本語特化モデルの Ruri を用意します
Ollama.pull_model(client, name: "kun432/cl-nagoya-ruri-base")
桃太郎、金太郎、浦島太郎は、青空文庫のテキストを元として Markdown 形式に加工したものを使用します
urls = [
"https://raw.githubusercontent.com/RyoWakabayashi/elixir-learning/main/livebooks/bumblebee/colab/momotaro.txt",
"https://raw.githubusercontent.com/RyoWakabayashi/elixir-learning/main/livebooks/bumblebee/colab/kintaro.txt",
"https://raw.githubusercontent.com/RyoWakabayashi/elixir-learning/main/livebooks/bumblebee/colab/urashimataro.txt"
]
テキスト埋め込み用の関数を定義します
Ollama からの応答は配列として得られますが、これをテンソルに変換し、行列演算できるようにしています
embed = fn input ->
{:ok, %{"embeddings" => embeddings}} =
Ollama.embed(
client,
model: "kun432/cl-nagoya-ruri-base",
input: "文章: #{input}"
)
embeddings
|> hd()
|> Nx.tensor()
end
各物語のテキストを URL からダウンロードしてきます
documents =
Enum.map(urls, fn url ->
url
|> Req.get!()
|> Map.get(:body)
end)
実行結果(省略して記載)
[" むかし、むかし、あるところに、おじいさんとおばあさんがありました。" <> ...,
" むかし、金太郎という強い子供がありました。" <> ...,
" むかし、むかし、丹後の国水の江の浦に、浦島太郎というりょうしがありました。" <> ...]
物語はそのまま埋め込むには長すぎるため、一定の大きさの「かたまり」=「チャンク」に分割します
本記事では改行2回と句点で分割した上で、 256 文字を超えるまでは一つのチャンクにしています
chunked_documents =
documents
|> Enum.flat_map(fn document ->
document
|> String.split("\n\n")
|> Enum.flat_map(fn paragraph ->
paragraph
|> String.split("。")
|> Enum.map(fn sentence -> sentence <> "。" end)
|> Enum.reduce([""], fn sentence, acc ->
[last_chunk | others] = acc
if String.length(sentence <> last_chunk) > 256 do
[sentence | acc]
else
[last_chunk <> sentence | others]
end
end)
|> Enum.filter(fn chunk -> String.length(chunk) > 2 end)
end)
end)
実行結果
["\n そこであわてておじいさんがお湯をわかすやら、おばあさんがむつきをそろえるやら、大さわぎをして、赤さんを抱き上げて、うぶ湯をつかわせました。するといきなり、\n「うん。」\n と言いながら、赤さんは抱いているおばあさんの手をはねのけました。\n「おやおや、何という元気のいい子だろう。」\n おじいさんとおばあさんは、こう言って顔を見合わせながら、「あッは、あッは。」とおもしろそうに笑いました。\n そして桃の中から生まれた子だというので、この子に桃太郎という名をつけました。。",
"」\n と勇ましいうぶ声を上げながら、かわいらしい赤さんが元気よくとび出しました。\n「おやおや、まあ。」\n おじいさんも、おばあさんも、びっくりして、二人いっしょに声を立てました。\n「まあまあ、わたしたちが、へいぜい、どうかして子供が一人ほしい、ほしいと言っていたものだから、きっと神さまがこの子をさずけて下さったにちがいない。」\n おじいさんも、おばあさんも、うれしがって、こう言いました。",
"その間に、おばあさんは戸棚の中からさっきの桃を重そうにかかえて来て、\n「ほら、ごらんなさいこの桃を。」\n と言いました。\n「ほほう、これはこれは。どこからこんなみごとな桃を買って来た。」\n「いいえ、買って来たのではありません。今日川で拾って来たのですよ。」\n「え、なに、川で拾って来た。それはいよいよめずらしい。」\n こうおじいさんは言いながら、桃を両手にのせて、ためつ、すがめつ、ながめていますと、だしぬけに、桃はぽんと中から二つに割れて、\n「おぎゃあ、おぎゃあ。",
...]
処理効率のため、順序は入れ替わっています
全てのチャンクを埋め込みます
all_embeddings = Enum.map(chunked_documents, &embed.(&1))
実行結果
[
#Nx.Tensor<
f32[768]
[0.015611808747053146, 0.01750940829515457, 0.021557766944169998, ...]
>,
#Nx.Tensor<
f32[768]
[-0.002605994464829564, 0.016695819795131683, 0.049481216818094254, ...]
>,
#Nx.Tensor<
f32[768]
[0.010766686871647835, 8.750376873649657e-4, 0.020604467019438744, ...]
>,
...
]
テキストを 768 次元のベクトルに変換できました
ベクトルを HNSW 用のインデックスに登録します
{:ok, index} = HNSWLib.Index.new(:cosine, 768, 1000)
for embeddings <- all_embeddings do
HNSWLib.Index.add_items(index, embeddings)
end
HNSWLib.Index.get_current_count(index)
実行結果
{:ok, 74}
3 編の物語が 74 個のベクトルとして登録されました
ドキュメントの検索
検索キーワード(クエリ)に近いドキュメント上位 5 件を検索する関数を定義します
search = fn query, documents ->
{:ok, %{"embeddings" => embeddings}} =
Ollama.embed(
client,
model: "kun432/cl-nagoya-ruri-base",
input: "クエリ: #{query}"
)
query_embeddings =
embeddings
|> hd()
|> Nx.tensor()
{:ok, labels, _dist} = HNSWLib.Index.knn_query(index, query_embeddings, k: 5)
labels
|> Nx.to_flat_list()
|> Enum.map(&Enum.at(documents, &1))
end
試しに検索してみましょう
search.("桃太郎に登場する動物は?", chunked_documents)
実行結果
["\n 桃太郎はたくさんの宝物をのこらず積んで、三にんの家来といっしょに、また船に乗りました。帰りは行きよりもまた一そう船の走るのが速くって、間もなく日本の国に着きました。\n 船が陸に着きますと、宝物をいっぱい積んだ車を、犬が先に立って引き出しました。きじが綱を引いて、猿があとを押しました。\n「えんやらさ、えんやらさ。」\n 三にんは重そうに、かけ声をかけかけ進んでいきました。\n うちではおじいさんと、おばあさんが、かわるがわる、\n「もう桃太郎が帰りそうなものだが。」\n と言い言い、首をのばして待っていました。",
"そして桃の絵のかいてある軍扇を手に持って、\n「ではおとうさん、おかあさん、行ってまいります。」\n と言って、ていねいに頭を下げました。\n「じゃあ、りっぱに鬼を退治してくるがいい。」\n とおじいさんは言いました。\n「気をつけて、けがをしないようにおしよ。」\n とおばあさんも言いました。\n「なに、大丈夫です、日本一のきびだんごを持っているから。」と桃太郎は言って、\n「では、ごきげんよう。」\n と元気な声をのこして、出ていきました。おじいさんとおばあさんは、門の外に立って、いつまでも、いつまでも見送っていました。",
" 桃太郎はずんずん行きますと、大きな山の上に来ました。すると、草むらの中から、「ワン、ワン。」と声をかけながら、犬が一ぴきかけて来ました。\n 桃太郎がふり返ると、犬はていねいに、おじぎをして、\n「桃太郎さん、桃太郎さん、どちらへおいでになります。」\n とたずねました。\n「鬼が島へ、鬼せいばつに行くのだ。」\n「お腰に下げたものは、何でございます。」\n「日本一のきびだんごさ。」\n「一つ下さい、お供しましょう。」\n「よし、よし、やるから、ついて来い。",
"\n けれども、体が大きいばっかりで、いくじのない鬼どもは、さんざんきじに目をつつかれた上に、こんどは犬に向こうずねをくいつかれたといっては、痛い、痛いと逃げまわり、猿に顔を引っかかれたといっては、おいおい泣き出して、鉄の棒も何もほうり出して、降参してしまいました。\n おしまいまでがまんして、たたかっていた鬼の大将も、とうとう桃太郎に組みふせられてしまいました。桃太郎は大きな鬼の背中に、馬乗りにまたがって、\n「どうだ、これでも降参しないか。」\n といって、ぎゅうぎゅう、ぎゅうぎゅう、押さえつけました。",
" おじいさんとおばあさんは、それはそれはだいじにして桃太郎を育てました。桃太郎はだんだん成長するにつれて、あたりまえの子供にくらべては、ずっと体も大きいし、力がばかに強くって、すもうをとっても近所の村じゅうで、かなうものは一人もないくらいでしたが、そのくせ気だてはごくやさしくって、おじいさんとおばあさんによく孝行をしました。\n 桃太郎は十五になりました。\n もうそのじぶんには、日本の国中で、桃太郎ほど強いものはないようになりました。桃太郎はどこか外国へ出かけて、腕いっぱい、力だめしをしてみたくなりました。"]
桃太郎に含まれるチャンクだけが抽出されました
浦島太郎に関するドキュメントも検索してみましょう
search.("浦島太郎が助けたのは何ですか?", chunked_documents)
実行結果
["\n 浦島は何を見ても、おどろきあきれて、目ばかり見はっていました。そのうちだんだんぼうっとしてきて、お酒に酔った人のようになって、何もかもわすれてしまいました。。",
" むかし、むかし、丹後の国水の江の浦に、浦島太郎というりょうしがありました。\n 浦島太郎は、毎日つりざおをかついでは海へ出かけて、たいや、かつおなどのおさかなをつって、おとうさんおかあさんをやしなっていました。\n ある日、浦島はいつものとおり海へ出て、一日おさかなをつって、帰ってきました。途中、子どもが五、六人往来にあつまって、がやがやいっていました。何かとおもって浦島がのぞいてみると、小さいかめの子を一ぴきつかまえて、棒でつついたり、石でたたいたり、さんざんにいじめているのです。",
"\n 浦島はうれしいのとかなしいのとで、胸がいっぱいになっていました。そしてかめの背中にのりますと、かめはすぐ波を切って上がって行って、まもなくもとの浜べにつきました。\n「では浦島さん、ごきげんよろしゅう」\nと、かめはいって、また水のなかにもぐって行きました。浦島はしばらく、かめの行くえを見送っていました。。",
"\n するとそこへ、よぼよぼのおばあさんがひとり、つえにすがってやってきました。浦島はさっそく、\n「もしもし、おばあさん、浦島太郎のうちはどこでしょう」\nと、声をかけますと、おばあさんはけげんそうに、しょぼしょぼした目で、浦島の顔をながめながら、\n「へえ、浦島太郎。そんな人はきいたことがありませんよ」\nといいました。浦島はやっきとなって、\n「そんなはずはありません。たしかにこのへんに住んでいたのです」\nといいました。",
"浦島は見かねて、\n「まあ、そんなかわいそうなことをするものではない。いい子だから」\nと、とめましたが、子どもたちはきき入れようともしないで、\n「なんだい。なんだい、かまうもんかい」\nといいながら、またかめの子を、あおむけにひっくりかえして、足でけったり、砂のなかにうずめたりしました。浦島はますますかわいそうにおもって、\n「じゃあ、おじさんがおあしをあげるから、そのかめの子を売っておくれ」\nといいますと、こどもたちは、\n「うんうん、おあしをくれるならやってもいい」\nといって、手を出しました。"]
浦島太郎の物語だけが取得できています
RAG チャットの実装
ユーザーからの質問に近いドキュメントを検索し、検索結果の情報だけを元に回答するチャットを実装します
# 出力用フレーム
output_frame = Kino.Frame.new()
# ストリーミング用フレーム
stream_frame = Kino.Frame.new()
# 入力用フォーム
input_form =
Kino.Control.form(
[
input_text: Kino.Input.textarea("メッセージ")
],
submit: "送信"
)
initial_messages = [
%{
role: "system",
content: """
あなたは親切なアシスタントです
コンテキスト情報に基づいてユーザーの質問に答えてください
## 重要な注意点
- 一般的な情報ではなく、コンテキスト情報のみに基づいて回答してください
"""
}
]
# フォーム送信時の処理
Kino.listen(input_form, initial_messages, fn %{data: %{input_text: input}}, messages ->
Kino.Frame.append(output_frame, Kino.Markdown.new("ユーザー: " <> input))
contexts =
input
|> search.(chunked_documents)
|> Enum.join("\n")
content =
"""
## コンテキスト情報
#{contexts}
## ユーザーの質問
#{input}
"""
messages = messages ++ [%{role: "user", content: content}]
{:ok, stream} =
Ollama.chat(
client,
model: "hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf",
messages: messages,
stream: true
)
full_response =
stream
|> Stream.transform("AI: ", fn chunk, acc ->
response = acc <> chunk["message"]["content"]
markdown = Kino.Markdown.new(response)
Kino.Frame.render(stream_frame, markdown)
{[chunk["message"]["content"]], response}
end)
|> Enum.join()
Kino.Frame.render(stream_frame, Kino.Markdown.new(""))
Kino.Frame.append(output_frame, Kino.Markdown.new("AI: " <> full_response))
{:cont, messages ++ [%{role: "assistant", content: full_response}]}
end)
# フレームを空にしておく
Kino.Frame.render(output_frame, Kino.Markdown.new(""))
Kino.Frame.render(stream_frame, Kino.Markdown.new(""))
# 入出力を並べて表示
Kino.Layout.grid([output_frame, stream_frame, input_form], columns: 1)
実行結果
アプリ化
最後に実装したフォームをアプリとして動かしてみましょう
不要セルの削除
以下の画像に示すセル(お試し用のコードを書いていたセル)を削除しましょう
アプリの設定
Livebook 左メニューのロケットアイコンをクリックし、アプリの設定を開きます
既に設定済の状態ですが、アプリの設定を確認しておきます
"Configure" のボタンをクリックしてください
今回のアプリでは最後に作ったフォームだけが表示されるようになっています
特に変更せず、右上のバツボタンでモーダルを閉じます
アプリの起動
左メニューから "Launch preview" をクリックしてください
アプリがローカルマシン上で起動し、以下のように情報が表示されます
"/apps/rag" の部分がリンクになっているのでクリックします
新しいセッションを開くか確認され流ので、 "+ New session" のボタンをクリックしてください
少し待つとフォームが表示されます
RAG チャットアプリとして会話できるようになりました
まとめ
Livebook で RAG チャットアプリを実装できました
ドキュメントを別ノートブックから外部データベースに登録するようにしておけば、より大量のドキュメントも扱うことができます
今回はチャンキング(かたまりに分けること)を適当に自前実装しましたが、チャンキング戦略も RAG の精度に大きく影響するので色々試してみてください