【RAG】Amazon BedrockとConnect、Kendraを利用し、社内情報や社外の最新情報などの取り込んだデータをもとに回答するコールセンター向けAIチャットボットを構築してみた

2023.10.13

はじめに

Amazon BedrockとAmazon Connect、Amazon Kendraを利用し、電話での質問に対して、取り込んだ情報をもとに検索し、回答する(Retrieval Augmented Generation(以降、RAG))コールセンター向けAIチャットボットを構築してみました。

以前、Connectをインターフェースとして、BedrockのClaude V2に質問するチャットボットを構築しましたが、今回はKendraを採用したRAG版です。

最近、社内の業務効率化などの目的で、AIの言語モデル(以降、LLM)を用いて社内情報を活用するための手法として、RAGが話題になっています。

RAGとは、ユーザーからの問い合わせ(プロンプト)に基づいて外部データから関連するドキュメントを検索し、その結果をもとにLLMが質問への回答を生成するという手法です。

RAGの検索(Retrieval)の部分は、Amazon Kendraという様々なデータソースから特定の情報を迅速に見つけ出すことができるエンタープライズ向けの検索サービスで実現できます。

また、先日リリースした生成AIプラットフォームのAmazon BedrockのAPIを介してLLMが利用できます。

つまり、事前にKendraに社内情報をインポートし、ユーザーからの問い合わせに対して、Kendraで検索し、その結果をもとにLLMが回答を生成することでRAGを実現できます。

ユーザーからの問い合わせを行うインターフェースとして、クラウド型コンタクトセンターのAmazon Connectと、チャットボット等の会話インターフェースを簡易に利用できるAmazon Lexを採用します。

構成

今回の構成の図は、以下になります

Kendra

今回の検証において、Kendraのインデックスには、Network Load Balancer(NLB)のAWSドキュメントをウェブクローラーでインポートします。

Kendraのデータソース(取り込み元)としては、ウェブサイトやS3バケットに保存したドキュメントなどが利用できます。

実際には、社内情報のドキュメントをS3バケットなどに保存して、Kendraにインポートさせると思いますが、今回は、検証が簡単なウェブクローラーを利用します。

Lambda

Lambdaで行っていることは、次の3つです

  1. Lexで文字起こしされた文章をBedrockのClaude V2に整形してもらいます
  2. 整形された文章をもとに、Kendraでの検索結果で関連性の高い上位7つの参考ドキュメントを取得
  3. BedrockのClaude V2に対して、2.のドキュメントと質問内容をもとに、回答を生成させ、Lexに渡します。

デモ動画

2023年の8月に、NLBにセキュリティグループを割り当てることができるアップデートがありました。

Bedrockの日本語対応のClaude V2に聞いた場合、最新の情報は持っていないため、NLBはセキュリティグループを適用できないと答えます。

そのため、Kendraに最新のアップデート情報を含むAWSドキュメントをインポートすることで、アップデート情報にも的確に回答することができるはずです。

以下は、電話をかけた際の対話の様子を示したイメージです:

以下が実際のデモ動画です。

チャットボットの回答内容は、アップデート日や注意点が的確で、日本語の違和感がなく回答としては全く問題ないですね。

参考情報から、ネットワークロードバランサーがセキュリティグループを関連付けることができるようになったのは2023年8月10日からです。
作成時にセキュリティグループをネットワークロードバランサーに関連付けることができます。
関連付けない場合は、後から追加することはできません。
セキュリティグループの設定はいつでも変更できますが、作成後に初めて関連付けることはできません。
ターゲットのセキュリティグループも、ロードバランサーからのトラフィックを受け入れるよう設定する必要があります。

ただし、質問してから回答までのレスポンス時間は30秒と長いです。改善点は、例えば以下が考えられます。

  • LambdaでClaude V2を2回利用しているので、質問を整形する処理は、Claude V2よりもレスポンス時間が短いClaude Instantを採用する
    • 後述しますが、Claude Instantの場合、プロンプトの工夫が必要です

構築

以下の流れで構築します。

  1. Bedrockの有効化
  2. Kendraのインデックスを作成
  3. Lambda関数の作成
  4. Lexを構築
  5. Connectのコンタクトフローを作成

Claude V2はバージニアリージョンのみで利用できるため、リソースは、全てバージニアリージョンで作成します。

Bedrockの有効化

AnthropicのClaudeClaude Instantを利用可能な状態にしておきます。

Kendraのインデックスを作成

下記の記事通りにKendraのインデックスを作成します。

参考記事と変える点について、データソースは、AWSのNLBのドキュメントURLにします。

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/network/introduction.html

Lambda関数を作成

ランタイムがPython3.11のLambda関数を作成します。以下を参考に、IAMロールやBedrockを利用するためのBoto3ライブラリをアップロードしてください。

上記に加え、以下の設定をしました。

  • IAMロールには、以下の2つを割り当てます
    • CloudWatchLogsFullAccess
    • AmazonKendraFullAccess
  • 実行時間は、デフォルトの3秒から1分に変更しました。

コードは下記の通りです。

