見出し画像

Amazon Bedrockを活用した社内ナレッジ検索ボットRAGシステムを構築してみた

はじめに

こんにちは。ゆこゆこ SREチームのS.Sです。
今回はAWSの生成AI系サービスであるAmazon Bedrockを利用して社内ナレッジ検索ボット(Retrieval-Augmented Generation)システムを構築した経験をお話しします。
なお、前半に実際に構築したRAGの使用感を記載します。
細かい構築の流れなどは後半に記載させていただきますので、
ご興味がある方は最後までお読みいただけると幸いです。


生成AIを利用するきっかけ

ゆこゆこでは定期的にAWS社とMTGを行っています。
その場では、ソリューションアーキテクトや営業担当者への技術相談/新サービスの紹介等をしていただいています。
新サービスの中で「今生成AIが注目を集めています!」というお話をしていただきました。
一方で、ゆこゆことしても「AIを利用して業務の効率を向上したい」という思いがありました。
このような背景のもと、ゆこゆこの「生成AIを活用したい」というニーズと、AWSの「生成AI(Bedrock)を使ってほしい」という提案がうまくマッチしました。

実際に構築したRAGシステムを使ってみた

詳細は後述しますが、まずは実際の使用例を見てみましょう!
下記が実際のナレッジボットの画面になります。

第1段階として作成したため、現時点では非常にシンプルな画面となっています

ナレッジとしては、弊社ゆこゆこが運営するゆこゆこネットへ掲載する記事のルールや、景品表示法等の法律についての内容が保存されています。

では実際にいくつか質問をしてみましょう。
「景品表示法」とはどのようなものか質問をしてみました。

景品表示法について聞いてみました

回答として、以下のような内容が出力されました。

ナレッジ内の「景品表示法とは」というセクションを基に回答をしてくれました

次はゆこゆこ特有の回答を返してくれるか質問をしてみます。

抽象的な質問でしたが、ナレッジに基づき回答をしてくれました

RAGシステムを作成、利用してみた結果

社内ナレッジを基に、質問に対し回答を返してくれるボットを作成しました。
回答はナレッジを基にしているものの、Bedrockで回答を生成しているので一般的な回答内容も含まれるようです。
そのため、ナレッジのどの部分から引用したか という点を回答に含むようにしました。
これにより、回答が誤っていたり不明瞭な時に、質問者が回答の正当性を確認しやすくなったのではないでしょうか。



ここからは、システムの詳細な構成と構築手順について説明します。

RAGシステムの概要

構築したRAGシステムは、以下のコンポーネントで構成されています

  • フロントエンド:S3静的ウェブホスティング(index.html配置)

  • API層:Amazon API Gateway

  • バックエンド:AWS Lambda

  • 検索エンジン:Amazon OpenSearch Serverless

  • 大規模言語モデル:Amazon Bedrock (Claude)

  • ストレージ:Amazon S3(ナレッジPDFファイル保存用)

システム構築の過程

  • 要件定義: 以下の要件を定義しました

  1. PDFで提供されるナレッジベースの活用

  2. ウェブブラウザからアクセス可能な簡易的なUI

  3. コスト効率の高いサーバーレスアーキテクチャの採用

  4. 高精度な検索と回答生成

アーキテクチャの設計

  • アーキテクチャ設計:
    要件に基づき、以下のフローを設計しました:

  1. ユーザーがS3ホスティングのウェブページで質問を入力

  2. API Gatewayを経由してLambda関数が呼び出される

  3. LambdaがOpenSearch Serverlessで関連文書を検索

  4. 検索結果をプロンプトとしてBedrock (Claude)に送信

  5. Bedrockが生成した回答をユーザーに表示

実装

  • フロントエンド実装

    1. S3バケットの作成と静的ウェブホスティングの設定

    2. シンプルなHTML/CSS/JavaScriptによるUIの実装

  • API GatewayとLambda関数の設定

    1. RESTful APIの作成

    2. Lambdaの統合設定

  • OpenSearch Serverlessの設定

    1. コレクションとインデックスの作成

    2. IAMロールの設定による適切なアクセス権限の付与

  • Lambda関数の実装

    1. OpenSearch Serverlessでの検索ロジック

    2. Bedrockを使用した回答生成ロジック

  • ナレッジベースの登録

    1. S3バケットへのPDFアップロード

    2. BedRockでOpenSearch Serverlessを利用したナレッジベースの作成

サンプルコード

下記がLambdaのPythonコードです。
一部マスクをかけています。また、プロンプトは求めている回答に合わせて調整が必要です。

