見出し画像

langchain 0.1.11 一通り確認(2)

langchain 0.1.11 一通り確認(1)
公式サイトのcookbook RAGにあるサンプルコードを一通り試す

こんにちはmakokonです。
今回は、Langchainのサンプルコードを自分の環境で検証する第2弾ということでRAGを試してみようと思います。
RAG:“retrieval-augmented generation”「検索拡張生成」

今回も基本的には公式ページの焼き直しですが、分かる範囲でサンプルコードの初心者向け解説をつけています。


python 仮想環境


まずは、環境の準備をしましょう。
お試しということもあり、pythonの仮想環境を用意します。
今回はMac-OS上のターミナルで試してみます。
こんな感じ
os version 少々古いですが、pythonバージョン3.11.2なんで問題ないでしょう。念の為pipもアップグレードして、環境設定完了

$ mkdir langchain-0.1.11
$ python -m venv langchain-0.1.11
$ cd langchain-0.1.11/
$ source bin/activate

$ sw_vers            
ProductName:		macOS
ProductVersion:		13.6.1

$ python --version               
Python 3.11.2
$ pip list
Package    Version
---------- -------
pip        22.3.1
setuptools 65.5.0
pip install --upgrade pip 
$ pip list                 
Package    Version
---------- -------
pip        24.0
setuptools 65.5.0


$ sw_vers
ProductName: macOS
ProductVersion: 13.6.1
BuildVersion: 22G313

コーディング

RAG プロンプト+LLM応答に検索ステップを追加する

まずは、基本として、プロンプト+LLMに検索ステップを追加する公正です。LLMはOPENAIを使います。
ライブラリをインストールします。


$ pip install --upgrade --quiet  langchain langchain-openai faiss-cpu tiktoken
$ pip list|grep lang
langchain                0.1.11
langchain-community      0.0.27
langchain-core           0.1.30
langchain-openai         0.0.8
langchain-text-splitters 0.0.1
langsmith                0.1.23

確認用コードです。sample01.py

from operator import itemgetter

from langchain_community.vectorstores import FAISS
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

vectorstore = FAISS.from_texts(
    ["harrison worked at kensho"], embedding=OpenAIEmbeddings()
)
retriever = vectorstore.as_retriever()

template = """Answer the question based only on the following context:
{context}

Question: {question}
"""
prompt = ChatPromptTemplate.from_template(template)

model = ChatOpenAI()

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

print(chain.invoke("where did harrison work?"))

$ python sample01.py
Harrison worked at Kensho.

実行結果 sample01

問題なく動きましたね。
念の為、この処理の流れを書いておきます。

このプログラムは、自然言語処理で特に質問応答システムを扱っています。
1.モジュールのインポート:
itemgetterは、辞書から特定のキーの値を取得する際に使用されますが、このプログラムでは直接使用されていません。
FAISS, StrOutputParser, ChatPromptTemplate, RunnableLambda, RunnablePassthrough, ChatOpenAI, OpenAIEmbeddingsは、異なるライブラリからのクラスや関数のインポートで、テキスト処理、検索、モデルのプロンプト生成、実行可能なラムダ関数、OpenAIのAPIを使った機能などを提供します。
2.ベクトルストアの作成:
FAISS.from_textsメソッドを使って、与えられたテキスト(この場合は"harrison worked at kensho")からFAISSベクトルストアを作成します。embedding=OpenAIEmbeddings()により、OpenAIのモデルを使用してテキストの埋め込みを生成します。
3.検索エンジンの設定:
vectorstore.as_retriever()を使って、作成したベクトルストアから検索エンジン(retriever)を作成します。
4.プロンプトテンプレートの設定:
プロンプトのテンプレートを定義し、ChatPromptTemplate.from_templateメソッドを使用してプロンプトのテンプレートを作成します。このテンプレートは、質問応答のコンテキストと質問をフォーマットするために使用されます。
5.モデルの設定:
ChatOpenAIクラスのインスタンスを作成し、OpenAIのAPIを使用して質問応答モデルを設定します。
6.処理チェーンの構築:
複数の処理ステップをパイプライン(|演算子を使用)で連結して、処理チェーンを構築します。このチェーンは、検索エンジンと質問を入力として受け取り、プロンプトを生成し、モデルを通じて質問に答え、最後に文字列として出力を解析(StrOutputParser())します。
7.質問の処理:
chain.invoke("where did harrison work?")を呼び出すことで、質問「where did harrison work?」を処理チェーンに渡します。これにより、プログラムは質問に基づいてテキスト「harrison worked at kensho」から情報を取得し、質問に対する回答を生成します。
8.結果の出力:
処理チェーンを通じて得られた回答がコンソールに出力されます。

