LCEL (LangChain Expression Language）完全に理解した - Amazon Bedrock APIで始めるLLM超入門⑨

LCELからBedrockを呼び出してみます。

# LCELとは
LangChainでコンポーネントをchain（連続呼出）する共通のInterfaceおよびその記法です。
Interfaceは以下のページが分かり易かったですが、要は`Runnable`共通のメソッドを実装しているというのと、入出力の型はコンポーネント毎に異なる（chainを組む時に入出力の型を意識して合わせる必要がある）というのが理解のポイントかなと思いました。

https://python.langchain.com/docs/expression_language/interface

使い方は公式のクックブックがわりと分かりやすいです。

https://python.langchain.com/docs/expression_language/cookbook/

# LangChainの最新化

langchainとlangchain-communityを最新にします。安定バージョンになったという話を信じてバージョンを指定せずに入れてみます。
```
pip install -U langchain langchain-community
```
記述時点の最新バージョンは`0.1.9`です。

# コンポーネント単位で動作を確認する

世のサンプルを見ると最初からコンポーネントをchainしている例が多いので、まずは個々のコンポーネントを単独で動かしてみます。

## LLMコンポーネント
- 入力：プロンプト文字列
- invoke時の実行内容：LLMの呼出
- 出力：生成後文字列
```Python:LLM.py
from langchain.globals import set_debug
set_debug(False) # debug時はTrue

from langchain_community.llms import Bedrock

# ユーザー入力
user_input="カレーの作り方"

# LLMの定義
LLM = Bedrock(
    model_id="anthropic.claude-instant-v1",
    model_kwargs={"max_tokens_to_sample": 1000},
)

# chainの定義
chain = LLM

# chainの実行
answer = chain.invoke(user_input)
print(answer)
```
```:実行結果
カレーの基本的な作り方は以下のとおりです。
（以下略）
```
入力を元にLLMの呼出が行われます。BedrockのAPIを直接叩いているのとほぼ同じです。

## Promptコンポーネント
- 入力：辞書型
- invoke時の実行内容：プロンプトの生成
- 出力：プロンプト文字列
```Python:prompt.py
from langchain.globals import set_debug
set_debug(False) # debug時はTrue

from langchain_core.prompts import PromptTemplate

# ユーザー入力
user_input="カレーの作り方"

# プロンプトの定義
prompt = PromptTemplate.from_template("""
あなたは次の質問をしました。<question>{question}</question>
""")

# chainの定義
chain = prompt

# chainの実行
answer = chain.invoke({"question": user_input})
print(answer)
```
```:実行結果
text='\nあなたは次の質問をしました。<question>カレーの作り方</question>\n'
```
入力（辞書型）を元にプロンプトを生成します。
辞書型なので、invoke時の引数は辞書型で渡します。この例ではテンプレートに合わせて`"question"`を渡しています。
invoke時には`{question}`が展開されたプロンプトが返ります。

## Retrieverコンポーネント
- 入力：検索文字列
- invoke時の実行内容：検索の実行
- 出力：検索結果（Documentの配列）

RAGのRです。invokeするとドキュメントの検索を実行してくれます。

Knowledge baseの例
```Python:Retriever_knowledgebase.py
from langchain.globals import set_debug
set_debug(False) # debug時はTrue

from langchain_community.retrievers import AmazonKnowledgeBasesRetriever

# ユーザー入力
user_input="Bedrockとは"

# Retriever(KnowledgeBase)の定義
# Knowledge base ID、取得件数、検索方法（ハイブリッド）
retriever = AmazonKnowledgeBasesRetriever(
    knowledge_base_id="XXXXXXXXXX",
    retrieval_config={
        "vectorSearchConfiguration": {
            "numberOfResults": 10, 
            "overrideSearchType": "HYBRID"
        }
    }
)

# chainの定義
chain = retriever

# chainの実行
result = chain.invoke(user_input)
print(result)
```
```:実行結果
[Document(page_content=(1件目の検索結果),
 Document(page_content=(2件目の検索結果),
 …]
```

:::note
2024/3/2
Knowledge baseが<a href="https://aws.amazon.com/jp/about-aws/whats-new/2024/03/knowledge-bases-amazon-bedrock-hybrid-search/">ハイブリッド検索なるものに対応した</a>ので、サンプルプログラムも対応しました。
今まではKendraはキーワード検索（全文検索）、Knowledge baseはベクトル検索で、それぞれ良し悪しある状態でしたが、今回の変更で両方の検索手段を使ってくれるという事で、Knowledge baseがかなり優位になったかと思います。

<a href="https://qiita.com/minorun365/items/0772bfa4d3426ef62480
">こちらのページ</a>が詳しいです。
:::

Kendraの例
```Python:Retriever_kendra.py
from langchain.globals import set_debug
set_debug(False) # debug時はTrue

from langchain_community.retrievers import AmazonKendraRetriever

# ユーザー入力
user_input="Bedrockとは"

# Retriever(Kendra)の定義（Kendra Index ID、言語、取得件数）
retriever = AmazonKendraRetriever(
    index_id="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
    attribute_filter={"EqualsTo": {"Key": "_language_code","Value": {"StringValue": "ja"}}},
    top_k=10
)

# chainの定義
chain = retriever

# chainの実行
result = chain.invoke(user_input)
print(result)
```
```:実行結果
[Document(page_content=(1件目の検索結果),
 Document(page_content=(2件目の検索結果),
 …]
```

# 複数のコンポーネントをchainする

個々のコンポーネントをシンプルに理解できたところで、複数のコンポーネントでchainを組んで連続実行をしていきます。

## PromptコンポーネントとLLMコンポーネントのchain
標準的なケースとしてプロンプトの生成とLLMの呼出をLCELで表記してみます。

```Python:Prompt+LLM.py
from langchain.globals import set_debug
set_debug(False) # debug時はTrue

from langchain_core.prompts import PromptTemplate
from langchain_community.llms import Bedrock

# ユーザー入力
user_input="好きな食べ物は？"

# プロンプトの定義
prompt = PromptTemplate.from_template("""
あなたはquestionに猫の気持ちで答えます。
<question>{question}</question>
""")

# LLMの定義
LLM = Bedrock(
    model_id="anthropic.claude-instant-v1",
    model_kwargs={"max_tokens_to_sample": 1000},
)

# chainの定義
chain = prompt | LLM

# chainの実行
answer = chain.invoke({"question": user_input})
print(answer)
```
上記の`chain = prompt | LLM`がchainの定義になります。
chainへの入力がpromptの入力になる → promptのinvoke → promptの出力がLLMの入力になる → LLMのinvoke → LLMの出力
という動きをします。

chain時には前後のコンポーネント間で出力と入力の型を合わせながら繋いでいく必要があります。

Promptコンポーネントの入力は辞書型だったので、invoke時に辞書型の`"question"`としてユーザーからの入力を渡します。
Promptコンポーネントはそれを辞書型として受けて、テンプレート中の`{question}`内に展開してプロンプトを生成します。
生成されたプロンプトがLLMコンポーネントに渡されて、LLMの呼出が行われます。
```:実行結果
「にゃあ~私の好きな食べ物は魚とチキンだにゃ。散歩のあともお腹ペコペコで、早くおやつをもらいたいにゃ。人間のものはだめだにゃ、猫用のおやつをくれるんだね。お腹決定!にゃあ~」
```
## RetrieverコンポーネントとPromptコンポーネントとLLMコンポーネントのchain（＝RAG）

Kendraを使った標準的なRAGのケースをLCELで記述してみます。
KendraじゃなくてKnowledge baseを使う場合は、Retriever定義を差し替えれば良いだけです。

```Python:RAG1.py
from langchain.globals import set_debug
set_debug(False) # debug時はTrue

from langchain_core.prompts import PromptTemplate
from langchain_community.llms import Bedrock
from langchain_community.retrievers import AmazonKendraRetriever
from langchain_core.runnables import RunnablePassthrough

# ユーザー入力
user_input="Bedrockで使用可能なモデルは？"

# promptの定義
prompt = PromptTemplate.from_template("""
あなたはcontextを参考に、questionに回答します。
<context>{context}</context>
<question>{question}</question>
""")

# LLMの定義
LLM = Bedrock(
    model_id="anthropic.claude-instant-v1",
    model_kwargs={"max_tokens_to_sample": 1000},
)

# Retriever(Kendra)の定義（Kendra Index ID、言語、取得件数）
retriever = AmazonKendraRetriever(
    index_id="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
    attribute_filter={"EqualsTo": {"Key": "_language_code","Value": {"StringValue": "ja"}}},
    top_k=10
)

# chainの定義
chain = {"context": retriever, "question": RunnablePassthrough()} | prompt | LLM

# chainの実行
answer = chain.invoke(user_input)
print(answer)
```
上記の`chain = {"context": retriever, "question": RunnablePassthrough()} | prompt | LLM`がchainの定義で、大まかに書くと `retriever | prompt | LLM`の順番を定義しています（意味的には、Retriever(Kendra)の呼出(検索)、検索結果の元にしたプロンプトの生成、LLMの呼出を定義）。
但し、chainの前後のコンポーネントで入出力の型や項目を合わせる必要があるので、それ以外の記述を追加しています。

まず先頭のretrieverコンポーネントは文字列をそのまま受けられるので、chainのinvoke時にはユーザー入力をそのまま渡して大丈夫です。
但し次のpromptコンポーネントは辞書型で受ける必要があるので、retrieverの実行結果がテンプレートの`{context}`に入るように、`"context"`に格納しています（`"context": retriever`部分）。また、promptのテンプレートにはもう一つ`{question}`を渡す必要があるので、`"question"`に大元のユーザー入力がそのまま格納（パススルー）されるように`"question": RunnablePassthrough()`としています。
promptコンポーネントは上記の`{context}`と`{question}`を展開したプロンプトを生成し、それを元にLLMの呼出が実行されます。

## もう少しちゃんとしたRAGのchain

上記のRAGは、Retrieve時と回答生成時に同じユーザー入力を使用していますが、検索効率を考えてRetrieve時の検索キーワードをLLMに考えてもらうようにします。
というLCELを記述してみます。

```Python:RAG2.py
from langchain.globals import set_debug
set_debug(False) # debug時はTrue

from langchain_core.prompts import PromptTemplate
from langchain_community.llms import Bedrock
from langchain_community.retrievers import AmazonKendraRetriever
from langchain_core.runnables import RunnablePassthrough

# ユーザー入力
user_input="Bedrockで使用可能なモデルは？"

# Retrieve用のプロンプトの定義
prompt_pre = PromptTemplate.from_template("""
あなたはquestionから、検索ツールへの入力となる検索キーワードを考えます。
questionに後続処理への指示（例：「説明して」「要約して」）が含まれる場合は取り除きます。
検索キーワードは文章では無く簡潔な単語で指定します。
検索キーワードは複数の単語を受け付ける事が出来ます。
検索キーワードは日本語が標準ですが、ユーザー問い合わせに含まれている英単語はそのまま使用してください。
回答形式は文字列です。
<question>{question}</question>
""")

# 回答生成用のプロンプトの定義
prompt_main = PromptTemplate.from_template("""
あなたはcontextを参考に、questionに回答します。
<context>{context}</context>
<question>{question}</question>
""")

# LLMの定義
LLM = Bedrock(
    model_id="anthropic.claude-instant-v1",
    model_kwargs={"max_tokens_to_sample": 1000},
)

# Retriever(Kendra)の定義（Kendra Index ID、言語、取得件数）
retriever = AmazonKendraRetriever(
    index_id="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
    attribute_filter={"EqualsTo": {"Key": "_language_code","Value": {"StringValue": "ja"}}},
    top_k=10
)

# chainの定義
chain = (
    {"context": prompt_pre | LLM | retriever, "question":  RunnablePassthrough()}
    | prompt_main 
    | LLM
)

# chainの実行
answer = chain.invoke({"question": user_input})
print(answer)
```
これも抽象的に書くと、`prompt_pre | LLM | retriever | prompt_main | LLM`という順番を定義しています（コンポーネント順に、検索用プロンプトの生成、検索キーワードの生成、検索の実行、回答用プロンプトの生成、回答の生成）。
これもコンポーネントの前後の型を合わせたり、また、最初の呼出時の入力を2つ目のプロンプトまで持ちまわる必要があるので、こういう書き方になっています。

`"context": prompt_pre | LLM | retriever`で、chainのinvoke時の入力を元に検索用のプロンプト生成→LLMで検索キーワード生成→retriever実行（Kendra検索実行）を行い、`"context"`に格納します。

また、`prompt_main`は`{context}`の他に元の質問`{question}`を受け取る必要があるので、パススルー`RunnablePassthrough()`で元の入力を渡します。

最後に、LLMにてcontextとquestionを元にした回答を生成します。

# 感想

LCEL以前のChainに比べると、プロンプトや実行順序や実行内容を自分でコントロールできるので、確かに慣れればブラックボックス感は減ると思いました。

あと、プロンプトを含めてどう動いているかを自覚しながら書いているので、このレベルであればLangChainじゃなくても普通に書けるなとも思いました。

LCELを使ったチャットアプリについては以下で作成しています。

https://qiita.com/cyberBOSE/items/35fd2aed5a42b963ccb6


https://qiita.com/cyberBOSE/items/fe66c2049aaa04568d61

# 参考ページ

以下のページはとても参考になりました。

https://note.com/npaka/n/nc9598129ca3f

https://qiita.com/shimajiroxyz/items/c4be2867e56793ccb1b7

https://qiita.com/hmasdev/items/6414fd7a63dc47a9ef91