見出し画像

LangChainを使って自然文の入力を構造化データに変換する実験

はじめに

OpenAIのAPIを使って、LangChainからいろいろできるのはわかってきた。

この記事を拝見し、たしかに自然文の入力から構造化データが得られたらとっても便利だな、と思い、あれこれ試してみることにした。

実験の設定

一定のまとまりの自然文から、所望の情報を抽出してJSON形式に構造化することを目的とする。今回はWikipediaのナポレオン戦争の項目から文章を拾い、その中から情報を抽出してJSONにしてみる、ということを試みた。

実験実施

実験その1~入力文章から補完なしにJSON出力

まずは特に深く考えず、入力文章から所望の情報を抜き出し、JSON形式に整形して出力するプロンプトを考える。いきなりだがコードの全体を示す。

# 環境変数の準備
import os
os.environ["OPENAI_API_KEY"] = "INPUT_YOUR_OWN_API_KEY"

from langchain.llms import OpenAI, OpenAIChat
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain

# 例示付きプロンプトの設定
template = """入力された文章から必要な情報を抽出し、指定のJSON形式で出力してくだささい。

# 入力例
---
第一次イタリア遠征(第一次対仏大同盟)

外交関係は第一次対仏大同盟、戦役はイタリア戦役 (1796-1797年)を参照
1792年のフランス革命戦争の勃発により、1793年にイギリス、オーストリア、プロイセン、スペインなどによって第一次対仏大同盟が結成された。この戦いにおいてフランスの総裁政府は、ライン方面から2個軍、北イタリア方面から1個軍をもってオーストリアを包囲攻略する作戦を企図していた。
1796年3月、イタリア方面軍の司令官に任命されたナポレオン・ボナパルトは攻勢に出る。まず、これまで最前線でフランス軍と対峙してきたサルデーニャ王国をわずか1か月で降伏させ、オーストリア軍の拠点マントヴァを包囲した。オーストリア軍はマントヴァ解放を目指して反撃に出るが、ナポレオンの前にカスティリオーネの戦い(8月5日)、アルコレの戦い(11月15日-17日)、リヴォリの戦い(1797年1月14日)で敗北する。2月2日にマントヴァは開城。オーストリアは停戦を申し入れ、4月18日にレオーベンの和約が成立した。
10月17日、フランスとオーストリアはカンポ・フォルミオの和約を締結。フランスは南ネーデルラントとライン川左岸を併合し、北イタリアにはチザルピーナ共和国などのフランスの衛星国が成立した。オーストリアの脱落で第一次対仏大同盟は崩壊した。
---

# 出力例
---
(
  'event':(
    name:'第一次イタリア遠征',
    'year':1796,
    'related_persons':['ナポレオン・ボナパルト'],
    'details':(
      'place':['ライン方面','北イタリア方面','マントヴァ'],
      'related_nations':['フランス','イギリス','オーストリア','プロイセン','スペイン','サルディーニャ王国'],
      'battle':(
        'name':'カスティリオーネの戦い',
        'date':'1792年8月5日',
      ),
      'battle':(
        'name':'アルコレの戦い',
        'date':'1796年11月15日',
      ),
      'battle'(
        'name':'リヴォリの戦い',
        'date':'1797年1月14日',
      )
    )
  )
)
---

Human: {input}
AI:"""

prompt = PromptTemplate(
    input_variables = ['input'],
    template = template
)

llm = OpenAIChat(model_name="gpt-3.5-turbo",
                 temperature = 0.2)

llm_chain = LLMChain(
    llm = llm,
    verbose = True,
    prompt = prompt
)

