今更ですが、Intel Mac で Ollama + Qdrant + Langchain を使って RAG をしてみた

# はじめに
今更ですが、タイトルの件、ちょっと試しにやってみました。

ちなみに開発環境は以下の通り。
<dl>
  <dt>MacMini（Intel Core i7）</dt>
  <dd>OS: Sqeuoia 15.2</dd>
  <dd>メモリ: 32GB</dd>
</dl>
  
＃外付けGPU つけてますが、インストールしたものたちは使用していないみたいです。
＃なので CPUパワーでぐりぐりしています。Apple Silicon Mac だといろいろと速いかもです。
＃Ruby は rbenv でインストールしています。
＃あといろいろと brew でもインストールしています。必要なものは適宜インストールしてください。

# RAGってなに？
RAGとは 検索拡張生成（Retrieval Augmented Generation）で、LLM にオリジナルのデータを反映した文言を生成させよう！ということで考えられました。
LLM は膨大なデータで学習していますが、ローカルな（手前味噌の）情報で生成させようとするとなんかいろいろ嘘とか付くので、それを改めようということです。

仕組みとしては、LLM に質問とオリジナルデータを渡して生成してもらう、という感じです。
詳しくはネットで検索してみてください。

# 準備するもの
- LLM（大規模言語モデル）
- ベクトルデータデータベース
- LLM とベクトルデータを結ぶもの

