🦜

LangChain の新記法「LangChain Expression Language (LCEL)」入門

2023/12/02に公開

LangChain Advent Calendar 2023 の 2 日目の記事です。

LangChain Expression Language (LCEL) とは

LangChain Expression Language (LCEL) は、LangChain でのコードの新しい記述方法です。

公式ドキュメント: https://python.langchain.com/docs/expression_language/

LCEL ではプロンプトや LLM を | で繋げて書き、処理の連鎖 (Chain) を実装します。
2023 年 10 月後半頃から、LangChain では LCEL を使う実装が標準的となっています。

この記事では LCEL の基本的な使い方を紹介していきます。

LCEL の基本的な使い方

prompt と model をつなぐ

まず、LCEL を使う最もシンプルな例として、prompt と model をつないでみます。

はじめに、prompt (PromptTemplate) と model (ChatOpenAI) を準備します。

from langchain.chat_models import ChatOpenAI
from langchain.prompts import PromptTemplate

prompt = PromptTemplate.from_template("""料理のレシピを考えてください。

料理名: {dish}""")

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

そして、これらをつないだ chain を作成します。

chain = prompt | model

この chain を実行します。

result = chain.invoke({"dish": "カレー"})
print(result.content)

すると、以下のように LLM の生成した応答が表示されます。

材料:
- 牛肉または鶏肉 300g
- 玉ねぎ 1個
<以下略>

prompt (PromptTeamplte) の穴埋めと、model (ChatOpenAI) の呼び出しが連鎖的に実行された、ということです。

LCEL では、chain = prompt | model のように、プロンプトや LLM を | で繋げて書き、処理の連鎖 (Chain) を実装します。

LCEL 以前は chain = LLMChain(prompt=prompt, llm=llm) のように書いて Chain を実装していました。
これらを比較すると、LCEL のほうが直感的なコードに見えると思います。

output_parser も繋ぐ

2 つ目の例として、prompt と model に加えて、output_parser も繋いでみます。

LLM に料理のレシピを生成させて、その結果を Recipe クラスのインスタンスに変換する、という流れを実施してみます。

まず、Recipe クラスを定義し、output_parser (PydanticOutputParser) を準備します。

from dotenv import load_dotenv
from langchain.chat_models import ChatOpenAI
from langchain.output_parsers import PydanticOutputParser
from langchain.prompts import PromptTemplate
from pydantic import BaseModel, Field

class Recipe(BaseModel):
    ingredients: list[str] = Field(description="ingredients of the dish")
    steps: list[str] = Field(description="steps to make the dish")

output_parser = PydanticOutputParser(pydantic_object=Recipe)

続いて、prompt (PromptTemplate) と model (ChatOpenAI) を準備します。

prompt = PromptTemplate.from_template(
    """料理のレシピを考えてください。

{format_instructions}

料理名: {dish}""",
    partial_variables={"format_instructions": output_parser.get_format_instructions()},
)

model = ChatOpenAI(model="gpt-3.5-turbo-1106").bind(
    response_format={"type": "json_object"}
)

LCEL の記法で、prompt と model、output_parser を繋いだ chain を作成します。

chain = prompt | model | output_parser

chain を実行してみます。

result = chain.invoke({"dish": "カレー"})
print(type(result))
print(result)

すると、最終的な出力として、Recipe クラスのインスタンスが得られました。

<class '__main__.Recipe'>
ingredients=['カレールー', '肉(豚肉、鶏肉、牛肉など)', 'じゃがいも', 'にんじん', '玉ねぎ', 'にんにく', '生姜', 'トマト缶', '水', '塩', 'コショウ', 'サラダ油'] steps=['1. じゃがいもとにんじんを洗い、皮をむいて食べやすい大きさに切る。', '2. 玉ねぎ、にんにく、生姜をみじん切りにする。', '3. 鍋にサラダ油を熱し、にんにくと生姜を炒める。', '4. 肉を加えて炒め、色が変わったら玉ねぎを加える。', '5. じゃがいもとにんじんを加えて炒める。', '6. トマト缶を加え、水を注ぎ入れて煮込む。', '7. カレールーを加えて溶かし、塩とコショウで味を調える。', '8. ご飯の上にカレーをかけて完成。']

LCEL のしくみ

ここで、LCEL の記法がどのように実現されているのかを少し解説します。

LCEL は、LangChain の各種モジュールが継承している「Runnable インタフェース」などによって実現されています。

LangChain (langchain-core) のソースコードで、Runnable は抽象基底クラス (ABC) として定義されています。

class Runnable(Generic[Input, Output], ABC):

引用元: https://github.com/langchain-ai/langchain/blob/f4d520ccb5ea2bc648a88adf689eb866384b9ae1/libs/core/langchain_core/runnables/base.py#L83

