前回:LangChain の Embedding と CustomAgent で発注書を作る
前回の最後で触れたように、(CustomAgentの実験としてはともかく)データベースから商品を引っ張ってきて発注書なり領収書なりを作る場合、何も全てを LLM に処理させる必要はないわけです。実際の業務であれば、技術的な面白さよりも正確さが求められるわけなので、 LLM の解釈によって実行結果が左右されるのは困ります。
ということで、方針を変えてみます。
発注書を作る方針
- Excel なり Google Workspace のスプレッドシートなりのV(X)LookUp 関数を使って、商品コードから価格や名称を参照するようにする
- 正しいコンマ区切りで入力された場合は、 LLM を通さない
- LLM を通す場合でも通さない場合でも、 similarity_search を使って最も近い商品コードを見つけ出すようにする
- ユーザーからの入力が名称なのか商品コードなのかは、LLM ではなくsimilarity_search の score (vector間の距離)を用いる
以上のように変更してみます。
1については、LLMが商品を正しく認識できたとしても、それ以外のメタ情報についても正しく解釈してくれる保証がないためです。それは確かに全部 LLM がやってくれた方が未来は感じられますが、そもそも人間も間違えて問題になるからシステム化されているので敢えてそれを LLM にやらせる必要もないでしょう。
2についても同様で、人間が正確にカンマ区切りのフォーマットで入力してくれるのであれば敢えてLLMを通す必要はないでしょう。もう少し複雑なAPIであれば JSON にパースするなどの工夫が必ず必要になるでしょうが、名称(またはコード)と数値のペアであれば人間でも順序の間違いなどなしに処理可能です。また、LLMを通すと現状では時間がかかってしまうため、ユーザーの熟練度によって自動的に選択されるのがスマートでしょう。
3, 4 が前回からの大きな変更点と言えます。前回はユーザーからの入力がIDなのか名称なのかは、LLMに判断させていました。いわゆるプロンプト芸に近いです。しかしデータベースを Embedding しているのですから、IDと名称両方で検索してよりvector間の距離が近いものを採用すれば正解に限りなく近いはずです。そして実装も楽です。
やってみる
importからprompt templateまで
# slack bolt
from slack_bolt import App
# socket mode での動作
from slack_bolt.adapter.socket_mode import SocketModeHandler
# GPT-3.5-turbo
from langchain.chat_models import ChatOpenAI
from langchain import PromptTemplate, LLMChain
from langchain.memory import ConversationBufferMemory
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.vectorstores import FAISS
from openpyxl import Workbook, load_workbook
import tempfile
import uuid
# 環境変数にAPIキーを設定
import os
SLACK_APP_TOKEN = os.environ["SLACK_APP_TOKEN"] # "xapp-XXXXXXXXXXXX"
SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"] # "xoxb-XXXXXXXXXXXX"
# Slack Bot の作成
app = App(token=SLACK_BOT_TOKEN)
embeddings = OpenAIEmbeddings()
name_db = FAISS.load_local("name_index", embeddings)
code_db = FAISS.load_local("code_index", embeddings)
# モデル作成
llm = ChatOpenAI(temperature=0)
template = """Give the name and quantity of the product the user wants. You must'nt translate the name.
You must respond with a new line for each item of information and each item of information separated by a comma as follows,
name,quantity
Begin! Remember that you must answer the name,and quantity for all items included in the request. You MUST NOT translate the name.
Human: {input}
AI Assistant: """
prompt = PromptTemplate(template=template, input_variables=["input"])
ユーザー向けのフロントとしてはやはり Slack が使いやすいので、Slack boltもimportしています。また、表計算といえばやはり Excel ということで(GoogleのAPIまで叩くのが面倒なので)、openpyxl でExcelを操作し、slack sdk を使ってダウンロードを行います。
このやり方の注意点としては、openpyxl で 数式入りのExcelを操作しても、実際にExcelで編集可能状態で開くまで数式は計算されない点です。Excelを作りつつ PDF も同時に作成したいといった場合には、数式は諦めて Python で計算した内容を直接 Excelに書き込む必要があります(後からユーザーが微調整しづらいです)。ただの加算乗算程度なのでこんな記事を読む人には問題ないと思いますが、運用にあわせてどちらを採用するか決めるといいと思います。
slackの入力を作る
@app.command("/create_order")
def command_handler(ack, body, logger, client):
ack()
trigger_id = body["trigger_id"]
open_modal(client, trigger_id)
@app.shortcut("create_order")
def shortcut_handler(ack, body, logger, client):
ack()
trigger_id = body["trigger_id"]
open_modal(client, trigger_id)
def open_modal(client, trigger_id):
client.views_open(
trigger_id=trigger_id,
view={
"type": "modal",
"callback_id": "order_modal",
"title": {"type": "plain_text", "text": "order"},
"blocks": [
{
"type": "input",
"block_id": "destination",
"element": {
"type": "plain_text_input",
"action_id": "destination_input",
"multiline": True
},
"label": {"type": "plain_text", "text": "宛先"},
},
{
"type": "input",
"block_id": "content",
"element": {
"type": "plain_text_input",
"multiline": True,
"action_id": "content_input"
},
"label": {"type": "plain_text", "text": "内容"},
},
],
"submit": {"type": "plain_text", "text": "送信"},
},
)
Slack 上では、 / コマンドかショートカットから create_orderが実行されたらモーダルを表示して、ユーザーからの入力を受け付けます。
,区切りで入力するだけでは、「Excel でやればよくね?」と上司から圧力をかけられること請け合いなので、宛先も入力できるようにしておきます。わざわざExcelを起動してテンプレート開いて……とやるよりはこの時点で楽だと思います。多分。
ショートカットや / コマンドは Slack API の管理画面からパーミッションを与え、既にインストールされているアプリであれば reinstall する必要があります。後で file のダウンロードとユーザー情報の参照も行うので、ついでに権限をつけておくといいです。
ユーザーからの入力をチェックする
def check_input(input_str):
lines = input_str.split('\n')
items = []
for line in lines:
parts = line.split(',')
if len(parts) != 2:
return False
name, quantity = parts
try:
quantity = int(quantity)
except ValueError:
return False
return True
1行ごとに、カンマ区切りの名前(またはid, code)と数量のペアであれば LLM を通さずに similarity_search してしまった方が精度が出る上、高速かつ安価なのでそこそこちゃんと判定させます。
LLMを走らせる
def parse_user_input(input):
chain = LLMChain(prompt=prompt, llm=llm, verbose=True)
return chain.run(input=input)
if not check_input(content):
content = parse_user_input(content)
content = search_product_code(content)
なんか毎回 LLMChain を作成するコードになっていますが、 prompt template と一緒に、最初から LLM を読み込ませて chain にしておいていい気がします。
prompt template については、実験してみた結果、横文字の商品名を入れるとしばしば英語などに変換されてしまう(ピクセル→pixelなど)現象が確認されたので、強めに訳すなよと prompt に書いています。それでも駄目そうなら、promptを日本語にしてもいいとは思います。token が嵩みますが……。
後は、今回のようなシンプルなデータでなければ、 Output Parser を使うのがいいと思います。
similarity_search をスコア付きで実行する
def get_product_code_or_page_content(document_tuple):
document = document_tuple[0]
metadata = document.metadata
if "商品コード" in metadata:
return metadata["商品コード"]
else:
return document.page_content
def search_product_code(input):
lines = input.split('\n')
result = ''
for line in lines:
parts = line.split(',')
name, quantity = parts
n = name_db.similarity_search_with_score(name, k=1)
c = code_db.similarity_search_with_score(name, k=1)
best_name_result, best_code_result = n[0], c[0]
best_result = min(best_name_result, best_code_result, key=lambda x: x[1])
best_code = get_product_code_or_page_content(best_result)
result += f"{best_code},{quantity}\n"
return result.strip()
この時点で、ユーザーから自然言語でデータの作成指示が来ていたとしても LLM によってカンマ区切りにされているはずなので分割を行います。LLMを使う場合、プログラム内でも文字列に変換した方が取り回しが容易なので、出力も改めてカンマ区切りにしています。が、これは Excel への書き込み関数を以前から使い回している関係なので、普通に考えれば List と tuple の二次元構造でいいでしょう。
LangChain の vector search では、 similarity_search_with_score メソッドを用いれば vector 間の距離も浮動小数点で返してくれるので、これを評価値として用います。
名前が vector 化されたデータベースと、 codeが vector 化されたデータベース両方で、最上位の結果のみを検索(k=1)します。結果は、documentと distance がペアで返されるので、distance を比較しスコアがいい方(距離が近い=数値が近い)方を返します。
ただし、これだと名前が返されたのかIDが返されたのか分からないので、metadata内のキーに「商品コード(codeなりidなりベースのデータにあわせて)」が含まれているかどうかで判定を行い、必ずcodeが返るようにしています(min関数ではなく最初からifでいいのでは? という考えもあり)。
仕上げ
@app.view("order_modal")
と、modalの入力を受け付ける関数から、入力のチェックを行って整形・Excelに書き込みを行えば完成です。Excelの書き込みなどは、前回とほぼ同じなので割愛です。
まとめ
- LLM には自然言語処理だけを任せて、従来の IT システム的な正確さが必要な部分には従来的な手法を使った方がいいという判断が大事(特に、ChatGPTから入った場合、なんでもLLMに投げたくなる
- Embeddings が過小評価されているらしい。雑な検索には実装も楽でとても便利
- LLM とチャット(slack)の相性がとてもいいので顧客向けにリリースできない段階でもどんどん実装していくと人間の生産性が大きく向上しそう
特に、ChatGPTのような、 streaming 出力以外では結果が出るまでに時間がかかるのでいちいちアプリケーションを起動したりブラウザのタブを開いたりと実行するよりも、 slack の /コマンドでmodalを起動して入力・LLMその他の処理が終了したら DM が来るというフローがとても自然なので導入に対するエンドユーザーの意識的なハードルを低くできるのが大きなメリットだと思います。