🤖

LangChainで対話テキストからイベント情報をまとめる2

2023/01/09に公開

「LangChain」を使い対話テキストからイベント情報をまとめる機能を試作しました。
記事では具体例として飲み会としましたが、広義のイベント情報を対話テキストから抽出することを目的としています。

(前提)
LLMの力を活用して、エージェントのようにふるまうAIを実現してみたく、トライアルとしてチャレンジしました。
私の普段の仕事はデータアナリティクスでプログラミングではないので、解説もそれほど専門的ではありません。この点ご容赦ください。

前回

https://zenn.dev/sator926/articles/92f10e233a5aeb

LangChainの基本機能「LLM呼び出し」「LLMチェーン」使ってイベント情報抽出器(event_extractor)を作りました。

本記事ハイライト

まずはふと思い立って前回と全く同じタスクをChatGPT(GPT-3.5)に投げてみました。結果をご覧ください。

笑ってしまうような精度ですね笑
私が前回記事でしたようなイベント名、日時等を分離して抽出するという大規模モデル以前のプログラミング的作業は、GPT-3.5以降は不要になりそうです。(GPT-3ではまだ精度は出なかったので、分離して個別に処理するやり方は有用です)

実施後のインプットとアウトプットが以下になります。興味があれば読み進めていただきコメントなどいただけるとうれしいです!

# インプット対話テキストの準備
mytalk = """Aさん:今度の新年会はいつにしますか。
Bさん:私は1月下旬がちょうどいいです。
Cさん:私は1月だと1/20か、1/30がちょうどいいです。
Dさん:私は1月だと参加は難しそうです。
Aさん:じゃあいったん1/30にしましょうか。
Bさん:わかりました。時間は何時にします?
Aさん:そうですね、じゃあ18:00にしましょうか。
Cさん:ちょっと早くないですか?19:00はどうでしょう。
Aさん:了解です。じゃあ19時で!場所はいつもの黒木屋にしましょうか。
Cさん:了解です。楽しみにしてます。
Dさん:楽しんできてください。いけそうだったらまた連絡します。
"""

# event_extractorの実行
ans = event_extractor(mytalk)
print(ans)

# アウトプット
130日、19時より黒木屋にて新年会を開催します。Aさん、Bさん、Cさんの3名が参加予定です。どなたでもお気軽にご参加ください。

プロンプトエンジニアリング

プロンプトとは、大規模言語モデル(LLM)に入力する命令文です。画像生成系モデルではよく「呪文」と言われています。
プロンプトエンジニアリングとは、この命令文の表現をチューニングして回答の精度を高める作業を指します。(PE詳細はSangmin様のnote[1]を参照ください)
今回はLLMの回答を見ながらトライ&エラーで調整しました。まず元のプロンプトは以下です。

"""
次の文章の中から開催されるイベント名を{type}型で返してください。不明な場合はNULLを返してください。
{talk}
"""

チューニング後のプロンプトは以下です。

"""
次の文章の中から開催されるイベント名を"イベント名:???"の形で返してください。不明な場合は"イベント名:未定"を返してください。
{talk}
"""

変更点は以下3点です。

  • 型指定の削除

  • 返り値の形を指定(返り値不明時も含む)

  • 参加者チェーンの「参加者」の表現を「参加予定者」に変更

大規模モデル以前の考え方に則り型を指定していましたが、あまり意味がないようなので削除しました。
返り値の形は「イベント名:新年会」となるように指示しました。今回は、最後に得られた情報を結合して再度LLMに与えるので、その際にそのまま与えられるような形式にしました。
参加者の表現の変更は、LLMらしい調整です。「参加者情報を返せ」と書くと、参加者について確信が持てないらしいLLMが「未定」を頻繁に返してくることから、「予定」とつけることでその閾値を引き下げたイメージです。体感では「未定」と返す率が減りました。

シーケンシャルチェーン

