本番データでのRAGシステム構築例紹介

背景や目的


1.生成AIへの期待は依然高いままですが、企業独自の情報を回答させる”RAG(検索拡張生成)”の情報が増えてきたように感じます。
2.そのRAGについて、一般のITシステムとして使うための知見を多く得るために、今回は本番利用中のデータを一部利用し、構築してみました。 
3.環境としては、チャット画面と各種処理部をAWS上に、VectorStoreはPineconeで、それぞれ構築しています。

取り組もうとした事


以前の簡易実験(→RAGアプリの精度を確認してみた)で気づいた事を今回試します。
1.検索対象データは単一ファイルでは無く、専用のデータセット(VectorStore)を使おう。
2.そのVectorStoreは元データとの同期処理等も行い、本番運用可能な形でつくってみよう。
3.幻覚的回答をさせないようにプロンプトをもう少し工夫してみよう。 

利用したデータ


1.本番システムで利用中のデータの一部を利用します。
2.具体的には、客先提供中システムのサポート対応記録のデータです。データには、問合せ内容が含まれています。
3.実際のデータ
 ・保持形式: RDB(PostgreSQL)
 ・必要データの抽出方法:対象システムの機能でCSV出力
 ・項目:問合せID,日付,時間(分),問合せ内容,対応,等118列あります。
 ・件数:661件
 ・データイメージ:

システム構成


1.構成のポイント
・”RAGの利用に相応しいVectorStore”をキーワードにしてwebで調べ、紹介回数の多そうな「Pinecone」を採用しました。
・以下Vector値の作成には、OpenAIのモデルを使用しました。
 - 元データのVector値
 - 入力した質問文のVector値
・プロンプトについては、幻覚を排除するために、以下の処理を実装しました。
 - system_promptで”不確かな情報で回答しないよう”に指示。
 - 検索結果は、人が読んで理解できる体裁に整形してから、コンテクストに含める。
 - 会話継続の時、会話履歴をコンテクストに含めるが、その際同様に整形する。

2.全体構成
・ 処理部分:AWS Lambda
・ 検索ワード作成、テキスト生成:OpenAI モデル
・ VectorStore :Pinecone
・ 会話履歴保存先:AWS DynamoDB

構築


以下の手順で構築しました。
1.元データの前処理
2.VectorStoreの作成
3.クライアント画面の作成
4.サーバ側:基本応答部
5.サーバ側:ベクター検索処理
6.サーバ側:テキスト生成処理(初回)
7.サーバ側:テキスト生成処理(継続)

・上記手順の1~4は以前の記事で紹介済なので、本記事では5~7を説明します。

構築1)元データの前処理


別記事(→Pineconeを使いVectorStoreを構成してみた)に記載されています。

構築2)VectorStoreの作成


別記事(→Pineconeを使いVectorStoreを構成してみた)に記載されています。

構築3)クライアント画面の作成


別記事(→AWS Lambdaでチャットボット画面を作成)に記載されています。

構築4)サーバ側:基本応答部


別記事(→AWS Lambdaでチャットボット画面を作成)に記載されています。

構築5)サーバ側:ベクター検索処理


先行処理で得た検索ワードリスト(入力文)を使い、ベクター検索をしています。
検索ワードリストには、先行処理のパラメータに依存し、入力文全文1個だけか、入力文中の名詞複数個、が入ってきますが、ここでは入力文全文1個だけが処理される場合の説明をします。
・ 入力文のベクター値をOpenAIのembeddings.create()にて得る。[8~9行]
・ そのベクター値を引数にして、Pineconeのindex.query()を実行(=検索実行)。[10行目]
・ 検索結果はsimilarity=0.8で10件得る。[3行目top_k_num]
 (similarityはpython2の9行目で設定)
・ numで設定した件数100件をコンテクストリストとして整形し、終了。[26~42行]
 (numはpython2の9行目で設定)

