LLMを利用したFAQ検索の評価データセットの作成〜その2〜

DALL-E 3で生成

こんにちは、AIチームの戸田です。

以前同じタイトルでブログを公開しました。

LLMを利用したFAQ検索の評価データセットの作成

前回の取り組みではLLMを用いて、以下の2つの手法を用いて、評価用の想定質問(クエリ)の生成を試みました。

  1. FAQのタイトルのパラフレーズを生成
  2. FAQの回答内容から質問内容を抽出

どちらの手法も概ね評価データとして適切なクエリが生成されましたが、1の手法は生成のバリエーションに乏しく、2の手法ではFAQが回答している内容と関係のない文章から質問が生成されてしまう、という問題がありました。

今回はそのブラッシュアップとして、2の手法をベースに生成時のpromptの工夫について取り組んだので、その際の取り組み内容と知見を共有しようと思います。

今回の取り組みの背景やFAQ検索の評価データセットの形式については前回の記事をご参照ください。

生成promptの改善

前回はLlamaIndexのDataGeneratorのpromptに日本語出力になるような1フレーズを追加したものでした。

templete = f"""You are a Teacher/Professor. Your task is to setup
 {num_questions_per_chunk} questions for an upcoming
 quiz/examination. The questions should be diverse in nature
 across the document. Restrict the questions to the
 context information provided.
 lang: ja"""

前回の設定としては、先生/教授という役割を与え、コンテキストから小テストを作るというタスクとして指示していました。この方法では、FAQの内容に基づくクエリは生成できそうですが、ユーザーの実際の問い合わせ文言とは合致しない可能性があります。またFAQの主題と離れた問題が作られてしまう可能性もあります。

この問題を解決するため、FAQの評価データ生成に特化したpromptを新たに作成しました。コンテキストの内容から外れないようにする指示に加え、上記で触れたユーザーが問い合わせてくるような文言にするような指示を追加しました。また一般的にpromptは英語で書いた方がよい、と言われていますが、前回はOff Target Problem(日本語で回答してほしいのに英語で回答されてしまう)が多くみられたので、今回は日本語でpromptを作りました。

templete1 = f"""あなたの仕事は、与えられたFAQから、そのFAQへの想定質問を作成することです。以下に示すルールを満たす必要があります。

    1. 与えられたFAQを無視して読んでも意味のわかる質問であること。
    2. 与えられたFAQから完全に答えられる問題であること。
    3. 問題の難易度は中程度であること。
    4. 質問は合理的で、人間が理解し回答できるものでなければならない。
    5. 「ログインできない」「退会したい」のような実際のユーザーが問い合わせてくるような文言にすること。
    6. 短く簡潔な言葉遣いを心がけること。

応答はカンマで区切られた値のリストでなければなりません。 例: `ログインできない, 退会したい`

### FAQ
{faq}"""

リストで出力するようにしているので、LangChainのoutput_parserを使うことでリストとして取得することができます。

from langchain.output_parsers import CommaSeparatedListOutputParser
from langchain.llms import OpenAI

model = OpenAI(temperature=0, model_name="gpt-3.5-turbo-0613")

input_prompt1 = # 上記のprompt

output = model(input_prompt1)
out_lst = output_parser.parse(output)

実際はparserが失敗することもあり、そのエラーをキャッチしてリトライするような仕組みにしましたが、本記事では割愛させていただきます。

結果を見てみたところ、リストとして出力するように指示していますが、箇条書きで出力されてしまうケースもあったようなので、改行で分割し、以下のような正規表現による後処理を行います

import re

def remove_to_bullet(text):
    text = re.sub(r'^\s*\d+\. ', '', text, flags=re.MULTILINE)
    text = re.sub(r'^質問\d+:\s*', '', text, flags=re.MULTILINE)
    text = re.sub(r'^FAQ:\s*', '', text, flags=re.MULTILINE)
    text = re.sub(r'- ', '', text, flags=re.MULTILINE)
    text = re.sub(r'Q. ', '', text, flags=re.MULTILINE)
    text = re.sub(r'^Q\d+\. ', '', text, flags=re.MULTILINE)
    return text
    
remove_to_bullet("Q1. ペナルティとは何ですか?")  # ペナルティとは何ですか?

後処理された生成データをサンプリングしてみてみます。