念の為日本語でも確認しておきましょう。
変更箇所だけ示します。

vectorstore = FAISS.from_texts(
    ["makkonは大阪で惰眠を貪っている。"], embedding=OpenAIEmbeddings()
)

print(chain.invoke("makokonは何をしていますか?"))
print(chain.invoke("惰眠を貪っているのは誰ですか?"))

$ python sample01.py
makokonは大阪で惰眠を貪っています。
makkon

実行結果 sample01 日本語確認

日本語でも問題ないですね。
このようにRAGでは、事前情報をvectorstoreで与えることによって、その内容に応じて答えてくれます。

質問を辞書で送って、生成用パラメータをうけわたす

次の事例では、質問を辞書で送って、質問の他に、回答する言語を指定して、検索部分をスルーして、生成部分に引き渡す例が紹介されています。

確認用コードです。sample02.py


from operator import itemgetter

from langchain_community.vectorstores import FAISS
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

vectorstore = FAISS.from_texts(
    ["harrison worked at kensho"], embedding=OpenAIEmbeddings()
)

retriever = vectorstore.as_retriever()

template = """Answer the question based only on the following context:
{context}

Question: {question}

Answer in the following language: {language}
"""

prompt = ChatPromptTemplate.from_template(template)

model = ChatOpenAI()

chain = (
    {
        "context": itemgetter("question") | retriever,
        "question": itemgetter("question"),
        "language": itemgetter("language"),
    }
    | prompt
    | model
    | StrOutputParser()
)

print(chain.invoke({"question": "where did harrison work", "language": "italian"}))
print(chain.invoke({"question": "where did harrison work", "language": "japanese"}))


$ python sample02.py
Harrison ha lavorato a Kensho.
ケンショで働いていました。

実行結果 sample02

languageに"italian"を指定するとイタリア語で、"japanese"を指定すると日本語で答えてくれました。
簡単に説明しましょう。
プロンプトテンプレートには、
Answer in the following language: {language}
という記述があります。だけど、retrieverのは、この件に関する情報が含まれていません。だからといって、questionに例えば、「この質問に日本語で答えてください」のように含めるのも、面倒なこともあるし、この例題では大したことありませんが、検索精度を悪化させる原因にもなります。(vectorstor には、言語の情報がないから)
そこで、この例題では、
質問の部分を{"question": "where did harrison work", "language": "italian"}のように辞書にします。questionは、sample01と同じなので検索結果は同じになります。

chainの部分の処理では、以下のように動作します:

  • "context"キー:入力辞書から"question"キーに対応する値を取得し、その後、パイプ操作 (|) を使って、その値をretrieverに渡します。retrieverは、questionに関する事前情報の検索結果を返します。

  • "question"キー:入力辞書から"question"キーに対応する値を直接取得します。

  • "language"キー:同様に、入力辞書から"language"キーに対応する値を取得します。

なお、itemgetter("key")は、入力辞書から"key"に対応する値を直接入手することができます。
temgetterを使用することで、辞書からの値の抽出をより簡潔に、読みやすく記述できます。また、パイプ演算子 (|) と組み合わせることで、取得した値を直接別の関数に渡すことができ、コードの流れを簡潔に保つことができます。 便利ですね。
そして、この3つ(context,question,language)を改めて辞書形式にして、次のプロンプトに渡します。この3要素の辞書を使うことで、テンプレートのlanguage設定などが機能します。

Conversational Retrieval Chain 会話型検索

次のサンプルでは、会話履歴を簡単に追加できます。これは主に、chat_message_history を追加することを意味します。
今回は会話履歴は手動管理です。
このサンプルではメモリがあってもほとんど回答に影響しませんが、メモリの渡し方がわかります。sample03.py

from operator import itemgetter

from langchain_community.vectorstores import FAISS
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

# for sample03 Conversational Retrieval Chain
from langchain_core.messages import AIMessage, HumanMessage, get_buffer_string
from langchain_core.prompts import format_document
from langchain_core.runnables import RunnableParallel
from langchain.prompts.prompt import PromptTemplate


vectorstore = FAISS.from_texts(
    ["harrison worked at kensho"], embedding=OpenAIEmbeddings()
)