次にLLMチェーンの実行方法をシーケンシャルチェーンに変更します。
実行方法の変更の前に、シーケンシャルチェーンで実行するためにLLMチェーンに以下のように情報を追加します。

 # イベントチェーン(prompt, chain)定義
  event_prompt = PromptTemplate(
      input_variables=["talk"],
      template="""次の文章の中から開催されるイベント名を"イベント名:???"の形で返してください。不明な場合は"イベント名:未定"を返してください。
      {talk}
      """
  )
  event_chain = LLMChain(
      llm=myllm, 
      prompt=event_prompt,
      output_key='event'  # output_key追加
      )
  
  # 日にちチェーン(prompt, chain)定義
  date_prompt = PromptTemplate(
      input_variables=["talk"],
      template="""次の文章の中から開催されるイベントの開催日を"開催日:???"の形で返してください。不明な場合は"開催日:未定"を返してください。
      {talk}
      """
  )
  date_chain = LLMChain(
      llm=myllm, 
      prompt=date_prompt,
      output_key='date'  # output_key追加
      )

  # 時間チェーン(prompt, chain)定義
  time_prompt = PromptTemplate(
      input_variables=["talk"],
      template="""次の文章の中から開催されるイベントの開始時間を"開始時間:???"の形で返してください。不明な場合は"開催時間:未定"を返してください。
      {talk}
      """
  )
  time_chain = LLMChain(
      llm=myllm, 
      prompt=time_prompt,
      output_key='time'  # output_key追加
      )

  # 開催場所チェーン(prompt, chain)定義
  place_prompt = PromptTemplate(
      input_variables=["talk"],
      template="""次の文章の中から開催されるイベントの開催場所を"開催場所:???"の形で返してください。不明な場合は"開催場所:未定"を返してください。
      {talk}
      """
  )
  place_chain = LLMChain(
      llm=myllm, 
      prompt=place_prompt,
      output_key='place'  # output_key追加
      )

  # 参加者チェーン(prompt, chain)定義
  sanka_prompt = PromptTemplate(
      input_variables=["talk"],
      template="""次の文章の中から開催されるイベントの参加予定者を"参加予定者:???"の形で返してください。不明な場合は"参加予定者:未定"を返してください。
      {talk}
      """
  )
  sanka_chain = LLMChain(
      llm=myllm, 
      prompt=sanka_prompt,
      output_key='sanka'  # output_key追加
      )

後からシーケンシャルチェーンで呼び出すためのキーを設定しました。
次に実行部分を変更します。変更前が以下です。

# イベントチェーン実行
event_ans = event_chain.run({
    'talk':mytalk,
    'type':"str"
})

# 日にちチェーン実行
date_ans = date_chain.run({
    'talk':mytalk,
    'type':"str"
})

# 時間チェーン実行
time_ans = time_chain.run({
    'talk':mytalk,
    'type':"str"
})

# 開催場所チェーン実行
place_ans = place_chain.run({
    'talk':mytalk,
    'type':"str"
})

# 参加者チェーン実行
sanka_ans = sanka_chain.run({
    'talk':mytalk
})

変更後は以下になります。まずはシーケンシャルチェーンの定義です。

# シーケンシャルチェーンの定義
overall_chain = SequentialChain(
    chains=[event_chain, date_chain, time_chain, place_chain, sanka_chain],
    input_variables=["talk"],
    output_variables=["event", "date", "time", "place", "sanka"],
    verbose=True
)

chainsとしてさきほど設定したキーを入れます。これにより、対象チェーンが連続して実行されます。同様に各チェーンの出力キーとしてoutput_variablesを設定します。
続いてこのシーケンシャルチェーンを実行します。

# シーケンシャルチェーンの実行
event_info = overall_chain({
    'talk':talk
})

verbose=Trueとすると以下のように過程が表示されます。

> Entering new SequentialChain chain...
Chain 0:
{'event': '\nイベント名:新年会'}

Chain 1:
{'date': ' 開催日:1/30'}

Chain 2:
{'time': ' 開始時間:19:00'}

Chain 3:
{'place': ' 開催場所:黒木屋'}

Chain 4:
{'sanka': '\n参加予定者:Aさん、Bさん、Cさん'}


> Finished SequentialChain chain.

返り値は入力テキストを含むdict型となります。

event_info

{'talk': 'Aさん:今度の新年会はいつにしますか。\nBさん:私は1月下旬がちょうどいいです。\nCさん:私は1月だと1/20か、1/30がちょうどいいです。\nDさん:私は1月だと参加は難しそうです。\nAさん:じゃあいったん1/30にしましょうか。\nBさん:わかりました。時間は何時にします?\nAさん:そうですね、じゃあ18:00にしましょうか。\nCさん:ちょっと早くないですか?19:00はどうでしょう。\nAさん:了解です。じゃあ19時で!場所はいつもの黒木屋にしましょうか。\nCさん:了解です。楽しみにしてます。\nDさん:楽しんできてください。いけそうだったらまた連絡します。\n',
 'event': '\nイベント名:新年会',
 'date': ' 開催日:1/30',
 'time': ' 開始時間:19:00',
 'place': ' 開催場所:黒木屋',
 'sanka': '\n参加予定者:Aさん、Bさん、Cさん'}

以上がシーケンシャルチェーンへの変更です。
シーケンシャルチェーンを使うことで、定義したチェーンの実行を柔軟に変更できそうです。