実際にユーザーが行いそうな質問形式になっており、FAQの主題とも合うようなクエリとなっていることがわかると思います。

生成したデータの検証

前回生じた問題は概ね解決できているようでしたが、データを眺めていると、また別の問題が生じているようでした。

質問の視点がユーザーではない

以下のようなデータが生成されていましたが、これらはユーザーからの問い合わせとして違和感があります。

例えば2行目のfaq_056の回答内容をみてみると以下のような部分があります。

タイトル: Amebaアプリでログインできない

Amebaアプリが最新バージョンでない場合、ログインに失敗する可能性があります。
利用中のアプリが最新バージョンになっているかご確認いただき、
    .
    .
    .
以下をご確認の上、設定内容の変更をお試しください。
■Android端末をご利用の場合
・日付と時刻の設定方法を見直す
    .
    .
    .
■iOS端末をご利用の場合
・SafariブラウザのCookie設定を変更する
    .
    .
    .

理想的には「ログインできない」や「ログインに失敗する」という問い合わせがこのFAQに対する想定質問として適切なようにみえますが、今回生成された文言は以下のようなカスタマーサポートにおけるオペレーターとユーザーの対話シーンで使われるような文言になっています。対話例を以下に示します。

ユーザー「ログインできません」
オペレーター「端末はiPhoneですか?Androidですか?」
ユーザー「Androidです」
オペレーター「Android端末をご利用の場合、日付と時刻の設定は正しくされていますか?

これはカスタマーサポートのオペレーター側が問題を絞り込むためにユーザー側に問いかけるような質問になっています。つまりFAQの内容に関する質問ではありますが、その視点が異なるのです。FAQの評価データとしてはユーザーからの問い合わせが欲しいので、こういったデータは除外したいです。

回答内容を見ても質問に答えられない

一見問題なさそうな以下のようなデータがありました。

しかし、このクエリに対応するFAQの回答内容は

タイトル: タグ

HTMLなどウェブページを作成する言語の中でつかわれる書式のことです。
文字に色をつけたり、大きさを指定したり、画像を挿入したりすることができます。
ブログの記事はタグの組み合わせで成り立っています。

となっており、この回答内容を使ってクエリの見出しの作成に関する質問に答えるのは困難に思えます。おそらくですが、今回使ったLLM(gpt 3.5 turbo)にはHTMLのhタグの知識があり、コンテキストに関連する問題としてその知識を使った問題を作ってしまたのではないかと考えました(この辺り想像になるので詳しい方がいましたらご教授願いたいです)。

このようにprompt1でFAQの内容から生成するよう指示していたにも関わらず、FAQに実際に記載してあることの範囲外の質問を生成してしまったケースも何件か見られました。こういったケースも除外したいです。

LLMに生成したクエリを検証してもらう

上記で紹介したような問題のあるクエリを除外したいのですが、人手でチェックするのは大変なので、この確認作業もLLMにしてもらおうと思います。

生成フェーズで提示したprompt1で生成されたクエリに続く対話として、そのクエリに問題が起こっていないかを確認するprompt2を作ります。

from langchain.output_parsers import PydanticOutputParser
from pydantic import BaseModel, Field

template2 = """想定質問作成時によくある間違いとして以下が挙げられます。

    1. 視点: 質問がユーザーが情報を求める形式になっておらず、カスタマーサポートのオペレーターがユーザーに問いかける形式になっている
    2. 不整合: FAQに答えが存在しない質問や、FAQの範囲外の質問を想定質問として設定している

作成した想定質問にこれらの問題が起こっているか確認してください

{format_instructions}
"""

class QueryChecker(BaseModel):
    is_problem_1_occurring: bool = Field(description="`1. 視点`の問題が起こっているか")
    is_problem_2_occurring: bool = Field(description="`2. 不整合`の問題が起こっているか")

parser = PydanticOutputParser(pydantic_object=QueryChecker)
format_instructions = parser.get_format_instructions()
prompt2 = PromptTemplate(
    template=template2,
    input_variables=[],
    partial_variables={"format_instructions": format_instructions}
)

input_prompt2 = prompt2.format()

後段で処理が行いやすいようにpydanticで出力形式を指定するPydanticOutputParserを利用しました。これにより、pythonの辞書形式のような形で出力を得ることができます。

自身の出力を修正させるように、対話形式でLLMにpromptを入力します。簡単に図示すると以下のようになります。

