CCCMKホールディングス TECH Labの Tech Blog

TECH Labスタッフによる格闘記録やマーケティング界隈についての記事など

LangChain Expression Language (LCEL)を使って色々Chainを組んでみました!

LangChain Expression Language (LCEL)を使って 色々Chainを組んでみました!

こんにちは、CCCMKホールディングスTECH LABの三浦です。

先日申請していた子どものパスポートを子どもと一緒に取りに行きました。子どものパスポートは有効期限が5年なので、次回は5年後、新しいパスポートを取りに行くことになります。5年後、子どもは今よりもきっと成長していて、もしかしたら一人でパスポートを取りに行くのかもな、と思うと、少ししんみりしました。

さて、ChatGPTを使ったアプリケーションを開発していると、ChatGPTに対してあるプロンプトを実行させ、その結果を入力とした別のプロンプトを実行する、といった、シーケンシャルなロジックを組むことがあります。こういったロジックを組むのに便利なのがLangChainというフレームワークです。

LangChainは先日v0.1.0という安定板がリリースされ、様々な機能が整理されたように感じています。特にLangChainのコアとなる機能は"LangChain-Core"というパッケージに集約されるようになりました。LangChainのコア機能とはつまりシーケンシャルなロジック(=Chain)を組むための機能で、このコア機能をLangChainでは"LangChain Expression Language (LCEL)"で実現することを推奨しています。

LCELは以前からドキュメントを眺めたりしていたのですが、ドキュメントを眺めているだけでなく、色々と使ってみることでLCELを使うメリットが分かってきました。今回LCELを使って色々なケースを想定したChainを作ってみましたので、この記事でまとめてみたいと思います。

LCEL

LCELはChainを構築するためのLangChainの専用言語で、"Runnable"オブジェクトという構成要素をpipe("|")で繋げてChainを構築します。作ったChainもまたRunnableオブジェクトとなるため、別のChainの構成要素として利用することが可能です。Runnableには予めバッチ処理、非同期処理、ストリーミング実行(ChatGPTなどの実行結果を少しずつ出力する)が実装されているため、LCELを使って構築したChain全体もこれらの機能をすぐに利用できる点が大きな利点と言えると思います。

ここからはLCELを使ってどのようにChainを作ることが出来るのか、実際に作ったものを元にご紹介します。最初は動的にプロンプトを作成し、ChatGPTに渡す"Prompt+LLM"という基本的なパターンです。

Prompt+LLMパターン

シンプルなパターン

プロンプトのテンプレートを用意して、その一部分を動的に変えてLLMに与える基本系の中でも最もシンプルな使い方です。promptchat_modelもどちらもChainの構成要素として機能することが出来ます。(どちらもRunnableを継承した"RunnableSerializable"から派生しているようです。) prompt|chat_modelもまたRunnableとなり、invokeメソッドを呼び出すことで処理を実行することが可能になります。

from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import AzureChatOpenAI

prompt = ChatPromptTemplate.from_template(
    """
    {japanese}を英語で言うと?
    English: 
    """
)
chat_model = AzureChatOpenAI(
    deployment_name="gpt-35-turbo-16k",
    api_version="2023-05-15"
)

chain = prompt|chat_model
chain.invoke({"japanese":"リンゴ"})
AIMessage(content='Apple')

Output parserを使う

前のパターンでは出力結果はlangchain_core.messages.ai.AIMessageで得られましたが、文字列の方が扱いやすいことも多いと思います。その場合にはOutput parserを使うことで様々な型に結果を変換することが可能です。

たとえばStrOutputParserを使うと文字列で結果を得ることが出来ます。

from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template(
    """
    {japanese}を英語で言うと?
    English: 
    """
)
chat_model = AzureChatOpenAI(
    deployment_name="gpt-35-turbo-16k",
    api_version="2023-05-15"
)

chain = prompt|chat_model|StrOutputParser()
chain.invoke({"japanese":"リンゴ"})
'Apple'

Output parserは他にも様々なものがあり、たとえばjsonで結果を出力出来るJsonOutputParserというものもあります。これを用いれば結果を指定したフォーマットのjson形式で出力させることも出来ます。

from langchain_core.output_parsers import JsonOutputParser
from langchain_core.pydantic_v1 import BaseModel,Field

class Translate(BaseModel):
    English:str = Field(description="英語に翻訳したものを表示します。")
    French:str = Field(description="フランス語に翻訳したものを表示します。")

parser = JsonOutputParser(pydantic_object=Translate)

prompt = PromptTemplate(
    template="""
    与えられた日本語を以下の言語で翻訳してください。

    {format_instructions}

    日本語: {japanese}
    """,
    input_variables=["japanese"],
    partial_variables={
        "format_instructions":parser.get_format_instructions()
    }
)

