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

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

FAQ(Frequently Asked Questions)は、ユーザーがよく持つ疑問や問題点に対する回答をまとめたもので、ウェブサイトやマニュアル、カスタマーサポートなど様々な場面で利用されています。FAQの効率的な検索手法は、ユーザーサポートの向上や情報提供の効率化に直結するため、長い間研究や開発の対象となってきました。

しかし、新しい技術や手法が次々と登場する中で、その性能を比較・評価するためのデータセットは少ないのが現状です。

そこで本記事では、LLM(Large Language Model)を利用したFAQ検索の性能を評価するための新しいデータセットの作成方法について紹介します。

評価データセットに必要な要素

FAQ検索の評価を行うためのデータセットを作成する際、以下の要素が必要となります。

  1. タイトル
    • FAQの主題やカテゴリを示す短い文章やフレーズです。
    • 「〇〇とは?」のような"よくある質問"の質問文であることが多いです。
    • ユーザーが一目でそのFAQの内容を把握するための情報として利用されます。
  2. 回答内容
    • 質問に対する具体的な回答や解説を含むテキストです。
    • ユーザーはこの内容を見ることで問題を解決できます。
  3. ユーザーの質問
    • 実際のユーザーが持つ疑問や問題点を示すテキストです。
    • どのFAQに関連するかの紐付け情報も持っています。
    • この質問をもとに、最も適切なFAQを検索するシミュレーションを行います。

これらの要素をもとに、評価データセットを構築することで、FAQ検索技術の性能を評価することが可能となります。

1. タイトル2. 回答内容は既にあるさまざまなFAQを使うことができますが、3. ユーザーの質問は個人情報が含まれている可能性があり、プライバシーの保護や法律の制約から非公開なケースが多いです。そこで、LLMを利用して、実際のユーザーの質問データを生成するアプローチが考えられます。LLMは、大量のテキストデータから学習するため、人間のような自然な言葉で質問を生成することができます。これにより、実際のユーザーの質問データに近い、バリエーション豊かでリアルな質問データをシミュレートすることができます。

本記事ではAI Shiftの親会社であるサイバーエージェントが提供するAmebaブログヘルプページをベースに作ろうと思います。

ヘルプページをクローリングして整形したデータの先頭3件を以下に示します。

各カラムは

  • ID: FAQのユニークID
  • Title: タイトル
  • Content: 回答内容

となっています。

LLMによるユーザー質問文の作成

LLMによるデータ生成の方法は多くありますが、ここでは3種類紹介します。

タイトルからパラフレーズを使って生成

1. タイトルをベースに類似する文章を作成する方法です。LLM登場以前から同義語を辞書的に置換するなどの方法が用いられていましたが、今回はLangChainを使ってこれを実行してみます。

LangChainのドキュメントのExtending Datasetsにあるサンプルコードを流用します。日本語のデータなのでpromptを一部変更します。

import re
from typing import List

from langchain.chains import LLMChain
from langchain.chat_models import ChatOpenAI
from langchain.output_parsers import ListOutputParser
from langchain.prompts import (
    ChatPromptTemplate,
    HumanMessagePromptTemplate,
    SystemMessagePromptTemplate,
)


class NumberedListOutputParser(ListOutputParser):
    def parse(self, output: str) -> List[str]:
        return re.findall(r"\d+\.\s+(.*?)\n", output)


paraphrase_llm = ChatOpenAI(temperature=0.5)
prompt_template = ChatPromptTemplate.from_messages(
    [
        SystemMessagePromptTemplate.from_template(
            "You are a helpful paraphrasing assistant tasked with rephrasing text."
        ),
        SystemMessagePromptTemplate.from_template("Input: <INPUT>{query}</INPUT>"),
        HumanMessagePromptTemplate.from_template(
            "What are {n_paraphrases} different ways you could paraphrase the INPUT text?"
            " Do not significantly change the meaning."
            " Respond using numbered bullets. If you cannot think of any,"
            " just say 'I don't know.'"
            " lang: ja"  # 追加
        ),
    ]
)

paraphrase_chain = LLMChain(
    llm=paraphrase_llm,
    prompt=prompt_template,
    output_parser=NumberedListOutputParser(),
)

デフォルトのプロンプトには数値つきの箇条書きで答えることや、わからない場合は'I don't know.'と答えることなどが指示されており、これらのフォーマットに沿ってLangChain内部で後処理が行われ、出力は単純な文章のリストとなります。

これを使ってデータ生成します。