retriever = vectorstore.as_retriever()

# 質問の再構成プロンプトの設定:
_template = """Given the following conversation and a follow up question, rephrase the follow up question to be a standalone question, in its original language.

Chat History:
{chat_history}
Follow Up Input: {question}
Standalone question:"""

CONDENSE_QUESTION_PROMPT = PromptTemplate.from_template(_template)

# 回答生成プロンプトの設定:
template = """Answer the question based only on the following context:
{context}

Question: {question}
"""
ANSWER_PROMPT = ChatPromptTemplate.from_template(template)

# 文書結合関数の定義
DEFAULT_DOCUMENT_PROMPT = PromptTemplate.from_template(template="{page_content}")


def _combine_documents(
    docs, document_prompt=DEFAULT_DOCUMENT_PROMPT, document_separator="\n\n"
):
    doc_strings = [format_document(doc, document_prompt) for doc in docs]
    return document_separator.join(doc_strings)
    
# 文書結合関数の定義
_inputs = RunnableParallel(
    standalone_question=RunnablePassthrough.assign(
        chat_history=lambda x: get_buffer_string(x["chat_history"])
    )
    | CONDENSE_QUESTION_PROMPT
    | ChatOpenAI(temperature=0)
    | StrOutputParser(),
)
_context = {
    "context": itemgetter("standalone_question") | retriever | _combine_documents,
    "question": lambda x: x["standalone_question"],
}
conversational_qa_chain = _inputs | _context | ANSWER_PROMPT | ChatOpenAI()

# 処理チェーンの実行と出力
response = conversational_qa_chain.invoke(
    {
        "question": "where did harrison work?",
        "chat_history": [],
    }
)
print(response)

response = conversational_qa_chain.invoke(
    {
        "question": "where did he work?",
        "chat_history": [
            HumanMessage(content="Who wrote this notebook?"),
            AIMessage(content="Harrison"),
        ],
    }
)    
print(response)






$ python sample03.py
content='Harrison worked at Kensho.'
content='Harrison worked at Kensho.'

実行結果 sample03

それでは簡単に説明しましょう。

プログラムの説明

このプログラムは、質問応答システムにおける会話型の検索と回答生成のための処理フローを実装しています。とくに、会話の履歴を統合して質問することに特徴があります。

必要なライブラリのインポート
自然言語処理と検索、質問応答の生成に関連するモジュールやクラスがインポートされています。今回の追加分は
from langchain_core.messages import AIMessage, HumanMessage, get_buffer_string from langchain_core.prompts import format_document from langchain_core.runnables import RunnableParallel from langchain.prompts.prompt import PromptTemplate
です。

ベクトルストアの初期化:
FAISS.from_textsを使用して、テキスト "harrison worked at kensho"に基づいてFAISSベクトルストアを初期化し、OpenAIEmbeddingsを使ってテキストの埋め込みを生成します。

質問の再構成プロンプトの設定:
会話の履歴とフォローアップ質問を受け取り、それを独立した質問に再構成するプロンプトテンプレートが設定されます。

回答生成プロンプトの設定:
特定のコンテキストと質問に基づいて、回答を生成するためのプロンプトテンプレートが設定されます。

文書結合関数の定義:
検索によって得られた複数の文書(ドキュメント)を結合するための関数です。これは、特定のセパレータを使用して文書を一つの文字列に結合します。
処理チェーンの構築:
RunnableParallel、RunnablePassthrough、PromptTemplate、ChatOpenAI、StrOutputParserなどを使用して、複数の処理ステップを組み合わせた処理チェーンが構築されます。このチェーンは、質問の再構成、検索、回答生成を行います。

処理チェーンの実行と出力:
conversational_qa_chain.invokeメソッドを使用して、特定の質問と会話履歴を処理チェーンに渡し、処理を実行します。このメソッドは、3つの異なるシナリオ(質問と会話履歴の組み合わせ)で呼び出され、それぞれのシナリオに対する回答が生成されます。
生成された回答がコンソールに出力されます。

つまり、ざっくり流れを示すと、
このプログラムは、会話の文脈を考慮した質問応答システムの一例を示しています。会話履歴から質問を再構成し、関連する情報を検索してから、そのコンテキストに基づいて質問に回答します。

会話メモリをLangchainで管理する

せっかくLangchainを使っているのですから、会話履歴を手動で管理するのも変な話です。Langchainには会話履歴用のメモリを利用する方法があります。次のサンプルではその使い方を説明しています。sample04.py

