CCCMKホールディングス TECH Labの Tech Blog

TECH Labスタッフによる格闘記録やマーケティング界隈についての記事など

LangChainのRetrievalQAとAgentの日本語対応方法について調べてみました

こんにちは、CCCMKホールディングスTECH LABの三浦です。

家族の誕生日に何かサプライズをしたいけど、何をしたらいいかな・・・と考えていた時に、ふとChatGPTに聞いたらなんて答えるんだろう、と思い、試しに聞いてみました。ChatGPTが提示したアイデアはちょっと自分が意図していたものとはズレたものでしたが、何度かやり取りをするうち、自分の中で考えがまとまっていくのを感じました。何かを考える時、自分一人で悩むことも大切ですが、こうやってAIとやり取りしながら考えるという方法も、これから活用していきたいな、と感じました。

ChatGPTなどのLLMを活用していく上で、Promptの作り方や与え方を工夫することがとても重要です。これを1から自力で作ろうとするとかなりソースコードが複雑になってしまいますが、LangChainを使い、そのフレームワークに従うことで複雑なPrompt周りの処理をとても簡潔に書くことが出来ます。

私もLangChainの"Use cases"を見ながら色々と試しているのですが、時々思ったような回答が得られないことがありました。特に日本語で質問文を入力しているのに、返ってくる回答が英語になってしまう、というケースは多く見られます。これはLangChainの内部で実行されるPromptが英語で記述されており、複雑な処理をLangChainで組もうとすればするほど、LLMに最終的に渡されるPromptの大半が英語で記述されてしまうことに原因があるのでは、と考えるようになりました。

LangChainのドキュメントを調べてみると、実は内部で呼ばれるPromptをカスタマイズ出来ることが分かりました。今回は私がLangChainの中で使う機会が多いRetrievalQAAgentで呼ばれるPromptをカスタマイズし、日本語対応する方法について調べてみました。

RetrievalQA

RetrievalQAは入力された質問に対し、LLMが外部データソースを参照して回答することが出来るようにするLangChainで提供されるChainの一つです。この処理の中で呼ばれるPromptをカスタマイズし、日本語にしてみます。

参考にしたのはLangChainの"Use cases"の中の"QA using a Retriever"です。

python.langchain.com

例として、先日私が書いたこちらのブログ記事の内容を参照したRetrievalQAを作成します。

techblog.cccmk.co.jp

まずは参照するデータソースを取得し、chunkに細分化します。先ほどのURLから取得したHTMLには余分な情報が多数含まれているので、BeautifulSoupを使って記事本文部分だけを取得しています。

import urllib

from bs4 import BeautifulSoup
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.document_loaders import TextLoader

# HTMLの取得
req = urllib.request.Request("ブログのURL")
with urllib.request.urlopen(req) as res:
  html = res.read().decode()

# BeautifulSoupで解析し、記事本文文章を取得
content = BeautifulSoup(html).find("div",attrs={"class":"本文のclass名"}).text

# 一度テキストファイルに書き込む
with open("./temp.txt","w") as f:
  f.write(content) 

# テキストファイルから読み込み
loader = TextLoader("./temp.txt")
data = loader.load()

# テキストを細分化する
spliter = RecursiveCharacterTextSplitter(chunk_size=100, chunk_overlap=0)
data_splited = spliter.split_documents(data)

次は細分化されたデータが格納されたdata_splitedに対し、細分化された要素ごとに埋め込み表現を取得し、VectorStoreDBに格納します。今回はDBとしてChromaDBを使用しています。

from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma

embeddings = OpenAIEmbeddings(
              model="text-embedding-ada-002",
              chunk_size=1)

vector_store = Chroma.from_documents(
                  documents=data_splited,
                  embedding=embeddings)

次は使用するPromptテンプレートをカスタマイズし、日本語のPromptテンプレートを作成します。

from langchain.prompts import PromptTemplate

prompt_template = """
以下の参考用のテキストの一部を参照して、Questionに回答してください。もし参考用のテキストの中に回答に役立つ情報が含まれていなければ、分からない、と答えてください。

{context}

Question: {question}
Answer:
"""

