【完成品編】FAQがAIの力でパワーアップ！誰でもナレッジ情報を自由自在に活用できる仕組みをAmazon Bedrock（RAG）+ LangChain + GASで構築

# はじめに
こんにちは！CBcloudのRyoです:grinning:

私の所属するCBcloudでは、物流ラストワンマイルの配送プラットフォーム「ピックゴー」を運用しており、荷物を送りたい方、荷物を届けてくれる方を24時間365日、サービスを通してつなげています！

https://pickgo.town/?utm_source=google&utm_medium=cpc&utm_campaign=brand&utm_id=749340152&utm_term=pickgo&af_pid=google&af_c=brand&gad_source=1&gclid=CjwKCAjwyJqzBhBaEiwAWDRJVBRiSmk6aRJqjngMWLKe1uuwT-EGw6Ktv9Vu7EG8o7545efU0FJaeBoCKuMQAvD_BwE

弊社では24時間365日サービスをつなげていると同時に、もちろん荷物を送りたい方、荷物を届けてくれる方をサポートするサポートチームが存在します

今回はそのサポートチームに新たにAIの力を導入...だけではなく**AIが使う知識をサポートチームのスタッフみんなで作れる機能**を作ってみました！
まだ実際に外部ユーザーへの提供はしていないですが、社内スタッフたちでAIが使う知識を作り、検証している段階です
今回はそれを実装するにあたっての背景や流れを記事にしていきます

# 背景
弊社のサポートチームは荷物を送りたい方と運ぶ方、両方のサポートしているのですが、なんと驚くべきことに、これらのユーザーを担当する部署が弊社は**分かれていないんです**
物流業界的には意外と珍しくないかもしれませんが、これはサポート窓口によくある分担や分業が難しい理由があるんですね

例えば荷物を送りたい人が
「ごめんやっぱ明日運んでほしい！」→運行予定の人に連絡しなきゃ！
だったり、荷物を送りたい人が
「渋滞で遅れるかも！」→荷物の主に連絡しなきゃ！

と物流のラストワンマイルは双方が関わるものなので、部署を分けるとそれはもう情報の行き来が大変で、それらを同時にこなす**高いレベルのサポート**が必要なんです:muscle:

### とは言っても業務はとても大変で...
サポートチームは日々、多くの問い合わせや対応に追われています荷物を送りたい方からの依頼や変更、荷物を届ける方からの状況報告など、さまざまな情報が飛び交う中で、迅速かつ正確な対応が求められてます さらに、24時間365日体制でサービスを提供しているため、サポートチームの負担は非常に大きいものです

## だからAIを使うことに
そこで、私たちはAIを活用することにしました AIの導入により、サポートチームの業務を効率化・かつ迅速で正確な対応を実現できると思ったからです
しかし、いざ実践してみようと思った矢先問題がありました

**AIに学習させる知識は誰が保守するんだ・・・？**

そう単にAIを導入するだけでは確かに一時的には楽になるかもしれませんが、常に情報が最新とは限りません
必要な情報をAIにその場でインプットするRAG（検索拡張生成）も、取ってくるデータが古いと意味がないのです

そこで目をつけたのが**現場のサポートチームです**
現場のサポートチームは常に前線の情報を受けており、それに加えサポートの歴戦の猛者達でもあるので質疑応答には詳しいはずです

しかし、もちろんここで当然の問題が


**「AIって確かに効率化されそうだけど・・・嘘をつかないよう調整したり、直したりするのは専門家でないと難しいのでは・・・？」**

私自身開発の検索としてChatGPTなどには触れていましたが、AI自身の開発面では全くのど素人だったので同じ意見ではありました

そこで私が所属するサポートチームをサポートする**オペレーションシステムチーム**は考えました

「サポートチームをエンパワー（力を与える）する身として、なんとかしてみんなAIを使えるだけじゃなく<font color="Red">AIの調整がみんなでできるようにしたい！</font>」

そんな思いからサポートチーム全員がAIに使う知識を自由にカスタマイズできる仕組みを構築しようということになりました

今回は調査ということで予算も抑えつつ、1週間ほどで構築したので粗があるかもしれませんがご容赦ください・・・！