python1a
def exec_rag(question,search_word_list,similarity,num): #検索関数 question:質問(str),search_word_list:検索用ワード(list),similarity:類似度(-1~1),num:個数(1~100)
    llm_context = ''
    top_k_num = 10
    context_candidates,work_list,priority_id_list,use_context = [],[],[],[]
    index = pc.Index("starter-index-test1")
    openai_client = OpenAI(api_key = OPENAI_API_KEY)
    for search_word in search_word_list:
        res_openai = openai_client.embeddings.create(input=search_word,model="text-embedding-ada-002")
        search_word_emb = res_openai.data[0].embedding
        search_results = index.query(vector=search_word_emb,top_k=top_k_num,include_values=False,include_metadata=True)
        work_list_tmp = []
        for val in search_results['matches']:
            if val['score'] >= similarity:
                context_candidates.append([val['id'],val['score'],val['metadata']['doc']])
                work_list_tmp.append(val['id'])
        work_list.append(work_list_tmp)
    for idx1,id_list1 in enumerate(work_list): #検索ワードにまたがり重複したQAを優先情報とする。
        for idx2,id_list2 in enumerate(work_list):
            if idx1 >= idx2:
                continue
            priority_id_list.extend(list(set(id_list1) & set(id_list2)))
    if len(priority_id_list) != 0:
        c = collections.Counter(priority_id_list)
        values, counts = zip(*c.most_common())
        priority_id_list = list(values)#各ワード別検索結果毎に多いidの順、重複無し
    c = 0 # コンテクストリストの作成開始
    for priority_id in priority_id_list:
        for candidate_list in context_candidates:
            if candidate_list[0] == priority_id:
                use_context.append(candidate_list)
                break
    c = c + len(use_context)
    context_candidates_others = [candidate_list for candidate_list in context_candidates if candidate_list[0] not in priority_id_list]#優先以外の情報を用意
    context_candidates_others = list(map(list, set(map(tuple, context_candidates_others)))) #重複排除
    context_candidates_others = sorted(context_candidates_others, reverse=True, key=lambda x: x[1])
    for candidate_list in context_candidates_others:#最終コンテクストのlist作成
        if c >= num :
            break
        use_context.append(candidate_list)
        c = c + 1
    for candidate_list in use_context:#最終コンテクストの整形
        llm_context = llm_context + candidate_list[2] + '\n-----------------\n' 

・以下python2は、上記検索関数を呼び出す回答作成関数です。
・lambda_handlerから入力文を受理し、検索実行結果を返却します。
・入力文から単語を抽出する関数(make_search_word)を実験的に試しましたが、
 本記事での紹介は割愛します。前述の通り、入力文全文処理モードで動作させます。

python2
def make_answer(question,session_id,continuation,do_makeword): #回答作成関数 question:質問文,session_id:セッションID,continuation:True継続/False初回,do_makeword: True する/False しない
    keyword_list,add_keyword_list = [],[]
    if not continuation :
        keyword_list.append(question)
        if do_makeword :
            word_num = 4
            add_keyword_list = make_search_word(question,word_num) #第二引数は出力数
            keyword_list.extend(add_keyword_list) 
        answer,system_prompt = exec_rag(question,keyword_list,similarity=0.80,num=100)
    else:
        answer,system_prompt = continuous_genarate(question,session_id)
    now_time = record_conversations(session_id,question,answer,keyword_list,system_prompt)
    if now_time :
        return answer,system_prompt,now_time
    else:
        return '**異常終了1**','**異常終了1**',''

・下記python5内のlambda_handlerは、サーバ側で最初に処理される関数です。
・ブラウザで入力した質問文を受理し、上の関数make_answerを呼んでいます。
・今回は入力文全文での検索をするので、90行目の第四引数は「False」にしています。

python5
import json
import base64 
import urllib.parse 
import textwrap
from openai import OpenAI
import boto3
from pinecone import Pinecone
from http import cookies
import uuid
from datetime import datetime, timedelta
import collections

OPENAI_API_KEY = "sk-******"
pc = Pinecone(api_key='*******') #pinecone index操作用のインスタンス
IP_RANGE =['']#必要であれば設定してください。
dynamodb_client = boto3.client('dynamodb')
DN_TABLE_NAME = '********'
DN_P_KEY = 'session_id'

