LangChain の Embedding と CustomAgent で発注書を作る

FacebooktwitterredditpinterestlinkedinmailFacebooktwitterredditpinterestlinkedinmail

LLMのEmbedding利用では、自社マニュアルなどのチャットボット・QA利用が真っ先に思い浮かびます(会話履歴も実装したもの)。一方で、通常企業で営まれる定常業務においては正確な数値や文字列が求められることも多くあります。お金の計算などはその最たるものですね。

LangChainには LLM Math という、LLMから数値計算を行わせるのに便利なモジュールもありますが、今回はそれを利用する前に、商品データベースから「適当な」言葉でデータを引っ張り出して発注書や請求書を作ってみます。

LangChainのEmbeddingで発注書を作る

何が問題か?

市場に広く流通する商品には通常、JANコードやGTINコード、あるいはASINといった、製品を一意に特定できるコードが付与されています。一方で、人間が日頃商品を区別するときには商品名を主に利用しています。あるいは、ネットのURLと記事タイトルといった比較でもいいかもしれません。

この本は、ASIN コードでは「4873119278」ですが、書名は「退屈なことはPythonにやらせよう 第2版 ―ノンプログラマーにもできる自動化処理プログラミング」ですね。

これを検索するときに、人間が ASINコードを完璧に覚えていたり、書名も途中の部分でもいいので特徴的な部分を「正確に」覚えていれば検索は比較的容易です。例えば、「退屈なことはPythonに%」とかでSQL検索ができれば割と簡単に見つけられるでしょうが、「退屈なことはPythonで%」としてしまうと検索できなかったりします。

LangChain を使って自然言語で SQL データベースを操作する【GPT-3.5-turbo】

こういうことをユーザーが理解して、SQLに変換するつもりで LLM に指示を出してくれればいいのですが、おそらくあまり期待できないでしょう(そもそも例に出す本を検索するために、筆者も「python 自動化」というまあまあ雑なキーワード検索をしています)。

また、JANコードやASINコードなどは数値ですが、いわゆる型番と愛称のようなものもあります。「Xperia 5 IV」という名称に対して、型番は「XQ-CQ44」みたいな感じですね。一般ユーザーは多分名前で呼びますが、お店の人や工場関係者など業務で扱う人は型番を使うのではないかと推察されます。

この型番も、ハイフンだったりアンダースコアだったり、メーカー毎に大体パターンはあるものですが、多数の商品を扱う場合には表記揺れがでてしまう場合があります。1文字違っているだけで検索できないというのは、なかなかにストレスがあります。

というわけで、EmbeddingとVectorSearchを使って、商品データベースからデータを取得、ついでにExcelファイルに書き込んで総額計算をさせてみます。LLMで総額計算までさせた方が「すごそう」ですが、引っ張ってくるデータを間違えるならともかく、総額計算を間違えた書類を作らせる訳にはいきません。実用を考えて計算はExcelに任せます。

SQLやExcelのマスターデータをvectorにする

pandas の DataFrame を活用する

参考:https://python.langchain.com/en/latest/modules/indexes/document_loaders/examples/dataframe.html

Pythonで表形式なデータ(Tabular Data)を扱う場合には大体出てくるDataFrameを、Embeddingしてvectorstoreに格納できる状態にするDocumentLoaderが出来ていたのでこれを活用します。今回はcsv形式で読み込みます。

# Embedding用
from langchain.embeddings.openai import OpenAIEmbeddings
# Vector 格納 / FAISS
from langchain.vectorstores import FAISS
from langchain.document_loaders import DataFrameLoader
import pandas as pd

df = pd.read_csv('Source/SyohinList.csv')
loader = DataFrameLoader(df, page_content_column="商品名")

今回は、source/SyohinList.csvファイルにマスターデータが入っているものとして扱います。このファイルの先頭行は、

商品コード,商品名,売価

このようになっています。商品データベースから引っ張ってきて余計なデータを削除したものです。

DataFrameLoaderでは、page_content_column で、Embedding対象となる列を選択します。ここでは、「商品名」がvector化されます。そして、それ以外の列はmetadataとして格納されます。今回は商品コードでもvector検索を行いたいので、

loader = DataFrameLoader(df, page_content_column="商品コード")

というパターンも用意します(※筆者は別ファイルとしましたが、1つの.py内で両方処理してもいいと思います)。

docs = loader.load()

embeddings = OpenAIEmbeddings()
db = FAISS.from_documents(docs, embeddings)

db.save_local("code_index")
#db.save_local("name_index")

後は、環境変数にOpenAIのAPIキーを読み込み、embeddingを実行します。商品コードのvector index と、商品名のvector indexを別々に保存します。後で、 agent の Tools とするためですね。

