RAGを使った生成AIボットでユーザの意図を理解して対話するためのフローを考えてみた

2023.10.27

はじめに

新規事業部統括部インターンの高橋です。ここ最近はすさまじいスピードで様々な生成AIがリリースされていますね。そのなかでもChatGPTをはじめとするLLMの活用には注目が集まっており、多くの方が使用していると思います。

LLM(Large Language Model)とは大量のテキストデータを学習した言語モデルのことで、一般的な事柄であれば私たちの質問に対して十分納得感のある回答を返してくれます。しかし、LLMが学習したデータに含まれない事柄については正しく回答することができません。

この問題を解決する方法としてRAG(Retrieval Augmented Generation)という手法が用いられます。これは、LLMに外部の情報源(ここでは社内ドキュメントなど)を与えることで、それを参照して回答を生成してもらおうという手法です。ユーザは事実に基づく情報をベースにした回答を得られるほか、回答の情報源にアクセスできるため最終的な信頼性のチェックも行うことができます。

クラスメソッド株式会社では、RAGの方式を用いた社内情報のドキュメントに関するQ&Aができるチャットボットを社内で運用、検証しています。多くの場合ではこのチャットボットによって回答を探す手間や質問に対応する時間を削減できましたが、ユーザの質問があいまいであったり抽象的な内容であった場合にはいい回答が得られないことがありました。(チャットボットの詳細についてはリンク先のページにありますのでご覧ください。)

この記事では単に検索や回答生成をしてもらうチャットボットにとどまらず、人とのコミュニケーションツールとしての可能性に焦点を当てて考察します。さらにシステムの改良案を実装し、実際に使用してどのような動作になるのかを試してみました。

RAGを使った社内情報を回答できる生成AIボットで業務効率化してみた

背景・課題

ベクトル検索で欲しい情報が得られないときの問題点と改良方法を考えてみた | DevelopersIO

人とのやり取り(理想)

RAGを使う際の課題や改良点を探るために、まずは人に回答をしてもらう際のやり取りについて考えてみます。人に質問をする際には単に質問に対して回答してもらうだけではなく、その他にも様々な対応をしてもらっていることに気づきます。例えば上図にあるように、質問が明確ではないときでも意図をくみ取って解釈してくれたり、そのままでは回答できない場合は追加の情報を聞き返されたり、あるいは勘違いをしている際は訂正をしてもらえるかもしれません。このようなやり取りは「質問」に対する「回答」のように一回で完結するもののではなく、複数回のやり取りを通して質問者と回答者の認識のすり合わせや状況の共有がなされた後、解決に向かうというプロセスを踏んでいます。

検索エンジン・RAGの回答生成(現状)

Googleなどの検索エンジンで調べる際についても考えてみます。人とのコミュニケーションは双方向の様々な種類の対応を含むやり取りであると述べました。しかし、検索エンジンはユーザが指定した検索クエリに対して関連性の高いwebページを提示すること、つまり単方向の情報提示しかできません。そのため入力したクエリによって欲しい情報が見つからなかった場合は、適切なクエリを選定するために何度も試行錯誤が必要になることも少なくありません。

RAGを用いたQAシステムは情報提示に加え回答生成を行います。検索エンジンと比べ、「直接的な回答」を自然言語で得られるため、ユーザが自分で情報を取捨選択して必要な形にする時間や手間が減るというメリットがあります。しかし、やはり単方向のやり取りにとどまるため人とのコミュニケーションのような柔軟さはありません

ギャップ(課題)

人とのやり取りと、検索エンジンやRAGの回答生成との間に存在する一番のギャップは双方向のコミュニケーションができるかどうかということだと思います。検索エンジンやRAGはユーザの入力にかかわらず結果が出力されるため、期待通りの情報が得られなかった場合にユーザは再度クエリを入力する必要があります。しかしChatGPTのようなAI技術の進化により、自然言語を用いた会話形式のやり取りが実現されています。この点に着目すると、RAGも情報提示や回答生成のみならず、より人間らしいコミュニケーションを取る"アシスタント"としての役割を果たせるようになると期待できます。ユーザの期待やニーズに柔軟に対応することで、より効果的で満足度の高い情報提供が期待されます。

その第一歩として、まずは人との対話をベースとして情報検索ができるようなシステムを検討していきます。

システム構成の改良案

ベーシックなRAGシステム(現行方式)

ベーシックなRAGは検索と回答生成の二つのステップからなります。

  • 検索ステップ:ユーザからの入力分(質問など)を受け取り、外部データベースから関連性の高い情報を含む文書を検索する
  • 回答生成ステップ:検索された文書をプロンプトとともにLLMに入力し、回答を生成する

