💾

LangChain `with_structured_output` メソッドによる構造化データ抽出

2024/05/24に公開

これは何?

昨日、ALGOMATIC社のerukitiさんの記事がバズっていました。スキーマを用いてプロンプト生成すると構造化データを取り出しやすいよと言うお話でした。便利ですよねぇ。

https://tech.algomatic.jp/entry/2024/05/23/140219

LangChainの比較的新しいメソッド(with_structured_output)を利用すると似たような内容を比較的簡単に行えます。あまり知られていないかもしれないので、備忘録がてら雑にまとめてみます。(備忘録なのでLangChainのことは詳細に説明しません、スミマセン🙇)

with_structured_output メソッドとは

with_structured_output メソッドは、LangChain で構造化データ抽出を行うための統一されたインターフェースです。以下の2ステップで利用できます。

  1. 構造化データをPydanticで定義する
  2. その定義を.with_structured_outputでLLMに取り付ける

これにより、言語モデルの違いを意識せずに、汎用的かつ簡潔なコードで構造化データ抽出を行うことができます。百聞は一見にしかずということで、非常に簡単な例を見て見ましょう。

from typing import Optional
from langchain_openai import ChatOpenAI
from langchain_core.pydantic_v1 import BaseModel, Field


class Item(BaseModel):
    item_name: str = Field(description="商品名")
    price: Optional[int] = Field(None, description="商品の値段")
    color: Optional[str] = Field(None, description="商品の色")


prompt = "与えられた商品の情報を構造化してください: {item}"
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
structured_llm = llm.with_structured_output(Item)
res = structured_llm.invoke(prompt.format(item="Tシャツ 赤 142,000円"))
res
# >> Item(item_name='Tシャツ', price=142000, color='赤')

このように、商品を構造化データに変更することができます。内部ではTool Calling (Function Calling) が呼ばれているだけです。


Tool Callingが発火していることを示すLangSmithトレース

利用するLLMを変更することも可能です。

# LLMの呼び出し元だけ変える
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(model="claude-3-haiku-20240307", temperature=0)

# あとは同じ
structured_llm = llm.with_structured_output(Item)
res = structured_llm.invoke(prompt.format(item="Tシャツ 赤 142,000円"))
res
# >> Item(item_name='Tシャツ', price=142000, color='赤')

このように、利用するLLMの呼び出し方法を変えるだけで、残りのコードは変更する必要がありません。僕がClaudeを試した範囲では、XMLで定義しなくても上手く動いてくれました。(下のフリーレンの例も含め)

ちなみにGeminiではVertexAIを利用する実装(ChatVertexAI)だとwith_structured_outputが利用可能ですが、Google AI Studioを利用する実装(ChatGoogleGenerativeAI)にはまだ実装されていないようです。と言う感じで、ややこしいので例に挙げません。

フリーレンの例

冒頭で参照した記事と同じ例を実装すると、以下のようになります。Claude Haikuがたまーに言うことを聞かないのでPydanticの定義をやや冗長になっていますが、まぁ許容範囲ないかなと思います。SonnetやChatGPT3.5だともう少し雑にDescription書いても上手くやってくれていました。(Haikuが言うこと聞かない例: フリーレンの年齢を1000歳"以上"と何度も主張して型定義を破っていたので(0-9999)と指示した、など。まぁその通りなんですが…😇)

from pprint import pprint
from typing import List, Optional
from langchain_core.pydantic_v1 import BaseModel, Field

# models
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
from langchain_google_genai import ChatGoogleGenerativeAI


class Stats(BaseModel):
   strength: int = Field(..., description="筋力 (3-18)")
   intelligence: int = Field(..., description="知力 (3-18)")
   dexterity: int = Field(..., description="器用さ (3-18)")
   agility: int = Field(..., description="素早さ (3-18)") 
   luck: int = Field(..., description="運 (3-18)")


class Character(BaseModel):
   name: str = Field(..., description="キャラクターの名前")
   age: int = Field(..., description="キャラクターの年齢 (0-9999)")
   attributes: List[str] = Field(..., description="キャラクターの属性")
   personality: str = Field(..., description="キャラクターの性格")
   stats: Stats
   background: str = Field(..., description="生い立ち")
   magic: Optional[str] = Field(None, description="使える魔法を一つ")


prompt = """
与えられたキャラクターの情報を構造化してください。
型定義を **必ず** 守ること
====
{character}
"""