def check_ip(ip_address, IP_RANGE):#アクセス元ipチェック関数
    print('using_IP:',ip_address)
    if ip_address in IP_RANGE:
        print('Match!!')
        return True
    else:
        print('Miss!!')
        return False

def decode_body(event_body):
    body_query = base64.b64decode(event_body).decode()
    body_dict = urllib.parse.parse_qs(body_query)
    for key in body_dict: # valueが配列に入っているので出す
        body_dict[key] = body_dict[key][0]
    return body_dict    
    
def lambda_handler(event, context):
    #IPアドレスチェック
    ip_address = '' 
    if event.get('requestContext'):
        http_info = event.get('requestContext').get('http')
        ip_address = http_info['sourceIp']
    valid_ip = check_ip(ip_address, IP_RANGE)
    if not valid_ip:
        view_str = '500 Unauthorized '+ip_address
        return {'statusCode': 500,'body': json.dumps(view_str)}
    #body部のデコード
    if event.get('body'):
        request_body = decode_body(event['body'])
        print("decoded_body",request_body)
    #パラメータの受理(question,clear) 
    question,clear = '','0'
    if event.get('queryStringParameters'):
        question,clear = event.get('queryStringParameters').get('question'),event.get('queryStringParameters').get('clear')
    elif event.get('body'):
        question,clear = request_body.get('question'),request_body.get('clear')  
    #session_id処理
    COOKIE_KEY = 'cookie-key_proto2'
    session_id,continuation = '',False
    if not 'cookies' in event:
        session_id = str(uuid.uuid4())
        print('IS_cookies:NO!! -> New Question!!')
    else:
        print('IS_cookies:YES!!')
        C = cookies.SimpleCookie()
        C.load('; '.join([cookie for cookie in event['cookies']]))
        cookie_dict = {k: v.value for k, v in C.items()}
        if not COOKIE_KEY in cookie_dict:
            session_id = str(uuid.uuid4())
            print('COOKIE_KEY:Nothing -> New Question!!')
        else:
            session_id = cookie_dict[COOKIE_KEY]
            print('COOKIE_KEY:Exist!!')
            if is_conversation_log(session_id):
                continuation=True
                print('Continue Conversation')
            else:
                continuation=False
                print('New Question!!')

    if clear == '1':
        session_id,continuation = str(uuid.uuid4()),False
        print('conversation_clear!!')
    #cookie用response作成
    set_cookie = '{cookie_key}={session_id}'.format(cookie_key=COOKIE_KEY,session_id=session_id)
    #回答生成要求部
    if clear == '1':
        answer,system_prompt,now_time,question = '新しい質問をどうぞ。','','','' #生成しない。
    else:
   #引数3は継続mode(True:継続/False:初回)/引数4はワード検索mode(True:する/False:しない)
        answer,system_prompt,now_time = make_answer(question,session_id,continuation,False) 
    #レスポンスデータ作成
    message = {
        "question":question,
        "exec_time":now_time, #record_conversationsで作成したタイムスタンプ。
        "answer":answer 
    }
    print(message)
    response = {
        'statusCode': 200,
        'isBase64Encoded': False,
        'headers': {
            'Access-Control-Allow-Credentials' : True, 
            'Content-Type': 'application/json',
            'Set-Cookie': set_cookie,
        },
        'body': json.dumps(message, ensure_ascii=False),
    }
    
    return response

構築6)サーバ側:テキスト生成処理(初回)


1.DynamoDBのセットアップ
まず会話履歴保存用のテーブルを作成しておきます。今回はDynamoDBを使います。
・ AWSコンソールでDynamoDBに遷移し、トップ画面で「テーブルの作成」を押します。

・ 画面「テーブルの作成」にて、以下を設定値としてテーブルを作成します。

設定項目
テーブル名rag_proto2r1 ※任意で結構です。
パーティションキーsession_id 文字列
ソートキーnow_time 文字列
デフォルト設定チェック