import json
import boto3
from decimal import Decimal
bedrock_runtime_client = boto3.client('bedrock-runtime')

def decimal_to_int(obj):
    if isinstance(obj, Decimal):
        return int(obj)

def elicit_slot(slot_to_elicit, intent_name, slots):
    return {
        'sessionState': {
            'dialogAction': {
                'type': 'ElicitSlot',
                'slotToElicit': slot_to_elicit,
            },
            'intent': {
                'name': intent_name,
                'slots': slots,
                'state': 'InProgress'
            }
        }
    }

def confirm_intent(message_content, intent_name, slots):
    return {
        'messages': [{'contentType': 'PlainText', 'content': message_content}],
        'sessionState': {
            'dialogAction': {
                'type': 'ConfirmIntent',
            },
            'intent': {
                'name': intent_name,
                'slots': slots,
                'state': 'Fulfilled'
            }
        }
    }

def close(fulfillment_state, message_content, intent_name, slots):
    return {
        'messages': [{'contentType': 'PlainText', 'content': message_content}],
        "sessionState": {
            'dialogAction': {
                'type': 'Close',
            },
            'intent': {
                'name': intent_name,
                'slots': slots,
                'state': fulfillment_state
            }
        }
    }

def get_retrieval_result(query_text,index_id):
    kendra_client = boto3.client('kendra')
    response = kendra_client.query(
        QueryText=query_text,
        IndexId=index_id,
        AttributeFilter={
            "EqualsTo": {
                "Key": "_language_code",
                "Value": {"StringValue": "ja"},
            },
        },
    )

    # Kendra の応答から最初の7つの結果を抽出
    results = response['ResultItems'][:7] if response['ResultItems'] else []

    for i in range(len(results)):
        results[i] = results[i].get("DocumentExcerpt", {}).get("Text", "").replace('\\n', ' ')
        
    return json.dumps(results, ensure_ascii=False)

def get_arrange_question(user_prompt):
    
    prompt = f"""\n\nHuman:
    以下の文章に句読点をいれて整えて下さい。
    「{user_prompt}」
    Assistant:
    """

    # modelId = 'anthropic.claude-instant-v1' 
    modelId = 'anthropic.claude-v2' 
    accept = 'application/json'
    contentType = 'application/json'

    body = json.dumps({
        "prompt": prompt,
        "max_tokens_to_sample": 400,
    })

    response = bedrock_runtime_client.invoke_model(
        modelId=modelId,
        accept=accept,
        contentType=contentType,
        body=body
    )

    response_body = json.loads(response.get('body').read())
    # print("Received response_body:" + json.dumps(response_body, ensure_ascii=False))
    return response_body.get('completion')

def get_bedrock_response(user_prompt):
    # Kendra インデックス ID に置き換えてください
    index_id = '<index ID>'  
    
    prompt = f"""\n\nHuman:
    [参考]情報をもとに[質問]に適切に答えてください。200字以内で答えてください。答える際、200字以内と言わなくてよいです。
    [質問]
    {user_prompt}
    [参考]
    {get_retrieval_result(user_prompt,index_id)}
    Assistant:
    """

    modelId = 'anthropic.claude-v2' 
    accept = 'application/json'
    contentType = 'application/json'

    body = json.dumps({
        "prompt": prompt,
        "max_tokens_to_sample": 400,
    })

    response = bedrock_runtime_client.invoke_model(
        modelId=modelId,
        accept=accept,
        contentType=contentType,
        body=body
    )

    response_body = json.loads(response.get('body').read())
    # print("Received response_body:" + json.dumps(response_body, ensure_ascii=False))
    return response_body.get('completion')

def Bedrock_intent(event):
    print("Received event:" + json.dumps(event, default=decimal_to_int, ensure_ascii=False))
    intent_name = event['sessionState']['intent']['name']
    slots = event['sessionState']['intent']['slots']
    user_prompt = event['inputTranscript']

    if slots['freeinput'] is None:
        return elicit_slot('freeinput', intent_name, slots)

    confirmation_status = event['sessionState']['intent']['confirmationState']

    if confirmation_status == "Confirmed":
        return close("Fulfilled", 'それでは、電話を切ります', intent_name, slots)

    elif confirmation_status == "Denied":
        return close("Failed", 'お力になれず、申し訳ありません。電話を切ります', intent_name, slots)

    # confirmation_status == "None"
    response_text = get_bedrock_response(get_arrange_question(user_prompt))
    print("Received response_text:" + json.dumps(response_text, ensure_ascii=False))

    return confirm_intent(
        f'それでは、回答します。{response_text}。以上が回答になります。回答に納得したかたは、はい、とお伝え下さい。納得いかない場合、いいえ、とお伝え下さい',
        intent_name, slots)