import pandas as pd

ameba_df = pd.read_csv("{データセットPATH}")

n_paraphrases = 2  # パラフレーズを1つだけ生成したかったが、なぜか'設定値 - 1'の生成がされる傾向があった。
paraphrase_lst = []
for i, q_text in tqdm(ameba_df[["ID", "Title"]].values):
    result = await paraphrase_chain.arun(
        query=q_text, n_paraphrases=n_paraphrases
    )
    for q in result:
        paraphrase_lst.append((i, q))

paraphrase_df = pd.DataFrame(paraphrase_lst, columns=["ID", "Query"])

paraphrase_df.head(3)

生成されたデータを見てみると、何パターンか後処理が必要なものがあるので、これを修正/除外します。

'''
<OUTPUT>退会手続きに関して</OUTPUT>
のようにpromptに引きずられてタグがついてしまっている
-> 正規表現で対処
'''

import re

def remove_html_tags(text):
    clean = re.compile('<.*?>')
    return re.sub(clean, '', text)

paraphrase_df["Query"] = paraphrase_df["Query"].map(remove_html_tags)


'''
Titleと同じ文章が生成されてしまっている
-> 除外
'''

same_title_idx = []
for _, row in paraphrase_df.iterrows():
    if row["Query"] != ameba_df[ameba_df["ID"] == row["ID"]]["Title"].iloc[0]:
        same_title_idx.append(row.name)

paraphrase_df = paraphrase_df.loc[same_title_idx]


'''
Off-Target Problem: https://arxiv.org/abs/2305.10930
(ターゲットの言語は日本語だけど英語で生成されてしまっている)
-> 半角スペースの数でフィルタリング
'''

non_english_idx = [row.name for _, row in paraphrase_df.iterrows() if len(row["Query"].split()) <= 5]

paraphrase_df = paraphrase_df.loc[non_english_idx]


'''
生成フォーマットに沿わない生成になっている
例えば
Ameba Pick 利用規約
というタイトルに対して
Ameba Pick 利用規約の内容を言い換える方法が2つあります。意味を大きく変えずに、以下の番号付きの箇条書きで回答します。
という生成がされている
-> テキスト長の差でフィルタリング
'''

diff_title_idx = []
for _, row in paraphrase_df.iterrows():
    org_title = ameba_df[ameba_df["ID"] == row["ID"]]["Title"].iloc[0]
    l_diff = abs(len(row["Query"]) - len(org_title))
    if l_diff < 20:  # 決めうち
        diff_title_idx.append(row.name)
        
paraphrase_df = paraphrase_df.loc[diff_title_idx]

生成したデータをランダムにサンプリングして見てみます。

for _, row in paraphrase_df.sample(3).iterrows():
    org_title = ameba_df[ameba_df["ID"] == row["ID"]]["Title"].iloc[0]
    print(f"[{row.ID}]")
    print("タイトル:", org_title)
    print("生成文:", row["Query"])
    print()

単純な辞書ベースの類義語置換よりはバリエーションのある言い換えになっているのではないでしょうか。

回答内容から質問の生成

LlamaIndexのDataGeneratorを使い、2. 回答内容から、その内容に関する質問を生成します。本来、この機能はニュース記事のような構造化されていない文章から、その記事に関する5W1Hの質問を抽出する、といった用途で使われるようなのですが、一つのFAQの内容を一つの記事と見立てれば、そのFAQに関する質問を生成できると考えました。

DataGeneratorの生成がどのような質問になるかはpromptで指示することができますが、今回はデフォルトのテンプレートをベースにします。

num_questions_per_chunk = 1
question_gen_query = (
     "You are a Teacher/Professor. Your task is to setup"
     f" {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"  # 追加
)
question_gen_query

ChatGPTなどのLLMを利用する際、役割を明確に与えるとより良い結果が得られると言われていますが、今回の問題に対してはTeacher/Professorという役割が与えられています。言われてみれば確かに教師は試験の問題を作ることがあるので、文書からそれに関する問題を作るという行動に対しては納得の役割でした。自分には思いつかなかったので非常に興味深かったです。

こちらのpromptを使ってデータを生成します。

lst = []
for i, doc in tqdm(enumerate(documents), total=len(documents)):
    dataset_generator = DatasetGenerator.from_documents(
        [doc],
        question_gen_query=question_gen_query,
    )
    questions = dataset_generator.generate_questions_from_nodes(num=1)
    for q in questions:
        lst.append((ameba_df.iloc[i]["ID"], q))