import json
import boto3
import requests
from requests_aws4auth import AWS4Auth
from typing import Dict, Any, List

# 定数の定義
REGION = 'ap-northeast-1'
SERVICE = 'aoss'
OPENSEARCH_ENDPOINT = 'https://{YOUR_OPENSARCH_ENDPOINT}.ap-northeast-1.aoss.amazonaws.com'
INDEX_NAME = '{YOUR_INDEX_NAME}'
TITAN_EMBED_MODEL_ID = 'amazon.titan-embed-text-v1'
CLAUDE_MODEL_ID = 'anthropic.claude-v2:1'
MIN_SCORE = 0.1

# AWSセッションとクレデンシャルの設定
session = boto3.Session()
credentials = session.get_credentials().get_frozen_credentials()

# AWS4Auth(OpenSearch Serverlessの認証に使用)
awsauth = AWS4Auth(credentials.access_key, credentials.secret_key,
                   REGION, SERVICE, session_token=credentials.token)

# OpenSearchのエンドポイントとインデックスの設定
url = f'{OPENSEARCH_ENDPOINT}/{INDEX_NAME}/_search'

# Bedrockクライアントの初期化
bedrock = boto3.client('bedrock-runtime')

# クエリテキストのベクトル化
def get_query_embedding(query: str) -> List[float]:
    embedding_body = json.dumps({"inputText": query})
    embedding_response = bedrock.invoke_model(
        modelId=TITAN_EMBED_MODEL_ID,
        contentType='application/json',
        accept='application/json',
        body=embedding_body
    )
    embedding_result = json.loads(embedding_response['body'].read())
    return embedding_result['embedding']
   
#OpenSearchでベクトル検索とキーワード検索を実行
def search_opensearch(query_vector: List[float], query: str) -> str:
    search_query = {
        "size": 10,
        "query": {
            "bool": {
                "should": [
                    {
                        "knn": {
                            "bedrock-knowledge-base-default-vector": {
                                "vector": query_vector,
                                "k": 10
                            }
                        }
                    },
                    {
                        "match": {
                            "AMAZON_BEDROCK_TEXT_CHUNK": query
                        }
                    }
                ]
            }
        },
        "min_score": MIN_SCORE,
        "_source": ["AMAZON_BEDROCK_TEXT_CHUNK", "bedrock-kb-source-uri"]
    }
    response = requests.get(url, auth=awsauth, json=search_query)
    return response.text  # JSON 文字列を返す

# Bedrockモデルを使用して回答を生成
def generate_bedrock_response(relevant_content: str, query: str) -> str:
    prompt = f"""Human: あなたはAIアシスタントです。社員からの質問に対し、提供された情報に基づいて回答してください。

    会社規則:
    {relevant_content}

    質問: {query}

    回答の際は以下の点を厳守してください:
    1. 参照した規則や条項をそのまま引用し、引用元(例:大項目名と元ファイルの何ページに記載されているか)を明示すること
    2. 引用した内容と一般的な知識に基づく解釈を分けて回答すること
    3. 必要に応じて、具体例や事例を挙げて説明を補足すること
    4. 提供された会社規則に関連する情報がない場合は、その旨を明確に伝え、一般的な知識や推測に基づく回答は避けること

    A: 承知しました。提供された会社規則と指示に基づいて、詳細かつ構造化された回答を提供いたします。

    Assistant:"""

    body = json.dumps({
        "prompt": prompt,
        "max_tokens_to_sample": 1000,
        "temperature": 0.3,
        "top_p": 0.95,
    })

    bedrock_response = bedrock.invoke_model(
        modelId=CLAUDE_MODEL_ID,
        contentType='application/json',
        accept='application/json',
        body=body
    )

    response_body = json.loads(bedrock_response['body'].read())
    answer = response_body['completion'].strip().lstrip("はい、承知しました。").strip()
    return answer