プログラム sample04

from operator import itemgetter

from langchain_community.vectorstores import FAISS
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

# for sample03 Conversational Retrieval Chain
from langchain_core.messages import AIMessage, HumanMessage, get_buffer_string
from langchain_core.prompts import format_document
from langchain_core.runnables import RunnableParallel
from langchain.prompts.prompt import PromptTemplate

# for sample04 Conversationalbuffermemory + Retrieval Chain
from langchain.memory import ConversationBufferMemory

# 事前知識
vectorstore = FAISS.from_texts(
    ["harrison worked at kensho"], embedding=OpenAIEmbeddings()
)

retriever = vectorstore.as_retriever()

# 質問の再構成プロンプトの設定:
_template = """Given the following conversation and a follow up question, rephrase the follow up question to be a standalone question, in its original language.

Chat History:
{chat_history}
Follow Up Input: {question}
Standalone question:"""

CONDENSE_QUESTION_PROMPT = PromptTemplate.from_template(_template)

# 回答生成プロンプトの設定:
template = """Answer the question based only on the following context:
{context}

Question: {question}
"""
ANSWER_PROMPT = ChatPromptTemplate.from_template(template)

# 文書結合関数の定義
DEFAULT_DOCUMENT_PROMPT = PromptTemplate.from_template(template="{page_content}")


def _combine_documents(
    docs, document_prompt=DEFAULT_DOCUMENT_PROMPT, document_separator="\n\n"
):
    doc_strings = [format_document(doc, document_prompt) for doc in docs]
    return document_separator.join(doc_strings)

# メモリの定義    
memory = ConversationBufferMemory(
    return_messages=True, output_key="answer", input_key="question"
)

# 文書結合関数の定義
# First we add a step to load memory
# This adds a "memory" key to the input object
loaded_memory = RunnablePassthrough.assign(
    chat_history=RunnableLambda(memory.load_memory_variables) | itemgetter("history"),
)
# Now we calculate the standalone question
standalone_question = {
    "standalone_question": {
        "question": lambda x: x["question"],
        "chat_history": lambda x: get_buffer_string(x["chat_history"]),
    }
    | CONDENSE_QUESTION_PROMPT
    | ChatOpenAI(temperature=0)
    | StrOutputParser(),
}
# Now we retrieve the documents
retrieved_documents = {
    "docs": itemgetter("standalone_question") | retriever,
    "question": lambda x: x["standalone_question"],
}
# Now we construct the inputs for the final prompt
final_inputs = {
    "context": lambda x: _combine_documents(x["docs"]),
    "question": itemgetter("question"),
}
# And finally, we do the part that returns the answers
answer = {
    "answer": final_inputs | ANSWER_PROMPT | ChatOpenAI(),
    "docs": itemgetter("docs"),
}
# And now we put it all together!
final_chain = loaded_memory | standalone_question | retrieved_documents | answer

# 最初の会話
print("Turn 1:")
inputs = {"question": "where did harrison work?"}
result = final_chain.invoke(inputs)
print(result)
print(f"\nresponse = {result['answer'].content}")

# 会話履歴のメモリへの保存と書き戻し
print("\nmemory:")
memory.save_context(inputs, {"answer": result["answer"].content})
print(memory.load_memory_variables({}))

# 2ターン目の会話
print("\nTurn 2:")
inputs = {"question": "but where did he really work?"}
result = final_chain.invoke(inputs)
print(result)
print(f"\nresponse = {result['answer'].content}")

$ python sample04.py
Turn 1:
{'answer': AIMessage(content='Harrison worked at Kensho.'), 'docs': [Document(page_content='harrison worked at kensho')]}

response = Harrison worked at Kensho.

memory:
{'history': [HumanMessage(content='where did harrison work?'), AIMessage(content='Harrison worked at Kensho.')]}

Turn 2:
{'answer': AIMessage(content='Harrison worked at Kensho.'), 'docs': [Document(page_content='harrison worked at kensho')]}

response = Harrison worked at Kensho.

実行結果 sample04

会話メモリを実装し、その結果を確認しました。公式sampleで間違いなく動くようです。念の為出力は、会話の応答、応答の中のテキスト、メモリの保存と下記戻した中身の確認をしていますが、想定通りの形式と内容であり、従来のコードはほぼ変更なく使えそうです。

ほぼ、sample03の説明とかぶるので、メモリ管理と処理チェーンの設定に焦点を当てて説明します。

