Livebook で生成 AI RAG チャットアプリを実装しよう

## はじめに

Livebook は Elixir のコードをブラウザから実行し、様々なことを自動化できるツールです

前回の記事で通常の生成 AI チャットを構築しました

https://qiita.com/RyoWakabayashi/private/b3c4853c104daef9354d

今回の記事ではそれを踏まえて、事前に登録しておいたドキュメントを検索して回答する RAG (検索拡張生成) チャットを実装します

## 事前準備

事前準備は一部前回の記事と重複していますが、 **扱うモデルが Gemma2 の日本語特化版に変わっています**

### Ollama のインストール

Ollama の公式サイトからインストーラーをダウンロードし、実行してください

https://ollama.com/

インストールすると、ターミナル（ Windows の場合はコマンドプロンプトや PowerShell など）から生成 AI とチャットできるようになります

本記事では日本語特化版の Gemma2 2B モデルを使用します

https://huggingface.co/google/gemma-2-2b-jpn-it

ターミナルで以下のコマンドを実行してください

```bash
ollama run hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf
```

Gemma2 と日本語でチャットできますが、浦島太郎についてはあまり詳しくないようです

```
>>> 浦島太郎が助けたのは何ですか？
浦島太郎が助けたものは **日本の歴史と文化** です。

彼は、**日本の神話や伝説** を形作ったり、**日本の精神的な象徴** を表現したりしています。
```

### Livebook のインストール

Livebook の公式サイトからインストーラーをダウンロードして実行してください

https://livebook.dev/#install

インストールしたら Livebook を起動してください（macOS の場合は Launchpad から Livebook のアイコンをクリック）

![スクリーンショット 2025-03-16 22.10.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/fc5e999c-3ce7-4e47-86f2-453fb1629dac.png)

ブラウザで以下のような画面が開きます

![スクリーンショット 2025-03-16 22.11.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c1f6a067-8cc5-41a6-8cae-59800aa393ad.png)

## Livebook でのチャット実装

### ノートブックの取得

Livebook の画面右上 `Open` のボタンをクリックしてください

開いた画面で `From URL` タブを選択し、 Notebook URL に `https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/ollama/rag_chat.livemd` と入力してください

`Import` ボタンをクリックすると、以下のような画面が表示されます

![スクリーンショット 2025-03-16 22.37.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/a6808549-53e2-44b4-9cd6-141af0329060.png)

### セットアップ

一番上のセットアップセルを実行してください

```elixir
Mix.install([
  {:ollama, "~> 0.8"},
  {:nx, "~> 0.9"},
  {:hnswlib, "~> 0.1"},
  {:kino, "~> 0.15"},
  {:req, "~> 0.5"}
])
```

セットアップセルの実行により、以下のモジュールがインストールされました