・ AWSコンソールでIAM画面に遷移し、メニュー「ロール」を開きます。
・ その画面で、Lambda関数に割り当てたロール名を押し、以下ポリシーをアタッチします。

設定項目
ポリシー名AmazonDynamoDBFullAccess

2.テキスト生成処理(初回)の処理内容
「構築5)サーバ側:ベクター検索処理」の処理で検索結果を得ていますので、それをコンテクストにしてLLMへ回答作成依頼をします。下部コードセルpython1bを参照下さい。
・ テキスト生成のLLMは、(試験時は)OpenAIの” gpt-4-turbo”を使用しました。
 (お試しになる時は、どんなGPTモデルでも構いません。)
・ システムプロンプトは幻覚を排除するための内容を指定しました。[6~9行目]

python1b
def exec_rag(question,search_word_list,similarity,num): #question:質問(str),search_word_list:検索用ワード(list),similarity:類似度(-1~1),num:個数(1~5)

 ・・・前半部省略・・・

   system_prompt = textwrap.dedent("""\
        あなたは質問に回答するチャットbotです。
        以下のコンテクストを参考にして質問に回答して下さい。
        コンテクストの中に質問に対する答えがない場合や、わからない場合、不確かな情報で回答しないでください。
        わからない場合は正直に「わかりませんでした」と答えてください。
        ## コンテクスト(開始) ##
        {}
        ## コンテクスト(終了) ##
    """).format(llm_context)
    response_gpt = openai_client.chat.completions.create(
        #model="gpt-4",
        model="gpt-4-turbo-2024-04-09",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": question}
        ],
        #temperature=1
    )
    answer = response_gpt.choices[0].message.content
    return answer,system_prompt

・下記コードセルpython3は、会話履歴保存関数です。
・ 回答作成後に、以下データを会話履歴としてDynamoDBへ保存しています。[4~11行目]
 (呼び出し元は、構築5で紹介した関数make_answerです。)
  - session_id:セッションID
  - question :ユーザからの質問文
  - answer :LLMからの回答
  - keyword_list :検索で使用したワード(※保守用)
  - system_prompt :LLMに与えたシステムプロンプト(※保守用)

python3
def record_conversations(session_id,question,answer,keyword_list,system_prompt): #会話履歴保存関数
    now_time = str((datetime.utcnow() + timedelta(hours=9)).strftime("%Y-%m-%d %H:%M:%S"))
    keyword_list_str = ','.join(keyword_list)
    dynamodb_client.put_item(TableName=DN_TABLE_NAME,Item={
        DN_P_KEY: {"S": session_id},
        "question": {"S": question},
        "answer": {"S": answer},
        "keyword_list": {"S": keyword_list_str},
        "system_prompt": {"S": system_prompt},
        "now_time": {"S": now_time},
    })
    dynamo_response = dynamodb_client.query(TableName=DN_TABLE_NAME,KeyConditionExpression= DN_P_KEY + '= :val',ExpressionAttributeValues={":val": {"S": session_id}},)
    return now_time

構築7):サーバ側:テキスト生成処理(継続)


1回質問をした後は、回答処理を変えています。下部コードセルpython4を参照下さい。
・ Request受信時、Cookie内セッションIDが、会話履歴に存在したら継続と判断。[38~43行目]
 (呼び出し元は、構築5で紹介した関数lambda_handlerです。)
・ 会話履歴は存在する分だけ以下のように整形し、LLMに与えます。[6~14行目]

[時刻] ・・・会話時刻・・・ ———-
[利用者]
 <プロンプト>
   ・・・システムプロンプトの中身・・・
 <質問>
   ・・・ユーザの質問文・・・
[生成AI]
 ・・・回答文・・・
[時刻] ・・・会話時刻・・・ ———- 以降繰り返し、、

・ システムプロンプトでは、会話履歴を考慮するよう指示しています。[17~21行目]
・ 2回目以降のシステムプロンプトは冗長になるので、固定内容で指定します。[11行目]
・ 生成後は、初回と同様に会話履歴を登録して処理終了です。

