ChatGPTを使って自動FAQ回答Chatbotを作ってみた

## はじめに
はじめました。BitStar CTOの山下です。
BitStarでは主に160万件のデータを取り扱う日本最大級のソーシャルデータベースを開発しています。

## 開発の背景
2023年で一番盛り上がった話題といえばChatGPTではないでしょうか？
おそらくエンジニアの方であれば一度は触ったことあるのではないでしょうか？
BitStarでも社内でCPOの出水より「BitStar Labo」というワーキンググループを立ち上げまして、社内で勉強会を実施していました。エンジニアはもちろん映像企画・編集や営業の方も参加してChatGPTを活用したディスカッションを行っておりました。
大きく２つのチーム、画像生成系のCreationチームと、文章生成系のTransformationチームに分かれて週次で意見交換を行ってきました。
今回はその中でも実際に社内運用を始めたChatGPTによる自動FAQ回答Chatbotを作りました。

**BitStar Labo Kickoff 資料**
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/210754/9476a234-ba1d-4ce3-86e1-d8086ed63792.png" width="400">


## 開発の経緯
社内ではSlackを使っていろんな問い合わせが日々行き交っていると思います。
BitStarでは各部署別に問い合わせ専用のSlackChannelを作って部署間での困りごとがいつでも誰でも相談できるようにしています。
とはいえ、同じような問い合わせが来ることが多いのではないでしょうか？
下手したら「それ2分前の問い合わせと同じｊｙ、、、」っといったこともあります。
問い合わせ対応で作業が中断されてしまいタイムロスになりますし、とはいえ問い合わせ対応しないと社内業務が停滞してしまうのでどちらも良くないです。
なのでChatGPTを活用して自動FAQ回答Chatbotを作れないか？っという発想に至りました。

## やってみたこと1: Fine-tuning
ChatGPTは一般的な質問には回答できますが、社内システムについての回答とか学習データ外のデータは当然学習に入ってないのは明確なのでまずそれを覚えさせる必要がありました。

ChatGPTのサイトのFine-tuningのページ
https://platform.openai.com/docs/guides/fine-tuning/create-a-fine-tuned-model
を参考にトレーニングファイルを作りました。

training_file.json
```JSON
{"prompt": "質問1", "completion": "回答1"}
{"prompt": "質問2", "completion": "回答2"}
...
```
実際に投げたトレーニングデータは機密ではないものの社内情報なので割愛させていただきます。

あとは資料に沿ってトレーニングデータのアップロードとトレーニングの実行を行いました。

```PHP
require('vendor/autoload.php');

$yourApiKey = 'sk-XXXX';
$client = OpenAI::client($yourApiKey);

$response = $client->files()->upload([
    'purpose' => 'fine-tune',
    'file'    => fopen('training_file.json', 'r'),
]);
var_dump($response);
```

```PHP
$response = $client->fineTunes()->create([
    'training_file'   => 'file-XXXX',
    'validation_file' => 'file-XXXX',
]);

var_dump($response);
```

```PHP
var_dump($client->fineTunes()->list());
var_dump($client->fineTunes()->retrieve('ft-XXXX'));
```

あとはpromptする際のmodel指定でFine-tuningしたモデルを指定すれば動くはず。。。
っと思いきや全然思った通りの回答をしてくれません。
やはり、ChatGPT自体が膨大なデータで処理してるせいでしょうか？
なので別の手段を模索してみます。

## やってみたこと2: Embeddings
Fine-tuningで上手く行かなかったのでEmbeddingsを試してみます。
ChatGPTでは文章の文脈をベクトルデータに変換して処理してるようでして、ベクトル化する作業のAPIを叩いてみます。

Embeddingの取得は単純に質問と回答を羅列したデータを入れます。

```PHP
$emb = getEmbedding($client, '質問' . PHP_EOL . '回答');
var_dump($emb);

function getEmbedding($client, $string) {
    $response = $client->embeddings()->create([
        'model' => 'text-embedding-ada-002',
        'input' => $string,
    ]);
    return $response['data'][0]['embedding'];
}
```

実行すると1,536次元のfloatが返ってきます。
```PHP
array(1536) {
  [0]=>
  float(-0.010690415)
  [1]=>
  float(0.0017817359)
  [2]=>
  float(-0.008952798)
  [3]=>
  float(-0.0092718145)
...
  [1534]=>
  float(-0.015434924)
  [1535]=>
  float(-0.022086738)
}
```

こいつを質問と回答とSerializeしたデータに分けてDBに保管します。

```PHP
$emb = getEmbedding($client, $question . PHP_EOL . $answer);
$insert = [
    'question'   => $question,
    'answer'     => $answer,
    'embeddings' => serialize($emb),
];
$ai_id = $mysql->insert('qa', $insert);
```

過去の問い合わせから適当に数十件FAQをDBに入れます。

次に質問があったときに関連度が高いFAQと取り出す処理を追加して関連度が高いFAQを使って質問に回答するようにChatGPTにpromptを投げて回答を得ます。

