Vポイントマーケティング|TECH LABの Tech Blog

TECH LABのエンジニアが技術情報を発信しています

ブログタイトル

ExpertGenQAによる自動QA生成を試してみました(実装編)

こんにちは。AIエンジニアリンググループの矢澤です。よろしくお願いします。

前回の記事で、ExpertGenQAによる自動QA生成の概要や実験結果について話しました。 本記事では、実験で使用したスクリプトを共有し、処理の流れや論文との差異について説明します。

実装

以下では、ExpertGenQAの論文を基にPythonで実装したスクリプトについて説明します。

まずは実装したソースコードを載せます(モジュールのインポートやユーティリティ関数の内容などは省略)。

def generate_qas(documents, n_topics=10, K=5, n_fewshots=3, test_mode=False):
    """
    QAの生成
    """
    # 辞書の値は実際には使用されない
    QUESTION_STYLES = {
        "ルール適用": "特定のルールをどのように解釈すべきかを扱う",
        "シナリオベース": "ルールガイダンスを必要とする特定の状況を提示する",
        "用語の明確化": "専門用語の定義と説明に重点を置く",
    }

    logger.info("【質問生成】開始")

    # Azure OpenAIモデルの作成
    aoai_model = create_aoai()

    if test_mode:
        n_topics = 5
        K = 3
        n = 1
        documents = {
            doc_name: documents[doc_name]
            for doc_name in random.sample(list(documents.keys()), 3)
        }

    all_qas = []
    for doc_name, doc in documents.items():
        all_topics = extract_topics(doc, aoai_model, n_topics=n_topics)

        for style in QUESTION_STYLES.keys():
            logger.info(f"{style=}")

            for _ in range(K):
                few_shot = sample_fewshot(SAMPLE_QAS, style, n_fewshots=n_fewshots)

                for topic in all_topics:
                    question, answer = generate_qa(
                        doc, all_topics, topic, few_shot, aoai_model
                    )
                    all_qas.append([doc_name, style, topic, question, answer])

    qas_df = pd.DataFrame(
        all_qas, columns=["Document", "Style", "Topic", "Question", "Answer"]
    )
    qas_df.drop_duplicates(inplace=True)
    n_questions = len(qas_df)

    logger.info(f"{n_questions=}")
    logger.info(qas_df.head())

    logger.info("【質問生成】完了")
    return qas_df


def create_aoai():
    aoai_endpoint = os.getenv(
        "AZURE_OPENAI_API_BASE", "https://azoai4c3.openai.azure.com/"
    )
    aoai_deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT", "gpt-4o")
    aoai_version = os.getenv("AZURE_OPENAI_API_VERSION", "2024-02-01")

    aoai_model = AzureChatOpenAI(
        azure_endpoint=aoai_endpoint,
        azure_deployment=aoai_deployment,
        api_version=aoai_version,
        temperature=1,  # 論文と同じ値
    )
    return aoai_model


def extract_topics(document, aoai_model, n_topics=10):
    logger.info("【トピック抽出】開始")

    # ユーザープロンプトとどちらが良いかは要調査
    SYSTEM_PROMPT = f"文書:{document}\n"

    # トピック数を明示
    SYSTEM_PROMPT += f"""
        -----
        
        指定された文章を分析し、その主なトピック{n_topics}個を特定してください。
        キーが「Topic」で、値が主なトピック名の配列である JSON 形式で回答してください。
    """

    # JSONの中身がシングルクォートだとJSONDecodeErrorになる
    SYSTEM_PROMPT += '''
        例:
        {"Topic": ["トピック1", "トピック2", "トピック3"]}
    '''

    answer = _answer_w_aoai(aoai_model, SYSTEM_PROMPT)
    logger.info(f"{answer=}")

    answer_json = _convert_str_to_json(answer)
    topics = answer_json["Topic"]

    logger.info("【トピック抽出】完了")
    logger.info(f"{topics=}")
    return topics


def generate_qa(document, all_topics, topic, few_shot, aoai_model):
    logger.info("【質問生成】開始")

    # 文字列に変換しないと、出力QAのJSON形式がおかしくなる
    fewshot_str = "\n".join([json.dumps(fs, ensure_ascii=False) for fs in few_shot])

    # 「1つ」と明示しないと複数生成される
    SYSTEM_PROMPT = f"""
        文章:{document}

        -----
        
        上記の文章は以下のトピックをカバーしています:
        {all_topics}

        文章からトピック '{topic}' に関連する質問・回答の組を、以下のJSON形式で生成してください。
        質問・回答の組は必ず1つのみとし、複数の質問・回答は出力しないでください。
    """

    SYSTEM_PROMPT += """
        {"Question": "質問内容", "Answer": "回答内容"}
    """

    SYSTEM_PROMPT += f"""
        例:
        {fewshot_str}        
    """
    logger.debug(f"{SYSTEM_PROMPT=}")

    qa = _answer_w_aoai(aoai_model, SYSTEM_PROMPT)
    qa_json = _convert_str_to_json(qa)

    logger.info("【質問生成】完了")
    logger.info(f"{qa_json=}")
    return qa_json["Question"], qa_json["Answer"]