LLMによる回答の生成

続いて回答のつくり方を従来的プログラミングからLLMによる生成に切り替えます。まずは元の回答です。

print('イベントは'+event_ans+'で、開催日は'+date_ans+'、開始時間は'+time_ans+'、開催場所は'+place_ans+'、参加者は'+sanka_ans+'の予定です。')


イベントは花見で、開催日は1/30、開始時間は19:00、開催場所は黒木屋、参加者は Aさん、Bさん、Cさんの予定です。

この程度の回答であれば、LLMに必要な情報を与えれば生成できると考え、LLM生成に切り替えました。まずはさきほどのシーケンシャルチェーンの返り値を使って、インプットする情報を作ります。

eventlist = event_info['event']+event_info['date']+event_info['time']+event_info['place']+event_info['sanka']

LLMがうまく解釈してくれるだろうと考え、情報をただ連結しています。中身は以下のようになります。

イベント名:新年会 開催日:1/30 開始時間:19:00 開催場所:黒木屋
参加予定者:Aさん、Bさん、Cさん

この情報を再度LLMに与えて、回答を生成してもらいます。コードは以下です。

# イベントサマリ回答チェーン(prompt, chain)定義
eventsummary_prompt = PromptTemplate(
    input_variables=["talk"],
    template="""次のリスト情報をもとに開催されるイベントの概要を返してください。
    
    {talk}
    """
)
eventsummary_chain = LLMChain(
    llm=myllm, 
    prompt=eventsummary_prompt
    )
    
# イベントサマリ回答チェーン実行
summary_ans = eventsummary_chain.run({
    'talk':eventlist
})

実行結果は以下のようになり、期待通り説明文が生成されています。

print(summary_ans)

130()に黒木屋で開催される新年会にAさん、Bさん、Cさんが参加予定です。開始時間は19:00です。

ここまでで前回ソースコードへの改良は完了です。
最後に一連の処理を関数化します。

イベント情報抽出の関数化

イベント情報抽出の一連の処理を「event_extractor」として関数化します。
これは、シンプルに一連の処理を関数内に列挙するのみです。

from langchain.llms import OpenAI
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain
from langchain.chains import SequentialChain


def event_extractor(talk:str) -> str:
  myllm = OpenAI(temperature=0.9)

  # イベントチェーン(prompt, chain)定義
  event_prompt = PromptTemplate(
      input_variables=["talk"],
      template="""次の文章の中から開催されるイベント名を"イベント名:???"の形で返してください。不明な場合は"イベント名:未定"を返してください。
      {talk}
      """
  )
  event_chain = LLMChain(
      llm=myllm, 
      prompt=event_prompt,
      output_key='event'
      )
  
  # 日にちチェーン(prompt, chain)定義
  date_prompt = PromptTemplate(
      input_variables=["talk"],
      template="""次の文章の中から開催されるイベントの開催日を"開催日:???"の形で返してください。不明な場合は"開催日:未定"を返してください。
      {talk}
      """
  )
  date_chain = LLMChain(
      llm=myllm, 
      prompt=date_prompt,
      output_key='date'
      )

  # 時間チェーン(prompt, chain)定義
  time_prompt = PromptTemplate(
      input_variables=["talk"],
      template="""次の文章の中から開催されるイベントの開始時間を"開始時間:???"の形で返してください。不明な場合は"開催時間:未定"を返してください。
      {talk}
      """
  )
  time_chain = LLMChain(
      llm=myllm, 
      prompt=time_prompt,
      output_key='time'
      )

  # 開催場所チェーン(prompt, chain)定義
  place_prompt = PromptTemplate(
      input_variables=["talk"],
      template="""次の文章の中から開催されるイベントの開催場所を"開催場所:???"の形で返してください。不明な場合は"開催場所:未定"を返してください。
      {talk}
      """
  )
  place_chain = LLMChain(
      llm=myllm, 
      prompt=place_prompt,
      output_key='place'
      )

  # 参加者チェーン(prompt, chain)定義
  sanka_prompt = PromptTemplate(
      input_variables=["talk"],
      template="""次の文章の中から開催されるイベントの参加予定者を"参加予定者:???"の形で返してください。不明な場合は"参加予定者:未定"を返してください。
      {talk}
      """
  )
  sanka_chain = LLMChain(
      llm=myllm, 
      prompt=sanka_prompt,
      output_key='sanka'
      )
  
  # シーケンシャルチェーンの定義
  overall_chain = SequentialChain(
      chains=[event_chain, date_chain, time_chain, place_chain, sanka_chain],
      input_variables=["talk"],
      output_variables=["event", "date", "time", "place", "sanka"],
      verbose=True
  )

  # シーケンシャルチェーンの実行
  event_info = overall_chain({
      'talk':talk
  })

  # 入力情報の作成
  eventlist = event_info['event']+event_info['date']+event_info['time']+event_info['place']+event_info['sanka']

  # イベントサマリ回答チェーン(prompt, chain)定義
  eventsummary_prompt = PromptTemplate(
      input_variables=["talk"],
      template="""次のリスト情報をもとに開催されるイベントの概要を返してください。
      {talk}
      """
  )
  eventsummary_chain = LLMChain(
      llm=myllm, 
      prompt=eventsummary_prompt
      )
  # イベントサマリ回答チェーン実行
  summary_ans = eventsummary_chain.run({
      'talk':eventlist
  })

  return(summary_ans)

