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のレスなんかは、関連ない訳ではないけど全然関係ない話をし始める人といえばそうなので、話題としてはずれているという扱いですね……)。

とは