上記のRAGのシステムではプロンプトの内容に関係なく検索ステップがはじまります。しかし、先ほど述べた「対話」の機能を実現するためには、ユーザとやり取りをして、質問を具体的にするステップが必要です。そのため検索をする前段階にLLMをもう一つ用意して、ユーザの入力文の評価してもらうことにします。

対話ステップの導入(提案方式)

提案方式では、検索ステップの前に対話ステップを導入します。対話ステップでは、LLMを用いてユーザの意図は明確か、回答のために不足している情報はないかなどの評価を行い、必要に応じてユーザに質問を返します。対話を行いながら、回答をするために十分な情報があると判断した場合は、会話の履歴をもとに質問内容を再構成し検索ステップに移行します。これにより双方向のコミュニケーションを実現できそうです。

そのためには、ベーシックなRAGのシステムに加えて、

  • ユーザの入力文を評価し、必要に応じて返答する機能
  • 会話の履歴から質問を再構成する機能

が必要になります。

ユーザの意図を推定する評価指標

対話ステップでの適切な返答や検索ステップへの移行を実現するには、入力文からユーザの意図を推定することが必要です。ユーザの意図を推定するために、いくつかの指標を定め、LLMによって入力文の状態を数値化して評価することにしました。試行錯誤の上、今回は以下の指標で評価をすることにしました。

  • 質問の明確さ
    • ユーザの入力文の意図が明確かどうかを表す指標です。基本的にはこの指標のスコアをベースにして検索ステップに移行するか、対話をして情報を明確にするかの判断をします。
  • 質問か相談か
    • ユーザからの入力文は質問であるとは限らず、場合によっては相談であったり、アドバイスや提案を求める場合もあり得そうです。この指標によって返答のパターンを変えることができると、より柔軟な対応ができそうです。
  • 一般的にドキュメントにありそうか内容か
    • 検索ステップにおいて関連性の高い文書が見つかりそうかどうかを表す指標です。対話ステップでは外部データベースに格納されている文書はわかりませんが、質問の回答が一般的にドキュメントに記載されていそうな内容かどうかを評価します。
  • 一般的に社内の担当者に聞いたほうが良い内容か
    • 内容によってはドキュメントには書いておらず、担当者に聞いたほうが適切だと考えられる質問もあります。返答する際の補足としてこの指標を使うことがあります。

実装

LLM(model)としてOpenAIのgpt-4を使用し、Function callingによって回答を制御しました。

Function callingは、LLMの出力を関数呼び出しのための引数として利用する機能です。指定された形式に従って出力をフォーマッティングできるので、APIを呼出した後のフローの構築が容易になります。

入力文の評価方法

今回は以下のFunctionを用意してプロンプトの評価をしました。

evaluate_func = {
    "name": "evaluate_user_prompt",
    "description": "ユーザの質問の評価を出力します。各項目について1から5の5段階で評価してください。1に近いほど高い評価です。",
    "parameters": {
        "type": "object",
        "properties": {
            "clarity": {
                "type": "number",
                "description": "質問の明確さ。会話が複数回続いている場合、今までのやり取りを総合して評価してください",
            },
            "is_question": {
                "type": "number",
                "description": "質問かどうか",
            },
            "is_consultation": {
                "type": "number",
                "description": "相談かどうか",
            },
            "in_internal_docs": {
                "type": "number",
                "description": "一般的に社内ドキュメントにありそうな内容かどうか",
            },
            "ask_person": {
                "type": "number",
                "description": "担当者など人に聞いた方がいい内容かどうか",
            },
            "ask_missing_info": {
                "type": "string",
                "description": "質問だと考えられる場合、回答する上で不足している説明を求めてください。",
            },
            "res_consultation": {
                "type": "string",
                "description": "相談と考えられる場合、具体的な悩みを引き出してください。",
            },
        },
        "required": [
            "clarity",
            "is_question",
            "is_consultation",
            "in_internal_docs",
            "ask_person",
        ],
    },
}

評価は以下の5つの指標について、1から5の5段階で行います。(1が最も高評価)

  • clarity:質問の明確さ
  • is_question:内容が質問であるか
  • is_consultation:内容が相談であるか
  • in_internal_docs:一般的な社内ドキュメントに記載のありそうな内容であるか
  • ask_person:担当者など人に聞いたほうが良い内容であるか

さらに以下の状況に応じて返答の文章を出力します。この返答をもとにユーザとの対話が行われることを想定しています。

  • ask_missing_info
    • プロンプトが質問と推定される場合、回答のために不足している情報や不明瞭な点がある場合、それをユーザに求める文章
  • res_consultation
    • プロンプトが相談と推定される場合、相談内容の詳細を引き出すための文章