# 出力
output = llm_chain.predict(input = 'エジプト遠征(エジプト・シリア戦役)\nフランス軍は、強力な海軍を有し制海権を握っているイギリスに対しては打撃を与えられなかった。そこでナポレオンはイギリスとインドとの連携を絶つため、オスマン帝国領エジプトへの遠征を総裁政府に進言した。1798年5月19日、ナポレオンの率いるエジプト遠征軍はトゥーロン港を出発。途中マルタ島を占領し、7月2日にエジプトのアブキール湾に上陸した。7月21日にはピラミッドの戦いで現地軍に勝利。次いでカイロに入城した。しかし8月1日のナイルの海戦において、ネルソン率いるイギリス艦隊にフランス艦隊は大敗し、ナポレオンはエジプトに孤立してしまう。\n他方、イギリスがマルタ島を占領したことで、海上の通商権を侵害されたデンマーク、スウェーデンと、イギリスの地中海進出に難色を示したロシアがプロイセンと結び、1800年に第二次武装中立同盟を結成する。これに対してイギリスは、1801年、デンマークの首都コペンハーゲンを攻撃した(コペンハーゲンの海戦, 4月2日)。この結果、武装中立同盟は解体し、ロシア、スウェーデンはイギリスと和解、デンマークはフランスに接近していった。')

# 出力結果の整形
output_json = output.replace('(','{').replace(')','}')
print(output_json)

まずプロンプトに入力例とそれに対応する出力例を示している。入力の例は第一次イタリア遠征からとった。その文章に示される内容から、発生年、関与した人物、関与した国、ならびに各種戦闘を情報として抽出するようにしている。
なお、どうやらLangChainは中括弧({}のこと)が予約文字となっているようで、{input}などのvariableを示す部分以外で使用するとエラーが発生した。そのため苦肉の策として小括弧(()のこと)で代替している。最後にわざわざreplaceしているのはそのせい。
そして、テストとしてエジプト遠征のテキストを入力とした。その結果を以下に示す。

{
  'event':{
    'name':'エジプト遠征(エジプト・シリア戦役)',
    'year':1798,
    'related_persons':['ナポレオン'],
    'details':{
      'place':['トゥーロン港','マルタ島','アブキール湾','カイロ'],
      'related_nations':['フランス','イギリス','インド','オスマン帝国','デンマーク','スウェーデン','ロシア'],
      'battle':{
        'name':'ピラミッドの戦い',
        'date':'1798年7月21日',
      },
      'battle':{
        'name':'ナイルの海戦',
        'date':'1798年8月1日',
      },
      'related_events':{
        'name':'第二次武装中立同盟',
        'year':1800,
      },
      'battle':{
        'name':'コペンハーゲンの海戦',
        'date':'1801年4月2日',
      }
    }
  }
}

なかなか良い感じに出力されている。
残念な点としては、related_personsにネルソン提督が入っていないことくらいか。
ひとつ驚いたことは、特に指定もしていなかったrelated_eventsが勝手に挿入されたこと。所望の情報ではないが、こういうのが自動で挿入されていくと便利かもしれない。一方で、所定の形式からは外れてしまっているため、出力が安定しない、という懸念にもつながるかもしれない。

実はこの形に至るまで何度か試してみている。所望の結果を得るにはプロンプトの工夫がもっと必要なのかもしれない。

実験その2~足りない情報を検索で補完させる

さて、入力文から必要な情報を抽出して所望の形式に変換する、ということがそれなりにできることが分かった。ならば、足りない情報を検索して補完する、ということはどこまでできるのだろうか?
LangChainのagentを使ってやってみよう。以下のnote記事を参考にした。

さて、コードである。

# 環境変数の準備
import os
os.environ["OPENAI_API_KEY"] = "INPUT_YOUR_OWN_API_KEY"
os.environ['SERPAPI_API_KEY'] = 'INPUT_YOUR_OWN_API_KEY' # 検索API用

# 応答用
from langchain.llms import OpenAI, OpenAIChat
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain

# エージェント
from langchain.agents import ZeroShotAgent, Tool, AgentExecutor, load_tools, initialize_agent

