nikkie-ftnextの日記

イベントレポートや読書メモを発信

文脈をプロンプトに含めてChatGPTとアニメ(ムビマス)についておしゃべりしていたスクリプトを、LangChain v0.2で動くように更新しました

はじめに

絵羽模様😭😭😭 nikkieです。

過去にLangChainのRetrievalQAを素振りしました。

これはLangChain v0.0.228で動かしたのですが、v0.2で動くように更新します。

目次

LangChainのRetrievalQAを使い文脈をプロンプトに含めることで、ChatGPTとアニメについておしゃべり

劇場版『THE IDOLM@STER MOVIE 輝きの向こう側へ!』(ムビマス)のセリフをembeddingsにしてvector store(Chroma)に保存しておきます。
ユーザがチャットを入力したら、入力に近いセリフをvector storeから取得して文脈に追加し、ChatGPTに送ってチャットさせます。
狙いはムビマスの内容を踏まえたおしゃべりで、RAGの1つの実装例(とてもドラフト)かなと思っています

2023年7月時点では

  • もう時間がないんですか
    • -> はい、志保さんが言っている通り、時間がない状況のようですね。
  • リーダーって誰でしたっけ?
    • -> 春香がリーダーになることが話題になっています。

すっげ〜〜!! ChatGPTさんはPでした1

LangChainはセマンティックバージョニングを無視したv0.0.x時代2を経て、v0.1 -> v0.2 へとバージョンアップしています(執筆時点の最新はv0.3)。

私がキャッチアップしたかったので、素振りとしてLangChain v0.2の世界線へ転生させました。
なお、コードは動くのですが、結果が再現していません(後述)

LangChain v0.2への書き換え

古い書き方が残っていると以下のドキュメントが案内されます3

ここを参考にv0.2で動くようにしていきました。

依存ライブラリ

クソデカだったLangChainが分割された(小さいは正義🙌)ので、ムビマスを踏まえたおしゃべりスクリプトの依存は以下のように変わります。

import文の変更を示します。

ムビマスのセリフをembeddingsにしてChromaに保存するスクリプト

-from langchain.document_loaders import TextLoader
+from langchain_community.document_loaders import TextLoader
-from langchain.embeddings.sentence_transformer import SentenceTransformerEmbeddings
+from langchain_huggingface import HuggingFaceEmbeddings
-from langchain.text_splitter import CharacterTextSplitter, _split_text_with_regex
+from langchain_text_splitters import CharacterTextSplitter
+from langchain_text_splitters.character import _split_text_with_regex
-from langchain.vectorstores import Chroma
+from langchain_chroma import Chroma

SentenceTransformerEmbeddingsですが、これはHuggingFaceEmbeddingsエイリアスでした。
https://github.com/langchain-ai/langchain/blob/langchain-community%3D%3D0.2.17/libs/community/langchain_community/embeddings/sentence_transformer.py#L5
HuggingFaceEmbeddingsではlangchain-huggingfaceライブラリへの移行が促され、結果としてimportが大きく変わりました。

ユーザの入力に対して、近いセリフを文脈に追加してChatGPTに送ってチャットするスクリプト
(embeddingにして保存するスクリプトにないimportだけ示します)

from langchain.chains import RetrievalQA
-from langchain.chat_models import ChatOpenAI
+from langchain_openai import ChatOpenAI

RetrievalQAの最新化

移行ドキュメントに沿って、RetrievalQAを書き換えます。
移行ドキュメントはプロンプトを劇的に書き換えている4のですが、私は一度に変える箇所を可能な限り減らしたく、同じプロンプトになるように注意深くやりました。

移行前のプロンプトをソースから知る

from langchain.chains import RetrievalQAとimportしているクラスの実装を見ていきます。
https://github.com/langchain-ai/langchain/blob/langchain%3D%3D0.2.16/libs/langchain/langchain/chains/__init__.py#L74 より実体はlangchain.chains.retrieval_qa.base.RetrievalQA

# https://github.com/langchain-ai/langchain/blob/langchain%3D%3D0.2.16/libs/langchain/langchain/chains/retrieval_qa/base.py#L215
class RetrievalQA(BaseRetrievalQA):

移行元のコードでは、RetrievalQA.from_chain_type()を呼び出しています。
このメソッドの実装はベースクラスのBaseRetrievalQAにあり
https://github.com/langchain-ai/langchain/blob/langchain%3D%3D0.2.16/libs/langchain/langchain/chains/retrieval_qa/base.py#L105
langchain.chains.question_answering.chain.load_qa_chain関数を呼び出します。
https://github.com/langchain-ai/langchain/blob/langchain%3D%3D0.2.16/libs/langchain/langchain/chains/question_answering/chain.py#L234

この関数はchain_typeに応じたload関数を実行する実装です。
移行元コードでchain_type="stuff"と呼び出したとき、_load_stuff_chain()関数が実行されています
https://github.com/langchain-ai/langchain/blob/langchain%3D%3D0.2.16/libs/langchain/langchain/chains/question_answering/chain.py#L65

