🔖

Azure OpenAI Service で Custom Question Answering の要約をしてみた

2023/04/19に公開

先日 C# で Azure OpenAI Service を使う方法を書きました。最初がトークンを C# で数える方法と、次が Azure.AI.OpenAI パッケージを使う方法です。

今日は、Azure OpenAI Service と Custom Question Answering を組み合わせて使って遊んでみようと思います。

Custom Question Answering とは

Azure の Cognitive Service の 1 つで簡単に言うと質問を投げ込むと回答を返してくれる Web API を作れるようなサービスです。
質問と回答のペアをサービスに登録していって、学習させていくものになります。

Azure OpenAI Service と組み合わせてみる

Custom Question Answering は、非常に便利なのですが質問に対する回答のメンテナンスが大変になりがちです。
なので、汎用的に使える FAQ にしようとすると回答が長文になりがちだったりして、長文になると回答を読むのが大変になってしまいます。

さらに、あいまいな質問をすると複数個の回答が返ってくることもあるので、その場合は表示のさせ方の工夫にもよりますが素直に表示すると利用者は凄い長文の中から自分の知りたいことを読み取るといったことをしないといけません。
ということで、この長文から自分が知りたいことを要約して抽出するという作業を Azure OpenAI Service を使って解決してみようと思います。

Custom Question Answering のデータソース

こういったものを作るときに質問データを作るのが大変ですよね…。
Custom Question Answering は FAQ の Web ページや、質問と回答のペアのテキストファイルをアップロードすることで学習させることができます。
対応しているフォーマットは以下のドキュメントにあります。

https://learn.microsoft.com/ja-jp/azure/cognitive-services/language-service/question-answering/reference/document-format-guidelines

今回は、以下のサイトをデータソースとして登録しました。

登録すると自動的に解析して、こんな感じのナレッジベースを作ってくれます。

テスト画面では実際に質問を入力すると、どんな回答が返ってくるのか確認できます。Inspect を押すと一番スコアの高かった回答以外にどんなものが返ってきているのかが確認できます。このスコアが一番高かった回答以外も OpenAI に食わせて色々やってみたいと思います。

Custom Question Answering から回答の取得

ということで、まずは OpenAI に食わせるための回答を Custom Question Answering から取得してみます。
Azure.AI.Language.QuestionAnswering パッケージを使って取得します。

本当は Azure SDK を DI で使うためのお作法にしたがって DI するのがいいんですが、今回は使い捨てサンプルということなので ASP.NET Core Blazor Service でプロジェクトを作って全部のロジックをページに書いていきます。
実際にはAzure SDK for .NET での依存関係の挿入を参考にして DI してください。クライアントのインスタンスの管理なども、ちゃんとやってくれるのでお任せしてしまいましょう。

では、ASP.NET Core Blazor Server のプロジェクトを作成して Azure.AI.Language.QuestionAnswering パッケージを追加します。
ユーザーシークレットに Custom Question Answering を呼び出すためのエンドポイントと API キーとナレッジベースの名前を設定しておきます。

secrets.json
{
  "CQA": {
    "Endpoint": "https://xxxx.cognitiveservices.azure.com",
    "Key": "API キー",
    "ProjectName": "プロジェクト名",
    "DeploymentName": "デプロイ名(多分 production)"
  }
}

この設定がある前提で Index.razor を以下のように変更してナレッジの検索機能を実装します。上に書いた通りクライアントのインスタンスを毎回作ったりアンチパターンになっていますが、今回は使い捨てサンプルで全体を 1 ファイルで見通せることに注力しているのでこれでいきます。本番でやるときはちゃんと DI しましょうね!

Index.razor
@page "/"
@using Azure.AI.Language.QuestionAnswering;
@using Azure;
@inject IConfiguration Configuration

<PageTitle>Index</PageTitle>

<div>
    <label>
        質問:
        <input type="text" @bind="_question" />
    </label>
    <button class="btn btn-primary" @onclick="OnSearchClick" disabled="@string.IsNullOrWhiteSpace(_question)">検索</button>
</div>