chat_model = AzureChatOpenAI(
    deployment_name="gpt-35-turbo-16k",
    api_version="2023-05-15"
)

chain = prompt|chat_model|parser
chain.invoke({"japanese":"リンゴ"})
{'English': 'Apple', 'French': 'Pomme'}

promptを定義する時にPromptTemplatepartial_variablesパラメータを使ってjson形式の指示文章をプロンプトに埋め込むようにしています。print(parser.get_format_instructions())を実行すると確認することが出来ますが、json形式の指示文章はLangChainがあらかじめ作成したものがベースとなっており、英語でかつ日本語部分がUnicodeエスケープ形式で表示されています。日本語の場合はもしかしたら上手く機能しないことがあるかもしれません。

RAGパターン

基本的なパターンにおけるLCELに加え、RAGを実装するためのLCELの使い方も調べてみました。これまでよりも少し複雑なChainになります。まずいつものように当ブログの記事の原稿をVectorStore化し、Retrieverを生成しておきます。

from langchain.text_splitter import CharacterTextSplitter
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_community.vectorstores import FAISS
from langchain_openai import AzureOpenAIEmbeddings

loader = UnstructuredMarkdownLoader("data/blog.md")
docs = loader.load()
text_splitter = CharacterTextSplitter(
    chunk_size=256,
    chunk_overlap=32
)
splitted_docs = text_splitter.split_documents(docs)
embeddings = AzureOpenAIEmbeddings(
    model="text-embedding-ada-002"
)
vector_store = FAISS.from_documents(
    splitted_docs,
    embedding=embeddings
)
retriever  = vector_store.as_retriever()

RAG基本形

このretrieverもRunnableから派生したクラスのオブジェクトで、invokeメソッドを持っており、LCELのチェーンに組み込むことが出来ます。以下がRAGをLCELで実装する時の基本形です。

from operator import itemgetter

prompt = PromptTemplate.from_template(
"""
ユーザーの質問に、参照情報を参照して回答してください。

###参照情報###
{context}

###ユーザーの質問###
{question}

回答:
"""
)
model = AzureChatOpenAI(
    model="gpt-35-turbo-16k",
    api_version="2023-05-15"
)

rag_chain = (
    {
        "question":RunnablePassthrough(),
        "context":itemgetter("question")|retriever 
    }
    |prompt
    |model
    |StrOutputParser()
)
rag_chain.invoke({"question":"Fine-Tuningが必要ないRAG改善方法はありますか?"})

rag_chainの最初の構成要素はdict型ですが、実行時にRunnableParallelというRunnable派生クラスのオブジェクトに置き換えられます。また、キーcontextに対応する値はitemgetter("question")|retrieverで、別のChainが指定される形です。このChainでは入力として与えられたキー・バリューのペアから"question"キーに対応する値をitemgetter("question")で取得し、retrieverに渡す動作をします。itemgetter("question")のような呼び出し可能なオブジェクトもまた、Runnable派生クラス(RunnableLambda)のオブジェクトに置き換えられ、実行されます。

ちなみにitemgetter("question")を通さずにretrieverを実行すると、私の環境で"TypeError: expected string or buffer"のエラーが発生しました。

複雑なChainになると、処理の流れを図示したいケースが出てくるかもしれません。以下の様に実行することで、処理の流れをグラフで表示することが出来ます。ただし、Pythonのグラフ描画用のライブラリgrandalfの事前のインストールが必要です。

rag_chain.get_graph().print_ascii()
                      +---------------------------------+             
                      | Parallel<question,context>Input |             
                      +---------------------------------+             
                           ***                   ****                 
                       ****                          ***              
                     **                                 ****          
+--------------------------------+                          **        
| Lambda(itemgetter('question')) |                           *        
+--------------------------------+                           *        
                 *                                           *        
                 *                                           *        
                 *                                           *        
     +----------------------+                         +-------------+ 
     | VectorStoreRetriever |                         | Passthrough | 
     +----------------------+                       **+-------------+ 
                           ***                   ***                  
                              ****           ****                     
                                  **       **                         
                     +----------------------------------+             
                     | Parallel<question,context>Output |             
                     +----------------------------------+             
                                       *                              
                                       *                              
                                       *                              
                              +----------------+                      
                              | PromptTemplate |                      
                              +----------------+                      
                                       *                              
                                       *                              
                                       *                              
                              +-----------------+                     
                              | AzureChatOpenAI |                     
                              +-----------------+                     
                                       *                              
                                       *                              
                                       *                              
                              +-----------------+                     
                              | StrOutputParser |                     
                              +-----------------+                     
                                       *                              
                                       *                              
                                       *                              
                          +-----------------------+                   
                          | StrOutputParserOutput |                   
                          +-----------------------+                   

RAG+追加情報

