Frqux’s AI laboratory

AIの技術が進化する今日

Azure OpenAI APIを使ったLangChain使用方法

目次

LangChainって何?

 LangChainはプロンプトエンジニアリングのサポートをするライブラリです。


 おそらくこの記事を見ている方は既に知っていると思うので、詳しい説明は割愛します。

 「いや知らないよ!」という方は、後に別記事で説明記事を投稿するのでそちらへ飛ぶか、ググるだけでもわかりやすく説明してくれている方が多くいるので、そちらの記事に目を通してきてくださると、理解しながらこの記事を見れると思います。


 LangChainを使う目的としては以下のようなことが挙げられると思います。

  • 自社のデータを参照してGPTに回答させたい。(Retrieval)
  • GPTの回答を自動で解析してもらいたい。(Chains)(Agents)
  • GPTに計算知識を与えたい。(Agents)
  • 質問によって使うモデルを切り替えたい。(Models)

  などなど...

 このような問題を解決するべく生まれたのがLangChainです。


Azure OpenAIって何?

 この記事を見ている方なら知っているうえでたどり着いたのだと思うので、ここでも詳しい説明は割愛いたします。

 簡単に言うと、GPT-3.5やGPT-4はOpenAIのほかにMicrosoftにも提供を行っています。
 現在この2社のみがGPTのAPIを公開するサービスを持っています。

 そのMicrosoftがGPTを提供するために始まったサービスが、Azure OpenAIです。


 じゃあOpenAIのAPIとAzureのGPTは何が違うの?と思うかもしれません。

 サービスや料金自体に違いはないのですが、応答速度が違います。

 Azure OpenAIのAPIサービスを使ったほうが生成が早いと言われています。
 ※しかし、最近利用者が増えたせいか、GPT-4での生成がかなり遅くなりました。しかし、GPT-3.5-turboでは高速な生成がまだ可能なので、Azureを選ぶという選択肢はアリです。


 Azure OpenAIは誰でも登録できるわけではなく、APIの利用を申請して認証された方がリソースを作成でき、APIの利用が可能になります。

 なので、お金を払えばだれでもOKのOpenAI APIと違い、利用者が制限されることで安定した高速な利用が可能になっています。


LangChainの使い方

 今回、LangChainはPythonで使います。C#JavaScriptでも利用できるらしいので、そちらを知りたい方はこの記事は参考にならないかもしれません。


実験環境

  • Python 3.10.12
  • jupyterlab 4.0.6
  • openai 0.28.0
  • langchain 0.0.310

ライブラリバージョンでほかに知りたいものがあれば気軽にコメントでお知らせください。


基本ライブラリのインポート

import os
import openai

from langchain.llms import AzureOpenAI
from langchain.chat_models import AzureChatOpenAI
from langchain.embeddings import OpenAIEmbeddings

各ライブラリの役割について説明します。

  • os ... 環境変数を編集するために使用します。場合によっては".env"ファイルを作ってそこからAPIキーなどを抽出する使い方もできるので必須です。
  • openai ... openaiのライブラリです。こちらはlangchainに渡すAPIキーや環境変数を指定するために使用します。
  • AzureOpenAI ... Azure OpenAIの大規模言語モデル(llm)を利用するために使用します。主に"text-davinci-003"(GPT-3)を使用するために使われると思います。
  • AzureChatOpenAI ... Azure OpenAIのチャットモデルを利用するために使用します。主に"GPT-3.5-turbo"や"GPT-4"を使うために使用されます。
  • OpenAIEmbeddings ... Azure OpenAIのエンベディングモデルを利用する際に使用されます。主に"text-embedding-ada-002"を使う際に使用されます。


環境変数の設定

 Azure OpenAIのAPIをLangChainで使う際には、環境変数を設定しなければなりません。そこで以下のようなコードを実行してください。