python4
def continuous_genarate(question,session_id): #継続会話用の生成関数。question:質問(str),session_id:セッションID
    llm_context = ''
    openai_client = OpenAI(api_key = OPENAI_API_KEY)
    dynamo_response = dynamodb_client.query(TableName=DN_TABLE_NAME,KeyConditionExpression= DN_P_KEY + '= :val',ExpressionAttributeValues={":val": {"S": session_id}},)
    for idx,item in enumerate(dynamo_response['Items']):
        llm_context = llm_context +'[時刻]'+item['now_time']['S']+'----------\n'
        llm_context = llm_context +'[利用者]\n'
        if idx == 0 : #system_promptの前処理:1回目は会話履歴が無いので、そのまま利用。
            system_prompt_val = item['system_prompt']['S']
        else : #system_promptの前処理:2回目以降の分は、冗長かつ膨大になりそうなので、固定文にした。
            system_prompt_val = 'あなたは質問に回答するチャットbotです。'
        llm_context = llm_context +' <プロンプト>\n'+system_prompt_val+'\n'
        llm_context = llm_context +' <質問>\n'+item['question']['S']+'\n'
        llm_context = llm_context +'[生成AI]\n'+item['answer']['S']+'\n'

    system_prompt = textwrap.dedent("""\
        あなたは質問に回答するチャットbotです。
        以下の会話履歴は、あなた(生成AI)と利用者の最近の会話内容です。
        これを参考にして質問に回答して下さい。
        会話内容の中に存在するコンテクストに質問に対する答えが無い場合は、一般論として、ヒントとなる情報を回答して下さい。
        その際は、憶測の情報である事を述べる言葉を必ず使って下さい。
        ## 会話履歴(開始) ############################################################
        {}
        ## 会話履歴(終了) ############################################################
    """).format(llm_context)
    response_gpt = openai_client.chat.completions.create(
        model="gpt-4-turbo-2024-04-09",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": question}
        ],
        #temperature=1
    )
    answer = response_gpt.choices[0].message.content

    return answer,system_prompt

def is_conversation_log(session_id): #会話履歴内存在確認の関数
    dynamo_response = dynamodb_client.query(TableName=DN_TABLE_NAME,KeyConditionExpression= DN_P_KEY + '= :val',ExpressionAttributeValues={":val": {"S": session_id}},)
    if dynamo_response['Count'] > 0 :
        return True
    else:
        return False

Lambda関数へのセット


Lambda関数にセットする手順を整理しておきます。
(Lambda関数の「コード」タブ内に1ファイル(lambda_function.py)で書く前提です。)
1.実際は利用者画面部分が必要なので、それを先に準備します。
 本記事の構築3がそれに該当します。過去記事「→AWS Lambdaでチャットボット画面を作成」の「クライアント画面の作成」に手順がありますので、そちらを実施してください。
2.次にバックエンド処理を設置します。
 先にバックエンド用のLambda関数が必要なので、それを作成しておきます。
 過去記事「→AWS Lambdaでチャットボット画面を作成」の「サーバ応答部:
 Lambda関数を用意」に手順がありますので、そちらで新規作成をしてください。
 そのLambda関数に本記事の構築5~7のコードをセットしてゆきます(以下3.~5.)。
3.コードセルpython5をコピーします。
4.コピーしたpython5内の「def lambda_handler(event, context):」の上に、
 python1b をコピーし、その中の「・・・前半部省略・・・」の所に 
 python1a の2行目以降をコピーします。
5.さらに、python4python3python2をその順番で「def lambda_handler(event, context):」の上にコピーします。
6.Lambda 関数を保存して完了です。

試験仕様


1.試験仕様
試験は以下のように実施します。
(1)質問をして、回答を得て、良い回答かどうかチェックします。
(2)テスト項目では、以下を考慮します。
   ・存在する情報の事を聞く質問をし、良い回答がされるか。
   ・存在しない情報の事を聞く質問をし、「わからない」と答えるか。
   ・マスキングされたデータ(***) が有る場合、大丈夫か。
   ・継続の会話が妥当かどうか。

2.試験項目
 実際の試験項目は、以下の通りです。 合計20項目。