今回は、入力キーワードそのものに「単純に近い」要素を探せばいいので性質上 HyDE仮説は向いていないと判断し、通常のEmbeddingを使っています。

入力意図を理解してデータを検索・抽出するAgentを作る

参考:https://python.langchain.com/en/latest/modules/agents/agents/custom_llm_agent.html

想定される入力 と Agent

User
Pixel6aを3台と、Xperia 5 IVを2台

みたいな入力があったら、Excelにそれぞれの売価と台数を計算し、細目なども記載されてほしいというのが自然な要望かと思います。あるいは、APIなどを利用して、オンラインのシステムに発注がなされるようなパターンでもいいですね(LLMの先がちょっと変わるくらいです)。

もちろん、これをそのままsimilarity_searchに入れても期待する結果にはなりませんね。PixelとXperiaで個別に検索を行ってほしいです。

そういう場合に便利なのが、LangChainのAgentです。メモリを実装したりもできますが、ここではシンプルにワンショットでデータが出力されるようにしましょう(ユーザーに確認を求めるダイアログを求めると途端に複雑さとTokenが増します)。

とりあえずimport

from langchain.chat_models import ChatOpenAI
from langchain.agents import Tool, AgentExecutor, LLMSingleActionAgent, AgentOutputParser
from langchain.vectorstores import FAISS
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.prompts import StringPromptTemplate
from langchain import LLMChain
from typing import List, Union
from pydantic import BaseModel, Field
from langchain.schema import AgentAction, AgentFinish
import re

沢山ありますね。embedding, vector searchの他に、Custom LLM Agent もちょっといじってみたいので、その関係を import しています。

2種類のsimilarity_search をTool化

embeddings = OpenAIEmbeddings()
code_db = FAISS.load_local('code_index', embeddings)
name_db = FAISS.load_local('name_index', embeddings)
tools = [
    Tool(
        name = "product search by code",
        func=code_db.similarity_search,
        description="useful for getting product name and price by product_code, like 'XQ-CQ44'.You can get 'unit price' from '売価'."
    ),
    Tool(
        name = "product search by name",
        func=name_db.similarity_search,
        description="useful for getting product name and price by product_name, like 'エクスペリア 5'.You can get unit_price' from '売価'"
    )
]

先ほど生成したvector storeを読み込み、similarity_searchメソッドをtoolのfuncとして登録しています。また、動作を安定させるために、名称検索ではカタカナを用いるようにしています。Xperia表記でも動作はしますが、分かりやすさのためにこうしています。

よりカスタマイズされたprompt

template = """Give the name, price and quantity of the product the user wants. Access the list of products using the following tools:

{tools}

Use the following format:

Demand: the input requests for which you must provide information
Thought: you should always think about what to do
Action: the action to take, should be one of [{tool_names}]
Action Input: the input to the action
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: You must respond with a new line for each item of information and each item of information separated by a comma as follows,
CODE,name,unit price,quantity

Begin! Remember that you must answer the name, price and quantity for all items included in the request.

Demand: {input}
{agent_scratchpad}"""

# Set up a prompt template
class CustomPromptTemplate(StringPromptTemplate):
    # The template to use
    template: str
    # The list of tools available
    tools: List[Tool]
    
    def format(self, **kwargs) -> str:
        # Agentの中間ステップを取得し、それらをフォーマットする(AgentAction, Observation tuples)
        intermediate_steps = kwargs.pop("intermediate_steps")
        thoughts = ""
        for action, observation in intermediate_steps:
            thoughts += action.log
            thoughts += f"\nObservation: {observation}\nThought: "
        # agent_scratchpad に thoughtsをセット
        kwargs["agent_scratchpad"] = thoughts
        # Toolを展開する
        kwargs["tools"] = "\n".join([f"{tool.name}: {tool.description}" for tool in self.tools])
        # ツールの名前を展開する
        kwargs["tool_names"] = ", ".join([tool.name for tool in self.tools])
        return self.template.format(**kwargs)

prompt = CustomPromptTemplate(
    template=template,
    tools=tools,
    # `agent_scratchpad`, `tools`, and `tool_names` 変数は通常必要ですが、format内部で代入しているため不要です
    # 一方で、 `intermediate_steps` が必要です
    input_variables=["input", "intermediate_steps"]
)

Customize された agent を作成するために、まずは promptと、そのpromptをフォーマットするメソッドを定義します。以前はprefix, suffixくらいしか作成できませんでしたが、verboseで出力されていたような、agentの内部で用いられるテキストも修正できるようです。

