LangChain と Embeddings で対話例から LLM に応答させる

FacebooktwitterredditpinterestlinkedinmailFacebooktwitterredditpinterestlinkedinmail

前置き

ChatGPT (GPT-3.5 or GPT-4) を仕事でも使いたいとなると、真っ先に出てくるのが QA Bot ではないでしょうか。筆者もシナリオ型の QA Bot を作ったことがありますが、細かな条件付けなどをしなくても、それっぽい検索( similarity_search )と巨大な LLM による応答の生成はチュートリアルくらいでもかなりの精度を出してくれます。

後は、生成系なので要約もかなり使える! となってはいますが、実際の仕事で要約を必要とする人はあまり多くないのではないかな……と感じます。それこそ論文を沢山読むような研究者や大会社の偉い人なんかは便利なんだろうと思いますが、労働者階級の目線では LLM に文章を入れる前処理みたいな扱いかなぁと思います(Summarize された Memory とかがそれに該当するかなと思います)。

筆者などは面倒臭いコードやライブラリ叩けばすぐできそうだけどライブラリを調べるのが億劫な処理をしたいときなどに ChatGPT Plus の GPT-4 にコードを書かせたりして活用していますが、一般的にはブームが一段落して若干の失望期に入ったように思います。

この失望感が何かなと考えると「QA 以外にも望むような応答をしてほしい」ということになるかと思います。

「山」と言えば「川」みたいな、 QA とはちょっと違う、場合によっては飛躍したように感じる応答例を生成する方法ですね。

一般的にはこれは Fine tuning で実現するような内容ではあると思いますが、とても大変なので、 Embedding で無理矢理やってみます。

使用する会話コーパス

今回は、おーぷん2ちゃんねる会話コーパスを利用しました。

権利表記

@inproceedings{open2chdlc2019,
  title={おーぷん2ちゃんねる対話コーパスを用いた用例ベース対話システム},
  author={稲葉 通将},
  booktitle={第87回言語・音声理解と対話処理研究会(第10回対話システムシンポジウム), 人工知能学会研究会資料 SIG-SLUD-B902-33},
  pages={129--132},
  year={2019}
}

ブログに掲載するには過激な内容が出てしまうことも多いのですが、一方で対話としてはかなり非正規な内容になるので実験例としては面白いです。

同梱されている cleaning.py で NG キーワードを除外し、左2列のみを残して編集しました(が、なんか残ったみたいでゴミデータも入ってしまいました)

やること

通常の QA では、similarity_search で出て来た page_content を利用して回答を生成します。だから、 HyDE 仮説のように LLM に仮の回答を生成させてから Embedding, Vector Searchした方が精度が出るわけですね。

ただ今回は最初からある程度の飛躍的な回答を期待しているので、最初の書き込みに対する対話(レス)の内容を metadata に格納し、その内容を利用して応答を生成させます。

vector index の生成とテスト

FAISS DB の生成

# Embedding用
from langchain.embeddings.openai import OpenAIEmbeddings
# Vector 格納 / FAISS
from langchain.vectorstores import FAISS
from langchain.document_loaders import DataFrameLoader
import pandas as pd

import key, os
os.environ["OPENAI_API_KEY"] = key.OPEN_API_KEY

df = pd.read_csv('newsplus-ng.csv', encoding='utf-8')
loader = DataFrameLoader(df, page_content_column="root")

docs = loader.load()

embeddings = OpenAIEmbeddings()
db = FAISS.from_documents(docs, embeddings)

db.save_local("newsplus_index")

page_data と metadata に読み込みたいので、扱いが簡単な DataFrameLoader を使います。前処理として、一番左の列に root, その右隣に reply という名前を付けています。レスが続いた場合には3列目、4列目……と元データは続いていますが、今回は処理が面倒なので1列目2列目以外は使いません。

テスト QA の実行

まずは動作を確認するために、 similarity_search と QA Chain を実行してみます。

from langchain.chat_models import ChatOpenAI
from langchain.vectorstores import FAISS
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain import LLMChain
import key, os
from langchain.chains.question_answering import load_qa_chain