os.environ["OPENAI_API_TYPE"] = "azure"
os.environ["OPENAI_API_KEY"] = "@@@@@"
os.environ["OPENAI_API_BASE"] = "xxxxx"
os.environ["OPENAI_API_VERSION"] = "....."


OPENAI_API_TYPEはAzure OpenAIの場合は、ほとんどの方が"Azure"になると思います。

@@@@@の部分にはAzure OpenAIのAPIキーを入力してください。

xxxxxの部分にはAzure OpenAIでデプロイしたリソースのエンドポイントを入力してください。
(例)os.environ["OPENAI_API_BASE"] = "https://ここにリソース名.openai.azure.com/"

.....の部分にはAzure OpenAIでデプロイしたリソースのバージョンを入力してください。
(例)os.environ["OPENAI_API_VERSION"] = "2023-05-15"


次にopenaiのライブラリにも設定してあげます。

openai.api_type = "azure"
openai.api_key = "@@@@@"
openai.api_base = "xxxxx"
openai.api_version = "....."

ここにも同じく、API_TYPE、API_KEY、API_BASE(エンドポイント)、API_VERSIONの4つを入力します。


各モデルのインスタンスを作成

  • llmモデルの場合
llm = AzureOpenAI(
    deployment_name = "デプロイ名",
    model_name = "モデル名"
)

deployment_nameにはAzure OpenAIでデプロイしたllmリソースの名前を入力してください。
※(例)deployment_name = "test-gpt3",

model_nameにはAzure OpenAIでデプロイしたllmリソースのモデル名を入力してください。
※(例)model_name = "text-davinci-003"

chat = AzureChatOpenAI(
        client=None,
        deployment_name="デプロイ名",
        openai_api_base=openai.api_base,
        openai_api_version=openai.api_version or "",
        openai_api_key=openai.api_key or "",
        temperature=0.7,
        request_timeout=180,
    )

deployment_nameにはAzure OpenAIでデプロイしたチャットモデルリソースの名前を入力してください。
※(例)deployment_name="test-chatmodel"

  • エンベディングモデルの場合
embedding = OpenAIEmbeddings(
        model="エンベディングモデル名",
        deployment="デプロイ名",
        openai_api_key= openai.api_key,
        openai_api_base=openai.api_base,
        openai_api_type=openai.api_type,
        openai_api_version=openai.api_version,
    )

modelにはAzure OpenAIでデプロイしたエンベディングモデルリソースのモデル名を入力してください。
※(例)model="text-embedding-ada-002"

deploymentにはAzure OpenAIでデプロイしたエンベディングモデルリソースのデプロイ名を入力してください。
※(例)deployment="test-embedmodel"


あとはこれらをLangChainの記法に合わせて使用していけば問題なく使えるはずです。


ConversationalRetrievalChainの実行例

 先で設定したAzure OpenAIの設定をもとにLangChainを動かしてみたいと思います。

 今回実行するのは、ConversationalRetrievalChainです。

 ConversationalRetrievalChainは、独自データを参照させながらチャットモデルとの会話ができるようになるChainsです。

 詳しい説明は別記事でしますので、今は簡単にそういうものだと思ってください。


ライブラリのインポート

from langchain.chains import ConversationalRetrievalChain
from langchain.document_loaders.csv_loader import CSVLoader
from langchain.memory import ConversationBufferWindowMemory
from langchain.vectorstores import Chroma
import chromadb
from chromadb.config import Settings

from langchain.schema import (
    SystemMessage,
    HumanMessage,
    AIMessage,
)

from langchain.prompts import (
    SystemMessagePromptTemplate,
    HumanMessagePromptTemplate,
    AIMessagePromptTemplate,
    ChatPromptTemplate,
    PromptTemplate,
)