コードは以下のようになります。

from langchain.chat_models import ChatOpenAI
from langchain.schema import (
    AIMessage,
    HumanMessage,
)

input_prompt1 = # FAQの想定質問を生成するprompt1
query = # prompt1によって生成されたクエリ
input_prompt2 = # 生成されたクエリを評価するprompt2

chat = ChatOpenAI(temperature=0, model="gpt-3.5-turbo-0613")

chat_input = [
    HumanMessage(content=input_prompt1),
    AIMessage(content=query),
    HumanMessage(content=input_prompt2),
]

response = chat.predict_messages(chat_input)
out = parser.parse(response.content)

それぞれの検証項目に引っかかったデータをサンプリングしてみてみます。

検証項目1. 視点の問題

検証項目2. 不整合の問題

タイトル: 記事デザインを使って素敵な投稿を

Amebaアプリでは、記事デザインという記事ごとに適用されるデザインの形式が用意されています。
記事デザインを使うことで、同じ内容でも見やすく素敵な記事が簡単に投稿できます。
※パソコンからのご利用方法については、こちらのヘルプページをご参照ください。
記事デザインの使い方
1.アプリを起動し、ホーム画面右したのペンマークをタップします。
2.記事を編集する画面の下部にあるバナーから記事デザインの選択ができます。
3.記事デザインは、見出しのデザインのみなど一部の挿入も可能です。

どちらも上手く検出できているようです。ちなみに最初に提示したデータも無事検出することができていました。

promptの試行錯誤

今回はコーディングというよりLLMへの入力、所謂Prompt Engineeringを頑張っていました。まだこの分野の知見が少ないので、手探りな部分もありましたが、色々と知見があったのでここで共有したいと思います。なお、試行錯誤にあたりBest practices for prompt engineering with OpenAI APIを参考にしました。

例を示す

FAQの想定質問を生成する際、どうしてもFAQの主題と離れたものを生成してしまうことが多かったのですが、上記prompt1の5番目の条件の「ログインできない」「退会したい」のような実際のユーザーが問い合わせてくるような文言にすること。という文言を追加することでこれを緩和することができました。Prompt Engineeringのテクニックの1つであるfew-shotに近いような工夫だと思っていますが、複数の条件指示の中でもある程度効果を発揮できることがわかりました。

否定系の指示は多段構成で判別する

今回は対話形式で生成(step 1)→検証(step 2)と2回LLMを用い、後処理的に不適切なデータをフィルタリングしています。ここで「step 1の生成条件の部分にstep 2の検証内容を追加すれば一回の生成で解決するのでは?」と考えたのですが、これがなかなか上手くいきませんでした。

上記OpenAIのベストプラクティス集を見てみると、どうやらLLMは否定系の指示が苦手(できないわけではなさそう?)という情報をみつけ、「オペレーター側からの質問形式にしないでください」のような制約は守られにくいのかと思いました。そこで内容を転じて「オペレーター側からの質問形式ですか?」のように肯定的な内容でpromptを作り、そこに反応した部分を後処理的にフィルタリングすることを思いつき、これが上手くハマりました。

また多段構成で判断することで、prompt1で漏らしていた生成(prompt2の不整合の問題)をキャッチすることもできました。これに関しては何回か繰り返せばより精度は上がりそうな気がしますが、時間やお金(API利用料)と相談する必要がありそうです。

おわりに

本記事では、以前書いたLLMを利用したFAQ検索の性能を評価するための新しいデータセットの作成方法をブラッシュアップし、その知見を共有させていただきました。

Prompt Engineeringは奥が深く、世の中的にも色々ノウハウが溜まっていきているようなので、引き続きキャッチアップを続けたいです。

今回は1つのFAQから問題を生成しましたが、2つのFAQを参照し、両方のコンテキストを参照しなければ回答できない問題など、より高度な問題の生成に挑戦してみたいです。

最後までお読みいただきありがとうございました!

2023.11.21 追記

今回作ったデータセットはこちらで公開しています。以下のようにして読み込むことができます。

from datasets import load_dataset

# LLMで生成したユーザーの想定質問
query_dset = load_dataset("ai-shift/ameba_faq_search")

# 検索対象のFAQデータ
faq_dset = load_dataset("ai-shift/ameba_faq_search", data_files={"faq": "target_faq.csv"})

PICK UP

TAG