@if(_answers is not null)
{
    <h3>@_question に対する回答候補</h3>
    <table class="table table-bordered">
        <thead>
            <tr>
                <th>想定質問</th>
                <th>回答</th>
                <th>確かさ</th>
                <th>ソース</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var answer in _answers)
            {
                <tr>
                    <td>@answer.Questions.FirstOrDefault()</td>
                    <td>@answer.Answer</td>
                    <td>@answer.Confidence</td>
                    <td>@answer.Source</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private QuestionAnsweringClient _questionAnsweringClient = default!;
    private string _question = "";
    private IEnumerable<KnowledgeBaseAnswer>? _answers;

    protected override void OnInitialized()
    {
        var cqa = Configuration.GetRequiredSection("CQA");
        _questionAnsweringClient = new QuestionAnsweringClient(
            cqa.GetValue<Uri>("Endpoint"),
            new AzureKeyCredential(cqa.GetValue<string>("Key")!));
    }

    private async Task OnSearchClick()
    {
        if (string.IsNullOrWhiteSpace(_question)) return;

        var cqa = Configuration.GetRequiredSection("CQA");
        var answersResult = await _questionAnsweringClient.GetAnswersAsync(_question,
            new QuestionAnsweringProject(cqa.GetValue<string>("ProjectName")!, cqa.GetValue<string>("DeploymentName")!),
            new AnswersOptions
            {
                Size = 5,
            });

        _answers = answersResult.Value.Answers;
    }

    private async Task OnCallOpenAIClick()
    {
        
    }
}

実行して、適当に質問文を入力して検索ボタンを押すと以下のように回答候補が表示されます。

OpenAI に回答を生成してもらう

今回のナレッジベースのソースは割と短めの文章なので人間が呼んでも、割とわかりやすいですが OpenAI にちょっと頑張ってもらいましょう。
OpenAI を使うために必要な情報を secrets.json に足します。Endpoint, Key, DeploymentModelName があれば大丈夫です。

secrets.json
{
  "CQA": {
    "Endpoint": "https://xxxx.cognitiveservices.azure.com",
    "Key": "API キー",
    "ProjectName": "プロジェクト名",
    "DeploymentName": "デプロイ名(多分 production)"
  },
  "OpenAI": {
    "Endpoint": "https://xxxx.openai.azure.com/",
    "Key": "API キー",
    "DeploymentName": "gpt 3.5 turbo をデプロイしたときにつけた名前"
  }
}

プロジェクトに以下の 2 つのパッケージを追加します。

  • Microsoft.DeepDev.TokenizerLib
  • Azure.AI.OpenAI (2023/04/19 時点ではプレビュー版)

これを使って OpenAI に回答を生成してもらうためのコードを追加します。

Index.razor
@page "/"
@using Azure.AI.Language.QuestionAnswering;
@using Azure;
@using Azure.AI.OpenAI;
@using System.Text;
@using Microsoft.DeepDev;
@inject IConfiguration Configuration

<PageTitle>Index</PageTitle>

<div>
    <label>
        質問:
        <input type="text" @bind="_question" />
    </label>
    <button class="btn btn-primary" @onclick="OnSearchClick" disabled="@string.IsNullOrWhiteSpace(_question)">検索</button>
    <button class="btn btn-primary" @onclick="OnCallOpenAIClick">結果を OpenAI でさらにいい感じに</button>
</div>

@if(!string.IsNullOrWhiteSpace(_openAIAnswer))
{
    <div class="alert alert-primary" role="alert">
        <h3>OpenAI による要約回答</h3>
        <span>@_openAIAnswer</span>
    </div>
}