定義したevent_extractorを実行します。

ans = event_extractor(mytalk)


> Entering new SequentialChain chain...
Chain 0:
{'event': ' イベント名:新年会'}

Chain 1:
{'date': ' 開催日:1/30'}

Chain 2:
{'time': '\n開始時間:19:00'}

Chain 3:
{'place': '\n開催場所:黒木屋'}

Chain 4:
{'sanka': '\n参加予定者:Aさん、Bさん、Cさん'}


> Finished SequentialChain chain.

以下の回答が得られます。

print(ans)


130日の19:00より黒木屋にて、Aさん、Bさん、Cさんによる新年会を開催します。

以上で関数化が完了です。

LLM埋め込み関数の驚異的読解力と柔軟性

ここからはおまけですが、むしろメインといえるかもしれません。試しに必要な情報の一部を対話から欠損させます。以下のように開始時間を確定させませんでした。

mytalk2 = """Aさん:今度の新年会はいつにしますか。
Bさん:私は1月下旬がちょうどいいです。
Cさん:私は1月だと1/20か、1/30がちょうどいいです。
Dさん:私は1月だと参加は難しそうです。
Aさん:じゃあいったん1/30にしましょうか。
Bさん:わかりました。時間は何時にします?
Aさん:そうですね、じゃあ18:00にしましょうか。
Cさん:ちょっと早くないですか?時間は追って決めましょう。
Aさん:了解です。場所はいつもの黒木屋にしましょうか。
Cさん:了解です。楽しみにしてます。
Dさん:楽しんできてください。いけそうだったらまた連絡します。
"""

この対話テキストに対してevent_extractorを適用すると、以下のようになります。

ans = event_extractor(mytalk2)


> Entering new SequentialChain chain...
Chain 0:
{'event': ' イベント名:新年会'}

Chain 1:
{'date': ' 開催日:1/30'}

Chain 2:
{'time': ' 開始時間:未定'}

Chain 3:
{'place': ' 開催場所:黒木屋'}

Chain 4:
{'sanka': '\n参加予定者:Aさん、Bさん、Cさん'}


> Finished SequentialChain chain.

生成される回答は以下です。

print(ans)


130日に黒木屋にて新年会を開催します。開始時間は未定ですが、Aさん、Bさん、Cさんの参加が予定されています。ご参加お待ちしております。

この回答には2つの驚くべき点があります。

  • 文脈を読み取り、記載のある18時を採用せず開始時間は未定であると理解している点

  • 未定という情報を受け取り、回答に盛り込んでいる点

当たり前ですが、どこにもそんな例外処理の分岐などの記述はしていません。すべてLLM内部で処理されています。

感想

  • 関数にLLMを組み込んだ効果は衝撃的。インプットデータの読解能力とアウトプットの柔軟性が劇的に向上する。

  • 特に返り値をLLMで生成すると柔軟性が増すが、その出力が他の関数の入力となる場合受け取る側が大変。ただし、受け取る側もLLMにすればおそらくなんとかなってしまうのではないか。

  • 内部でLLMを多用するとトレードオフで回答の不安定性は増す。(記事中でも回答は呼び出すたびに変わっています)

  • プロンプトエンジニアリングは重要。非エンジニアであってもドメイン知識があれば取り組めるだろう。大規模モデルをベースとした開発では今後そのようなドメインスペシャリストがキーとなる可能性が高い。

  • シーケンシャルチェーンは効率的だったが、今考えると回答生成チェーンもその中に加えるべきだった。(今後改修予定)

  • 情報欠損の処理には驚愕した。モデルとやりとりしている感覚が薄い。モデルの向こう側にヒトを感じるレベル。

脚注
  1. https://note.com/sangmin/n/n170bdfd624bc ↩︎

Discussion