Railsで動くWebサービスとChatGPTを連携したいと思ったことがありますよね。今回は、サービスのFAQページの内容に応答するチャットボットを作成し、電話の問い合わせを減らす施策を考えてみましょう。
RubyにはLangChainやLlamaIndexのようなライブラリがありません。なので、llm_memoryというGemを作りました。LlamaIndexを大いに参考にしています。ただし、LlamaIndexは大きなバンドルであり、科学的な要素も含まれています。簡単に利用できる一方で、詳細に調べようとすると難易度が上がります。さらに、Python製なのでRailsとの統合が難しいです。
llm_memoryについて
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で下記のようにセットします。
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を取得する必要がありました。
以下にそのためのコードを示します。
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
(シンボル!)を含むハッシュの配列になっていることです。
[{
content: "...",
metadata: {}
},,,,]
5. 記憶
次に、データをVector DB(Redis)に格納します。日本語の場合、1tokenあたり、0.7~0.9文字 (参考:https://zenn.dev/microsoft/articles/dcf32f3516f013 )なので、max_chunkを512程度にしてRedisに記憶しています。
以下のコードでデータをRedisに格納します。
hippocampus = LlmMemory::Hippocampus.new(
chunk_size: 512,
index_name: "faq"
)
hippocampus.memorize(docs)
そして、質問を入れて結果を確認します。
query_str = "宿題の提出方法を教えてください"
related_docs = hippocampus.query(query_str, limit: 5)
512文字のmax_tokenを指定したので、大体400トークンぐらいが一つのcontentになります。
6. プロンプトを用意
次に、プロンプトを用意します。ERBで書きます。
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に展開されるようにします。
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を問い合わせて、出力する形になります。
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などで作れば、カスタマーサポートのコストも下がって効率化が期待できます!