使用したプロンプトはこちらです。

conversation_prompt = """
あなたはある会社に勤めており、社内のドキュメントをすべて把握しているとします。
自分の会社についての質問をされるので、質問を評価してください。
質問は十分詳細で意図が明確であれば良いのですが、必ずしもそうとは限りません。
また相談のような内容である可能性もあります。

質問に対してあなたは回答を出力する必要はありません。

関数にどのような値を入れるかについては勝手に決めずに、
質問があいまいな場合は、説明を求めてください。

質問はすべて社内に関する内容です。
"""
new_prompt = """
あたえられた会話の履歴から質問内容をまとめてください。
一つの質問文になるように文章を作成してください。
"""

判断ロジック

def evaluate_user_prompt(
        self,
        clarity: int = 1,
        is_question: int = 1,
        is_consultation: int = 1,
        in_internal_docs: int = 1,
        ask_person: int = 1,
        ask_missing_info: str = "",
        res_consultation: str = "",
        role: str = "assistant",
    ):
        print(
            f"clarity: {clarity}, is_question: {is_question}, is_consultation: {is_consultation}, in_internal_docs: {in_internal_docs}, ask_person: {ask_person}"
        )
        if clarity != 1:
            if res_consultation and is_consultation <= 2:
                self.conversation.add_message(role, res_consultation)
                user_prompt: str = self.ask_question(res_consultation)
                self.chat(user_prompt)
            elif ask_missing_info and is_question <= 2:
                self.conversation.add_message(role, ask_missing_info)
                user_prompt: str = self.ask_question(ask_missing_info)
                self.chat(user_prompt)
        elif is_consultation <= 2:
            if in_internal_docs <= 2:
                pass
            elif res_consultation:
                self.conversation.add_message(role, res_consultation)
                user_prompt: str = self.ask_question(res_consultation)
                self.chat(user_prompt)

evaluate_user_prompt()を使用して評価指標を基にしたフローの分岐判断をしています。ユーザの意図を捉えるための各評価指標の調整が難しく大変でした。ユーザが適切だと感じるフローを実現するためには各指標の調整が必要であり、今後の課題として引き続き取り組むべき点だと考えています。

今回実装したフローを言葉で説明すると以下のようになります。

clarityが2以上の場合で、

① res_consultationがレスポンスに含まれているかつ、is_consultationが2以下の場合

「ユーザの入力内容は相談だが、内容が明確ではない」として、より詳細な内容を尋ねるために返答をします。

② ask_missing_infoがレスポンスに含まれているかつ、is_questionが2以下の場合

「ユーザの入力内容は質問だが、内容が明確ではない」として、回答に不足している情報や不明確な点の説明を求めます。

③ res_consultationask_missing_infoがレスポンスに含まれない場合は、「意図は明確では ものの、不足している情報はなく回答できそうだ」とみなして検索ステップに移行します。

clarityが1かつ、is_consultationが2以下の場合で、

④ in_internal_docsが2以下の場合

「意図が明確かつ、内容は相談であるが、ドキュメントに記載のありそうな内容である」として、検索ステップに移行します。

⑤ res_consultationがレスポンスに含まれる場合

「意図が明確だが、相談であるため、より詳細な内容を尋ねたい」として、返答をします。

⑥ それ以外の場合は検索ステップに移行します。

実装したコード

クリックすると開きます
class Conversation:
    # 対話の履歴を記録するクラス
    def __init__(self, system_prompt: str):
        self.history = []
        self.add_message("system", system_prompt)

    def add_message(self, role: str, content: str):
        message = {"role": role, "content": content}
        self.history.append(message)

class NewPrompt:
    # 対話後、文章を再作成するクラス
    def __init__(self, system_prompt: str):
        self.history = [{"role": "system", "content": system_prompt}]
        self.model: str = "gpt-3.5-turbo"
        self.temperature: float = 0.0
        self.max_tokens: int = 512

    def create(self, history: list[dict]):
        if len(history) <= 2: # 対話の必要がなかった場合はそのままの文章が返される
            return history[1]["content"]
        else:
            self.history.extend(history[1:])
            self.history = [d for d in self.history if d["role"] != "assistant"]
            res = openai.ChatCompletion.create(
                model=self.model,
                messages=self.history,
                temperature=self.temperature,
                max_tokens=self.max_tokens,
            )
            return res.choices[0].message.content