以前プロンプト作成時に参考にすべき26の方針について述べられた論文を読んだことがあったのですが、その中に"誰向けの回答を作るのか"をプロンプトの中に明記すると良い、という方針があったことを思い出しました。先ほど作った基本形に、その情報を指定できる機能を追加してみます。

prompt = PromptTemplate.from_template(
"""
ユーザーの質問に、参照情報を参照し、対象者向けの回答をしてください。

###参照情報###
{context}

###ユーザーの質問###
{question}

###対象者###
{user_type}

回答:
"""
)

rag_chain = (
    {
        "question":itemgetter("question"),
        "context":itemgetter("question")|retriever,
        "user_type":itemgetter("user_type")
    }
    |prompt
    |model
    |StrOutputParser()
)
rag_chain.invoke(
    {
        "question":"Fine-Tuningが必要ないRAG改善方法はありますか?",
        "user_type":"10歳の子ども"
    }
)
'Fine-Tuningが必要ないRAG改善方法は、いくつかあります。\n\n1. ユーザーの質問文を適切に変換するテクニックを使用することです。ユーザーの質問がクエリとして不適切な場合もありますので、質問文を適切に変換することでより望ましい検索結果を得ることができます。\n\n2. 検索された結果について、関連度合いが高い順に並べ替えたり、重複を除外してLLMsに渡すことも有効です。このように検索結果を加工することでより良い回答を得ることができます。\n\n3. 参照させたドキュメントを適切に格納する方法も重要です。ドキュメントを分割するChunk処理のサイズやKnowledge Graphのような形でデータを格納する方法を適切に選択することで、RAGの改善に役立ちます。\n\nこれらの方法を使用することで、Fine-TuningなしでRAGの改善が可能です。ただし、10歳の子ども向けの回答としては、このような詳細な内容は難しいかもしれません。もし詳細な説明が必要であれば、保護者や教師に相談してみてください。'

RAG+クエリ修正

最後に前回調べたRAGの精度向上テクニックの中から"Step back prompting"という方法を試してみます。この方法はユーザーの質問を一度広い視野で捉えて書き直し、その書き直した質問で関連情報を取得する、というテクニックです。

このテクニックをLCELで実装するために、まず質問を書き換えるためのChainを構築します。

rewrite_prompt=PromptTemplate.from_template(
"""
次のユーザーの質問を、広い視野で見た内容に書き換えて下さい。
たとえば"○○さんの誕生日はいつですか?"のような質問の場合は"○○さんのこれまでの人生を教えて下さい"の様にします。

###ユーザーの質問###
{question}

書き換えた質問:
"""
)

rewrite_chain = rewrite_prompt|model|StrOutputParser()
rewrite_chain.invoke({"question":"Fine-Tuningが必要ないRAG改善方法はありますか?"})
'RAGを改善するためには、Fine-Tuning以外の方法はありますか?'

そしてこの書き換え用のChainを使って、RAG全体のChainを作り直します。

rag_prompt = PromptTemplate.from_template(
"""
ユーザーの質問に、参照情報を参照して回答してください。

###参照情報###
{context}

###ユーザーの質問###
{question}

回答:
"""
)

rag_chain = (
    {
        "question":RunnablePassthrough(),
        "context":rewrite_chain|retriever 
    }
    |rag_prompt
    |model
    |StrOutputParser()
)
rag_chain.invoke({"question":"Fine-Tuningが必要ないRAG改善方法はありますか?"})

rewrite_chain|retrieverのようにして質問文の書き換えと検索処理を実行しています。このように以前作っておいたChainを別のChainに組み込むことが出来る点がlcelの面白い点だな、と思います。

このように色々なChainを途中で挟むと、途中でどんなアウトプットがChatGPTから出力されているのかを確認したくなる時があります。その場合は以下の様にConsoleCallbackHandler()invoke呼び出し時に与えてあげることで、詳細にChainの各ステップでの実行内容を見ることが出来ます。

from langchain.callbacks.tracers import ConsoleCallbackHandler

handler = ConsoleCallbackHandler()
rag_chain.invoke(
    {"question":"Fine-Tuningが必要ないRAG改善方法はありますか?"},
    {"callbacks":[handler]}
)

まとめ

ということで、今回はLangChainのLangChain Expression Language (LCEL)を使って色々なChainを組んでみました。最初は慣れなかったのですが、いくつかChainを組んでみるとかなりしっくりくるようになりました。もしLCELを使わずに同様のことを実現しようとすると、色々と独自の関数を実装する必要が出てきてソースコードの量も増えてくると思います。 今回は実装しなかったのですが、Agent系の処理もLCELで実装出来るようです。が、Agent系の処理はLangGraphという別の仕組みを使って組むことも出来そうで、今度はこのLangGraphという仕組みを調べてみたいと考えています。また調査したら紹介したいと思います。