os.environ["OPENAI_API_KEY"] = key.OPEN_API_KEY

embeddings = OpenAIEmbeddings()
db = FAISS.load_local('newsplus_index', embeddings)


query = '花粉症が辛すぎるんだが?'
doc = db.similarity_search(query)

print(doc)

# load_qa_chainを準備
chain = load_qa_chain(ChatOpenAI(temperature=0), chain_type="stuff")

# 質問応答の実行
print(chain({"input_documents": doc, "question": query}, return_only_outputs=True))

出力:

[Document(page_content='花粉症は甘え。 \\n 気持ちがたるんでるだけ。', metadata={'reply': 'ネタのように見えてアレルギーの類が気合でなんとかなると本気で思ってる奴が存在するから笑えない \\n 花粉症はまだしも食物系のアレルギーを好き嫌いと混同してくるのはシャレにならん', }), Document(page_content='花粉症は甘え。 \\n 気持ちがたるんでるだけ。', metadata={'reply': 'そういうオマエに道路切削作業の誘導をやってもらおう。', '}), Document(page_content='花粉症って、いつ頃の時代からあるの?', metadata={'reply': '初めて確認され たのは戦後。', 'n}), Document(page_content='今朝から目が痒いんだが、もう花粉症出てる人いる?', metadata={'reply': '少しずつ飛んでるよ', })]    
{'output_text': '花粉症は本当につらい症状があります。症状を軽減するためには、医師の指示に従って薬を服用することや、マスクを着用することなどが有効です。また、花粉の飛散量が多い時期には、外出を控えるなどの対策も必要です。'}

さすが、花粉症だとブログに掲載してもあんまり困らない内容になってくれるようです。が、QA の回答は page_content の内容は役に立たないと判断したのか、普通の GTP-3.5-turbo の出力のように思います(空欄の列が出力されてしまっているので、1行目は加工しています)。

カスタム Prompt で対話を生成してもらう

では、にゅー速plusのレス例を元に、応答を生成してもらいます。

from langchain import PromptTemplate

replies = '\n'.join([f'{index}: {element.metadata["reply"]}' for index, element in enumerate(doc, 1)])

template = """
Generate a response to the input, referring to previous relevant responses.
Examples of past replies:
{reply}

Input:{input}
"""

prompt = PromptTemplate(
    input_variables=['reply', 'input'],
    template=template,
)

chain = LLMChain(llm=ChatOpenAI(temperature=0), prompt=prompt)
print(chain.run(input=query, reply=replies))

結果:

# promptに入れられるreplies
1: ネタのように見えてアレルギーの類が気合でなんとかなると本気で思ってる奴が存在するから笑えない \n 花粉症はまだしも食物系のアレルギーを好き嫌いと混同してくるのはシャレにならん
2: そういうオマエに道路切削作業の誘導をやってもらおう。
3: 初めて確認されたのは戦後。
4: 少しずつ飛んでるよ

# LLMからの出力
Response: 前にも言ったように、花粉症は本当につらいものですね。でも、アレルギーの類が気合でなんとかなると思っている人がいることには驚きます。食物系のアレルギーを好き嫌いと混同してくる人も
いるようで、本当にシャレになりません。道路切削作業の誘導をやってもらおうという提案もありましたが、それはちょっと厳しいかもしれませんね。花粉症は戦後に初めて確認されたそうですが、今でも少 しずつ花粉が飛んでいるようです。

微妙……。QA のように、回答を無理矢理作り出すようなことはしておらず、レスを元に記述されていることは読み取れます。しかし、全部くっつけてしまっているので訳が分からないですね。

Prompt を工夫してみる

LLM にこちらの意図が伝わっていなかった……というか、とりあえず metadata に格納された reply を元に QA ではない応答をさせてみただけなので、 prompt を工夫してみることにします。

4パターンの回答を作らせてみる

template = """
Interpret the intent of each of the four past relevant responses shown below and generate a response for each interpretation.
Examples of past replies:
{reply}

Input:{input}

Begin! Remember to generate an interpretation and a response for each example response.
Answer:"""

出力:

Interpretation 1: The person is expressing frustration with people who think allergies can be overcome with willpower.
Response: そうですね、アレルギーは本当に辛いですよね。でも、自分でできる対策を考えてみると良いかもしれません。

Interpretation 2: The person is suggesting that the speaker should do a difficult or unpleasant task.
Response: それはちょっと厳しいですね。でも、自分にできることを探してみると良いかもしれません。

Interpretation 3: The person is providing historical information.
Response: そうなんですね、知らなかったです。でも、今は花粉症の対策も色々あるので、試してみると良いかもしれません。

Interpretation 4: The person is saying that pollen is gradually spreading.
Response: そうですね、花粉は少しずつ広がっていくので、対策をしっかりと考えておくと良いかもしれません。

4パターンの回答にはなっていますが、回答例を踏まえての生成というよりは、入力を無視して reply の内容に対して reply を生成してしまっています。GPT-3.5-turbo ではこういった複雑なプロンプトは恐らく荷が重いので、大人しく分割(応答例ごとにLLMを動作させる)方がいい結果が得られるでしょう。

4パターンのうち1つから選ばせる

template = """
Select the one response from the following past relevant responses that you think is most appropriate for this input. Based on this, make your response as enjoyable as possible.
Examples of past replies:
{reply}

Input:{input}
Begin. Remember to use only one example response, and to create a response to the input.
"""

結果:

4: 少しずつ飛んでるよ。でも、花粉症に負けるな!マスクをして、薬を飲んで、頑張ろう!

これは結構面白いといえば面白いですが、解釈して作ったというよりは4の後に続く文章を考えたという感じでしょうか。とはいえ、4なので similarity_search で言えば最下位のスコアであるはずなので、それに対するレスが最適だと判断したというのは、なかなか面白いですね(1のレスなんかは、関連ない訳ではないけど全然関係ない話をし始める人といえばそうなので、話題としてはずれているという扱いですね……)。

とはいえ、 fine-tuning っぽい動作としてはそう言えるかもしれません。

元のレスも入れてみる

上記の例では、アンカーがつけられたレスの内容だけで応答を作らせていました。今度は、 page_content に格納されている元のレスも含めて回答を生成させてみましょう。

元レスも入れて回答させる

replies = '\n___________________________\n'.join([f'Human: {element.page_content}\nResponse: {element.metadata["reply"]}' for element in doc])
print(replies)

template = """
Generate a response to this input by referring to the following examples of past relevant conversations.
Example of past conversations:
{reply}

Input:{input}
Begin. Remember that the response is to the current input.
"""

出力:

Human: 花粉症は甘え。 \n 気持ちがたるんでるだけ。
Response: ネタのように見えてアレルギーの類が気合でなんとかなると本気で思ってる奴が存在するから笑えない \n 花粉症はまだしも食物系のアレルギーを好き嫌いと混同してくるのはシャレにならん     
___________________________
Human: 花粉症は甘え。 \n 気持ちがたるんでるだけ。
Response: そういうオマエに道路切削作業の誘導をやってもらおう。
___________________________
Human: 花粉症って、いつ頃の時代からあるの?
Response: 初めて確認されたのは戦後。
___________________________
Human: 今朝から目が痒いんだが、もう花粉症出てる人いる?
Response: 少しずつ飛んでるよ
Response: 花粉症は本当につらいですね。でも、周りには同じように苦しんでいる人がたくさんいます。適切な対策をして、できるだけ症状を軽減するようにしましょう。例えば、マスクを着用する、室内で
過ごす、アレルギー薬を服用するなどがあります。また、花粉症についての情報を調べて、自分に合った対策を見つけることも大切です。

QA を動かしたときのような、ほぼ過去のレスの内容が関係していない回答が生成されてしまいました(回答を生成するな、という QA にありがちな prompt を入れられないのも辛いところです)。

4つあるのがいけないというよりは、page_content の方が余り関係ない内容になってしまっているために起きているかもしれません。

一つ選ばせてみる

アンカー付きレスの内容だけのときでもいい結果が出力された、会話例 1つを LLM に選んでもらう方法で行ってみます。