# 例示付きプロンプトの設定
prefix = """入力された文章から必要な情報を抽出し、指定のJSON形式で出力してください。次のツールにアクセスすることができます。入力された文章からはわからない情報は検索してください。"""
suffix = """# 入力例
---
第一次イタリア遠征(第一次対仏大同盟)
1792年のフランス革命戦争の勃発により、1793年にイギリス、オーストリア、プロイセン、スペインなどによって第一次対仏大同盟が結成された。この戦いにおいてフランスの総裁政府は、ライン方面から2個軍、北イタリア方面から1個軍をもってオーストリアを包囲攻略する作戦を企図していた。
1796年3月、イタリア方面軍の司令官に任命されたナポレオン・ボナパルトは攻勢に出る。まず、これまで最前線でフランス軍と対峙してきたサルデーニャ王国をわずか1か月で降伏させ、オーストリア軍の拠点マントヴァを包囲した。オーストリア軍はマントヴァ解放を目指して反撃に出るが、ナポレオンの前にカスティリオーネの戦い(8月5日)、アルコレの戦い(11月15日-17日)、リヴォリの戦い(1797年1月14日)で敗北する。2月2日にマントヴァは開城。オーストリアは停戦を申し入れ、4月18日にレオーベンの和約が成立した。
10月17日、フランスとオーストリアはカンポ・フォルミオの和約を締結。フランスは南ネーデルラントとライン川左岸を併合し、北イタリアにはチザルピーナ共和国などのフランスの衛星国が成立した。オーストリアの脱落で第一次対仏大同盟は崩壊した。
---

# 出力例
---
(
  'event':(
    name:'第一次イタリア遠征',
    'year':1796,
    'related_persons':['ナポレオン・ボナパルト'],
    'related_events':(
        'event':(
            'name':'レオーベンの和約',
            'summary':'条約ではオーストリア領ネーデルラントがフランスに割譲されることを定めた。また秘密条項ではオーストリアがネーデルラントとイタリアの領地を失う代償としてヴェネツィア共和国が分割されることを定めた。'
        ),
        'event':(
            'name':'カンポ・フォルミオの和約',
            'summary':'フランスは南ネーデルラントとライン川左岸を併合し、北イタリアにはチザルピーナ共和国などのフランスの衛星国が成立した。オーストリアの脱落で第一次対仏大同盟は崩壊した。'
        ),
    ),
    'details':(
      'place':['ライン方面','北イタリア方面','マントヴァ'],
      'related_nations':['フランス','イギリス','オーストリア','プロイセン','スペイン','サルディーニャ王国'],
      'battle':(
        'name':'カスティリオーネの戦い',
        'date':'1792年8月5日',
        'related_persons':['ナポレオン・ボナパルト','ダゴベルト・ジークムント・フォン・ヴルムザー'],
        'field':['カスティリオーネ・デッレ・スティヴィエーレ'],
        'result':'オーストリア軍の敗北'
      ),
      'battle':(
        'name':'アルコレの戦い',
        'date':'1796年11月15日',
        'related_persons':['ナポレオン・ボナパルト','ヨーゼフ・アルヴィンツィ'],
        'field':['アルコレ沼沢地周辺'],
        'result':'オーストリア軍の敗北'
      ),
      'battle'(
        'name':'リヴォリの戦い',
        'date':'1797年1月14日',
        'related_persons':['ナポレオン・ボナパルト','ヨーゼフ・アルヴィンツィ'],
        'field':['リヴォリ・ヴェロネーゼ'],
        'result':'オーストリア軍の敗北'
      )
    )
  )
)
---

では始めましょう。出力は必ず出力例で示したJSON形式にしてください。

Human: {input}
AI:{agent_scratchpad}"""

# LLMの設定
llm = OpenAIChat(model_name="gpt-3.5-turbo",
                 temperature = 0.2) #llm  = OpenAI(temperature = 0.0) # チェックのためdavinciでもやってみる

# ツールの準備
tools = load_tools(['serpapi'], llm = llm)

# プロンプトの設定
prompt = ZeroShotAgent.create_prompt(
    tools,
    prefix = prefix,
    suffix = suffix,
    input_variables = ['input', 'agent_scratchpad'],
)

llm_chain = LLMChain(
    llm = llm,
    verbose = True,
    prompt = prompt
)