メモリの定義と使用

  • ConversationBufferMemoryクラスのインスタンスmemoryは、会話の履歴をメモリ上に保存し、後続の質問処理においてその情報を利用するために定義されています。return_messages=Trueにより、メモリから情報を取得した際にメッセージの内容が返されます。また、output_keyとinput_keyを設定することで、入出力の関連付けを定義しています。

処理チェーンの構築

  1. メモリの読み込み:

    • RunnablePassthrough.assignとRunnableLambdaを使用して、会話履歴(chat_history)をメモリから読み込むステップが定義されます。itemgetter("history")を用いて、履歴情報のみを抽出します。

  2. 独立した質問の生成:

    • ユーザーからの質問と会話履歴を元に、質問の再構成プロンプト(CONDENSE_QUESTION_PROMPT)を用いて独立した質問を生成します。このステップではChatOpenAIを使用して質問を再構成し、StrOutputParser()で文字列としての回答を取得します。

  3. 文書の検索:

    • 再構成された質問を用いて、retrieverを通じて関連する文書を検索します。

  4. 最終入力の構築:

    • 検索された文書を_combine_documents関数を通じて結合し、これを回答生成のコンテキストとして設定します。質問は再構成された質問が使用されます。

  5. 回答の生成:

    • 最終的なコンテキストと質問をANSWER_PROMPTとChatOpenAI()を通して処理し、回答を生成します。

処理チェーンの実行と結果

  • final_chain.invoke(inputs)を通じて、質問(例えば"where did harrison work?")を処理チェーンへと渡し、処理を実行します。これにより、質問に対する回答が生成されます。

  • 会話履歴のメモリへの保存と読み込みを通じて、複数ターンにわたる会話の文脈を保持します。これにより、後続の質問が以前の会話の文脈を考慮して処理されるようになります。

このプログラムは、会話の文脈をメモリに保存し、それを後続の質問応答に活用することで、よりリッチな会話型の質問応答システムを実現しています。

まとめ

以上、公式ページにあるRAGサンプルコードの検証を行いました。
提示されているすべてのコードが自分の環境で正常に動作したので、安心しました。サンプルコードの説明も分かる範囲で補足しました。
確認したのは、プロンプト+LLMの構成に

  • 検索ステップを追加する

  • 質問を辞書で渡して、きめ細かい応答を実現する

  • 会話メモリを実装し、履歴と質問を合わせて問い合わせる

  • 会話メモリもLangchainで管理する

でした。
RAGは、LLMに専門知識やローカルな知識を与えることで、モデルの学習できなかった質問に答えられるようにする重要な技術です。複雑で長大な知識とベクトル検索を組み合わせることで、利用範囲はおおいに拡大するでしょう。

#Langchain #Memory #RAG #prompt #LLM #OpenAI #Chain #itemgetter #公式ページの検証 #検索  

おまけ タイトル画の説明 by gpt-4-vision

この画像は、若い女の子が森の背景の前に立っている様子を捉えています。女の子はデニムのオーバーオールとピンクのセーターを着ており、手には「DICTIONARY」と書かれた大きな青い辞書を持っています。そこにはリアルに描かれたニシキヘビが彼女の肩に巻き付いていて、頭を辞書に近づけているかのように見えます。ヘビの皮膚模様は色とりどりでリアルな質感を持ち、女の子は本に集中しながらもリラックスしている様子が感じられます。全体のシーンはファンタジーまたは想像力豊かな読書の瞬間を表しているかのようです。

(この記事の扉であることを踏まえて、意味を考えてください)

この画像がPython言語とRAG(Retrieval-Augmented Generation)に関する記事のためのものであることを考慮すると、視覚的な比喩が使われていると考えられます。Pythonは、人気の高いプログラミング言語であり、同時に「ヘビ」を意味します。この画像の女の子が辞書(知識の象徴)を手にしており、Python(ヘビ、つまりプログラミング言語)がその周囲に絡みついている様子は、Pythonを使って知識を検索し、生成する作業が組まれていることを暗に示しています。RAG技術は、データベースや情報源からの情報の検索を強化し、新しいテキストの生成に利用することを目的としています。

要するに、この画像は知識の検索と生成を行うPythonプログラム(ここでのヘビ)と、学習と理解(女の子が辞書を読む行為)を組み合わせたものであり、それがRAGの概念を表しています。

この記事が気に入ったらサポートをしてみませんか?