class Chat:
    def __init__(
        self,
        function: list[dict] = [evaluate_func],
        model: str = "gpt-4",
        temperature: float = 0.0,
        max_tokens: int = 512,
    ):
        self.conversation = Conversation(conversation_prompt)
        self.new_prompt = NewPrompt(new_prompt)
        self.model = model
        self.temperature = temperature
        self.max_tokens = max_tokens
        self.function = function

    def ask_question(self, question: str) -> str:
        return str(input(question))

    def evaluate_user_prompt(
        self,
        clarity: int = 1,
        is_question: int = 1,
        is_consultation: int = 1,
        in_internal_docs: int = 1,
        ask_person: int = 1,
        ask_missing_info: str = "",
        res_consultation: str = "",
        role: str = "assistant",
    ):
        print(
            f"clarity: {clarity}, is_question: {is_question}, is_consultation: {is_consultation}, in_internal_docs: {in_internal_docs}, ask_person: {ask_person}"
        )
        if clarity != 1:
            if res_consultation and is_consultation <= 2:
                self.conversation.add_message(role, res_consultation)
                user_prompt: str = self.ask_question(res_consultation)
                self.chat(user_prompt)
            elif ask_missing_info and is_question <= 2:
                self.conversation.add_message(role, ask_missing_info)
                user_prompt: str = self.ask_question(ask_missing_info)
                self.chat(user_prompt)
        elif is_consultation <= 2:
            if in_internal_docs <= 2:
                pass
            elif res_consultation:
                self.conversation.add_message(role, res_consultation)
                user_prompt: str = self.ask_question(res_consultation)
                self.chat(user_prompt)

    def __call__(self, message: str):
        self.chat(message)
        prompt = self.new_prompt.create(self.conversation.history)
        print(prompt)

    def chat(self, message: str):
        self.conversation.add_message("user", message)
        response = self.create()
        response: dict = self.response_handler(response)

        if response["finish_reason"] == "function_call":
            function_name: str = response["function_name"]
            function_output: dict = response["function_output"]

            func = getattr(self, function_name)
            func(**function_output)

        elif response["finish_reason"] == "stop":
            print(response["content"])

    def create(self):
        res = openai.ChatCompletion.create(
            model=self.model,
            messages=self.conversation.history,
            temperature=self.temperature,
            max_tokens=self.max_tokens,
            functions=self.function,
        )
        return res

    def response_handler(self, response) -> dict:
        res = response.choices[0]
        finish_reason: str = res.finish_reason
        message = res.message

        if finish_reason == "function_call":
            function_name: str = message.function_call.name
            function_output: dict = json.loads(message.function_call.arguments)
            response = {
                "finish_reason": finish_reason,
                "function_name": function_name,
                "function_output": function_output,
            }
        elif finish_reason == "stop":
            content = message.content
            response = {
                "finish_reason": finish_reason,
                "content": content,
            }

        return response

chat = Chat(model="gpt-4")
chat(message="休暇について教えてください")

動かしてみる

作成したプログラムを動かしてみました。

情報が不足した質問の場合:

ユーザ:休暇について教えてください

Bot:clarity: 3, is_question: 1, is_consultation: 5, in_internal_docs: 1, ask_person: 5
休暇について具体的に何を知りたいのか教えてください。例えば、休暇の取得方法、休暇の種類、休暇の日数などです。

ユーザ:子どもの看護休暇は取れるでしょうか

Bot:clarity: 1, is_question: 1, is_consultation: 5, in_internal_docs: 2, ask_person: 1
「子どもの看護休暇を取ることは可能ですか?」 ←この文章を検索ステップの入力とします

最初の質問は休暇についてという幅広い質問だったため、ボットからより詳細な内容について尋ねる質問が返ってきました。質問を考え直すことで、具体性の高い内容で検索ステップに移ることができます。

質問か相談かわかりにくい内容だった場合:

ユーザ:案件で社外ユーザーと利用する会議システムは何がよいですか

Bot:clarity: 2, is_question: 1, is_consultation: 1, in_internal_docs: 3, ask_person: 2
具体的にどのような問題に直面しているのか、またはどのような目的で会議システムを選びたいのかを教えていただけますか?

ユーザ:zoomやteamsなどを考えていますが、社内利用を推奨されているものがあれば教えてください

Bot**:**clarity: 2, is_question: 1, is_consultation: 2, in_internal_docs: 1, ask_person: 3
「案件で社外ユーザーと利用する会議システムとして、zoomやteamsなどを考えていますが、社内利用を推奨されているものがあれば教えてください。」 ←この文章を検索ステップの入力とします

質問とも相談ともとれる内容です。res_consultationask_missing_infoがどちらも返されていれば相談であるres_consultationを優先して返答をします。ユーザが補足の返答をすると、入力文が再評価されます。clarityが1でない場合でもres_consultationask_missing_infoが返されない場合は検索ステップに移ります。