- [ollama](https://github.com/lebrunel/ollama-ex): Ollama の API にアクセスし、生成 AI を呼び出す
- [nx](https://github.com/elixir-nx/nx/tree/main/nx): 機械学習に必要な行列演算を実行する
- [hnswlib](https://github.com/elixir-nx/hnswlib): Hierarchical Navigable Small World (HNSW) = 効率的にベクトル検索を実行する
- [kino](https://github.com/livebook-dev/kino): ノートブックにリッチな UI を追加する
- [req](https://github.com/wojtekmach/req): HTTP リクエストを行う

### Ollama クライアントの準備

Ollama の生成 AI を呼び出すためのクライアントを用意します

```elixir
client = Ollama.init(base_url: "http://localhost:11434/api", receive_timeout: 300_000)
```

続いて、日本語特化版 Gemma2 モデルを用意します

```elixir
Ollama.pull_model(client, name: "hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf")
```

```elixir
Ollama.preload(client, model: "hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf")
```

### 通常のチャット

RAG を使わない通常のチャットを実装してみます

```elixir
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)
```

実行結果

![スクリーンショット 2025-03-21 21.25.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c0eb27cf-caf2-4e7d-bd58-a1a5eb26a26c.png)

スケールが大きい！

### ドキュメントの登録

事前知識として桃太郎、金太郎、浦島太郎の物語をデータベースに登録しましょう

まず、テキストをベクトルに変換（埋め込み）するためのモデルとして、日本語特化モデルの Ruri を用意します

https://zenn.dev/hpp/articles/b5132c64c40d24

```elixir
Ollama.pull_model(client, name: "kun432/cl-nagoya-ruri-base")
```

桃太郎、金太郎、浦島太郎は、青空文庫のテキストを元として Markdown 形式に加工したものを使用します

```elixir
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 からの応答は配列として得られますが、これをテンソルに変換し、行列演算できるようにしています

```elixir
embed = fn input ->
  {:ok, %{"embeddings" => embeddings}} =
    Ollama.embed(
      client,
      model: "kun432/cl-nagoya-ruri-base",
      input: "文章: #{input}"
    )

  embeddings
  |> hd()
  |> Nx.tensor()
end
```

各物語のテキストを URL からダウンロードしてきます

```elixir
documents =
  Enum.map(urls, fn url ->
    url
    |> Req.get!()
    |> Map.get(:body)
  end)
```

実行結果（省略して記載）

```elixir
["　むかし、むかし、あるところに、おじいさんとおばあさんがありました。" <> ...,
 "　むかし、金太郎という強い子供がありました。" <> ...,
 "　むかし、むかし、丹後の国水の江の浦に、浦島太郎というりょうしがありました。" <> ...]
```

物語はそのまま埋め込むには長すぎるため、一定の大きさの「かたまり」=「チャンク」に分割します

本記事では改行２回と句点で分割した上で、 256 文字を超えるまでは一つのチャンクにしています

```elixir
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)
```

実行結果

```elixir
["\n　そこであわてておじいさんがお湯をわかすやら、おばあさんがむつきをそろえるやら、大さわぎをして、赤さんを抱き上げて、うぶ湯をつかわせました。するといきなり、\n「うん。」\n　と言いながら、赤さんは抱いているおばあさんの手をはねのけました。\n「おやおや、何という元気のいい子だろう。」\n　おじいさんとおばあさんは、こう言って顔を見合わせながら、「あッは、あッは。」とおもしろそうに笑いました。\n　そして桃の中から生まれた子だというので、この子に桃太郎という名をつけました。。",
 "」\n　と勇ましいうぶ声を上げながら、かわいらしい赤さんが元気よくとび出しました。\n「おやおや、まあ。」\n　おじいさんも、おばあさんも、びっくりして、二人いっしょに声を立てました。\n「まあまあ、わたしたちが、へいぜい、どうかして子供が一人ほしい、ほしいと言っていたものだから、きっと神さまがこの子をさずけて下さったにちがいない。」\n　おじいさんも、おばあさんも、うれしがって、こう言いました。",
 "その間に、おばあさんは戸棚の中からさっきの桃を重そうにかかえて来て、\n「ほら、ごらんなさいこの桃を。」\n　と言いました。\n「ほほう、これはこれは。どこからこんなみごとな桃を買って来た。」\n「いいえ、買って来たのではありません。今日川で拾って来たのですよ。」\n「え、なに、川で拾って来た。それはいよいよめずらしい。」\n　こうおじいさんは言いながら、桃を両手にのせて、ためつ、すがめつ、ながめていますと、だしぬけに、桃はぽんと中から二つに割れて、\n「おぎゃあ、おぎゃあ。",
 ...]
 ```

:::note info
処理効率のため、順序は入れ替わっています
:::

全てのチャンクを埋め込みます

```elixir
all_embeddings = Enum.map(chunked_documents, &embed.(&1))
```

実行結果

```elixir
[
  #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 用のインデックスに登録します

```elixir
{: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)
```

実行結果

```elixir
{:ok, 74}
```

3 編の物語が 74 個のベクトルとして登録されました

### ドキュメントの検索

検索キーワード（クエリ）に近いドキュメント上位 5 件を検索する関数を定義します

```elixir
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
```

試しに検索してみましょう

```elixir
search.("桃太郎に登場する動物は？", chunked_documents)
```

実行結果

```elixir
["\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　もうそのじぶんには、日本の国中で、桃太郎ほど強いものはないようになりました。桃太郎はどこか外国へ出かけて、腕いっぱい、力だめしをしてみたくなりました。"]
```

桃太郎に含まれるチャンクだけが抽出されました

浦島太郎に関するドキュメントも検索してみましょう

```elixir
search.("浦島太郎が助けたのは何ですか？", chunked_documents)
```

実行結果

```elixir
["\n　浦島は何を見ても、おどろきあきれて、目ばかり見はっていました。そのうちだんだんぼうっとしてきて、お酒に酔った人のようになって、何もかもわすれてしまいました。。",
 "　むかし、むかし、丹後の国水の江の浦に、浦島太郎というりょうしがありました。\n　浦島太郎は、毎日つりざおをかついでは海へ出かけて、たいや、かつおなどのおさかなをつって、おとうさんおかあさんをやしなっていました。\n　ある日、浦島はいつものとおり海へ出て、一日おさかなをつって、帰ってきました。途中、子どもが五、六人往来にあつまって、がやがやいっていました。何かとおもって浦島がのぞいてみると、小さいかめの子を一ぴきつかまえて、棒でつついたり、石でたたいたり、さんざんにいじめているのです。",
 "\n　浦島はうれしいのとかなしいのとで、胸がいっぱいになっていました。そしてかめの背中にのりますと、かめはすぐ波を切って上がって行って、まもなくもとの浜べにつきました。\n「では浦島さん、ごきげんよろしゅう」\nと、かめはいって、また水のなかにもぐって行きました。浦島はしばらく、かめの行くえを見送っていました。。",
 "\n　するとそこへ、よぼよぼのおばあさんがひとり、つえにすがってやってきました。浦島はさっそく、\n「もしもし、おばあさん、浦島太郎のうちはどこでしょう」\nと、声をかけますと、おばあさんはけげんそうに、しょぼしょぼした目で、浦島の顔をながめながら、\n「へえ、浦島太郎。そんな人はきいたことがありませんよ」\nといいました。浦島はやっきとなって、\n「そんなはずはありません。たしかにこのへんに住んでいたのです」\nといいました。",
 "浦島は見かねて、\n「まあ、そんなかわいそうなことをするものではない。いい子だから」\nと、とめましたが、子どもたちはきき入れようともしないで、\n「なんだい。なんだい、かまうもんかい」\nといいながら、またかめの子を、あおむけにひっくりかえして、足でけったり、砂のなかにうずめたりしました。浦島はますますかわいそうにおもって、\n「じゃあ、おじさんがおあしをあげるから、そのかめの子を売っておくれ」\nといいますと、こどもたちは、\n「うんうん、おあしをくれるならやってもいい」\nといって、手を出しました。"]
```

浦島太郎の物語だけが取得できています

### RAG チャットの実装

ユーザーからの質問に近いドキュメントを検索し、検索結果の情報だけを元に回答するチャットを実装します

```eliixr
# 出力用フレーム
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)
```

実行結果

![urashima.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/8e54f32d-9dd8-4480-ad1d-13ab9406f5e2.gif)

## アプリ化

最後に実装したフォームをアプリとして動かしてみましょう

### 不要セルの削除

以下の画像に示すセル（お試し用のコードを書いていたセル）を削除しましょう

![スクリーンショット 2025-03-22 1.02.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/a601cef9-8391-46e1-a183-ca004c484b33.png)

![スクリーンショット 2025-03-22 1.03.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/9653f7ff-4629-40b0-94df-07026b9ff34d.png)

![スクリーンショット 2025-03-22 1.03.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/166e8111-981c-4e18-b12b-880330477534.png)

### アプリの設定

Livebook 左メニューのロケットアイコンをクリックし、アプリの設定を開きます

![スクリーンショット 2025-03-22 1.08.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f1b8d1bf-d05e-4b9f-a215-08c9e9684998.png)

既に設定済の状態ですが、アプリの設定を確認しておきます

"Configure" のボタンをクリックしてください

![スクリーンショット 2025-03-22 1.12.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/09abf0cc-3c53-4898-86e7-68f8c9580208.png)

今回のアプリでは最後に作ったフォームだけが表示されるようになっています

特に変更せず、右上のバツボタンでモーダルを閉じます

### アプリの起動

左メニューから "Launch preview" をクリックしてください 

アプリがローカルマシン上で起動し、以下のように情報が表示されます

![スクリーンショット 2025-03-22 1.13.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/454fe10e-5bca-45d6-a93e-da6da87c7466.png)

"/apps/rag" の部分がリンクになっているのでクリックします

新しいセッションを開くか確認され流ので、 "+ New session" のボタンをクリックしてください

![スクリーンショット 2025-03-22 1.15.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5b36a0be-5ca6-49c5-8cdc-c3946f3e3544.png)

少し待つとフォームが表示されます

RAG チャットアプリとして会話できるようになりました

![kintaro.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/aaae4467-da58-4d9b-bb94-2cf10aef4dc6.gif)

## まとめ

Livebook で RAG チャットアプリを実装できました

ドキュメントを別ノートブックから外部データベースに登録するようにしておけば、より大量のドキュメントも扱うことができます

https://qiita.com/RyoWakabayashi/items/e49dd0ba26537752c0c7

今回はチャンキング（かたまりに分けること）を適当に自前実装しましたが、チャンキング戦略も RAG の精度に大きく影響するので色々試してみてください

https://qiita.com/RyoWakabayashi/items/a05a05be2e1f30407dc2