characters = [
    """
    フリーレン

    本作の主人公[9]。魔王を討伐した勇者パーティーの魔法使い。長命なエルフ族の出身で、少女のような外見に反して1000年以上の歳月を生き続けている。人間とは時間の感覚が大きく異なるため、数か月から数年単位の作業をまったく苦にせず、ヒンメルらかつての仲間たちとの再会も50年の月日が経ってからのことだった。ヒンメルが天寿を全うして他界したのを機に、自身にとってはわずか10年足らずの旅の中でヒンメルの人となりを詳しく知ろうともしなかったことを深く後悔し、趣味の魔法収集を兼ねて人間を知るための旅を始める。生前時のヒンメルに対する意識は希薄であったが、幻影鬼(アインザーム)との遭遇時や、奇跡のグラオザームに「楽園へと導く魔法(アンシレーシエラ)」を使われた際などは幻想の中でヒンメルを思い描くなど、無自覚に意識しているような描写が散見されている。
    1000年以上前、故郷の集落を魔族に襲われ死にかけた際に、自身を救ってくれた大魔法使いフランメの弟子となる。生来の天才的資質に加えて、フランメから教わった戦闘や魔力制御の技術を1000年以上も研鑽し続けた結果、きわめて強大な魔力を得ている。さらに、その魔力をほぼ完全に隠匿する技術[注 1]も習得しており、敵の魔族に自身の実力を過小評価させた隙を突く戦法を得意とする。その実力は魔王亡き後の現在の魔族を弱いと感じ、七崩賢の一角である断頭台のアウラにさえ完勝するほど。魔族側からは、歴史上もっとも多くの同胞を葬り去った存在として「葬送のフリーレン」と呼び恐れられている[注 2]。ただし、自身の魔法を発動する一瞬だけ魔力探知が途切れるという弱点があり[注 3]、自身よりも魔力の低い魔法使いに計11回敗北した経験があるとも語っている[注 4]。
    「服が透けて見える魔法」や「かき氷を作る魔法」など、およそ戦闘に役に立たない魔法を収集するのが趣味で、そうした魔導書を対価に仕事を引き受けたりもする。再会したハイターの差し金で人間のフェルンを弟子に取って以降は、自身の旅に同行させている。
    性格はドライで厳しい一面もあるが、普段はやさしく面倒見も悪くない。普段は表情に乏しく淡々としており、一般的な富や地位、名声には興味を示さないが、大好きな魔導書を手に入れるために無茶をしたり、食い意地が張っていたり、朝が弱く寝坊がちだったり、自身の貧相な体型を気にしていたり、実年齢で年寄り扱いされるのを嫌うなど、これらの際の感情表現は豊かである。長命なエルフゆえに、人間など短命な他種族の思考・思想には鈍感で、それらの人々とのコミュニケーションはやや不器用。自身の故郷と仲間を奪った魔族に対する憎悪は深く、感情を表に出すことこそないながらも、敵対する魔族に対しては周囲の状況を顧みず問答無用で葬ろうとする。これには、「人間の言葉で人間を欺き人間の言葉が通じない猛獣」という魔族の本質を理解している理由もある。
    「歴史上で最もダンジョンを攻略したパーティーの魔法使い」と自称するだけあり、ダンジョンには詳しい。道中で宝箱を発見するとその中身に異常なまでの興味を示し、判別魔法で99パーセントミミック(宝箱に化けた魔物)とみやぶってなお、残り1パーセントの可能性[注 5]に賭けて宝箱を開け、上半身をミミックに噛まれてもがくという場面が何度も描かれている。
    """,
    """
    シュタルク

    勇者パーティーの戦士アイゼンの弟子で、師匠と同じく斧使い。17歳→19歳。極端に憶病かつ自己評価が低い性格であるが、実際は巨大な断崖に斧で亀裂を入れるほどの実力者。師匠とけんか別れをしたあと、紅鏡竜の脅威にさらされた村に3年ほど滞在していた。アイゼンの推薦でフリーレンの仲間に指名され、無自覚ながらも紅鏡竜を一撃で倒す能力を発揮し、彼女たちの旅に同行することとなる。中央諸国クレ地方にあった戦士の村出身で、幼少時は魔物とまともに戦えない失敗作だと父親から見下されていたが、兄のシュトルツからは認められ可愛がられていた。
    アイゼンから「とんでもない戦士になる」と言わしめるほどの素質の持ち主で、フェルンからは化け物かと疑われるほどの膂力と頑強さをもつ。男性に免疫がないフェルンからは無意識な恐れを抱かれ、自身も女性の扱いが苦手な一方で、互いに憎からぬ感情を抱いており、不機嫌になったフェルンに謝罪したり、デートのように連れ歩いたりするさまから、ザインからは「もう付き合っちゃえよ」などと漏らされている。男性の象徴に対する評価は芳しくなく、「服が透けて見える魔法」で自身の下半身を見たフェルンからは「ちっさ」と漏らされて傷つく場面がある。好物は自身の誕生日にアイゼンがふるまってくれるハンバーグ。</data>
    """
]

def extract_data(llm, prompt, character):
    structured_llm = llm.with_structured_output(Character)
    res = structured_llm.invoke(prompt.format(character=character))
    pprint(res.dict())

print('=== ChatGPT 3.5 Turbo ===')
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
for character in characters:
    extract_data(llm, prompt, character)