PROMPT = PromptTemplate(
          template=prompt_template, input_variables=["context","question"])

RetrievalQAのChain処理の中で質問文に近いテキストをChromaから取得し、{context}に埋め込んでくれます。あとはエンジンになるllmprompt_templateを使ってRetrievalQAを呼び出せばOKです。

from langchain.chat_models import AzureChatOpenAI
from langchain.chains import RetrievalQA

llm = AzureChatOpenAI(
        deployment_name="gpt-35-turbo")

qa_chain = RetrievalQA.from_chain_type(
              llm=llm, 
              chain_type="stuff", 
              retriever=vector_store.as_retriever(), 
              chain_type_kwargs={"prompt":PROMPT})

テストするとこんな感じです。

qa_chain.run("三浦の感想を要約してください。")
'三浦は、SUMMITに参加して色々なことを勉強し、これまでの考え方を改めるいいきっかけになったと感じ、今後のプレゼンに活かしたいと思っている。また、この機会を頂けたことに感謝していると述べている。'

実際に呼ばれるPromptの表示の仕方が分からず正確に確認することは出来ませんでしたが、処理としては正しく動いているようです。

英語で出力されている問題は、どちらかというと次に述べるAgentの処理で良く起こるように思います。

Agent

AgentはLangChainの中でも特に面白い機能だと思っています。Agentに複数のToolを与えると、ユーザーの問いかけに対してAgentがいい感じに最適なToolを選択し、いい感じに使ってくれ、そしてその結果をいい感じに解釈してくれる、まさにAIアシスタントのような動きをしてくれます。その動作の内部では自身の行動によって得られた観測を元に、次に取るべき行動をLLMを使用してテキストで考える、といったことが行われており、ここに日本語と英語が混在してしまうことでAgentのコントロールが効きづらくなる、といった問題が起こるようです。

Agentの内部での動作を制御するPromptを日本語で記述することで、この問題はだいぶ軽減できるようです。これからご紹介するPromptを日本語にカスタマイズする方法は、LangChainの"Docs"の"Custom LLM Agent (with a ChatModel)"を参考にしています。

python.langchain.com

まず、必要になるモジュールがいくつかあるため、それらをインポートします

import re
from typing import List, Union

from langchain.chat_models import AzureChatOpenAI
from langchain.agents import Tool, AgentExecutor, LLMSingleActionAgent, AgentOutputParser
from langchain.schema import AgentAction, AgentFinish, HumanMessage
from langchain.prompts import BaseChatPromptTemplate
from langchain import LLMChain

次はAgentが使用できるToolを定義します。先ほどRetrievalQAで作ったqa_chain.run()をToolとして与えてみます。

tools = [
  Tool(
    name="blog_search",
    func=qa_chain.run,
    description="""
    三浦の行動について会話形式で質問文を入力することで回答してくれるToolです。"○○について教えて下さい"、の様に質問文を入力することで適切な回答を得ることが出来ます。
    """
  )
]

Toolの設定で特に重要なのがdescriptionです。ここにはToolの説明を記述するのですが、Toolの説明に加え、具体的な使い方、特に引数の与え方をしっかり記述しておいた方が良いと思います。

次が今回の肝になるAgentの動作を決めるPromptのテンプレートです。今回色々試してみてたどり着いたのが以下のPromptテンプレートですが、まだ調整不足かな・・・と思う時もあります。特に"Action:" の行の"Actionの名前以外はここには絶対に含めてはいけません。"や"Final Answer:"の行の"かならずこのステップで回答を終了させてください。"は含めておくことでAgentの動作をある程度安定させられることが出来たように思います。

template = """
ユーザーからのリクエストに対して与えられたToolを使って答えてください。以下のToolが与えられています。

{tools}

以下の流れに沿ってリクエストを解決に導いてください。:

Question: あなたが解決すべきリクエストです。
Thought: Actionに移る前に常に何をすべきかを考えるようにしてください。
Action: [{tool_names}]から1つだけActionとして選んでください。Actionの名前以外はここには絶対に含めてはいけません。
Action Input: Actionに入力するInputです。選択したActionに合う形にしてください。
Observation: ここにはActionの結果得られたObservationが入ります。
...(Thought/Action/Action Input/Observationは最大5回まで繰り返すことが出来ます。)
Thought: これまでのプロセスから、あなたがた最終的に答えるべき回答を最後に考えます。
Final Answer: Questionに対する最終的な答えです。かならずこのステップで回答を終了させてください。

始めてください!

Question: {input}
{agent_scratchpad}"""