loadの中でついにプロンプトが登場!

_prompt = prompt or stuff_prompt.PROMPT_SELECTOR.get_prompt(llm)

https://github.com/langchain-ai/langchain/blob/langchain%3D%3D0.2.16/libs/langchain/langchain/chains/question_answering/stuff_prompt.py#L31

Use the following pieces of context to answer the user's question. 
If you don't know the answer, just say that you don't know, don't try to make up an answer.
----------------
{context}

移行元スクリプトで、langchain.verbose = Trueを指定して見えているプロンプトと一致しました!
なお、v0.0.228のときの記事にプロンプトのスクショが残っているのですが、このプロンプト自体もtypo修正レベルで更新されていました5

移行先のプロンプト

移行について案内されたドキュメントの中に、実装に取り入れたい内容がありました。
https://python.langchain.com/v0.2/docs/versions/migrating_chains/retrieval_qa/#lcel

You can customize and wrap this composition logic in a helper function, or use the higher-level create_retrieval_chain and create_stuff_documents_chain helper method:

2つのヘルパー create_retrieval_chain()create_stuff_documents_chain() を使うと、RAGのchainをinvokeした返り値にcontextが含まれるのです。
langchain.verbose = Trueで出力していたよりも、プログラム中でアクセスできたほうがより便利そうに思えたので、これらで書き換えることにしました。

試行錯誤の末、このようになりました

from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains.question_answering.stuff_prompt import system_template
from langchain_core.prompts import (
    ChatPromptTemplate,
    HumanMessagePromptTemplate,
    SystemMessagePromptTemplate,
)

messages = [
    SystemMessagePromptTemplate.from_template(system_template),
    HumanMessagePromptTemplate.from_template("{input}"),
]
CHAT_PROMPT = ChatPromptTemplate.from_messages(messages)
chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)
combine_docs_chain = create_stuff_documents_chain(chat, CHAT_PROMPT)
rag_chain = create_retrieval_chain(db.as_retriever(), combine_docs_chain)

最初はlangchain.chains.question_answering.stuff_prompt.CHAT_PROMPTのimportでいけると考えていました。
https://github.com/langchain-ai/langchain/blob/langchain%3D%3D0.2.16/libs/langchain/langchain/chains/question_answering/stuff_prompt.py#L28

ところがinvokeした(rag_chain.invoke({"input": sentence}))ときに

KeyError: "Input to ChatPromptTemplate is missing variables {'question'}. Expected: ['context', 'question'] Received: ['input', 'context']

なお、rag_chain.invoke({"question": sentence})では

KeyError: 'input'

です。
ヘルパー内部のLCELでは"input"というキーがあると仮定しているようです。
https://github.com/langchain-ai/langchain/blob/langchain%3D%3D0.2.16/libs/langchain/langchain/chains/retrieval.py#L61

このキーの不整合を解決するために、langchain.chains.question_answering.stuff_prompt.CHAT_PROMPTに限りなく近いプロンプトを独自に定義しました。
差分を示します

messages = [
    SystemMessagePromptTemplate.from_template(system_template),
-    HumanMessagePromptTemplate.from_template("{question}"),
+    HumanMessagePromptTemplate.from_template("{input}"),
]
CHAT_PROMPT = ChatPromptTemplate.from_messages(messages)

これで動くようになりました。
全容はこちらで確認できます。

ところが、おしゃべりが再現しない

2023年7月時点ではプロデューサーと感じられたのですが、v0.2で書き直したバージョンはあまりプロデューサーではありません。

事象

1例目

入力してください: もう時間がないんですか

いいえ、まだ時間はあります。

2例目

入力してください: リーダーって誰でしたっけ?

リーダーは春香です。

2例目は以下の回答になることもあります6

響がリーダーになるかどうかについての議論が行われているシーンです。誰が最終的にリーダーになるかはまだ決まっていません。

文脈に含められたムビマスのセリフを見ていきます

1例目

  • 響「うんうん。まだまだ自分ほどじゃないけどな」
  • 奈緒「それは今の可奈にはあかんて」
  • 響「ふふーん、なんくるないさー!」
  • 真「待って。ボクたち、無理なんてしてないよ?」

1例目は2023年7月時点のChromaからの検索結果と異なるんですよね。
志保さんの「もう時間が無いんです! 今進める人間だけでも進まないと、みんなダメになりますよ!?」が入ってこない。
代わりに真のセリフが入ってきています

2例目

  • 響「ねえ、誰がリーダーになるの?」
  • 伊織「リーダー?」
  • 志保「…話にならないです。なんであなたがリーダーなんですか」
  • 春香「ん…ええ!? わ、私がリーダーですか?」

2023年7月時点のChromaからの検索結果と一致しています。

再現しない事象について小まとめ

  1. 「もう時間がないんですか」という入力で文脈に含めたい志保さんのセリフが、文脈に入ってこない
    • 原因箇所候補:embeddingsないしはChroma
  2. 文脈に含めたセリフが完全に同じでも、ChatGPTの出力の精度が下がっている印象(この2例だけなのでバイアス高)
    • 原因箇所候補:ChatGPTのバージョン差異、プロンプトの微修正