print('=== Claude 3 Haiku ===')
llm = ChatAnthropic(model="claude-3-haiku-20240307", temperature=0)
for character in characters:
    extract_data(llm, prompt, character)

実行した結果は以下のとおりです。上手く動いていますね。

=== ChatGPT 3.5 Turbo ===
{'age': 1000,
 'attributes': ['魔法使い', 'エルフ'],
 'background': '1000年以上前、故郷の集落を魔族に襲われ死にかけた際に、自身を救ってくれた大魔法使いフランメの弟子となる。生来の天才的資質に加えて、フランメから教わった戦闘や魔力制御の技術を1000年以上も研鑽し続けた結果、きわめて強大な魔力を得ている。',
 'magic': '服が透けて見える魔法',
 'name': 'フリーレン',
 'personality': 'ドライで厳しい一面もあるが、普段はやさしく面倒見も悪くない',
 'stats': {'agility': 14,
           'dexterity': 12,
           'intelligence': 18,
           'luck': 15,
           'strength': 10}}
{'age': 19,
 'attributes': ['戦士'],
 'background': '勇者パーティーの戦士アイゼンの弟子で、師匠と同じく斧使い。中央諸国クレ地方にあった戦士の村出身で、幼少時は魔物とまともに戦えない失敗作だと父親から見下されていたが、兄のシュトルツからは認められ可愛がられていた。アイゼンから「とんでもない戦士になる」と言わしめるほどの素質の持ち主で、フェルンからは化け物かと疑われるほどの膂力と頑強さをもつ。男性に免疫がないフェルンからは無意識な恐れを抱かれ、自身も女性の扱いが苦手な一方で、互いに憎からぬ感情を抱いており、不機嫌になったフェルンに謝罪したり、デートのように連れ歩いたりするさまから、ザインからは「もう付き合っちゃえよ」などと漏らされている。男性の象徴に対する評価は芳しくなく、「服が透けて見える魔法」で自身の下半身を見たフェルンからは「ちっさ」と漏らされて傷つく場面がある。好物は自身の誕生日にアイゼンがふるまってくれるハンバーグ。',
 'magic': None,
 'name': 'シュタルク',
 'personality': '憶病かつ自己評価が低い',
 'stats': {'agility': 14,
           'dexterity': 12,
           'intelligence': 10,
           'luck': 11,
           'strength': 18}}

=== Claude 3 Haiku ===
{'age': 1000,
 'attributes': ['魔法使い', '勇者パーティーの一員', 'エルフ族'],
 'background': '1000年以上前、故郷の集落を魔族に襲われ死にかけた際に、大魔法使いフランメの弟子となった。フランメから教わった戦闘や魔力制御の技術を1000年以上も研鑽し続け、きわめて強大な魔力を得ている。さらに、その魔力をほぼ完全に隠匿する技術も習得しており、敵の魔族に自身の実力を過小評価させた隙を突く戦法を得意とする。',
 'magic': '魔力を完全に隠匿する技術、服が透けて見える魔法、かき氷を作る魔法など、様々な魔法を習得している。',
 'name': 'フリーレン',
 'personality': 'ドライで厳しい一面もあるが、普段はやさしく面倒見も悪くない。表情に乏しく淡々としているが、自身の趣味や欲求に関しては感情表現が豊か。人間など短命な他種族の思考・思想には鈍感で、コミュニケーションがやや不器用。魔族に対する憎悪が深く、敵対する魔族に対しては周囲の状況を顧みず問答無用で葬ろうとする。',
 'stats': {'agility': 14,
           'dexterity': 16,
           'intelligence': 18,
           'luck': 12,
           'strength': 15}}
{'age': 19,
 'attributes': ['斧使い', '戦士', '弟子'],
 'background': '中央諸国クレ地方にあった戦士の村出身。幼少時は魔物とまともに戦えない失敗作だと父親から見下されていたが、兄のシュトルツから認められ可愛がられていた。アイゼンの弟子として修行し、師匠と同じく斧使いとなった。紅鏡竜の脅威にさらされた村に3年ほど滞在していた。',
 'magic': 'なし',
 'name': 'シュタルク',
 'personality': '極端に憶病かつ自己評価が低い',
 'stats': {'agility': 15,
           'dexterity': 14,
           'intelligence': 12,
           'luck': 10,
           'strength': 18}}

その他の備考

OpenAIのモデルでJSONモードを利用したい場合は以下のように書きます。

structured_llm = llm.with_structured_output(Item, method="json_mode")

おわり

LangChainに慣れるのに少し時間がかかるものの、Pydanticでスキーマを定義した上で構造化データ抽出するのは非常に簡単です。この記事ではPythonを使いましたが、tsでも使えるはずです。皆さんも使って見てください!

参考

https://blog.langchain.dev/tool-calling-with-langchain/
https://python.langchain.com/v0.1/docs/modules/model_io/chat/structured_output/

Discussion