各ライブラリの役割について説明します。

  • ConversationalRetrievalChain ... 前述のConversationalRetrievalChainを行うためのライブラリです。
  • CSVLoader ... CSVのデータを読み取るためのライブラリです。今回参照させるデータはcsv形式なのでCSVLoaderを選択しました。
  • ConversationBufferWindowMemory ... AIとの会話履歴を保存するためのライブラリです。会話を実現させるために必要になってきます。
  • Chroma ... ベクトル化したデータを格納できるデータベースを扱うライブラリです。これによって読み込んだデータを格納して自由に扱うことができるようになります。
  • chromadb ... 前述のデータベースです。
  • Settings ... chromaDBに関する様々な設定を行うことができるライブラリです。
  • SystemMessage ... システムメッセージを格納するライブラリです。LangChainではロール(役割)を分けることができるので、その形式に当てはめるためのものになります。
  • HumanMessage ... ユーザーのメッセージを格納するライブラリです。
  • AIMessage ... AIのメッセージを格納するライブラリです。
  • SystemMessagePromptTemplate ... システムメッセージのプロンプトテンプレートを格納できるライブラリです。
  • HumanMessagePromptTemplate ... ユーザーのメッセージのプロンプトテンプレートを格納できるライブラリです。
  • AIMessagePromptTemplate ... AIのメッセージのプロンプトテンプレートを格納できるライブラリです。
  • ChatPromptTemplate ... チャット全体のプロンプトテンプレートを格納できるライブラリです。
  • PromptTemplate ... プロンプトテンプレートを作成し格納できるライブラリです。


 以上がConversationalRetrievalChainで使うライブラリになります。


memoryの初期化

 会話履歴(記憶)を保持するためのmemoryを初期化します。

 これから行うAIとの会話履歴がここに格納されていきます。

# 直近k個の会話履歴を保持するメモリ
memory = ConversationBufferWindowMemory(k=7, memory_key='chat_history', input_key="question", return_messages=True)

 今回使用するConversationBufferWindowMemoryですが、指定する"k"の数値の回数分の会話履歴を保存することができるものになります。

 今回kに7を入れてますので、7ターンの会話ののち初めの方から記憶が順に消去されていくことになります。


 ほかにも、全履歴をそのまま残す"ConversationBufferMemory"や、会話履歴をllmで要約して格納していく"ConversationSummaryBufferMemory"などがあります。


CSVLoaderでデータを取得・構造化を行う

 まずCSVLoaderでデータを取得しましょう。

loader = CSVLoader(file_path='data/tokyo.csv')
data = loader.load()

 今回、dataフォルダを作ってその中にtokyo.csvというcsvを入れています。
 tokyo.csvは、東京の世帯数および人口の推移について書かれたデータです。

 これを少し整形して、utf-8の形式で保存しなおしたものを読み取ります。

catalog.data.metro.tokyo.lg.jp


 どんな風に取得できたか確認してみましょう。

print("\n取得したデータ数 : ", len(data))
print("\n9番目のデータを見てみる : \n\n", str(data[9].page_content))

--------表示された情報--------
取得したデータ数 :  42

9番目のデータを見てみる : 

 年次: 1991
世帯数: 20453
人口総数(人): 58103
人口男性(人): 29947
人口女性(人): 28156
増加人口(人): 1601
対前年比増減率(%): 2.8
1世帯当たり人員(人): 2.84
1平方km当たり人口密度(人/平方km): 3242

 うまくデータを取得できました。

 これをベクトル化してchromaDBに格納します。

persist_directory = 'tokyoDB'
client = chromadb.PersistentClient(path=persist_directory)

db = Chroma(
            collection_name="langchain_store",
            embedding_function=embedding,
            client=client,
            )
db.add_documents(documents=data, embedding=embedding)

 ここでは、persist_directory変数にデータベースの名前を指定しています。すると、この名前でフォルダがつくられて、そこにaqlite3のデータベースが構築されます。


 一度データベースを構築しておくと、次から同じデータを参照したいときに、以下のようにコードを書けばデータベースを使えます。

 なので毎回csvを読み込んでエンベディングモデルでベクトル化して...のような手順が必要なくなるため、エンベディングモデルの利用料金の節約にもなります。