def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
    try:
        # クエリパラメータの取得
        query_params = event.get('queryStringParameters', {})
        query = query_params.get('q')

        # クエリが空の場合のエラーハンドリング
        if not query:
            return {
                'statusCode': 400,
                'body': json.dumps({'error': 'Missing query parameter "q"'}),
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*'
                }
            }

        # クエリのベクトル化
        query_vector = get_query_embedding(query)

        # OpenSearchでの検索
        results_json = search_opensearch(query_vector, query)

        # JSON 文字列をパース
        try:
            results = json.loads(results_json)
        except json.JSONDecodeError as e:
            print(f"Error decoding OpenSearch response: {e}")
            print(f"Raw response: {results_json}")
            return {
                'statusCode': 500,
                'body': json.dumps({'error': 'Error processing search results'}),
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*'
                }
            }

        # 検索結果の処理
        if 'hits' not in results:
            print("Unexpected OpenSearch response structure. 'hits' key not found.")
            print(f"Full response: {results_json}")
            return {
                'statusCode': 500,
                'body': json.dumps({'error': 'Unexpected search result structure'}),
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*'
                }
            }

        hits = results['hits']['hits']
        if not hits or all(hit['_score'] < MIN_SCORE for hit in hits):
            return {
                'statusCode': 200,
                'body': json.dumps({
                    'answer': '申し訳ありませんが、関連する会社規定が見つかりませんでした。より具体的な質問をしていただくか、別の表現で質問してみてください。',
                    'relevantSources': []
                }),
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*'
                }
            }

        # 関連コンテンツの抽出
        relevant_content = ""
        relevant_sources = []
        for hit in hits:
            if hit['_score'] >= MIN_SCORE:
                source = hit['_source']
                chunk = source.get('AMAZON_BEDROCK_TEXT_CHUNK', 'No Content')
                source_uri = source.get('bedrock-kb-source-uri', 'Unknown Source')
                relevant_content += f"[Source: {source_uri}] {chunk}\n\n"
                relevant_sources.append(source_uri)

        # Bedrockを使用して回答を生成
        answer = generate_bedrock_response(relevant_content, query)

        # 回答を返す
        return {
            'statusCode': 200,
            'body': json.dumps({
                'answer': answer,
                'relevantSources': relevant_sources
            }),
            'headers': {
                'Content-Type': 'application/json',
                'Access-Control-Allow-Origin': '*'
            }
        }

ポイント

  • ナレッジ・質問をベクトル化することで、ナレッジの検索精度向上を図っている

  • KNNクエリとテキスト検索を併用することで、回答精度を高めつつ未回答を防ぐ仕組みとなっている

  • プロンプトにより回答時に引用元を明記させている

  • プロンプトによりナレッジを基にした回答と一般回答と混在させないようにしている

  • モデル(Titan,Claude)については東京リージョンで利用できる最新を利用する

課題と解決策

  • ナレッジがPDFで提供されるため文字列検索が行いづらい

BedRockのナレッジベースを活用しました。
ナレッジベースはPDFに対応していたため、PDFをそのままソースとして設定しました。
また、ナレッジベース作成の際にベクトル化も自動で行ってくれました。

  • コストと性能のトレードオフ

コストの面から、初回選定時はナレッジ格納先をDynamoDBとしていました。
しかし、検索面で弱さが出てしまい、回答精度が満足のいくものではありませんでした。
そのため、コストの面を許容したうえで、OpenSearchを選定し検索機能の向上を図りました。

  • ナレッジのヒット率が低い

回答として「関連する会社規定が見つかりませんでした」というケースが多々ありました。
そのため、検索手法を変更したり結果として用いるスコアの調整を行いました。

今後の展望

  • CloudFrontの導入

可用性やセキュリティの面から、CloudFrontを導入を行う

  • 権限の最小化

検証を含め進めていたことで、広く権限をつけて進めていた
最小の実行権限となるよう、不要なアクションを削除する

  • Teamsの連携

サイトへのアクセスではなく、Teamsのチャットとの連携を行う
これにより、各メンバが質問を気軽に投げることができ、利用者の増加を図る

  • プロンプト調整

プロンプトを調整することで、回答の精度を上げる
想定される質問・回答の形式に合わせてプロンプトを改良し、より自然な対話を行えるようにする

  • ナレッジ更新時の自動ナレッジベース更新

現在はS3にPDF形式でナレッジが配置されている
定期、もしくはS3へのPUTをトリガーとしてナレッジベースとの同期処理を行う

まとめ

Amazon BedRockを中心としたRAGシステムの構築により、社内ナレッジの効率的な活用が可能になりました。
なお、回答精度としては社内の確認試験を約70%ほど正解できる水準となっており、回答が誤っていても引用元を明記することで質問者が正誤を確かめやすくなったと思います。
今後もこの技術を活用し、社内業務の効率化を図っていきます。
また、システム部門だけでなく、全社的に生成AIに対しアレルギーなく活用していける組織にしていきたいと考えています。
以上、生成AI活用の第一歩としてRAGの構築について書かせていただきました。
最後までお読みいただきありがとうございました。