```PHP
function question($q) {
    $sims = [];
    $e_question = getEmbedding($client, $q);
    $qas = $mysql->select('*', 'qa');
    foreach ($qas as $qa) {
        $sims[] = similarity(unserialize($qa['embeddings']), $e_question);
    }
    arsort($sims);

    $data = '';
    foreach ($sims as $k => $v) {
        $qa = $qas[$k];
        $qa = 'Q: ' . $qa['q'] . PHP_EOL . 'A: ' . $qa['a'] . PHP_EOL;
        if (strlen($data . $qa) <= 2000) {
            $data .= $qa;
        } else {
            break;
        }
    }

    $question  = '以下のQ&A集を使って質問に答えてください。' . PHP_EOL . PHP_EOL;
    $question .= '# Q&A集' . PHP_EOL . $data . PHP_EOL . PHP_EOL . '# 質問' . PHP_EOL . $q ;

    $response = $client->chat()->create([
            'model'    => 'gpt-3.5-turbo',
            'messages' => [['role' => 'user', 'content' => $question]],
    ]);

    return $response->choices[0]->message->content;
}

function similarity($u, $v) {
    $dotProduct = 0;
    $uLength = 0;
    $vLength = 0;
    for ($i = 0; $i < count($u); $i++) {
        $dotProduct += $u[$i] * $v[$i];
        $uLength += $u[$i] * $u[$i];
        $vLength += $v[$i] * $v[$i];
    }
    $uLength = sqrt($uLength);
    $vLength = sqrt($vLength);
    return $dotProduct / ($uLength * $vLength);
}
```
このとき2,000文字を超えるとChatGPTに投げれないので2,000文字で切ります。
similarity関数で1,536次元同士の距離を計測しています。

叩いてみます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/210754/a90ec396-74c0-faba-00be-d7db7d3e9a81.png" width="400">

いい感じです。問い合わせでEmeddingとPromptを1回づつ叩きますが、FAQのデータはすでにEmbeddingした結果が入っているので1.2秒で回答を得られますしGPT4ではなくGPT3を使うのでお財布にも優しいです。

## やってみたこと3: Slack連携する
今度はSlackで質問されたら自動的にスレッドに回答する部分を作ります。

Slack API画面からAppを作成します。
https://api.slack.com/apps/

下記がざっくり設定です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/210754/a592611b-c319-a50b-50e1-c8405da16ffe.png" width="400">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/210754/d2b6ad43-6934-28f6-f8df-ec1405772a91.png" width="400">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/210754/1e727f89-e311-a319-53d6-5ef17954642d.png" width="400">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/210754/95fc7ac2-a3ba-1131-56af-b350c23a9192.png" width="400">
app_menstionは不要かもです。
（最初メンションされたら反応するようにと考えたが問い合わせする人の手間なので辞めた）

```PHP
<?php
http_response_code(200);// とりあえず200返す
require('vendor/autoload.php');

define("SLACK_SIGNING_SECRET"  ,"XXXX");
define("SLACK_BOT_ACCESS_TOKEN","xoxb-XXXX");

// retryは無視
if (isset($_SERVER['HTTP_X_SLACK_RETRY_REASON'])) {
    exit;
}

// イベント発生時にHTTPPOSTリクエストを受信する
$data = json_decode(file_get_contents("php://input"), true);

// URLの確認であればexit
if ($data["type"] == "url_verification") {
    header('Content-Type: text/plain');
    echo $data["challenge"];
    exit;
}

//event_callbackの場合に自動応答する
if ($data["type"] == "event_callback") {
    if (isset($data["event"]["subtype"]) || $data["event"]["user"] == "U05D940CMMZ"){
        exit; // 自分自身(bot)のメッセージは処理しない
    }
    if (isset($data["event"]["thread_ts"]) || isset($data["event"]["message"]["thread_ts"])) {
        exit;// Threadには回答しない
    }
    send_slack_message($data);
}
function send_slack_message($data) {
    $message = $data["event"]["text"];
    $channel = $data["event"]["channel"];
    $user    = $data["event"]["user"];
    $curl    = curl_init();
    $ans     = question(trim(str_replace('<@U05D940CMMZ>', '', $message)));
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_URL            => 'https://slack.com/api/chat.postMessage',
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json; charset=utf-8',
            'Authorization: Bearer ' . SLACK_BOT_ACCESS_TOKEN
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'channel'   => $channel,
            'thread_ts' => $data["event"]["ts"],
            'text'      => $ans,
        ])
    ]);
    $response = curl_exec($curl);
}
```
無限ループしちゃったりRetryが無駄に走ったりで色々試行錯誤した結果上記になりました。
<@U05D940CMMZ>はBotに割り振られたSlackIDでしたメンションされても不要なので消します。

## 完成
これで完成しました。
約半年運用していますが特段問題なく利用できています。
ただ難点もありまして、多少困るのは文脈を読み取り過ぎてたまに嘘つく場合がありますし、
すごい時間かかる作業を安請負いすることがあります。
とはいえ高速に１次回答できるので非常に満足しています。

## 社内の反応

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/210754/62b0ba57-18c6-6a16-ca4a-ff632021ad1e.png" width="300">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/210754/82510c4d-85f7-9932-18a2-b5d4e8eb9344.png" width="300">

## 今後
今後は手動で回答した内容も自動で取り込めたらいいなと思いつつ、それはそれで膨大なFAQになってしまうのでいい感じのやつだけDBに登録できればなと思っています。

## おわりに
今回はChatGPTを活用した社内利用ですが、プロダクトに活かせるように絶賛開発中です。
BitStarではChatGPTを使った開発に興味のあるエンジニアを募集しております。
詳しくは下記を参照ください。

<a href="https://www.wantedly.com/projects/1533737"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/210754/f9dfdc8c-8b56-70cd-7b05-c4f83927792f.png"></a>