LangchainのStructured OutputとTool Callingを利用して構造化された形式で情報を抽出する
大規模言語モデル(LLM)の活用法の一つに「大量の文書の中から必要な情報を、指定した形式で抽出する」というものがあります。自然言語で回答してもらうのではなく、JSONスキーマなどであらかじめ指定した形式で回答をしてもらうことで、プログラム内での情報の活用につなげることができます。
この記事では、Langchainと主要なLLMのAPIを用いて、文章中から構造化データの形で必要な情報を抜き出す方法を試してみましたので紹介します。
環境と使用するLLM
いつものように、Windows10上でPythonとLangchainを利用します。Pythonは3.11、Langchainのバージョンは以下のとおりです。
PS D:\Documents\work\structured-output-test> pip list
Package Version
---------------------------- ---------
...
langchain 0.2.3
langchain-anthropic 0.1.15
langchain-community 0.2.4
langchain-core 0.2.5
langchain-google-genai 1.0.6
langchain-openai 0.1.8
...
LLMは、OpenAI、Anthropic、Googleの以下のモデルを利用しました。
- OpenAI: "gpt-3.5-turbo"
- Anthropic: "claude-3-haiku-20240307"
- Google: "gemini-1.5-flash-latest"
これまでいろいろなモデルで試してきた経験からすると、文章からの情報抽出では、コスト重視のモデルでも十分に実用になります。Claude3 haiku や Gemini 1.5 Flash は応答速度も速いので、大量のドキュメントなどを対象にしたい場合にはおすすめです。
事前に".env"ファイルを作成して、各社のAPIキーを設定しておいてください。
OPENAI_API_KEY=<OpenAI API key>
ANTHROPIC_API_KEY=<Anthropic API Key>
GOOGLE_API_KEY=<Google API Key>
Pydanticクラスとstructured_outputによる構造化データの抽出
Langchainを利用してLLMで構造化された情報を抽出するには、PydanticかJSONスキーマを定義します。今回は、Pydanticを利用します。
Pydanticクラスの定義
Pydanticは、Pythonのデータバリデーションや設定管理のためのライブラリです。Pythonクラスの形でデータモデルを定義することで、データの整合性を保証することができます。
今回は、例として、ゲームの情報を記載したWikipediaのページから、ゲームに関する情報を取得してみます。
Pydanticで、以下のようにGameData
を定義します。
from typing import List
from langchain_core.pydantic_v1 import BaseModel, Field
class GameData(BaseModel):
name: str = Field(description="ゲームの名前")
release_date: str = Field(description="ゲームのリリース年月日 (YYYYMMDD形式)")
category: str = Field(description="ゲームのジャンル")
sales: str = Field(description="発売元の会社名")
maker: str = Field(description="開発元の会社名")
platform: List[str] = Field(description="対応機種のリスト")
summary: str = Field(description="ゲームの特徴(200~300文字)")
boss: str = Field(description="ラスボスの名前")
description
に説明を書いておくと、これがLLMに渡されて、その内容に合致する情報を取得してくれます。
release_date
の「(YYYYMMDD形式)」や、summary
の「(200~300文字)」という指定も、(完全ではないですが)それなりに守ってくれます。
structured_outputを利用したLLMによる情報抽出
次に、LLMに対するクエリを実行する部分を見ていきます。
import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import (ChatPromptTemplate,
HumanMessagePromptTemplate)
from langchain.schema import HumanMessage, SystemMessage
from langchain_core.tracers.stdout import ConsoleCallbackHandler
# set OpenAPI Key
load_dotenv('.env')
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
# Prompt
prompt_template = """
以下の[text]から、ゲームに関する情報を構造化データとして抽出してください。
必ず指定した形式を守ってください。
[text]
{text}
"""
def extract_data(text: str):
# Prompt
prompt_msgs = [
SystemMessage(
content="You are a world class algorithm for extracting documents in structured formats."
),
HumanMessagePromptTemplate.from_template(prompt_template),
HumanMessage(content="Tips: Make sure to answer in the correct format."),
]
prompt = ChatPromptTemplate(messages=prompt_msgs)
# LLM
llm = ChatOpenAI(
temperature=0,
model_name="gpt-3.5-turbo"
)
# Chain
chain = prompt | llm.with_structured_output(GameData)
# invoke
result = chain.invoke(
{"text": text},
config={'callbacks': [ConsoleCallbackHandler()]}
)
return result
プロンプトと利用するLLMを用意し、Chainを定義して、それを実行するという手順は、通常のチャットベースのクエリと同じです。
プロンプトでは、text
にWikipediaのWebページから抽出した文章を与えます。
注目したいのは、chain
の定義の部分です。
# Chain
chain = prompt | llm.with_structured_output(GameData)
llm.with_structured_output(GameData)
のwith_structured_output
は、チャットモデルからの出力を特定の構造にフォーマットする機能です。先ほど定義したPydanticクラスを与えると、LLMのチャットモデルからの回答が、Pydanticクラスで定義した形式で帰ってきます。
なお、with_structured_output
にはJSONスキーマを与えることもできます。
LangchainのStructured Outputについては、以下のドキュメントをご覧ください。
ということで、
- Pydanticクラスを定義する
- LLMの
with_structured_output
にPydanticクラスを渡す
これだけで、LLMからの回答がPydanticで定義した形式で返ってくるようになります。
すでにLangchainで一般的なチャットモデルのアプリケーションを構築したことがあれば、簡単にできると思います。
動作確認
それでは、実際に動かしてみましょう。以下では、OpenAI、Anthropic、Googleの各モデルで動作させてみます。
OpenAI GPT-3.5-turbo での実行結果
まずは、OpenAIのGPT-3.5-turboで実行してみます。
from pprint import pprint
from langchain_community.document_loaders import WebBaseLoader
# Load text from Web site
web_loader = WebBaseLoader("https://ja.wikipedia.org/wiki/%E3%83%89%E3%83%A9%E3%82%B4%E3%83%B3%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88")
doc = web_loader.load()
text = doc[0].page_content[:10000]
result = extract_data(text)
pprint(result.dict())
WikipediaのWebページを取得するのに、LangchainのWebBaseLoaderを利用します。「ドラゴンクエスト」のWikipediaのページを取得してみます。
テキストのみを取得し、先ほど定義したextract_data()
に渡します。なお、トークン長の上限を超えないように、最初の1万文字のみを渡すようにしています。
実行結果(result
の内容)は以下のとおりです。
[outputs]
{'boss': '竜王',
'category': 'ロールプレイングゲーム',
'maker': 'チュンソフト',
'name': 'ドラゴンクエスト',
'platform': ['ファミリーコンピュータ (FC)',
'MSX',
'MSX2',
'スーパーファミコン (SFC)',
'ゲームボーイ (GB)',
'iアプリ',
'EZアプリ (BREW)',
'Vアプリ',
'Android',
'iOS',
'PlayStation 4 (PS4)',
'ニンテンドー3DS (3DS)',
'Nintendo Switch'],
'release_date': '19860527',
'sales': 'エニックス',
'summary': '『ドラゴンクエスト』は、1986年5月27日にエニックスより発売されたファミリーコンピュータ用ロールプレイングゲーム。プレイヤーは伝説の勇者「ロト」の血を引く勇者として、竜王にさらわれた姫を救い出し、竜王を倒すことが目的。ゲーム内では主人公のステータスや装備、呪文、道具などを駆使して冒険を進める。'}
きちんと必要な情報が抽出できていますね。platform
はリストの形で取得できていますし、release_date
は、「YYYYMMDD形式」と指定したとおり、19860527
となっています。
なお、chain.invoke
の結果は、Pydanticクラスの形で返ってきます。Pydanticクラスのdict()
メソッドでPythonのdict形式に変換して、それを表示させています。
Anthropic Claude3 Haiku での実行結果
次に、Anthropicの Claude3 Haiku で実行してみます。変更するのは、extract_data()
内のLLMの定義の部分だけです。
from langchain_anthropic.chat_models import ChatAnthropic
...
def extract_data(text: str):
...
# LLM
llm = ChatAnthropic(
model="claude-3-haiku-20240307",
temperature=0,
)
実行結果は以下のとおりです。
[outputs]
{'boss': '竜王',
'category': 'ロールプレイングゲーム',
'maker': 'チュンソフト',
'name': 'ドラゴンクエスト',
'platform': ['ファミリーコンピュータ (FC)'],
'release_date': '19860527',
'sales': 'エニックス',
'summary': '家庭用ゲーム機では日本初となるオリジナルタイトルのロールプレイングゲーム。主人公は伝説の勇者「ロト」の血を引く勇者として、姫を救出し竜王を倒すことが目的。移動画面は上から見下ろすトップビューで、戦闘は主人公と1 体のモンスターによる1対1の形式。パスワード機能により中断したゲームを再開できる。'}
こちらも、おおむね期待通りに情報を取得できていますね。platform
のところが「ファミリーコンピュータ」だけになっていますが、形式としてはリストで返ってきていますので、情報抽出の動作としてはOKでしょう。
Google Gemini 1.5 Flash での実行結果
最後に、Google Gemini 1.5 Flash で実行してみます。
from langchain_google_genai import ChatGoogleGenerativeAI
...
def extract_data(text: str):
...
# LLM
llm = ChatGoogleGenerativeAI(
model="gemini-1.5-flash-latest",
temterature=0,
)
実行してみると、以下のようなエラーが出てしまいました。
...
raise NotImplementedError()
NotImplementedError
NotImplementedError
と言っているので、Gemini用のwith_structured_output
はまだ実装されていないようです。
Langchainの以下のドキュメントを見る限り、 ChatVertexAI
であれば実装されているようです。
ちなみに、Structured Output 以外に、Tool Calling を利用してPydanticクラスでLLMの回答を取得することもできます。こちらであれば、Geminiでも動作します。
Tool Calling を利用する方法は、次で紹介します。
Pydanticクラスと Tool Calling による構造化データの抽出
Tool Calling は、以前、Function Calling と呼ばれていたLLMの機能です。Tool Calling は、チャットモデルが特定のタスクを実行するために外部のツールやAPIを呼び出す機能です。
この Tool Calling の機能で、ToolとしてPydanticクラスを渡すと、LLMからPydanticクラスで定義した形式で回答を得ることができます。
早速、試してみましょう。
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_core.output_parsers.openai_tools import PydanticToolsParser
def extract_data(text: str):
# Prompt
prompt_msgs = [
SystemMessage(
content="You are a world class algorithm for extracting documents in structured formats."
),
HumanMessagePromptTemplate.from_template(prompt_template),
HumanMessage(content="Tips: Make sure to answer in the correct format."),
]
prompt = ChatPromptTemplate(messages=prompt_msgs)
# LLM
llm = ChatGoogleGenerativeAI(
model="gemini-1.5-flash-latest",
temterature=0,
)
# parser
parser = PydanticToolsParser(tools=[GameData])
# Chain
chain = prompt | llm.bind_tools([GameData]) | parser
# invoke
result = chain.invoke(
{"text": text},
config={'callbacks': [ConsoleCallbackHandler()]}
)
return result
プロンプトとLLMモデルの定義はこれまでと同じです。異なるのは、Tool Calling を用いるためにllm.bind_tools()
を利用する点と、LLMからの出力をPydanticクラスとして受け取るPydanticToolsParser
を利用する点です。
# Chain
chain = prompt | llm.bind_tools([GameData]) | parser
llm.bind_tools([GameData])
で、Pydanticクラスを渡しています。with_structured_output
と異なるのは、ツールを複数渡すことができるため、リスト形式で渡す必要があるところです。今回は、Pydanticクラスとして定義したGameData
だけを渡します。
parser = PydanticToolsParser(tools=[GameData])
with_structured_output
では、LLMの回答結果を、直接Pydanticクラスの形で受け取ることができましたが、Tool Calling ではPydanticToolsParser
を利用してPydanticクラスに変換する必要があります。そのため、Chainの最後にPydanticToolsParser
を置いています。
それでは、実行してみましょう。
# Load text from Web site
web_loader = WebBaseLoader("https://ja.wikipedia.org/wiki/%E3%83%89%E3%83%A9%E3%82%B4%E3%83%B3%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88")
doc = web_loader.load()
text = doc[0].page_content[:10000]
result = extract_data(text)
print("Type: ", type(result))
pprint(result[0].dict())
Chainの実行結果は、Pydanticクラスのリストで返ってきますので、result[0].dict()
で最初の要素を取り出して、Pythonのdictに変換して結果を表示します。
以下が、Gemini 1.5 Flash での実行結果です。
{'boss': '竜王',
'category': 'ロールプレイングゲーム',
'maker': 'チュンソフト',
'name': 'ドラゴンクエスト',
'platform': ['ファミリーコンピュータ (FC)',
'MSX',
'MSX2',
'スーパーファミコン (SFC)',
'ゲームボーイ (GB)',
'iアプリ',
'EZアプリ (BREW)',
'Vアプリ',
'Android',
'iOS',
'PlayStation 4 (PS4)',
'ニンテンドー3DS (3DS)',
'Nintendo Switch'],
'release_date': '19860527',
'sales': 'エニックス',
'summary': '「ドラゴンクエスト」は、1986年5月27日にエニックス(現:スクウェア・エニックス)より発売されたファミリーコンピュータ(ファミコン、FC)用ロールプレイングゲーム。通称は「ドラゴンクエストI」。キャッチコピーは「今、新しい伝説が生まれようとしている」。\\n家庭用ゲーム機では日本初となるオリジナルタイトルのロールプレイングゲームとして知られる。当初、本作は単発作品であったため、詳しい人物設定や背景像などはなかったが、ゲームのシリーズ化に伴い、後続作品との関連性を持たせるため、後からさまざまな公式設定が追加された。後に発売される「ドラゴンクエストII 悪霊の神々」「ドラゴンクエストIII そして伝説へ…」は、本作との関連が深く、この3作は合わせて「ロトシリーズ」と呼ばれる。\\n社会現象を巻き起こした「ドラゴンクエストIII」の発売後には、本作「ドラゴンクエスト」の小説化やゲームブック化に加えドラマCD(CDシアター)化も行われた。'}
このように情報を抽出できました。summary
がやけに長いですが……。
ちなみに、LLMチャットモデルの定義を、OpenAI GPT や Anthropic Claude3 などに変更するだけで、他のLLMでも同じように動作します。
Tool Calling については、Langchainのドキュメントもご覧ください。
まとめ
LLMを用いて、ドキュメントから、抽出したい情報を、あらかじめ指定した形式で取得する方法について説明しました。
- 情報を取得したい形式をPydanticクラスで定義しておく
-
llm.with_structured_output()
でPydanticクラスを指定すると、LLMの回答がPydantic形式で得られる(ただし、現時点ではGeminiでは動作しない) - Tool Calling を実行する
llm.bind_tools()
でも、同様にPydanticクラスを渡すことで、指定した形式での情報抽出が可能だが、ParserでPydanticクラスに変換してやる必要がある
今回はWebページから情報を抽出しましたが、Loaderの部分を変更すれば、PDFファイルを読み込ませたり、素のテキストファイルを読みこませたりと、さまざまなドキュメントで応用が効きます。
Discussion