def sample_fewshot(faq_data: Dict[str, Dict], style_name: str, n_fewshots: int = 3):
    """
    FAQデータを基に、FewShot用のQAをサンプリング
    """

    few_shot = random.sample(faq_data[style_name], n_fewshots)
    return few_shot

ここで、今回実装するにあたりハマったポイントや、論文の内容との差異を共有します。

QAのスタイル(QUESTION_STYLES)

QAのスタイルは、論文で提案されていた3つ(ルール適用、シナリオベース、用語の明確化)をそのまま使用しています。 辞書の値として上記スタイルの説明を定義していますが、実際にはスタイルはFAQのサンプリングでしか参照されないため、この情報は使われないデータとなっています。 また、論文中でも記載のある通り、こちらのスタイルは技術ルールに関する文書から抽出したものなので、他の分野など場合によっては独自に定義すると良いかと思います。

LLMの生成(create_aoai)

今回はLLMのモデルとしてAzure OpenAIを使用し、モデルのバージョンは論文と同じGPT-4oにしました。 GPT-4o miniを使うことでコストを節約できますが、ある程度の性能を担保したかったことと、チャットボットの応答のように大人数や頻繁に実行するプログラムではないので、通常の4oを採用しています。 また、温度パラメーターも論文を参考に1.0に設定し、多様な出力が行われるようにしています。1

トピックの抽出(extract_topics)

文書のトピックセットは、論文のAppendixに記載されたプロンプトをLLMのシステムプロンプトに設定して抽出しています。 論文のプロンプトではトピック数が明示されていませんが、大量に抽出されてしまう恐れがあったので、実装では変数n_topics(デフォルトは10)として設定できるようにしました。 また、出力のJSON文字列がシングルクォートになっているとデコード時にエラーになるようだったので、ダブルクォートの形式を例として指示するようにしました。2

QAの生成(generate_qa)

QA生成では、論文と大きく異なる点として、質問(Question)だけでなく対応する回答(Answer)も生成するように指示しています。3 これはチャットボットの精度評価などで使用することを見越して、回答も生成した方が便利だと考えたためです。

あらかじめサンプリングしたFAQを基に新たなQAを生成しますが、FAQの辞書をそのまま挿入すると出力のJSON形式がおかしくなるようだったので、文字列としてマージしてから入力するようにしました。 更に、プロンプトについても論文の内容をそのまま入力すると、Few-Shotの例につられて複数のQAが生成されてしまうことがあるため、1つのみということを明示するようにしています。 トピック抽出と同様に、JSONの出力例や変換処理によってデコードエラーが起きないようにもしています。4

生成QAのマージ(qas_df)

生成されたQAは、ドキュメント名やスタイル、トピックの情報と併せて配列に追加していき、最終的にPandasのDataFranmeに変換してからCSVとして出力しました。 このとき同じQAが生成される可能性を考慮して、drop_duplicates関数で重複削除しています。 ただし、上記の実装では全く同じQAのみが削除されるため、似たような質問も削除する場合はLLMで重複判定したり、埋め込みの類似度を計算するなどの工夫が必要になるかと思います。

まとめ

本記事では、ExpertGenQAのPython実装や処理の詳細を説明しました。 今回は試していませんが、システムプロンプトやパラメーターを調整することで生成結果が変わるかもしれません。 特に、スタイルの内容やトピック数などは文書の種類に応じて再定義した方が良い可能性もあるため、今後はそのような実装のカスタマイズにも挑戦してみたいです。


  1. 温度パラメーターを低くすると、複数回実行時に同じQAが生成されやすくなり、一貫性を重視する場合に適しています。ただし、ドキュメント内で重複したQAが生成されやすく、最終的なQAの件数が減少する可能性があります。
  2. また、出力に"json"という文字列が含まれるなど不適切な形式でエラーになることがあったので、関数_convert_str_to_json内で変換してからデコードするようにしました。
  3. FAQをFew-Shotプロンプトとして与えるため、FAQについても質問と回答のペアを用意しておく必要があります。
  4. 今回は実装しませんでしたが、LangChainのwith_structured_outputなどを活用して所定の形式で出力するように制御する方法もありそうです。