いろいろ調べましたが、以下のものを選択しました。
LLM: [Ollama](https://ollama.com)
ベクトルデータデータベース: [Qdrant](https://qdrant.tech)
結ぶもの: [LangChain](https://www.langchain.com)

Ollama は、インストーラーがあって簡単に導入できました。
Qdrant は Pythonでないので、選びました。
Langchain は、定番っぽかったので。。

というわけで、インストールしていきます。

# Ollama をインストール
インストールは簡単です。
[Ollama のサイト](https://ollama.com) に行って各プラットフォーム用のインストーラーをダウンロードしてインストールします。

実行は、アプリケーションフォルダからラマのアイコンをダブルクリックで行けますが、その前に環境を設定する必要があります。
環境の設定は、コマンドラインで行います。

## LLM 学習済みデータを取得する
今回は、[ELYZA-japanese-Llama-2-7b-instruct-q4_k_M.gguf](https://huggingface.co/mmnga/ELYZA-japanese-Llama-2-7b-instruct-gguf) をダウンロードしました。
サイズ感と比較サイトでの性能で選択しました。
これをどこかに保存しておきます。

ollama フォルダ切って保存しますかね。
```sh
$ mkdir ollama
$ cd ollama
$ mkdir models
$ cd models
$ wget https://huggingface.co/mmnga/ELYZA-japanese-Llama-2-7b-instruct-gguf/blob/main/ELYZA-japanese-Llama-2-7b-instruct-q4_K_M.gguf
```

## Modelfile を作る
models に Modelfile を作成します。
```sh
$ cd models
$ vi Modelfile
```
で、Modelfile の中身ですが、少しクセがあるようです。
TEMPLATE にはモデルに合わせた書き方があるようで、今回は Llama2 なのでそれに合わせます。

以下が例。あと、FROM はなんか相対パスじゃないとできなかった。。説明では絶対パスでもできると書いてあったけど。。

```go
# for llama2
FROM ./ELYZA-japanese-Llama-2-7b-instruct-q4_K_M.gguf
PARAMETER temperature 0.3

TEMPLATE """
{{- if .System }}

{{ .System }}

{{end }}
{{- if .Prompt }}

{{ .Prompt }}

{{end }}
{{- if .Content }}
[REF]{{ .Content }}[/REF]
{{end }}

{{ .Response }}
"""

PARAMETER stop "
"
PARAMETER stop "
"
PARAMETER stop "
"
PARAMETER stop "
"
PARAMETER stop "[REF]"
PARAMETER stop "[/REF]"
```

今回いろいろ錯誤して ```[REF] [/REF]``` というのを追加しましたが、これが効いているかどうかは不明です。
それぞれのパラメータの説明が全然なくて、見よう見まねでやってみました。

ちなみに ```
``` とか ```
``` というのが Llama2 の識別子で、Llama3 とかは ```
``` とか違うみたいです。
そのあたりは、[ollama の github の template](https://github.com/ollama/ollama/tree/main/template) のソースを見てみてください。

保存ができたら、環境を作成します。
```sh
$ cd ollama
$ ollama create default -f ./models/Modlefile
```

default は create した環境の名称で、一般的には FROM で設定したモデル名（今回なら ELYZA-japanese-Llama-2-7b-instruct-q4_K_M）にしておくっぽいですが、よく使うので default にしました。

それから、連携で使うモデルも別途用意します。これは次のコマンドで大丈夫です。
```sh
$ ollama pull mxbai-embed-large
```
そしたらとりあえず以上で ollama の設定完了です。
Applicationフォルダからラマを起動させましょう。

ちなみに、ollama は起動すると 11434 ポートで受付できるようになっておりまして、次のコマンドで動作の確認ができます。

```sh
$ curl -X POST http://localhost:11434/api/generate -d '{"model": "default", "prompt": "明日は晴れますか？"}' 
```
モデル名を default にしたのは、これから ollama を使うときによく出てくるからなのです。。。

# Qdrant をインストール
Qdrant は github からインストールします。
しかし、[github のサイト](https://github.com/qdrant/qdrant) では docker 上で走らせる方法しか書いてないのです。そこで Googleさんで検索してみると、[本家サイトの Instllationページ の下の方](https://qdrant.tech/documentation/guides/installation/)に書いてありました。

Qdrant は rust で動いているので、初めに rust をインストールします。これは brew でインストールします。

```sh
$ brew install rust
```
そしたら、Qdrant をインストールします。
```sh
$ git clone https://github.com/qdrant/qdrant
$ cd qdrant
$ cargo build --release --bin qdrant
```
です。
コンパイルが完了したら、パスの通ったところにリンクを貼っておきましょう。

```sh
$ sudo ln -s ./target/release/qdrant /usr/local/bin/qdrant
```

実行は、
```sh
$ qdrant
```
です。すると画面につらつらとログが流れます。後ろで動かしたい場合は 最後に ```&``` でもつけましょうかね。

Qdrant は 6333 ポートで受付ます。確認するには、ブラウザで ```http://localhost:6333``` として確認します。
設定で https にすることもできるみたいです。
設定は qdrant/config 以下にあるので適宜変更しましょう。
今回は特に変更しませんでした。

# LangChain をインストール
LangChain はライブラリで、いろいろな言語に対応したものがあります。一般的には Python を指すみたいですが、ありがたいことに Ruby に対応したものもありました。

というわけで、[Ruby用のもの](https://github.com/patterns-ai-core/langchainrb)を使います。
gem でインストールできるので、Gemfile を作ってインストールします。
ちなみに Ruby は 3.3.6 を使っています。

```ruby
# ruby gems install
source "https://rubygems.org"

gem "langchainrb"
```
で、
```sh
$ bundle install
```
ですね。はい。

確認は、以下。
```test.rb
# -*- coding: utf-8 -*-
require "langchain"

ollama = Langchain::LLM::Ollama.new(
  url: "http://localhost:11434",
  default_options: {
    temperature: 0.3,
    completion_model: "default",
    embedding_model: "mxbai-embed-large",
    chat_model: "default" }
)

res = ollama.complete(prompt: "明日は晴れますか？")
p res
```
```sh
$ bundle exec ruby ./test.rb
```
なんか返ってきたらOKです。

しかし Langchain の使い方としては、これではなくて、もう少し別にあります。プロンプトに入れた文章に対しての応答生成だけなら、[ollama-ruby という gem](https://github.com/flori/ollama-ruby) もあるので、それでもできます。

# RAG をやってみる
そしたら各種準備ができたので、早速 RAG をやっていきたいと思います。

＃しかしながら、ベクトルデータデータベースとか使ったことないので、どうすれいいんだ？ Langchain ってどう使うの？
＃とか、いろいろGoogleさんに聞きながら、試行錯誤しました。
＃なんせ Googleさんとか MS Copilot とかに聞いても多くが Python の情報なので、、、
＃それでも、少ない情報のなか以下のサンプルコードができました。

というわけで、本当にこんな使い方で合っているのかどうかわかりませんが、以下 Ruby で書いたコードです。

```sh:Gemfile
# ruby gems install
source "https://rubygems.org"

gem "faraday"
gem "langchainrb"
gem "qdrant-ruby"

gem "natto"
```
```rag_test.rb
# -*- coding: utf-8 -*-
require "langchain"
require "qdrant"
require "natto"

qdrant = Qdrant::Client.new(url: "http://localhost:6333",
                            api_key: nil)
ollama = Langchain::LLM::Ollama.new(
  url: "http://localhost:11434",
  default_options: {
    temperature: 0.3,
    completion_model: "default",
    embedding_model: "mxbai-embed-large",
    chat_model: "default" }
)
natto = Natto::MeCab.new

# ---- collection create ----
#qdrant.collections.delete(collection_name: "test") # ← 必要であれば

# size -> 使用する LLM の embedding によって変わります。
#         今回は mxbai-embed-large を使って生成します。
#         確認したらサイズは 1024でした。
# distance -> Dot, Cosine, Euclid, Manhattan です。
#             各ベクトル間の距離を示します。
#             テキストの場合は Cosine が多く用いられているとか。
qdrant.collections.create(collection_name: "test",
                          vectors: { size: 1024, distance: :Cosine } )

# -- add point(テキストデータを登録します)
btexts = []
btext = "オレは高校生探偵・工藤新一。幼馴染で同級生の 毛利蘭 と遊園地に遊びに行って、黒ずくめの男 の怪しげな取引現場を目撃した。取引を見るのに夢中になっていたオレは、背後から近づいてくるもう一人の仲間に気づかなかった。
オレはその男に毒薬を飲まされ、目が覚めたら…体が縮んでしまっていた!!工藤新一 が生きていると奴らにバレたら、また命が狙われ、周りの人間にも危害が及ぶ。阿笠博士 の助言で正体を隠すことにしたオレは、蘭に名前を聞かれてとっさに、江戸川コナン と名乗り、奴らの情報をつかむために、 父親が探偵をやっている蘭の家に転がり込んだ。"
btexts.push(btext)

btext = "謎に包まれた黒ずくめの組織…。わかっているのは、そのコードネームがお酒にちなんだ名前であることくらいだ…。そんな奴らの正体を暴くため、小さな名探偵、江戸川 コナン の活躍が始まった!!たったひとつの真実見抜く見た目は子供、頭脳は大人。その名は、名探偵コナン!!"
btexts.push(btext)

btext = "世界的推理小説家の父を持つ高校生探偵・工藤新一。数々の難事件を解決してきた彼は、ある日、幼なじみの毛利蘭とデートの途中、謎の黒ずくめの男達の取引を目撃してしまった！！証人を消すべく毒薬を飲まされた新一は、何とか命をとりとめたものの、子どもの姿になってしまう…！"
btexts.push(btext)

btext = "そして彼は江戸川コナンの名を使い、難解な事件を解き続ける。全ては謎の組織の正体を突き止め、工藤新一としての自分を取り戻す為…この世に解けない謎なんてあるはず無い！迷宮入りなしの名探偵、真実はいつもひとつ！！"
btexts.push(btext)
# -- 以上、コナンのデータは、アニメ公式サイト、漫画公式サイトから

# -- 以下は嘘データです。参照情報を取り入れているか確認するため。
btext = "鳥取県鳥取市には日本で立ち入りことのできる１番大きい砂丘があります。近年では海外からの観光客も多くとても賑わっています。その近くにサンガーデンはあります。サンガーデンは1948年に地元の名手が賑わいを創造することを目的に建てられた、当時としては珍しい３階建ての西洋風な建物で、今ではそのレトロな風貌から一般の方だけでなくレイヤーの方たちからも人気です。"
btexts.push(btext)

# -- Qdrant にベクトルデータを登録します。
#    ollama.embed でベクトルデータを作成して、登録します。
#    ついでに payload にテキスト本文も入れておきます。
#    これは検索時にフィルターをかけるためです。
# ---- vector data store to qdrant ----
btexts.each_with_index do |btxt, i|
  res = ollama.embed(text: btxt)
  embedding = res.embedding
  qdrant.points.upsert(
    collection_name: "test",
    points: [
      { id: i,
        vector: embedding,
        payload: { "body" => btxt }
      }
    ]
  )
end

# ---- search point（ユーザーの問い合わせ文章） ----
stext = "サンガーデンの人気の秘密は？"

# -- データベースに登録した文章の大きさと質問文の大きさが
#    近いのが原因かどうかわかりませんが、ベクトル検索する
#    と全然キーワードがないものも引っ掛けてきてしまうので、
#    以下で、名詞に注目して、フィルターをかけています。
# ---- search filter for qdrant ----
nats = []
wd = ""
natto.parse(stext) do |nat|
  vs = nat.feature.split(",")[0..2]
  if vs[0] == "名詞"
    sf = nat.surface
    wd += sf unless ["接尾", "代名詞"].include?(vs[1])
    if ["接尾"].include?(vs[1])
      unless wd.empty?
        unless vs[2] == "人名" # 「さん」とか「君」とかを除外
          wd += sf
          nats.push(wd)
          wd = ""
        end
      end
    end
  else
    unless wd.empty?
      nats.push(wd)
      wd = ""
    end
  end
end
# ---- make qdrant filter ----
flt = []
nats.each do |nat|
  fl = { "key": "body", "match": { "text": nat } }
  flt.push(fl)
end

# -- 検索は、質問文のベクトルデータを作成して、そのベクトルデータに
#    近いものをとってくる、というものです。limit で件数を指定します。
#    今回は一つ一つの登録データが短いので 最大 3つ とってきて、合わせて
#    回答文を作るようにしています。
# ---- create search text vector and searching qdrant ----
res = ollama.embed(text: stext)
embedding = res.embedding
qres = qdrant.points.search(collection_name: "test",
                            vector: embedding,
                            filter: { "should": flt },
                            limit: 3,
                            with_payload: true)

body = ""
qres["result"].each do |re|
  btext = re["payload"]["body"].to_s.gsub(/\n|\r/, "")
  body.concat("#{btext}\n") unless body.include?(btext)
end

# -- 以下が RAG のメインになるところです。
#    qdrant.points.search でベクトル検索した結果の文章を使って、
#    llm に文章を生成させる箇所です。
#
#    Ollama をインストールしたときに作成した Modelfile に書いた
#    
 とか 
 とかを使います。
#    
 はシステム(LLM)に対しての指示です。
#    
 は質問文です。
#    [REF] は参考文章です。。。しかしこれが正しい書き方かは不明
#
#    それぞれを作って、一つにつなげて、LLM に投げ込みます。
#    するとあら不思議、勝手に回答文を作成してくれるのです。
# ---- create llm ----
sysmsg = "
あなたは誠実な報道官です。参考情報を元にユーザーからの質問に正確且つ簡潔に答えてください。
\n"
ptext = "
#{stext}
\n"
reftxt = "[REF]\n\n#{body}\n\n[/REF]\n"

result_text = ""
prompt = "#{sysmsg}#{ptext}#{reftxt}"
ores = ollama.complete(prompt: prompt, raw: true) do |ore|
  result_text.concat(ore.completion)
end

# -- 最後に質問文と回答文を表示します。
puts "---- Q&A ----"
puts "Q: #{stext}"
puts "A: #{result_text.strip}"
```

では実行。初回は ```$ bundle install``` をしてください。

```sh
$ bundle exec ruby ./rag_test.rb
```
するとつらつら流れて、最後に以下のような結果がでます。

```
---- Q&A ----
Q: サンガーデンの人気の秘密は？
A: サンガーデンが人気の秘密は以下の要因が考えられます。

- レトロ感：サンガーデンは1948年に建てられたため、当時としては珍しい西洋風の建物である。そのため、今ではレトロな風貌が人気を集めています。
- 撮影スポット：サンガーデンは外観だけでなく、中に入るとさらに多くの撮影スポットがあります。そのため、写真を撮ることが好きな方から人気を集めています。
- 絶景：サンガーデンは鳥取砂丘の近くに位置しており、そのため絶景を楽しむことができるのも魅力的です。
```

おーそれっぽい回答ができました。
プログラム中にも書きましたが、サンガーデンにまつわる話は架空のものですが、見事にそれっぽく生成していますね。

### 質問文をいろいろ変えて遊んでみる。
```
---- Q&A ----
Q: コナン君の恋人は誰ですか？
A: コナン君の恋人は蘭です。
```
おー合っていますね。
```
---- Q&A ----
Q: コナン君の正体は？
A: コナン君の正体は江戸川コナンです。
```
うーんちょっと違うかな。
```
---- Q&A ----
Q: 新一と蘭はどこへいきましたか？
A: 新一と蘭は江戸川コナンの家に転がり込みました。
```
なんと！（笑）
```
---- Q&A ----
Q: コナン君はサンガーデンに行きましたか？
A: コナン君はサンガーデンに行ったことはありません。
```
おー。
面白いのでキリがないですね。
正解だったり間違っていたりしますが、どうなんでしょうかね。
いろいろパラメータとかあるみたいですが、そのあたりとか調整が必要ですかね？
データも少ないですし、、、
しかし架空のサンガーデンも反映しているので、一応認識はしてるのかなぁと思ったり。。。

# 最後に
LLMとかベクトルデータデータベースとかLangChainとかRAGとか基本的なところから、いろいろなサイトを参照させていただきました。ありがとうございます。

ここまでくるのにそれなりにかかりましたが、いかがでしょうか。
＃しかし、こんなやり方であっているのだろうか？と。。。どうなんでしょうか。。。
＃誰か正解を教えてください。。。

以上です。