あとはLangChainのドキュメントの内容に従って進めていきます。Agentの処理実行中はintermediate_stepsにToolを使ったログ等が記録されていくため、intermediate_stepsを解析して都度Promptに組み込む処理をCustomPromptTemplateで実装します。

class CustomPromptTemplate(BaseChatPromptTemplate):
  template: str
  tools: List[Tool]

  def format_messages(self, **kwargs) -> str:
    intermediate_steps = kwargs.pop("intermediate_steps")
    thoughts = ""
    for action, observation in intermediate_steps:
      thoughts += action.log
      thoughts += f"\nObservation: {observation}\nThought: "
    kwargs["agent_scratchpad"] = thoughts
    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])
    formatted = self.template.format(**kwargs)
    return [HumanMessage(content=formatted)]

prompt = CustomPromptTemplate(
  template = template,
  tools=tools,
  input_variables=["input","intermediate_steps"]
)

次もLangChainのドキュメントの内容に従っています。AgentのエンジンのLLMが出力した内容を解析して"Action"や"Action Input"、"Final Answer"が含まれているかを確認し、その値を抽出するOutputParserを実装します。

class CustomOutputParser(AgentOutputParser):

  def parse(self, llm_output:str) -> Union[AgentAction, AgentFinish]:
    if "Final Answer:" in llm_output:
      return AgentFinish(
        return_values={"output": llm_output.split("Final Answer:")[-1].strip()},
        log=llm_output,
      )
    regex = r"Action\s*\d*\s*:(.*?)\nAction\s*\d*\s*Input\s*\d*\s*:[\s]*(.*)"
    match = re.search(regex, llm_output, re.DOTALL)
    if not match:
      raise ValueError(f"Could not parse LLM output: `{llm_output}`")
    action = match.group(1).strip()
    action_input = match.group(2)
    return AgentAction(tool=action, tool_input=action_input.strip(" ").strip('"'),log=llm_output)

output_parser = CustomOutputParser()

最後にAgentとAgentの行動を制御するAgentExecutorを構築します。まずはAgentです。

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)

stop=["\nObservation:"]はこのワードが出現したらLLMのテキスト生成を中断させるための指定です。"Observation"はToolを実行して得られるものであり、LLMが勝手に生成しないよう制御するために必要な指定です。

そしてAgentにToolの実行結果を渡したりする役割はAgentExecutorが担います。

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

では、テストをしてみます。

agent_executor.run("三浦がイベントに参加した期間はいつからいつまで?")

AgentExecutorを構築する際にverbose=Trueを指定したため、細かいログが出力されます。

> Entering new AgentExecutor chain...
Thought: この情報を得るためには、三浦が参加したイベント名や種類が必要になるかもしれません。
Action: blog_search
Action Input: "三浦が参加したイベントの期間はいつからいつまでですか?"

Observation:6/26(月)~6/29(木)まで。
得られた情報で、Questionに対する最終的な答えを考えます。
Final Answer: 三浦が参加したイベントの期間は、6/26(月)~6/29(木)でした。

> Finished chain.
Out[23]: '三浦が参加したイベントの期間は、6/26(月)~6/29(木)でした。'

Promptを日本語でカスタマイズしたことにより、Agentの途中の思考も日本語で行われるようになりました。今回の質問に対しては上手く答えてくれました。

まとめ

ということで、今回はLangChainで使う機会が多いRetrievalQAとAgentの内部で呼ばれるPromptを日本語にカスタマイズする方法について調べ、まとめてみました。特にAgentは入力から出力まで、全て日本語で実行させることでこれまでよりも安定した動きになったように感じました。LangChainの"Use cases"には面白そうな実装例がいくつも紹介されているのでそれらを試しながら他のLangChainの機能を日本語に最適化するための方法についても引き続き調べていこうと思います。