@if(_answers is not null)
{
    <h3>@_question に対する回答候補</h3>
    <table class="table table-bordered">
        <thead>
            <tr>
                <th>想定質問</th>
                <th>回答</th>
                <th>確かさ</th>
                <th>ソース</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var answer in _answers)
            {
                <tr>
                    <td>@answer.Questions.FirstOrDefault()</td>
                    <td>@answer.Answer</td>
                    <td>@answer.Confidence</td>
                    <td>@answer.Source</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private QuestionAnsweringClient _questionAnsweringClient = default!;
    private string _question = "";
    private IEnumerable<KnowledgeBaseAnswer>? _answers;
    private string? _openAIAnswer;

    protected override void OnInitialized()
    {
        var cqa = Configuration.GetRequiredSection("CQA");
        _questionAnsweringClient = new QuestionAnsweringClient(
            cqa.GetValue<Uri>("Endpoint"),
            new AzureKeyCredential(cqa.GetValue<string>("Key")!));
    }

    private async Task OnSearchClick()
    {
        if (string.IsNullOrWhiteSpace(_question)) return;

        var cqa = Configuration.GetRequiredSection("CQA");
        var answersResult = await _questionAnsweringClient.GetAnswersAsync(_question,
            new QuestionAnsweringProject(cqa.GetValue<string>("ProjectName")!, cqa.GetValue<string>("DeploymentName")!),
            new AnswersOptions
            {
                Size = 5,
            });

        _answers = answersResult.Value.Answers;
    }

    private async Task OnCallOpenAIClick()
    {
        var openAi = Configuration.GetRequiredSection("OpenAI");
        var client = new OpenAIClient(
            openAi.GetValue<Uri>("Endpoint"),
            new AzureKeyCredential(openAi.GetValue<string>("Key")!)
        );

        var options = new ChatCompletionsOptions
            {
                // 回答に使うトークンの最大値は 1000 まで
                MaxTokens = 1000,
            };
        // OpenAI に渡すプロンプトを生成
        var messages = GenerateMessages();
        foreach (var m in messages)
        {
            options.Messages.Add(m);
        }

        var answer = await client.GetChatCompletionsAsync(
            openAi.GetValue<string>("DeploymentName")!, 
            options);

        if (answer is null) return;

        _openAIAnswer = answer.Value.Choices[0].Message.Content;
    }

    private IEnumerable<ChatMessage> GenerateMessages()
    {
        if (_answers is null) throw new InvalidOperationException();

        var tokenizer = TokenizerBuilder.CreateByModelName("gpt-3.5-turbo");
        var systemMessage = new StringBuilder($"あなたは従業員の質問に答えるアシスタントです。従業員が「{_question}」と検索システムに尋ねたところ以下のような大量の質問が返ってきて困惑しています。" +
            "検索結果は以下のようになっています。");
        systemMessage.AppendLine("|タイトル|回答|スコア(0~1で1に近づくほど良い|");
        systemMessage.AppendLine("|---|---|---|");

        var tokenCount = tokenizer.Encode(systemMessage.ToString(), Array.Empty<string>()).Count;

        foreach (var answer in _answers)
        {
            var line = $"|{answer.Questions.FirstOrDefault()}|{answer.Answer}|{answer.Confidence}|";
            tokenCount += tokenizer.Encode(line, Array.Empty<string>()).Count;

            if (tokenCount >= 6000) break;
            systemMessage.AppendLine(line);
        }

        yield return new ChatMessage(ChatRole.System, systemMessage.ToString());
        yield return new ChatMessage(ChatRole.User, "この回答の中から私の質問に関連する部分のみを抜き出して要約してください。");
    }
}

OnCallOpenAIClick メソッドと GenerateMessages メソッドを追加しています。GenerateMessages メソッドは OpenAI に渡すプロンプトを生成するメソッドです。トークンが大きくなりすぎないように 6000 程度をめどに Custom Question Answering の回答をベースにプロンプトを作っています。そのプロンプトを使って OpenAI に回答を生成してもらっています。

実行してみましょう。まずは「OneDriveの最大容量」という質問文で Custom Question Answering に問い合わせを行います。そうすると以下のような結果になります。

ちょっと読み応えありますね…。では結果を OpenAI でさらにいい感じにしてもらいましょう。ボタンを押すと以下のようになりました。

まぁまぁ読みやすいですね。他のでも試してみました。

この下の結果では、回答が存在しないことを教えてくれますね。これは全部読んでから回答がなかった!!ってなるのに比べると便利かも。

まとめ

ということで、非常に簡単にですが Custom Question Answering と Azure OpenAI Service を組み合わせて使ってみました。
ナレッジベースに書いている内容が汎用的過ぎて人間が読み解くのが大変というケースでは AI にかわりに読んでもらうのとか良さそうですね。

GPT 4 早く使いたい…。

ソースコードは以下のリポジトリに上げています。

https://github.com/runceel/OpenAI-CustomQuestionAnswering/

Microsoft (有志)

Discussion