agent = ZeroShotAgent(llm_chain = llm_chain,
                      tools = tools)
agent_executor = AgentExecutor.from_agent_and_tools(agent = agent,
                                                    tools = tools,
                                                    verbose = True)

# 出力
output = agent_executor.run(input = 'エジプト遠征(エジプト・シリア戦役)\nフランス軍は、強力な海軍を有し制海権を握っているイギリスに対しては打撃を与えられなかった。そこでナポレオンはイギリスとインドとの連携を絶つため、オスマン帝国領エジプトへの遠征を総裁政府に進言した。1798年5月19日、ナポレオンの率いるエジプト遠征軍はトゥーロン港を出発。途中マルタ島を占領し、7月2日にエジプトのアブキール湾に上陸した。7月21日にはピラミッドの戦いで現地軍に勝利。次いでカイロに入城した。しかし8月1日のナイルの海戦において、ネルソン率いるイギリス艦隊にフランス艦隊は大敗し、ナポレオンはエジプトに孤立してしまう。\n他方、イギリスがマルタ島を占領したことで、海上の通商権を侵害されたデンマーク、スウェーデンと、イギリスの地中海進出に難色を示したロシアがプロイセンと結び、1800年に第二次武装中立同盟を結成する。これに対してイギリスは、1801年、デンマークの首都コペンハーゲンを攻撃した(コペンハーゲンの海戦, 4月2日)。この結果、武装中立同盟は解体し、ロシア、スウェーデンはイギリスと和解、デンマークはフランスに接近していった。')

# 出力結果の整形
output_json = output.replace('(','{').replace(')','}')
print(output_json)

基本的なプロンプトの設計思想はその1と同じとしている。今回は合わせて、出力例にrelated_eventsを加え、battle要素にrelated_personds、field、resultを加えた。battle要素の追加項目は入力文に記載のあるものもあればないものもある。入力にないものは検索して補完せよ、ということである。

今回も同様にエジプト遠征を題材として出力の実験を行った。以下が結果である。

{
  'event':{
    'name':'エジプト遠征',
    'year':1798,
    'related_persons':['ナポレオン・ボナパルト'],
    'details':{
      'place':['トゥーロン港','マルタ島','アブキール湾','カイロ'],
      'related_nations':['フランス','イギリス','オスマン帝国','デンマーク','スウェーデン','ロシア'],
      'battle':{
        'name':'ピラミッドの戦い',
        'date':'1798年7月21日',
        'related_persons':['ナポレオン・ボナパルト'],
        'field':['エジプト・ギーザ'],
        'result':'フランス軍の勝利'
      },
      'battle':{
        'name':'ナイルの海戦',
        'date':'1798年8月1日',
        'related_persons':['ホレーショ・ネルソン'],
        'field':['アブキール湾'],
        'result':'フランス艦隊の大敗北'
      }
    }
  }
}

その1と同様、この形式になるまでかなり試行を繰り返した。おかげで検索APIの無料枠マックスに近いところまでいってしまった。
特にこの実験ではなぜか出力がJSONにならないことが多かった。そのため、プロンプトにわざわざ「では始めましょう。出力は必ず出力例で示したJSON形式にしてください。」と入れたほどである。もしかしたらprefix、suffixの分け方がまずいのかもしれない。このあたり、詳しい人に教えていただきたいところだ。

さて、結果を眺めてみると、なんと今度はrelated_eventsが消えてしまっている。なんでだろうか?
一方でナイル海戦のrelated_personsはホレーショ・ネルソンとなっており、入力文には登場しない、ネルソン提督のフルネームが出てきている。ちゃんと検索して情報をとってきた証拠であろう。

まとめ

所望のJSON形式に一発でたどり着くのはなかなか難しいが、可能であることはわかった。一方で出力がなかなか安定しない問題はどうにかしたい。これはプロンプトをいじることで改善できるのだろうか?あるいは、LangChain側でなにかしら工夫してあげることでいけるのだろうか?

以上、簡単な実験ではあったが、可能性と課題が見えてきたところで今回はおしまい。

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