def lambda_handler(event, context):
    print("Received event:" + json.dumps(event, default=decimal_to_int, ensure_ascii=False))

    intent_name = event['sessionState']['intent']['name']

    if intent_name == 'bedrock':
        return Bedrock_intent(event)
  • 関数名:elicit_slot
    • Lexのスロットの値が埋まっていない場合に使用します。
  • 関数名:confirm_intent
    • Lexのスロットが全て埋まった時に使用します。
      • 確認プロンプトに設定しているプロンプトを伝えます。今回の場合、「それでは、回答します。.......」になります
  • 関数名:close
    • 確認プロンプトの後、インテントを終了するときに使用します。
      • クローズ時に設定しているプロンプトを聞きます。
  • 関数名:get_arrange_question
    • Lexで文字起こしされた文章をBedrockのClaude V2に整形させます
  • 関数名:get_bedrock_response
    • 整形された文章とKendraの検索結果ともとに回答を生成します
  • if slots['freeinput'] is None:このうち、freeinputは、Lexの章で説明するLexのスロット名です。
  • if intent_name == 'bedrock':このうち、bedrockは、Lexの章で説明するLexのインテント名です。
  • KendraのインデックスIDは、Kendraのコンソール画面から確認できます。

    • あくまでも検証なので、ハードコーディングしています。

Lambdaコードはあくまでも参考例とご認識下さい。実際の環境で使用する際には、「API呼び出しのレスポンスチェック」や「適切なエラーハンドリング」、「機密情報はハードコーディングしない」などを行う必要があります。

文字起こした内容を整形する必要性

Lambdaでは、Kendraで検索する前に、Lexで文字起こした内容をBedrock Claude V2で整形させています。

整形させる前のLexで文字起こした文章は、下記の通りです。カタカナや漢字に変換されていたりしていますが、無意味なスペースが多く、句読点はありません。

い つ から ネットワーク ロード バランサー は セキュリティ グループ を 適用できる よう に なり まし た か 注意 点 も教え て ください

Bedrock のClaude V2で整形は、下記の通りです。無意味なスペースがなくなり、句読点もつけてくれています。

いつからネットワークロードバランサーはセキュリティグループを適用できるようになりましたか。注意点も教えてください。

整形せず、Kendraで検索すると、今回の質問の回答に該当する「NLBのアップデート情報」のドキュメントは、関連性の上位20番目くらいでした。

ただし、整形後にKendraで検索すると、関連性の上位6番目くらいに出ました。

20番目までのドキュメントを取得すると、回答時の正確性に影響が出たため、整形後にKendraで検索するようにしています。

Lexでの文字起こしの精度がより上がれば(正確にはAmazon Transcribe)、文章の繋ぎもうまく補正し、整形する必要性はなくなりそうです。

Claude Instantの採用検討

Bedrock のClaude V2よりもClaude Instantの方がレスポンス時間は短いので、文字起こしの整形処理は、Claude Instantにしてもらいたいです。

ただし、Claude Instantの場合、たまに、整形しつつ、整形した内容に回答する動作が行われました。

プロンプトを工夫することで、整形のみを行うように改善はできそうですが、今回は文字の整形もClaude V2を採用しました。

質問してから回答までのレスポンス時間の比較表です。整形処理は、Claude Instantにすることで、レスポンス時間の改善に繋がります。

生成AIの利用回数 レスポンス時間
Claude V2を2回利用 おおよそ30秒
Claude V2を1回
Claude Instantを1回利用
おおよそ20秒

上記のレスポンス時間は、Lexの文字起こしの処理時間やKendraでの検索時間も含まれています。

Lexを構築

Amazon Lexのボットとインテントを作成します。

もちろん、対応言語は日本語です。

先程作成したLambdaをLexから呼び出すため、Lambdaを指定し保存します。

Lambdaを呼び出す設定は、分かりにくいのですが、[エイリアス]→対象のエイリアス(TestBotAlias)→[言語:Japanese (Japan)]を順にクリックすると、呼び出す設定画面がでます。

インテント名はbedrockとし、インテントを呼び出すためのサンプル発話は、はいにします。

スロット設定は以下です

  • スロット名はfreeinput
  • スロットタイプは、自由形式の入力を受け付けるAMAZON.FreeFormInput
  • プロンプトは、「ご質問ください」

AMAZON.FreeFormInputの詳細は、下記をご参照ください。

先程設定したLambdaが利用されるように、初期化と検証に Lambda 関数を使用にチェックを入れましょう。

この設定で、[インテントを保存]の後、[Build]し、正常に構築されることを確認します。

Connectのコンタクトフローを作成

対象のConnectインスタンスに、先程作成したLexを登録します

フローは、シンプルですが、下記の4つのブロックのみにしました。

音声設定は、Kazuhaにしてます。言語属性を設定のチェックも忘れずにしましょう。

[顧客の入力を取得する]ブロックでは、読み上げるテキストを入力し、Lexのボット名やエイリアス名、インテント名を記載して保存します。

フロー公開後、電話番号をフローに割り当てば、Connectの設定は、完了です。

電話をかけると、デモ動画の通り、AWSドキュメントをもとに回答をしてくれます。

最後に

レスポンス時間が長いなどの改善点があります。ClaudeV2 を2回リクエストしているので、プロンプトを調整した上でClaude Instantに代替することで、レスポンス時間を改善していきたいです。

また、今回の構成以外にもOpenAIの文字起こしのWhisperやFunction callingなども試して、レスポンス時間や精度の向上を検証します。