persist_directory = 'tokyoDB'
client = chromadb.PersistentClient(path=persist_directory)

vector_store = Chroma(
    collection_name="langchain_store",
    embedding_function=embedding,
    client=client,
)


 試しにデータ抽出を体験してみましょう。

 以下のコードでは、queryの文章と意味が近いデータを探して持ってきてくれます。

query = "1999年の東京の人口は何人ですか?"
retriever = vector_store.as_retriever(search_type="similarity_score_threshold", search_kwargs={"score_threshold": 0.7, "k":3})
docs_test = retriever.get_relevant_documents(query)
print("\t抽出データ数: ", len(docs_test))
print(docs_test)

 今回は類似度スコアが0.7以上("score_threshold": 0.7)のデータを最大3件("k":3)持ってくるように指示をしています。
 すると、以下のようなデータが取れました。

抽出データ数:  3
[Document(page_content='\ufeff年次: 1999\n世帯数: 25496\n人口総数(人): 64960\n人口男性(人): 33379\n人口女性(人): 31581\n増加人口(人): 953\n対前年比増減率(%): 1.4\n1世帯当たり人員(人): 2.55\n1平方km当たり人口密度(人/平方km): 3615', metadata={'row': 17, 'source': 'data/tokyo.csv'}), Document(page_content='\ufeff年次: 1989\n世帯数: 18279\n人口総数(人): 53642\n人口男性(人): 27513\n人口女性(人): 26129\n増加人口(人): 1658\n対前年比増減率(%): 3.2\n1世帯当たり人員(人): 2.93\n1平方km当たり人口密度(人/平方km): 3046', metadata={'row': 7, 'source': 'data/tokyo.csv'}), Document(page_content='\ufeff年次: 1998\n世帯数: 24829\n人口総数(人): 64007\n人口男性(人): 32914\n人口女性(人): 31093\n増加人口(人): 648\n対前年比増減率(%): 1\n1世帯当たり人員(人): 2.58\n1平方km当たり人口密度(人/平方km): 3562', metadata={'row': 16, 'source': 'data/tokyo.csv'})]

 年次が1999のデータを先頭に持ってきていることから、正しくデータが取れていることが分かります。


システムプロンプトを定義し、テンプレートを作成

 Q&Aを作るならシステムメッセージは欠かせませんし、設定したいと思いますので、やり方を紹介します。

 簡単に言うと、事前に会話履歴を定義して、その中にシステムメッセージを入れるというやり方です。

 まず、以下のようにシステムメッセージテンプレートを作成します。

system_prompt = """
あなたは、東京の情報を提供する優秀なチャットボットです。
以下にお客様とAIの親し気な会話履歴を提示しますので、それに基づいて発言しなさい。
発言内容はコンテキスト情報を参照して回答してください。コンテキスト情報にない場合は事前学習されたデータも使って回答してください。
'''
#会話履歴:

{history}
'''

'''
#コンテキスト情報:
      
{context}
'''
"""

 本来、記憶保持はmemoryがあるため会話が成り立つようになっているのですが、ユーザーの質問を要約してGPTに投げるチェインなので、会話が成り立たない場合が多い印象を受けました。なので、システムメッセージに会話履歴を入れて、それを参照するように指示をしています。
 しかし、この方法だとシステムプロンプトが冗長になってしまい、料金が会話履歴のぶん多くかかるのでご注意ください。

 その部分を改良したい場合は、condense_question_templateを変更してください。
 このテンプレートは、ユーザーの質問をGPTへ投げる際に要約されるよう指示するプロンプト(質問凝縮プロンプト)です。
 これを適切に変えることで、ユーザーの質問が適切な形でGPTに送られるようになるかと思います。

 ちなみに、デフォルトでは以下のようなプロンプトになっています。

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:\n{chat_history}
Follow Up Input: {question}
Standalone question:

 質問凝縮プロンプトを設定したら、以下のようにテンプレートとしてまとめておいてください。