この仕組みにより、サポートチームは現場での知見やノウハウをAIに反映させることができ、より実践的で最新の情報を確保できます　また、AIが自動的に問い合わせの内容を解析し、適切な対応を提案することで、サポートチームの負担を軽減し、荷物を運ぶ人の満足度も向上させることができるという想定です

次に、このシステムの実装背景や具体的な流れについて詳しく説明していきます


# ざっくり今回作るものについて

:::note warn
注意書き
ざっくり説明です
:::

![](https://storage.googleapis.com/zenn-user-upload/b3f0350f3a8e-20240614.png)

<br>


今回使うAIはRAGを使用した検索拡張AIです
従来の大量のデータを学習させてモデルをそのまま作ったり、既存のモデルをfinetuning(再トレーニング）させるようなものではなく、知識（ナレッジ）ベースを作っておきそこの情報を元に文章を生成してもらうものです


<br>

![](https://storage.googleapis.com/zenn-user-upload/34b0bbec11bc-20240614.png)

![](https://storage.googleapis.com/zenn-user-upload/33529fa6df27-20240614.png)

<br>

そして今回用意したのはスプレッドシートで
①AIと会話できる
②AIが使う知識ベースを出し入れできる
というものです
<br>
![](https://storage.googleapis.com/zenn-user-upload/e57c2f90c1c9-20240610.png)


# 完成品

## ①スプレッドシートからAIに質問をする

![](https://storage.googleapis.com/zenn-user-upload/f887ec4647c6-20240614.gif)

### 説明
スプレッドシートに専用のシートを作り、トピックと質問を選んだから質問するにチェックを押します
![](https://storage.googleapis.com/zenn-user-upload/757ac36807de-20240614.png)

チェックボックを押したらスクリプトを動かします
![](https://storage.googleapis.com/zenn-user-upload/0b13c183ae5f-20240614.png)

![](https://storage.googleapis.com/zenn-user-upload/f0d847c47e94-20240614.png)

返事が返ってきました、まだ何も覚えさせていないのでPickGoの詳細については知らないようです


## ②スプレッドシートからAIにデータを入れる
![](https://storage.googleapis.com/zenn-user-upload/b98c2df99f98-20240614.gif)

こちらも同じくスプレッドシートに専用のシートを作り、予めトピックを作っておきます
必要項目を入力したら同じくチェックを押してスクリプトを動かすだけです
![](https://storage.googleapis.com/zenn-user-upload/ad03338b81d3-20240614.png)

 - 想定質問
     - ユーザーがインプットされるであろう想定される質問を記入します
 - 回答
     - AIに回答させたい情報を記入します　ここがメインの情報源として使われます
 - 同期後初回テスト質問
     - AIに質問と回答を同期させた後の1回目の質問をここに書きます　これにより同期後ちゃんとデータが入ったかどうかがわかります
 - 同期する
     - ここにチェックを押してスクリプトを動かすことで同期されます
 - 初回テスト返答
     - 同期を行うと同期後初回テスト質問の回答がAIから返ってきます
 - 評価
     - 自由入力なのでここにあってたかどうかなどを記載します

今回は以下のような質問と回答を用意しました

```
    質問:PickGoってなんですか？
    回答:PickGo（ピックゴー）はCBcloud株式会社が提供する配送プラットフォームです
    荷物を配送したい荷主と、空いている配送事業者（トラックドライバーや運送会社など）をWebやアプリ上で直接マッチングできるITサービスです
    登録数は軽貨物4万台以上、一般貨物1000社以上、二輪車1万5000台以上です
```


無事同期が成功すればAIから返答が返ってきます

![](https://storage.googleapis.com/zenn-user-upload/5696ed7c1157-20240614.png)

```
    初回テスト質問:PickGoってなんですか？
    回答　　　　　:PickGo(ピックゴー)はCBcloud株式会社が提供する配送プラットフォームです
    荷物を配送したい荷主と、空いている配送事業者(トラックドライバーや運送会社など)をWebやアプリ上で直接マッチングできるITサービスです
```

## もう一度AIに質問してみよう

では①のシートに戻り、再度質問してみましょう
今度はPickGoってなんですか？以外に PickGoに登録数について教えて という質問を追加します
果たして想定質問以外のものにも答えられるでしょうか

![](https://storage.googleapis.com/zenn-user-upload/07b097c28626-20240614.png)


![](https://storage.googleapis.com/zenn-user-upload/3b6654baa9f0-20240614.gif)

返答が返ってきました

![](https://storage.googleapis.com/zenn-user-upload/979125473f1e-20240614.png)

```
    質問:PickGoに登録数について教えて
    回答:PickGoの登録数は軽貨物4万台以上、一般貨物1000社以上、二輪車1万5000台以上です
```

答えてくれてますね！回答の内容は想定質問だけに働くのではなく、知識データとして活用できています


# 中身に関して

まず今回使用しているメインのAI機能はAmazonBedrockと呼ばれるAWSのAI基盤モデルを利用できえるフルマネージド型サービスです
実際にAIの基盤となるモデルを作成せずとも既に各企業が作ったモデルを利用・拡張することができます

https://aws.amazon.com/jp/bedrock/?gclid=CjwKCAjwjqWzBhAqEiwAQmtgT352qya3OVtjnQ3_KCGBzmYH5kgtYMSWHuegi-hWVzqFlfNuWRodKRoCeuwQAvD_BwE&trk=38cc0cc4-7da9-4785-9231-aba910a719d1&sc_channel=ps&ef_id=CjwKCAjwjqWzBhAqEiwAQmtgT352qya3OVtjnQ3_KCGBzmYH5kgtYMSWHuegi-hWVzqFlfNuWRodKRoCeuwQAvD_BwE:G:s&s_kwcid=AL!4422!3!692062154795!p!!g!!bedrock!21048268290!157173581017


これに加え今回使う機能は
 - AWS
     - Amazon Bedrock及びナレッジベース
     - Lambda
         - LangChain
     - S3
     - 他IAMやSecretManagerなど
 - Vector DBのPinecone
 - Google Apps Script（スプレッドシート）

とシンプルではあるもの結構設定する部分もあります

全容については
Amazon Bedrock編
Lambda 編
GAS 編
と非常に長くなってしまうので、今回は最後のGASの部分をちょこっと解説します
そもそもAIとは...から始めるととても1つの記事に収まらなかったのでお待ちください
（この記事は全部載せの予定だったがあまりにも長くなったので分割）


## アーキテクチャ

![](https://storage.googleapis.com/zenn-user-upload/59bdcc4bb226-20240614.png)

ユーザーがスプレッドシートに質問を入れ、GASを起動させることで質問がAWSのLambdaへリクエストされます
LambdaはPythonで質問を受け取り、S3に会話履歴を保存したり取得したりします
さらに質問を使ってAmazon Bedrockに保存したナレッジベースを検索し、資料ドキュメントを返します
最後にLangChain(AIを効率的に使うライブラリ）を使って会話履歴と資料ドキュメント、上級プロンプト（※後述）をくっつけてAIサービスであるBedrockに問い合わせます

今回はGASからの問い合わせ部分のコードを記載します

## AIに質問するコード

基本的にLambdaを動かしてレスポンスを受け取るだけですが、ここでLambdaに送信するのは
 - 質問
 - セッションID（会話履歴をS3に保存、再度質問する際に履歴をくっつける）
 - 上級プロンプト
の３つです　上級プロンプトはAIに与えるロール（役割）みたいなものだと思ってください

```js
const template = `あなたはPickGoという物流マッチングサービスを運用する会社の質問応答エージェントです。以下の関連情報に基づいて、質問に簡潔に回答してください。質問に対して与えられた関連情報のみを使用して簡潔に回答してください。`

function getAIResponse(prompt, sessionId, template) {
  var scriptProperties = PropertiesService.getScriptProperties();
  var url = scriptProperties.getProperty('langChainLmabdaUrl');
  var apiKey = scriptProperties.getProperty('apiKey');

  return new Promise((resolve, reject) => {
    var options = {
      'method': 'POST',
      'headers': {
        'x-api-key': apiKey,
        'Content-Type': 'application/json'
      },
      'payload': JSON.stringify({ prompt: prompt, sessionId: sessionId, param_template: template }),
      'muteHttpExceptions': true
    };

    try {
      var response = UrlFetchApp.fetch(url, options);
      var responseCode = response.getResponseCode();
      var responseBody = response.getContentText();
      
      Logger.log('Response Code: ' + responseCode);
      Logger.log('Response Body: ' + responseBody);
      
      if (responseCode === 200) {
        var jsonResponse = JSON.parse(responseBody);
        resolve(jsonResponse); // 修正点: JSON形式のレスポンスボディ全体を返す
      } else {
        reject(new Error('Error response: ' + responseBody));
      }
    } catch (e) {
      Logger.log('Error: ' + e.message);
      if (e.response) {
        Logger.log('Error Response: ' + e.response.getContentText());
      }
      reject(e);
    }
  });
}

```

今回弊社のスタッフには質問と回答のステップから進めてもらうので、上級プロンプトを操作できないようにしていますが随時調整できるように変更予定です

あとはシートのチェックボックスに従ってくるくるAPIを回すコードを書いてます

```js
const ss = SpreadsheetApp.getActiveSpreadsheet();
const freeSheet = ss.getSheetByName('フリー質問');

function フリー質問する() {
  const syncValue = freeSheet.getRange('E:E').getValues();
  const promises = [];
  try {
    for (let i = 0; i < syncValue.length; i++) {
      if (syncValue[i][0] !== true) { continue; }
      const rowNumber = i + 1
      const rowValue = freeSheet.getRange(rowNumber + ':' + rowNumber).getValues()[0];
      const topic = rowValue[2] 
      const question = rowValue[3] 
      const prompt = `${question}`
      console.log(prompt)
      const id = rowValue[0] 
      promises.push(processFetchLambda2(rowNumber,prompt,id));
    }
  } catch (e) {
    // 例外が発生した場合の処理（必要に応じてログ出力など）
    console.log("エラーが発生しました: " + e.message);
    const ui = SpreadsheetApp.getUi(); 
    ui.alert(e.message);
  }

  Promise.all(promises)
    .then(responses => {
      // すべてのリクエストが成功した場合の処理
      responses.forEach(response => {
        console.log(response)
      });
      console.log('すべてのリクエストが完了しました。');
    })
    .catch(error => {
      // 全体としてのエラー処理
      console.log('エラーが発生しました: ' + error.message);
      const ui = SpreadsheetApp.getUi();
      ui.alert(error.message);
  });
}


function formatContextInfo(contextInfo) {
  return contextInfo.map(info => {
    var cleanName = info.name.replace('s3://dev-bedrcok-knowledge-base/', '');
    return `データ名: ${cleanName}, スコア: ${info.score}`;
  }).join('\n');
}
function processFetchLambda2(rowNumber, question, id) {
  return fetchLangChain(question, id, template)
    .then(response => {
      console.log(response);
      var message = response.message;
      var allContextInfo = formatContextInfo(response.allContextInfo);
      var useContextInfo = formatContextInfo(response.useContextInfo);

      // 応答をスプレッドシートに書き込む
      freeSheet.getRange(rowNumber, 5, 1, 1).setValue('');
      freeSheet.getRange(rowNumber, 6, 1, 1).setValue(message);
      freeSheet.getRange(rowNumber, 7, 1, 1).setValue(new Date());
      console.log(allContextInfo,useContextInfo)
    
      freeSheet.getRange(rowNumber, 8, 1, 1).setValue(allContextInfo); // 列HにallContextInfoを設定
      freeSheet.getRange(rowNumber, 9, 1, 1).setValue(useContextInfo); // 列IにuseContextInfoを設定
    })
    .catch(error => {
      console.log(`初回質問中にエラーが発生しました (行 ${rowNumber}): ` + error.message);
    });
}
```

## データの挿入

同じようにLambdaを叩くのですが、この際にはQ&Aのテキストとファイルの名前を送信しています
これはlamdba側でtxtファイルを作成し、それをS3に置くためのファイル名を指定しておく必要があるからです

そして今後解説する予定ですが、今回使うAWSの知識ベース（ナレッジベース）が5つまでファイルを保持することができるので、GASからは５つまでのトピックを作成し、そのQ&Aテキストを毎回洗い替え（削除して作成）して作成しています
つまり既にトピックのテキストファイルが作成されていれば必ず元のナレッジベースを削除してから作成を行なっています

削除の際はトピック名（ファイル名）を使ってナレッジベースを削除しています



```js
// 作成用
function createKnowledgeBase(fileName, qaTxt) {
  var scriptProperties = PropertiesService.getScriptProperties();
  
  var url = scriptProperties.getProperty('createKnowledgeBaseLambdaUrl');
  var apiKey = scriptProperties.getProperty('apiKey');

  return new Promise((resolve, reject) => {
    var options = {
      'method': 'POST',
      'headers': {
        'x-api-key': apiKey,
        'Content-Type': 'application/json'
      },
      'payload': JSON.stringify({ body: JSON.stringify({ fileName: fileName, qaTxt: qaTxt }) }),
      'muteHttpExceptions': true
    };

    console.log(options)
    

    try {
      var response = UrlFetchApp.fetch(url, options);
      var responseCode = response.getResponseCode();
      var responseBody = response.getContentText();
      
      Logger.log('Response Code: ' + responseCode);
      Logger.log('Response Body: ' + responseBody);

      if (responseCode !== 200) {
        reject(new Error(`HTTPエラー: ${responseCode}`));
      } else {
        resolve({ fileName: fileName, responseBody: responseBody });
      }
    } catch (e) {
      Logger.log('Error: ' + e.message);
      if (e.response) {
        Logger.log('Error Response: ' + e.response.getContentText());
      }
      reject(e);
    }
  });
}

// 削除用
function deleteKnowledgeBase(topicName) {
  console.log('deleteKnowledgeBaseが動きます',topicName)
  var scriptProperties = PropertiesService.getScriptProperties();
  
  var url = scriptProperties.getProperty('deleteKnowledgeBaseLambdaUrl');
  var apiKey = scriptProperties.getProperty('apiKey');

  return new Promise((resolve, reject) => {
    var options = {
      'method': 'POST',
      'headers': {
        'x-api-key': apiKey,
        'Content-Type': 'application/json'
      },
      'payload': JSON.stringify({ topicName: topicName  }),
      'muteHttpExceptions': true
    };

    console.log(options)
    

    try {
      var response = UrlFetchApp.fetch(url, options);
      var responseCode = response.getResponseCode();
      var responseBody = response.getContentText();
      
      Logger.log('Response Code: ' + responseCode);
      Logger.log('Response Body: ' + responseBody);

      if (responseCode !== 200) {
        reject(new Error(`HTTPエラー: ${responseCode}`));
      } else {
        resolve({ responseBody: responseBody });
      }
    } catch (e) {
      Logger.log('Error: ' + e.message);
      if (e.response) {
        Logger.log('Error Response: ' + e.response.getContentText());
      }
      reject(e);
    }
  });
}
```


あとはフリー質問と同じように必要項目を整えてチェックボックスごとにループするようなコードを用意したらGAS側は完成（コードは長くなってしまったのでGAS編お待ちください....(謝)）



## 現在の状態
このシートは現在弊社のオペレーションチームが2チーム(質問を作る側、質問する側)に分かれてどんどんQ&Aを使ってくれています
6/14時点で500問ほどできていて、あとはこれを取り込んで精度を確認してく予定です
結果が出次第共有していくのでもう少々お待ちください


続いて現場のチームがQ&Aを作る中で、こんな疑問があがってきました

 - 2つのトピックが関連する質問をしたらトピックを超えて質問って答えてくれるの？
 - 同じ情報を２つ入れたらどっちを答えてくれる？
 - AIがわかんなかった場合、有人対応に繋ぐことはできる？
 - 質問の内容で必要情報が書けてたら聞き直すことはできる？

と結構私でも「確かにどうなるんだ？」という質問がちらほら上がってくるようになりました

ただ実際にAIを調整（ここでいうと回答の内容調整）を行うことで「こんなことができないか」や「この場合どうなるんだろう」と非常に関心を持ってもらい、AIに対する抵抗感も薄くなってくれたのではないでしょうか

質問に関しても全般書き留めてもらい、一緒に調査したり試してみたりする予定です

今回GASやパイプラインは結構突貫で作ってはいますが、スタッフ皆にすぐにハンズオンできる環境を提供できたのはやはり良かったんじゃないかなと個人的には思います


## 最後に
まだテスト段階中ともあり実際の精度や良かったこと悪かったことなど結果はまだですが、少し自分でも触った限り非常に期待できそうな性能です
特に覚えさせた内容を部分的に回答させたりと従来のBOTと違い必要最低限の情報だけ提供してくれるので、今後のサポートに期待できそうです！

載せきれなかった解説や進捗など随時載せていきますのでそちらも合わせてご確認ください！

---

**2024/06/21**
続きはこちらです

https://qiita.com/joeharada/items/a1454bc8328866b9c7e9