Rails+AI: ChatGPTとllm_memoryで構築するFAQチャットシステム

Railsで動くWebサービスとChatGPTを連携したいと思ったことがありますよね。今回は、サービスのFAQページの内容に応答するチャットボットを作成し、電話の問い合わせを減らす施策を考えてみましょう。

RubyにはLangChainやLlamaIndexのようなライブラリがありません。なので、[llm_memory](https://github.com/shohey1226/llm_memory)というGemを作りました。LlamaIndexを大いに参考にしています。ただし、LlamaIndexは大きなバンドルであり、科学的な要素も含まれています。簡単に利用できる一方で、詳細に調べようとすると難易度が上がります。さらに、Python製なのでRailsとの統合が難しいです。

### llm_memoryについて

![](https://user-images.githubusercontent.com/1880965/236099477-421b2003-79d2-4a7c-8f80-1afac4fd616d.png)

[llm_memory](https://github.com/shohey1226/llm_memory)は、LlmaIndexと同じくin-context learningを利用しています。具体的には、データをチャンクに分割し、vector databaseにembeddingしたものを突っ込みます。ユーザの質問もベクター化し、類似度の高いコンテントを取得。その結果をプロンプトに入れてchatgptに問い合わせます。これにより、gpt3.5でも4Kのトークンリミットに収まり、情報をもとにユーザの質問に返答できるようになります。(今後、トークンリミットも大きくなっていく思いますが、その分コストがかかると思います)

llm_memoryのコンポーネントは以下の3つから成り立っています。

- LlmMemory::Wernicke: 外部データをロードする役割を持ちます。現在はファイルからのロードのみ対応していますが、今後はさまざまなローダータイプを追加する予定です。
- LlmMemory::Hippocampus: ベクターデータベースとのインタラクションを管理し、クエリに基づいて関連情報を取得します。現在は、Redisearchモジュールを使用したRedisをベクターデータベースとして使用しています。
- LlmMemory::Broca: Hippocampusコンポーネントから提供されるメモリを使用してクエリに応答します。プロンプトテンプレートにはERBを使用し、オンライン（例えば、LangChain Hub）でさまざまなテンプレートを見つけることができます。

_コンポーネント名は、脳の言語・記憶に関する用語に由来しています。ウェルニッケ野は他人の言語を理解する働き、海馬は記憶、ブローカ野は運動性言語中枢で言語の産出に関わる_


## FAQチャットのバックエンドの作成

それでは、FAQチャットのバックエンドを作成してみましょう。
（この記事ではChatUIは語りません！）

### 1. インフラサービスの準備

まずは、openaiのキーとRediscloudのURLを取得します。RedisearchというRedisのモジュールを利用してvector dbとして使います。これは、rediscloudのプロプラエタリなものなので標準のRedisには入っていません。RedisCloudにサインアップして取得してください。RedisCouldにはフリープランがありますので、Herokuではaddonとして提供されているので、すぐに導入できます。

### 2. モジュールのインストール

llm_memoryをGemに追加し、bundle installを行います。

```
gem "llm_memory"
```

### 3. OPEN AIのキーとRedisのURIを指定

次に、環境変数(OPENAI_ACCESS_TOKEN, REDISCLOUD_URL)、またはinitializerで下記のようにセットします。

```ruby
LlmMemory.configure do |c|
  c.openai_access_token = "xxxxx"
  c.redis_url = "redis://xxxx:6379"
end
```

### 4. データの取得

ここまででLLM Memoryを使う準備ができました。データを作成しましょう。Wernickeコンポーネントを利用せずに、ActiveRecordから直接データを作ります。FAQは、CMS(comfortable_mexican_sofa gem)を利用しています。該当データを取得します。今回、このデータは、WSYWIGのHTMLなので、textを取得する必要がありました。

以下にそのためのコードを示します。

```ruby
def extract_text_from_html(html)
  utf8_html = html.force_encoding("UTF-8")
  document = Nokogiri::HTML(utf8_html)
  document.css("style").remove
  document.css("br").each { |br| br.replace("\n") }
  document.css("a").each do |a|
    link = a["href"]
    text = a.text
    a.replace("#{text} (#{link})") # Replace the <a> tag with its text content and link
  end
  document.text.gsub(/　/, " ").gsub(/\s+/, " ").gsub(/(\r\n)+|\n+/, "\n").strip
end

docs = []
Comfy::Cms::Page.where(is_published: true, parent_id: 408).each do |page|            
  trans = page.translations.find_by(locale: "ja")
  next if trans.blank?
  raw = trans&.content_cache
  next if raw.blank?
  label = trans&.label
  content = extract_text_from_html(raw) 
  docs.push({
    content: content,
    metadata: {
      path: "https://xxx.com#{page.full_path}",
      label: label || page.label        
    }
  })
end
```

ポイントとして、docsの出力が下記のような`:content`と`:metadata`(シンボル！)を含むハッシュの配列になっていることです。
```ruby
[{
 content: "...",
 metadata: {}
},,,,] 
```

### 5. 記憶

次に、データをVector DB(Redis)に格納します。日本語の場合、1tokenあたり、0.7~0.9文字 (参考：https://zenn.dev/microsoft/articles/dcf32f3516f013 ）なので、max_chunkを512程度にしてRedisに記憶しています。

以下のコードでデータをRedisに格納します。

```ruby
hippocampus = LlmMemory::Hippocampus.new(
  chunk_size: 512, 
  index_name: "faq"
)
hippocampus.memorize(docs)
```

そして、質問を入れて結果を確認します。

```ruby
query_str = "宿題の提出方法を教えてください"
related_docs =  hippocampus.query(query_str, limit: 5)
```

512文字のmax_tokenを指定したので、大体400トークンぐらいが一つのcontentになります。

### 6. プロンプトを用意

次に、プロンプトを用意します。ERBで書きます。

```ruby
prompt = <<-TEMPLATE
Context information is below.
---------------------
<% related_docs.each do |doc| %>
<%= doc[:content] %>
URL: <%= doc[:metadata]["path"] %>

<% end %>
---------------------
Given the context information and not prior knowledge,
answer the question: <%= query_str %>
output format:
<answer>
URL: <referred URL>
TEMPLATE
```

### 7. 出力

最後に、respondの引数がERBに展開されるようにします。

```ruby
broca = LlmMemory::Broca.new(
  prompt: prompt, 
  model: "gpt-3.5-turbo", 
  temperature: 0, 
  max_token: 4096
)
message = broca.respond(query_str: query_str, related_docs: related_docs)
puts message
A. 宿題の提出方法は、生徒アカウントのトップナビゲーションバーから「ホームワーク」を選択して、ご希望のコース名をクリックすると表示されます。レッスン後に提出することをお勧めします。
```

いい感じですね！

次の質問に行く時は、下記のようにqueryを問い合わせて、出力する形になります。

```ruby
query_str = "連絡先を教えてください"
related_docs = hippocampus.query(query_str, limit:  5)
message = broca.respond(query_str: query_str, related_docs: related_docs)
```

## まとめ

元のデータの質と量にもよりますが、max_chunkとlimitの調整をすれば意外とうまく答えてくれるように思います。Promptエンジニアリングも少しは必要ですね。

以上で、ChatGPTとllm_memoryを用いたRails FAQチャットシステムの作り方についての説明を終わります。あとはchatUIをhowire/turbo streamなどで作れば、カスタマーサポートのコストも下がって効率化が期待できます！