調査

1について、たしかにembeddingsを得るモデルは更新されていたので、

2023年7月に指定したと思われるバージョンを指定してみています。

embedding_function = HuggingFaceEmbeddings(
    model_name="stsb-xlm-r-multilingual",
    model_kwargs={"revision": "bc1a68705f2e397259207e96349a36ccbc7e6493"},
)

しかしこれでも先の状態(志保さんのセリフが取れない)なんですよね。
「もう時間が無いんです!」だけでヒットしなくて、志保さんのセリフ全文を入れなければヒットしないようになっちゃってます

>>> db.similarity_search("もう時間が無いんです!")
[Document(metadata={'source': 'movie_master.txt'}, page_content='真「出た出た~!\u3000ふ~、生き返るな~!\u3000…ふぅ!」'), Document(metadata={'source': 'movie_master.txt'}, page_content='響「ふふーん、なんくるないさー!」'), Document(metadata={'source': 'movie_master.txt'}, page_content='響「わー、やったなー!\u3000待てー!」'), Document(metadata={'source': 'movie_master.txt'}, page_content='響「だめだ、外に出てくるよ!」')]
>>> 
>>> db.similarity_search("もう時間が無いんです! 今進める人間だけでも進まないと、みんなダメになりますよ!?")
[Document(metadata={'source': 'movie_master.txt'}, page_content='志保「もう時間が無いんです!\u3000今進める人間だけでも進まないと、みんなダメになりますよ!?」'), Document(metadata={'source': 'movie_master.txt'}, page_content='美希「みんな消えちゃえばいって思うなっ!!」'), Document(metadata={'source': 'movie_master.txt'}, page_content='響「そっかー、ライブが終わったら、美希も千早も遠くに行っちゃうんだなー」'), Document(metadata={'source': 'movie_master.txt'}, page_content='P「今からやれば十分間に合うわけだし、そう重く受け止めなくてもいいんじゃないか?」')]

langchain-huggingfaceやlangchain-chromaの実装を見ないとこれ以上は分からないかなという気持ちです。
なお、セリフテキストを行ごとにはembeddingにできていそうです(embeddingにした件数のprintを見ての判断)

2ですが、モデルの指定は

chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)

https://platform.openai.com/docs/models/gpt-3-5-turbo によると

gpt-3.5-turbo Currently points to gpt-3.5-turbo-0125.

2024/09/15時点ではgpt-3.5-turbo-0125の指定になっています。
2023年7月時点で指定していたモデルはリタイアしたように思われます

もしかすると検証回数不足で、元から春香さんと答えられたり答えられなかったりしていた可能性はあります(=チャットの精度は劣化していないのかも)。

終わりに

LangChain v0.0.228でRetrievalQAしていたスクリプトを、v0.2で動くように書き換えました。

  • LangChainが分割され、小さなモジュールを組合せるようになっている(エラーメッセージにも案内される)
  • create_retrieval_chain()create_stuff_documents_chain()の2つのヘルパーで、文脈に含められたテキストにプログラムからアクセスできてよさそう
    • このあたりをより使いこなすには、LCELへのキャッチアップが必要そう
  • embeddingsまわりで検索結果を再現しない例があり、まだ解決できていない

ライブラリのバージョンを上げただけなのに完全に再現しなかったので、なにか破壊的な変更を見落としているんだと思います(特にembeddingsまわり)。
LangChainのドキュメントのRetrievalQAからの移行ガイドは読めてありがたかったですが、ここからプロンプト自体も変えることになるわけで、ちょっと雑すぎるなあという感想です。
今回のように悪い結果になったときに、何が原因か切り分けていくのが難しくなっちゃうんじゃないでしょうか?


  1. Generative Producer Transformer (正しくは Pre-trained)
  2. セマンティックバージョニングでは「後方互換性を伴うバグ修正をした場合はパッチバージョンを上げます。」なんですが、琴葉ちゃん、お願いします! 「後方互換性がないのにパッチバージョンを上げてはいけません」(マイナーバージョンが上がるタイミング)
  3. 将来見えなくなったとき用のソース https://github.com/langchain-ai/langchain/blob/langchain%3D%3D0.2.16/docs/docs/versions/migrating_chains/retrieval_qa.ipynb
  4. RetrievalQAからの差分を示さずに https://smith.langchain.com/hub/rlm/rag-prompt を導入し、その後突然 https://smith.langchain.com/hub/langchain-ai/retrieval-qa-chat に変えます
  5. users -> user's https://github.com/langchain-ai/langchain/commit/ba20c14e28be35a70d11336429402482a3b44d4c
  6. temperature=0でも生成結果が1つだけに決まらないのは、「端数処理方法の異なるハードウェアを持つサーバー」や「GPUの詳細状態や履歴する処理順序」という要因があるそうです