condense_question_prompt = PromptTemplate(
    template=condense_question_template,
    input_variables=["chat_history", "question"]
)

 簡単に翻訳すると、「元の言語でユーザーの質問を会話履歴をもとに独立した質問に直しなさい。」という指示になっています。

 次にユーザーからの質問を入れるテンプレートを作成します。

human_prompt = "{question}"

 あとはこれをチャットテンプレートとしてまとめます。

messages = [
            SystemMessagePromptTemplate.from_template(system_prompt),
            HumanMessagePromptTemplate.from_template(human_prompt),
]
qa_prompt = ChatPromptTemplate.from_messages( messages )


Chainの作成

 ここまで来れば、あとは今まで設定した設定をつけたQ&Aを作るだけです。

 以下のように、対応した引数に入れることで完成します。

qa = ConversationalRetrievalChain.from_llm(
    llm=chat,
    retriever = vector_store.as_retriever(search_type="similarity_score_threshold", search_kwargs={"score_threshold": 0.7, "k":60}),
    memory=memory,
    combine_docs_chain_kwargs={"prompt": qa_prompt},
    condense_question_prompt=condense_question_prompt,
    condense_question_llm=chat
)

 この、condense_question_promptとcondense_question_llmは、質問凝縮プロンプトと、質問凝縮に使うモデルの指定をしています。

 もちろん使わない場合は指定せずに、以下のようにしても大丈夫ですので、質問凝縮はデフォルトのもので行いたい場合は書かなくて大丈夫です。

qa = ConversationalRetrievalChain.from_llm(
    llm=chat,
    retriever = vector_store.as_retriever(search_type="similarity_score_threshold", search_kwargs={"score_threshold": 0.7, "k":60}),
    memory=memory,
    combine_docs_chain_kwargs={"prompt": qa_prompt},
)

 次に、システムプロンプトに埋め込む会話履歴を作成する関数を作っておきます。
 これはmemoryに入っているデータを文字列に変換するだけなので、もっといいやり方は全然あると思いますので、ご自分で変更してください。

def list_to_string(input_list):
    # リスト内の要素を文字列に変換し、結合して文字列を作成
    result_string = ''.join(map(str, input_list))
    return result_string


チャットの実践

 では実際にチャットをしてみましょう。

# 入力テキスト
query = "1999年の東京の人口を教えてください。"

# 記憶(memory)の内容を読み込む ※dict型
buffer = memory.load_memory_variables({})

# 記憶(memory)の内容を読み込む ※list型
memory_context = buffer["chat_history"]

# 記憶をstr型に変換
memory_str = list_to_string(memory_context)

# Tempateの{history_mem}に値を埋め込んで実行
answer = qa.run({"question": query, "history": memory_str})
print("\n A# \n", answer)

 結果は以下のようになりました。

 A# 
 1999年の東京の人口総数は64,960人で、男性は33,379人、女性は31,581人でした。

 「CSVLoaderでデータを取得・構造化を行う」の説明の時に、試験的に取り出したデータと同じ数値になっていることから、うまくデータが取り出せていることが分かります。


おわりに

 今回、遅れながらAzure OpenAI利用者向けlangchainの解説をしました。

 あまりドキュメントがなく、調べるのに苦労しましたが何とか実装できてよかったと思います。

 また、いろんなサイトやブログを参考にしてコーディングをしたので、「別のブログに同じコードがあったな...」とかはあると思います。
 でも、極力オリジナリティが出るよう工夫はしているつもりですので大目に見てください...。

 ちなみに、私のコードは全然コピペしまくってもらって結構ですので、皆さんの役に立てば幸いです。

 
 余談ですが、最近Azure OpenAIでも、Azure AI Search(旧cognitive search)でコーディングなしでRAGを用いたアプリができたり、OpenAIのGPTsでも同じようなことができたりと、わざわざlangchainを使う必要がなくなってきているんですよね。

 閲覧ありがとうございました。