gen_from_content_df = pd.DataFrame(lst, columns=["ID", "Query"])
gen_from_content_df.head(3)

ドキュメントを1つずつ渡していますが、本来はここに関連文書をまとめて入力するような使い方をします。

パラフレーズの時と同様Off-Target Problemなどが見られるので、同様の後処理を行ったうえで、生成したデータをランダムにサンプリングして見てみます。

タイトルは入力していないですが、かなりタイトルに近い内容が生成されていることがわかります。しかしfaq_283はちょっと内容が乖離しているようなので、実際の回答内容を確認してみます

print(ameba_df.query("ID=='faq_283'")["Content"].iloc[0])

2行目の「ゲームを有利にすすめることも可能です。」についてのHowの質問が生成されてしまったようです。ここは生成時のpromptを工夫する必要がありそうです。

ユーザー質問の拡張

最初に紹介したパラフレーズによる生成方法は、辞書ベースの類義語置換よりもバリエーション豊かな言い換えが可能となりますが、実際のユーザーの検索パターンを考慮すると、さらに多様な表現が求められます。これを実現するために、存在するユーザー質問を拡張し、新たな質問データを生成するアプローチが考えられます。

本記事で扱うデータセットは、FAQデータのみを対象としていますが、実際のシステム運用においては、ユーザーの検索履歴や質問履歴を活用できます。これにより、より現実の利用シーンに即した質問データを生成できます。

本記事では生成までは行いませんが、LangChainのドキュメントのExtending Datasetsにあるサンプルコードを使った例を示します。

input_gen_llm = ChatOpenAI(temperature=0.5)
input_gen_prompt_template = ChatPromptTemplate.from_messages(
    [
        SystemMessagePromptTemplate.from_template(
            "You are a creative assistant tasked with coming up with new inputs for an application."
        ),
        SystemMessagePromptTemplate.from_template("The following are some examples of inputs: \n{examples}"),
        HumanMessagePromptTemplate.from_template(
            "Can you generate {n_inputs} unique and plausible inputs that could be asked by different users?"
            " lang: ja"  # 追加
        ),
    ]
)

input_gen_chain = LLMChain(
    llm=input_gen_llm,
    prompt=input_gen_prompt_template,
    output_parser=NumberedListOutputParser(),
)

texts = ... # ユーザーの検索履歴や質問履歴のリスト

example_inputs_str = "\n".join([f"- {t}" for t in texts])
n_inputs = 5  # 生成数
result = await input_gen_chain.arun(
        examples=example_inputs_str, n_inputs=n_inputs
)

機能の使い方としてはパラフレーズで使用したものとかなり近いものになっています。

評価での利用

実際に簡単な手法の検索性能を以下の手法で評価してみたいと思います。

検索対象:タイトル
類似度測定方法:CountVectorizerによって変換されたベクトルの内積
評価方法:Precision at 1 (P@1)

import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
from janome.tokenizer import Tokenizer

t = Tokenizer()

corpus = ameba_df["Title"].tolist()
corpus = [" ".join(t.tokenize(c, wakati=True)) for c in corpus]

vectorizer = CountVectorizer()
X = vectorizer.fit_transform(corpus)
 
ranks = []
for _, row in paraphrase_df.iterrows():
    answer_idx = ameba_df[ameba_df["ID"] == row["ID"]].index[0]
    v = vectorizer.transform([" ".join(t.tokenize(row["Query"], wakati=True))])
    dot = np.dot(v, X.T).toarray()[0]

    rank = dot.argsort()[::-1]
    ans_rank = np.array(range(len(rank)))[rank == answer_idx][0]
    ranks.append(ans_rank+1)

p_at_1 = (np.array(ranks) <= 1).mean()
print(p_at_1)  # 0.6673773987206824

P@1が0.667という結果になりました。本記事ではこの値に関して議論はしませんが、生成された文章はシンプルな手法で全問正解になるような単純な問題ではないことがわかるかと思います。

おわりに

本記事では、LLMを利用したFAQ検索の性能を評価するための新しいデータセットの作成方法について紹介し、生成したデータセットを使って簡単な評価をしてみました。

従来の辞書ベースのような手法より多様な文章が生成できることがわかりましたが、後処理が必要だったり、意図しない生成がまだまだあることがわかりました。

今後は生成時のpromptの工夫や、後処理的に意図しない生成をフィルタリングするなど、もう少しこのデータをブラッシュアップしていきたいと思います。

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

PICK UP

TAG