template = """
Please select the most relevant example of a past conversation from the following list that you think is closest to your input for this session, and then reply based on the content of that conversation.
Examples of past conversations:
{reply}

Input:{input}
Begin. Remember that the response is to the current input.
"""

出力:

Response: 本当につらいですよね。花粉症はアレルギーの一種で、体質によって症状が異なるため、対処法も人それぞれです。医師の診断や薬の服用、マスクの着用など、自分に合った対策を取ることが大切
です。お大事に。

さっきと大差なく、乱数が変わっただけという感じですね。prompt を工夫することによって改善(あるいはGPT-4などのより高度な LLMの利用によって改善)されることも考えられますが、会話例をベースに応答を生成させる場合には、promptになんでもかんでも詰め込むというよりは、「応答例」だけを入れた方がよさそうです。

応用範囲を考えてみる

入力に対するsimilarity_search の対象と関連付けられた文書(今回はアンカー付きのレス)を分けて、LLM に応答を作成させてみました。GPT-3.5が普通に生成するような親切なアドバイス以外の出力を得られましたが、どういうときに利用可能か? を考えて見ます。

ユーザーサポートのサジェスト

この記事の冒頭でもQA BOTについて触れましたがメールやチャットで行われるユーザーサポートでは、マニュアルに記述がある内容だけでは対応しきれない場合が多々あります。

あんまり考えたくはないですが、クレーム対応などが分かりやすいでしょう。お怒りの言葉に対して淡々とマニュアルに記載されている内容を答え続けるのは、カスハラに対しては非常に正しいような気がしますが、まあ普通の企業では NG な対応でしょう。

そういう場合には過去の上手く対応できた記事をベースに、LLMに応答のサジェストを作成させることで、サポートの対応負荷を減らせると考えられます。

エンタメへの応用

GPT-4を利用できる ChatGPT Plus に複雑な prompt を投げ込むことで、テキストベースのアドベンチャーゲームを作成したり、あるいはコンピューターゲームの NPC の会話への応用が期待されています。

しかし、 NPC 1体ごとに Finetuning を施したモデルを作成することは、 AAA タイトルでもなかなか難しいように思います(例えばポストアポカリプスで、相棒のキャラクター一人だけ、とかなら楽しいかもしれませんね)。

そういった場合には、 Embeddings を利用することで、ある程度以上の応答精度を見込めるでしょう。

販売促進など

Fine Tuning と比較して、 Embedding は短期間で実行が可能です。そのため、実店舗(あるいはEC)においてその日のおすすめ商品などを格納したデータベースの利用が考えられます。

生鮮食品では特売品や仕入れが日々変化するだけでなく、その利用方法(料理の方法や保存の方法)も重要になってきますから、1日ごとにデータを作成して販促に活用することもできるでしょう。

課題

課題としては、検索にひっかかればいい QA と異なり、検索対象と LLM による処理対象の最低2種類のデータが必要になる点です。販売促進などでは、金額などのメタデータの追加が必要になると思います。

そうなると、データの生成が課題になります。

特にユーザーサポートでのサジェストなどでの活用では、「ユーザーからの入力」と「人間の返事」をペアで管理する必要があります。だからこの記事では、おーぷん2ちゃんねる会話コーパスを利用したのですが、通常業務などではメーラーなどからデータを吸い出すしか方法がないと思います。

このあたりは、ちゃんとした方法論を有する機械学習エンジニアでないと実現が難しいかもしれません。

まとめ

  • 会話コーパスを使うと膨大なデータから色々な回答を作れて楽しい
  • QA 以外の会話を行わせる場合、page_content と metadata で明確に切り分けが可能
    • DataFrameLoader を使うと楽
  • (GPT-3.5では、)複数の事例をまとめて扱うのは少し難しいので、並列して LLM を稼動させた方がよさそう
  • 1つだけを選ばせることはできる。また、それがsimilarity_search の結果と一致するとは限らない
  • 例え会話データであっても、両者(全員)の会話データを全て含めた方がいい結果がでるとは限らない
FacebooktwitterredditpinterestlinkedinmailFacebooktwitterredditpinterestlinkedinmail

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください

最新の記事