Runnable では __or____ror__ が実装されています。

    def __or__(
        self,
        other: Union[
            Runnable[Any, Other],
            Callable[[Any], Other],
            Callable[[Iterator[Any]], Iterator[Other]],
            Mapping[str, Union[Runnable[Any, Other], Callable[[Any], Other], Any]],
        ],
    ) -> RunnableSerializable[Input, Other]:
        """Compose this runnable with another object to create a RunnableSequence."""
        return RunnableSequence(first=self, last=coerce_to_runnable(other))

    def __ror__(
        self,
        other: Union[
            Runnable[Other, Any],
            Callable[[Other], Any],
            Callable[[Iterator[Other]], Iterator[Any]],
            Mapping[str, Union[Runnable[Other, Any], Callable[[Other], Any], Any]],
        ],
    ) -> RunnableSerializable[Other, Output]:
        """Compose this runnable with another object to create a RunnableSequence."""
        return RunnableSequence(first=coerce_to_runnable(other), last=self)

引用元: https://github.com/langchain-ai/langchain/blob/f4d520ccb5ea2bc648a88adf689eb866384b9ae1/libs/core/langchain_core/runnables/base.py#L354

Python では、__or____ror__ によって | を演算子オーバーロードすることができるため、chain = prompt | model のような記法ができるということです。

もう少しだけ複雑な LCEL の例

LCEL のしくみをなんとなく知ったところで、もう少しだけ複雑な LCEL の例もいくつか見てみます。

ルールベースの処理 (通常の関数) をはさむ

LLM を使ったアプリケーションでは、LLM の応答に対してルールベースでさらに処理を加えたり、何らかの変換をかけたいことも多いです。
LCEL では、Chain の連鎖に任意の処理 (関数) を加えることができます。

たとえば、LLM の生成したテキストに対して、小文字を大文字に変換する処理を連鎖させたい場合は、以下のように実装できます。

from langchain.schema.runnable import RunnableLambda

def upper(inp: str) -> str:
    return inp.upper()

chain = prompt | model | output_parser | RunnableLambda(upper)

独自の処理と LLM の呼び出しを連鎖させたい、というユースケースはかなり多いので、その流れを直感的に書けるのはとても嬉しいです。

RAG (Retrieval-Augmented Generation)

最後に、最近話題の RAG (Retrieval-Augmented Generation) の例です。

RAG では下図のように、ユーザの入力に関係する文書を VectorStore から検索して、プロンプトに含めて使います。

文書をベクトル検索したりしてプロンプトに含めることで、社内文書などの LLM が本来知らない情報をもとに回答させることができる、という手法です。

RAG を LCEL で実装するため、まずは retriever (LangChain における文書を検索するインタフェース) を準備します。

from langchain.chat_models import ChatOpenAI
from langchain.embeddings import OpenAIEmbeddings
from langchain.prompts import ChatPromptTemplate
from langchain.schema.runnable import RunnablePassthrough
from langchain.vectorstores.faiss import FAISS

texts = [
    "私の趣味は読書です。",
    "私の好きな食べ物はカレーです。",
    "私の嫌いな食べ物は饅頭です。",
]
vectorstore = FAISS.from_texts(texts, embedding=OpenAIEmbeddings())

retriever = vectorstore.as_retriever(search_kwargs={"k": 1})

prompt・model・output_parser を準備します。

prompt = ChatPromptTemplate.from_template(
    """以下のcontextだけに基づいて回答してください。

{context}

質問: {question}
"""
)

model = ChatOpenAI(model="gpt-3.5-turbo-1106", temperature=0)

output_parser = StrOutputParser()

LCEL で chain を実装します。

chain = (
    {"context": retriever, "question": RunnablePassthrough()}
    | prompt
    | model
    | output_parser
)

この LCEL の chain では、最初に {"context": retriever, "question": RunnablePassthrough()} と書かれています。
これは、入力が retriever に渡されつつ、prompt にも渡される、というイメージです。

この chain を実行します。

result = chain.invoke("私の好きな食べ物はなんでしょう?")
print(result)

すると、検索した独自知識に基づいて回答してくれました。

回答: カレーです。

おわりに

この記事では、LangChain の新記法「LangChain Expression Language (LCEL)」を紹介しました。

LLM を使ったアプリケーション開発において、連鎖的に処理を実行したいことは非常に多いです。
そのような処理の流れを直感的に書けることはとても嬉しく、LCEL を知って以来、「LangChain を入れるのは重いけど、LCEL は使いたいなあ」と思うことも多かったです。

そんな中、2023 年 11 月末から、LangChain のコア機能の「langchain-core」というパッケージへの分離が始まりました。

https://github.com/langchain-ai/langchain/discussions/13823

「langchain-core」には、LangChain の主要な抽象化や LCEL が含まれ、今までよりも安定したパッケージにすることを意図しているそうです。
「LangChain の抽象化や LCEL だけ使えればいいのに」と思うことも多かった自分には非常に嬉しい取り組みです。
今後の LangChain のアップデートも楽しみです。

Discussion