また、promptのインスタンス化においては、format メソッド内での実装により、通常であれば必要となるようなagent_scratchpad, tools, tool_namesなどの変数をinput_variablesに代入しなくてもよくなっています。この辺り、普通にドキュメントを読みながらだとはっきりとは分からなかったので一度写経してみるとよさそうです。

出力をパース

class CustomOutputParser(AgentOutputParser):
    def parse(self, llm_output: str) -> Union[AgentAction, AgentFinish]:
        # Final Answerがあったら最終出力
        if "Final Answer:" in llm_output:
            return AgentFinish(
                # 通常、最終出力は outputキーを一つだけもつ辞書型
                # 現在のところは変更は推奨されない
                return_values={"output": llm_output.split("Final Answer:")[-1].strip()},
                log=llm_output,
            )
        # actionとaction inputをパース
        regex = r"Action: (.*?)[\n]*Action Input:[\s]*(.*)"
        match = re.search(regex, llm_output, re.DOTALL)
        if not match:
            # 悪名高い Could not parse LLM output の例外
            raise ValueError(f"Could not parse LLM output: `{llm_output}`")
        action = match.group(1).strip()
        action_input = match.group(2)
        # Return the action and action input
        return AgentAction(tool=action, tool_input=action_input.strip(" ").strip('"'), log=llm_output)
    
output_parser = CustomOutputParser()

llm = ChatOpenAI(temperature=0)

# LLMとpromptから chain を構築
llm_chain = LLMChain(llm=llm, prompt=prompt)
tool_names = [tool.name for tool in tools]
agent = LLMSingleActionAgent(
    llm_chain=llm_chain, 
    output_parser=output_parser,
    stop=["\nObservation:"], 
    allowed_tools=tool_names
)

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

CustomOutputParserを用いることで、LLMの中間・最終出力を制御します。とはいえ、ここについてはあまりいじることもないと思います。

ただ、Action: と Action Input: の正規表現に引っかからなかった場合に、悪名高い(私の中で) LLM_Output parser error の例外が発生するので、作っているものによってはここで例外を捉えてうまく逃がしてやるようにできるといいのかもしれません。

後は、これも重要ですがあまりいじることがない点として、stop=[“\nObservation:”]の存在があります。これはLLMが回答を停止させるためにあり、これがないとagentは正常に機能しません。

実行

agent_executor.run("エクスペリア5 2台と、ピクセル6a を1台")

結果サンプル

XQ-CQ44,エクスペリア 5 IV,90000,2
gglpl6a,Google ピクセル6a,60000,1

このような出力が得られます(final answerのpromptにより、コンマ区切りで出力)。

Excelに書き込む例

from openpyxl import load_workbook

def create_estimate_excel_from_csv(items_csv, name, template_file, output_file):
    # 入力文字列を行ごとに分割
    items = items_csv.strip().split('\n')

    wb = load_workbook(template_file)
    ws = wb.active

    ws.cell(row=4, column=2, value=name)

    # データを書き込み
    for row_num, item in enumerate(items, 2):
        product_code, product_name, price, quantity = item.split(',')
        price = int(price)
        quantity = int(quantity)

        #ws.cell(row=row_num, column=1, value=product_code)
        ws.cell(row=row_num, column=2, value=product_name)
        ws.cell(row=row_num, column=3, value=price)
        ws.cell(row=row_num, column=4, value=quantity)
        #ws.cell(row=row_num, column=5, value=price * quantity)

    # Excelファイルを保存
    wb.save(output_file)

openpyxlを使って、所定のセルにデータを書き込みます。

template_fileに元になるExcelファイルを指定すればフォーマット済みのExcelファイルに対して書き込みが行えますので、自然言語で発注書や請求書、見積書の明細の作成を指示できるようになりました。

これでいいの?

あんまりよくないと思います。

similarity_searchを直接 Toolに入れるのはよくないかもしれない

agentはあくまでもテキストとして解釈するので、page_content, metadata…のような表記では解釈を誤る可能性があります。ラッパークラスを作成して、LLMが解釈しやすいフォーマットで返すようにしたほうがいいでしょう。

agent使う意味ある?

similarity_searchはLLMから使う必要がありません。検索のキーとなる部分と個数さえ分かれば、従来のプログラミング方式で充分動作すると思います。よって、Chat modelにテキストフォーマットを依頼し、similarity_searchに入力、2種類のvector searchを行ってscoreで判断するのがいいように思います。

どう考えてもこれが一番スマートな気がしたので修正版(というか続き)

LangChain の Embeddings を利用してスマートに発注書を作る

あるいは、LLMに型番と商品名、どちらの形式により近しいかの意見を求めるといった使い方もありかもしれません。

FacebooktwitterredditpinterestlinkedinmailFacebooktwitterredditpinterestlinkedinmail

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください

最新の記事