(1)存在する情報の事を聞きます
     14件 ( 簡単そうなケース:8件 難しそうなケース:6件)
(2)存在しない情報の事を聞きます
     6件  (初回:3件 継続会話:3件)

試験結果:サマリー


20個の質問をした結果、概ね良好な内容でした。
75%は良い回答(15件/20件)で、残りの5件も幻覚が無いまずまずな回答でした。
・グループ1(情報存在、簡単)の試験(8件/8件)【100%】
・グループ2(情報存在、難しい)の試験(2件/6件)【33.3%】
・グループ3(情報未存在)の試験(5件/6件)【83.3%】

試験結果:グループ別の詳細(抜粋)


質問1(グループ1(情報存在、簡単)の試験 ): 「*****システムで、ログイン直後の担当課選択画面を表示するところで処理中のまま進まない。どうしたら良い?」
回答:「ユーザーが所属する課の情報が正しく設定されているか確認してみてください。ユーザマスタメンテ画面で、ログインユーザーの所属課を確認し、元々の課に所属していなければ、それが原因かもしれません。このような場合、情報を正しく付け直すことで問題が解消されるはずです。」
→参照されるべき情報を使った良い回答で【〇】です。

質問2(グループ2(情報存在、難しい)の試験): 「ログインできない。どうしたら良い?」 
回答:「ログインできない原因は様々です。原因と解決方法を特定するには具体的な状況やエラーメッセージなど、より詳細な情報が必要です。パスワードをお忘れの場合、一般的にはパスワードの初期化や再設定の手続きを実施します。それでも改善しない場合は、システム管理者やサポートデスクなどに問い合わせるようにしてください。」
→参照すべき情報を使っているのは良いが、対象システムが質問者と合っているかやや不安。
→ 「*****システムの事なら、・・」と前置きがあれば、ベストな回答なので【△】です。

質問3(グループ3(情報未存在)の試験): 「*****システムをmacで使う場合の注意点について質問された。どうしたら良い?」
回答:「コンテクストの中に*****システムをMacで使う場合の注意点についての情報は含まれていませんので、具体的な回答を提供することはできません。」
→情報が無く、わからない、と素直に伝えている良い回答で【〇】です。

まとめ


全体的に確認できた事や課題を最後に整理します。

【構築関連】
1.Pineconeは、pod typeとserverless typeの2typeがあり、今回は無償枠で利用可能なpod typeを利用し、今回のケースでは特に問題はありませんでした。indexを複数作成する場合は、上位のserverless typeが必要なようです。
2.個人情報対策としてマスキングをしました。今回は660件を約5時間かけて手作業で実施しましたが、RAGを実運用する際は自動化が必要かもしれません。
3.データの投入処理ではpandasを多用しました。この種の前処理では活躍するなぁ、と改めて思いました。
4.データの一括投入時、OpenAIのembeddings.create()が500件位で作成が滞る現象がありましたが、原因が分かりませんでした(攻撃対策の何かなのか不明)。今回は分けて対応しました。
5.会話継続時のsystem_promptの作り方は色々ありそうです。今回の方法が最善だったのか、継続して確認したいと思います。

【試験から】
1.本番データを使ったRAGは、概ね期待通りの動作をしました。
2.マスキング部分を聞く質問で無ければ、マスキングの悪影響は特にありませんでした。
3.前提的な情報は明示してもらわないと問題になってしまいそうなケースがありました。プロンプトの工夫等での対応を、次回以降で試したいと思います。
4.質問中の言葉がコンテクストと1文字違う(違いが少ない場合)時に、念のため前置きさせる等の工夫がまだ必要そうです。これもプロンプト等で出来るか、別途確認したいと思います。
5.検索結果の出力数が多いほど質問回答精度は良くなりますが、待ち時間が増えます。この点と費用の面も考慮して、総合的に検索関連のパラメータを決めると良さそうです。

ご連絡フォーム


フィードバックを是非お願いします。
本記事の方法での問題点や、よりよい方法のアイデアを頂けると大変助かります。

この記事に関して

その他のご連絡

DevAIsをもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む