十分明確な質問の場合:

ユーザ;Google共有ドライブにメンバーを追加する方法を教えて

Bot:clarity: 1, is_question: 1, is_consultation: 5, in_internal_docs: 1, ask_person: 5
「Google共有ドライブにメンバーを追加する方法を教えて」 ←この文章を検索ステップの入力とします

一度で十分な情報が含まれると判断した場合は、検索ステップに移ります。

工夫ができる点・今後に向けて

今回プロトタイプを作成して感じた工夫すべき点や課題感を整理します。

プロンプトを工夫する

まだ粗削りな部分が多く、文言レベルでの改善のほか、人とのコミュニケーションとのギャップを考えるといくつかの工夫が考えられます。

  • ユーザの情報を活用する

    通常人同士の会話は、相手の背景や状況に基づいて進行します。このような情報は会話の中では明示的には現れることは少なく、暗黙的に考慮されているものです。プロンプトにそのユーザのメタ情報(所属部署や勤務地、年齢、性別など)を追加することで、これらの要素を考慮した対話ができるようになるかもしれません。

  • 対話の履歴を取り込む

    人同士の会話では、過去に話した内容も参考にされます。対話の履歴をプロンプトに加えることで、ユーザの以前の質問や、内容にどのような傾向があるかなどを考慮にして対話ができるかもしれません。

Function Callingを工夫する

ユーザの入力文を評価するための指標や質問を聞き返す文章の生成方法を改良が考えられます。

  • 質問を聞き返す際に、どのような要素の情報が足りないかで細分化する

    例えば、回答に質問者の所属や勤務オフィスなどの情報が必要な場合は、その情報を聞き返すための返答を作成させます。Function callingでは引数の名前やdecriptionに応じて出力を制御できるので以下のような項目を追加するとよいかもしれません。

    "ask_user_meta_info": {
          "type": "string",
          "description": "質問者の所属等がわからなければ回答が難しい内容については追加の情報を求めてください。",
      },

    実際に上の項目を追加したところ、「案件で社外ユーザーと利用する会議システムは何がよいですか」という入力に対して、「あなたの部署やプロジェクトの詳細、または社外ユーザーとの会議でどのような情報を共有する予定なのかを教えていただけますか?」という質問が返されました。

  • 複数のFunctionを定義してフローの制御に使う

    複数のFunctionを定義することにより、特定の状況や文章のタイプ応じて適切なFunctionを選択することができます。例えば、入力文が「質問」だと判断した際に呼び出すFunctionと、「相談」だと判断した際に呼び出すFunctionの二つを用意すれば、フローの分岐が容易に実装できそうです。選択肢の一つとして検討する価値があると思います。

対話ステップの処理全般について

より多くのユーザが適切であると感じる判断基準を見つけることが理想ですが、そのような処理の流れや基準を設定することは、極めて難しい課題だと思います。その難しさと要因、そして工夫する点には以下のような点が挙げられます。

  • ユーザ毎に期待感が違う

    ユーザによってそれぞれ異なる期待を持ってチャットボットを使用します。あるユーザは初回の入力文だけで回答を得たいと考える一方、他のユーザはじっくりとした対話をしたいと思うかもしれません。ユーザのフィードバックなどを基にして、ユーザに応じてフローの分岐に用いる閾値の調整をするなどの案が考えられます。

  • 場合に応じて基準が変わる

    さらにユーザの期待は状況によっても変わります。普段はじっくりと対話をしたいユーザでも、ある時は初回の入力文で迅速な回答が欲しいときもあるかもしれません。このような、状況に応じた対応は人とのコミュニケーションにおいても難しい印象です。

  • 明確な基準がない

    ここまで述べた通りユーザや状況によって期待は変動し、これを一貫した基準でとらえることは難しいです。そこで評価指標と閾値によるフロー制御だけではなく、Function Callingを組み合わせて対話の流れを動的に制御するアプローチが考えられます。先ほども触れましたが、複数のFunctionを定義したときはその中から適切なFunctionが選択されます。分岐の選択を一部AIに任せることでより柔軟な対応ができるのではないかと考えています。

まとめ

RAGを用いたシステムの改良を検討し、コミュニケーションツールとしての更なる可能性を探りました。ユーザとの対話の中でその意図を推定するために入力文を評価するというアプローチをとり、評価のために設定した指標をLLMを用いて数値化し、対話のフローを設計しました。プロトタイプを実装し、目的通り動くことを確認しました。今後は今回挙げた工夫点について試してみたいと思います。