はじめに
Amazon Bedrock Knowledge Bases(以下、ナレッジベース)は利用していますか?
私たちのチームでは、システムの仕様書を取り込み、GenUを介して利用しています。
それでは、ナレッジベースの『メタデータ』は使っていますか?
メタデータは本来、RAGにおいてドキュメントをフィルタリングするための機能です。が、私たちのチームでは、そのメタデータがAPIのレスポンスに含まれることを利用し、以下のように、参照したファイルのパス(仕様書のあるファイルサーバのパス)を表示するようにしています。
動けばよしの精神でやっています。用語など不正確な部分があるかと思いますがご容赦ください。致命的であればコメントやDMでご連絡いただければ幸いです。
最後に実装例を示しています。稚拙ですが、せっかくなのでご参照ください。
背景
私のチームでは、担当システムの仕様書をナレッジベース(S3 Vectors)に取り込み、GenUを介して利用しています。(1500点以上のExcelファイルです、トホホ...)
ただ、RAGも100%正確ではありません。原因の1つとして、図が含まれていたり、セルが結合されていたり(あるあるですよね?)など、 そもそも仕様書がLLMにとって読みやすい形になっていないというのがあると思います。
かといって、それをすべて直すのは現実的ではありませんから、RAGはあくまでも検索の補助。
そしてもちろん、生成AIの出力ですから、ファクトチェックは人間の責任ですよね?
GenUとは:『生成AIを安全に業務活用するための、ビジネスユースケース集を備えたアプリケーション実装』です。AWS公式に公開されているプロジェクトです。
https://aws-samples.github.io/generative-ai-use-cases/ja/index.html
課題
人間が確認を行うために、チャットボットは参照したファイルパスを提示するようにしたいと考えました。[注1]
しかも、そのファイルパスは、単なるS3のファイルパスではなく、普段担当者が参照しているファイルサーバのパスにしたほうが効果的です。
そうすれば、コピペでファイルをすぐに参照できます。
しかし、
S3 Vectorsが日本語ファイル名をサポートしていない[注2]
ため、S3に仕様書をフォルダごとアップロードしても、日本語パスだと取り込みでエラーになります。
[注1] GenUでは、参照したファイルのS3パスを示し、DLできる機能があります。利用者本人の確認だけならそれで問題ありません。が、社内メンバーにソースを共有する場合は、普段使いのファイルサーバのパスを共有するのが便利です。
[注2] S3 Vectorsではなく、OpenSearchServerlessやNeptuneなど他のベクトルDBサービスなら可能かもしれません、が試せていません。
解決案
概要
以下の方法で解決しました。(※2,3の処理は、StepFunctionsで実装しています。『実装詳細』をご参照ください)
- まず、取り込みたいドキュメントを、仕様書のフォルダ階層そのままでS3にアップロード。
OriginalDocuments
に上げたものとします - 日本語だとダメなので、ハッシュ値など英数字ファイル名に変換して別フォルダにコピー。
ImportedDocuments
に階層を保持せずにコピーするものとします -
ImportedDocuments
内の仕様書1つずつに対して、metadata.json
を作成。その中に、S3のパスを記載 - チャットボットで表示する際に、
metadata
の情報を表示する。その際、OriginalDocuments
の部分を、利用しているファイルサーバに文字列置換
フォルダ構成的には次のイメージです。
<S3 Bucket>
├-- OriginalDocuments
| ├-- 基本設計書
| | ├-- 設計書1.xlsx
| | ├-- 設計書2.xlsx
| | └-- ....
| └-- 詳細設計書
| ├-- 詳細設計書1.xlsx
| ├-- 詳細設計書2.xlsx
| └-- ....
└-- ImportedDocuments
├-- hash-hogehoge.xlsx
├-- hash-hogehoge.xlsx.metadata.json
├-- hash-fugafuga.xlsx
├-- hash-fugafuga.xlsx.metadata.json
└-- ....
補足
概要3) metadata.jsonについて
取り込み対象ファイルに対して、[同じファイル名].metadata.json
が必要です。
hash-hogehoge.xlsx
の場合、hash-hogehoge.xlsx.metadata.json
です。
さて、この.metadata.json内に、次のように、S3のパスを記述します。
{
"metadataAttributes":
{
"originalPath": "OriginalDocuments/基本設計書/テーブル定義.xlsx"
}
}
概要4) APIのレスポンスにおけるmetadata
について
次に、レスポンスを確認しておきましょう。metadata.json
を作っておくと、こんなふうに返ります。metadata
がたしかに返るので、これをGenUの表示に利用できることがわかるかと思います。
{
"citations": [
{
"generatedResponsePart": {
"textResponsePart": {
"span": {"end": 160,"start": 0},
"text": "共通ユーザマスタテーブルには、..."
}
},
"retrievedReferences": [
{
"content": {
"text": "データ項目名n テーブルID テーブル名...",
"type": "TEXT"
},
"location": {
"s3Location": {
"uri": "s3://<bucket>/ImportedDocuments/f0f882ad9d7511a9aaaa.xlsx"
//☝️これはS3のキー
},
"type": "S3"
},
"metadata": {
"x-amz-bedrock-kb-source-uri": "s3://<bucket>/ImportedDocuments/f0f882ad9d7511a9aaaa.xlsx",
"x-amz-bedrock-kb-data-source-id": "xxxxxx",
"originalPath": "OriginalDocuments/基本設計書/テーブル定義.xlsx",
//☝️この通り、返ってくることが分かる
"x-amz-bedrock-kb-chunk-id": "xxxx-xxx-xxx..."
}
},
{...}//他のテキスト
]
}
],
"output": {
"text": "共通ユーザマスタテーブルには、..."
},
"sessionId": "xxxx..."
}
GenUを使うならば、この辺りを書き換えると良いでしょう。
originalPath = ref.metadata?.['originalPath'];
こんな感じで取り出せるので、referenceTextに追加されるように変更しましょう。
実装詳細
稚拙ですが、Stepfunctionsを貼っておきます。
StepFunctionsの実装について
- DynamoDBで変換前ファイルパス、変換後ファイル名、ファイル更新日(S3アップロード日時)を管理する機能を追加
- 仕様書の更新がある場合のみStateを回したいので、DynamoDB上のファイル更新日を取得→処理済みファイルの作成日時と同じならスキップする、としています
- テーブルの構成は次のとおり
- NewPath(パーティションキー)
- LastModified(ソートキー)
- OriginalPath
- Stateを二つに分割
-
UpdateKnowledge_main.json
で全ファイルを走査
→mapで処理して、UpdateKnowledge_sub
を呼び出し
-
- 登場してくる各Lambdaの役割
- GenerateNewFileName: ハッシュ値ファイル名に変換
- CopyFile: ファイル名をハッシュ値に変更しながらコピー先フォルダにコピー
(StateMachine内のCopyObjectだと、日本語ファイルがうまく処理できなかったため) - CreateMetadataJson(Lambda): 作成するmetadata.jsonの情報を作成
(originalPathの他に、category, subsystemを付与しています)
// 1. コピー元フォルダのファイルを一覧化
// 2. 拡張子が.xls* だったら、mapでサブマシンを呼び出し
// 3. 最後に、ナレッジベースの同期を実行
{
"Comment": "State machine to move files in S3 and update DynamoDB",
"StartAt": "ConfigureParameters",
"States": {
"ConfigureParameters": {
"Type": "Pass",
"Result": {
"source_bucket": "<コピー元S3バケット名>",
"destination_bucket": "<コピー先S3バケット名>",
"table_name": "<DynamoDBテーブル名>",
"source_folder": "OriginalDocuments/", // コピー元フォルダ
"destination_folder": "ImportedDocuments" // コピー先フォルダ
},
"Next": "ProcessFiles"
},
"ProcessFiles": {
"Type": "Map",
"Parameters": {
"source_bucket.$": "$.source_bucket",
"destination_bucket.$": "$.destination_bucket",
"table_name.$": "$.table_name",
"source_folder.$": "$.source_folder",
"destination_folder.$": "$.destination_folder",
"file_key.$": "$$.Map.Item.Value.Key",
"last_modified.$": "$$.Map.Item.Value.LastModified"
},
"Iterator": {
"StartAt": "IsFile",
"States": {
"IsFile": {
"Type": "Choice",
"Choices": [
{
"Not": {
"Variable": "$.file_key",
"StringMatches": "*.xls*"
},
"Next": "Continue"
}
],
"Default": "Step Functions StartExecution"
},
"Step Functions StartExecution": {
"Type": "Task",
"Resource": "arn:aws:states:::states:startExecution.sync:2",
"Parameters": {
"StateMachineArn": "arn:aws:states:<リージョン>:<アカウントID>:stateMachine:UpdateKnowledge_sub",
"Input.$": "$"
},
"Next": "Continue"
},
"Continue": {
"Type": "Pass",
"End": true
}
},
"ProcessorConfig": {
"Mode": "DISTRIBUTED",
"ExecutionType": "STANDARD"
}
},
"MaxConcurrency": 50,
"Label": "ProcessFiles",
"ItemReader": {
"Resource": "arn:aws:states:::s3:listObjectsV2",
"Parameters": {
"Bucket.$": "$.source_bucket",
"Prefix.$": "$.source_folder"
}
},
"ResultPath": null,
"Next": "StartIngestionJob"
},
"StartIngestionJob": {
"Type": "Task",
"Parameters": {
"DataSourceId": "<ナレッジベースのデータソースID>",
"KnowledgeBaseId": "<ナレッジベースID>"
},
"Resource": "arn:aws:states:::aws-sdk:bedrockagent:startIngestionJob",
"End": true
}
}
}
// 1. GenerateNewFileName(Lambda)でハッシュ値ファイル名に変換
// 2. DynamoDBをチェック。ハッシュ値ファイル名と更新日が同じ件数を取得
// 3. 2.の値が1以上なら処理を終了。0なら処理を継続
// 4. CopyFile(Lammbda)でファイル名をハッシュ値に変更しながらコピー先フォルダにコピー
// ※StateMachine内のCopyObjectだと、日本語ファイルがうまく処理できず、Lambdaを用意しました
// 5. CreateMetadataJson(Lambda)で作成するmetadata.jsonの情報を作成。
// ※originalPathの他に、category, subsystemを付与しています
// 6. metadata.jsonを生成して、S3にアップロード
// 7. 処理済み情報をDynamoDBに保存
{
"Comment": "State machine to move files in S3 and update DynamoDB",
"StartAt": "GenerateNewFileName",
"States": {
"GenerateNewFileName": {
"Type": "Task",
"Resource": "arn:aws:lambda:<リージョン>:<アカウントID>:function:GenerateNewFileName",
"ResultPath": "$.GenerateNewFileName",
"Next": "SetParameter"
},
"SetParameter": {
"Type": "Pass",
"Next": "CheckDynamoDB",
"Parameters": {
"source_bucket.$": "$.source_bucket",
"destination_bucket.$": "$.destination_bucket",
"table_name.$": "$.table_name",
"source_folder.$": "$.source_folder",
"destination_folder.$": "$.destination_folder",
"file_key.$": "$.file_key",
"last_modified.$": "$.GenerateNewFileName.last_modified",
"GenerateNewFileName.$": "$.GenerateNewFileName"
}
},
"Continue": {
"Type": "Pass",
"End": true
},
"CheckDynamoDB": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:dynamodb:query",
"Parameters": {
"TableName.$": "$.table_name",
"KeyConditionExpression": "NewPath = :new_path AND LastModified = :last_modified",
"ExpressionAttributeValues": {
":new_path": {
"S.$": "$.GenerateNewFileName.new_file_key"
},
":last_modified": {
"S.$": "$.last_modified"
}
}
},
"Next": "CheckDBCount",
"ResultPath": "$.CheckDynamoDB"
},
"CheckDBCount": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.CheckDynamoDB.Count",
"NumericGreaterThan": 0,
"Next": "GetExistingFileName"
}
],
"Default": "CopyFile"
},
"GetExistingFileName": {
"Type": "Pass",
"ResultPath": "$.new_file_key",
"Next": "Continue"
},
"CopyFile": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"Payload.$": "$",
"FunctionName": "arn:aws:lambda:<リージョン>:<アカウントID>:function:CopyFile:$LATEST"
},
"Retry": [
{
"ErrorEquals": [
"Lambda.ServiceException",
"Lambda.AWSLambdaException",
"Lambda.SdkClientException",
"Lambda.TooManyRequestsException"
],
"IntervalSeconds": 1,
"MaxAttempts": 3,
"BackoffRate": 2
}
],
"Next": "CreateMetadataJson",
"ResultPath": "$.CopyFile",
"ResultSelector": {
"statusCode.$": "$.Payload.statusCode",
"file_key.$": "$.Payload.file_key",
"dest_key.$": "$.Payload.dest_key"
}
},
"CreateMetadataJson": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"Payload.$": "$",
"FunctionName": "arn:aws:lambda:<リージョン>:<アカウントID>:function:CreateMetadatajson:$LATEST"
},
"Retry": [
{
"ErrorEquals": [
"Lambda.ServiceException",
"Lambda.AWSLambdaException",
"Lambda.SdkClientException",
"Lambda.TooManyRequestsException"
],
"IntervalSeconds": 1,
"MaxAttempts": 3,
"BackoffRate": 2
}
],
"Next": "SetMetadataJson",
"ResultPath": "$.GenerateSummary",
"ResultSelector": {
"statusCode.$": "$.Payload.statusCode",
"metadataAttributes.$": "$.Payload.metadataAttributes"
},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Next": "SaveMappingForError",
"ResultPath": "$.GenerateSummary"
}
]
},
"SetMetadataJson": {
"Type": "Pass",
"Next": "PutObject",
"ResultPath": "$.MakeMetadataJson",
"Parameters": {
"original_path.$": "$.file_key",
"metadata_json_filename.$": "States.Format('{}/{}.metadata.json', $.destination_folder, $.GenerateNewFileName.new_file_key)"
}
},
"PutObject": {
"Type": "Task",
"Parameters": {
"Body": {
"metadataAttributes.$": "$.GenerateSummary.metadataAttributes"
},
"Bucket.$": "$.destination_bucket",
"Key.$": "$.MakeMetadataJson.metadata_json_filename"
},
"Resource": "arn:aws:states:::aws-sdk:s3:putObject",
"Next": "SaveMapping",
"ResultPath": "$.PutObject"
},
"SaveMapping": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:dynamodb:putItem",
"Parameters": {
"TableName.$": "$.table_name",
"Item": {
"NewPath": {
"S.$": "$.GenerateNewFileName.new_file_key"
},
"OriginalPath": {
"S.$": "$.file_key"
},
"LastModified": {
"S.$": "$.last_modified"
}
}
},
"End": true
},
"SaveMappingForError": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:dynamodb:putItem",
"Parameters": {
"TableName.$": "$.table_name",
"Item": {
"NewPath": {
"S.$": "$.GenerateNewFileName.new_file_key"
},
"OriginalPath": {
"S.$": "$.file_key"
},
"LastModified": {
"S.$": "$.last_modified"
},
"Skipped": {
"N": "1"
}
}
},
"Catch": [
{
"ErrorEquals": [
"States.ALL"
],
"Next": "Continue"
}
],
"End": true
}
}
}
最後に、日本語ファイル名のファイルをコピーするLambdaを貼っておきます
import boto3
from unicodedata import normalize
def lambda_handler(event, context):
s3 = boto3.client('s3')
source_bucket = event['source_bucket']
source_key = event['file_key']
dest_bucket = event['destination_bucket']
dest_key = event['GenerateNewFileName']['new_file_key']
destination_folder = event['destination_folder']
dest_key = f'{destination_folder}/{dest_key}'
#print(source_key)
u_source_key = normalize('NFC', source_key)
#print(u_source_key)
s3.copy_object(
CopySource={'Bucket': source_bucket, 'Key': source_key},
Bucket=dest_bucket,
Key=dest_key
)
return {
'statusCode': 200,
'file_key': source_key,
'dest_key': dest_key
}
次のステップ
- GenUにStorage Browser for Amazon S3を組み込み、GenU上からファイルのアップロードを可能にする
- 今回紹介したStepFunctionsを実行するボタン、Lambdaを用意して、同期を簡単にする
Storage Browser for Amazon S3 とは: Storage Browser for Amazon S3 を使用して、アプリケーションを通じてユーザーをデータに接続
-
OriginalDocuments
フォルダからファイルを削除したらImportedDocuments
にも反映する
追加には対処できるのですが、削除ができません。S3のトリガーからLambda経由でできそうですが、こちらは未実装です。
おわりに
生成AI、RAGが普及して、業務は大幅に効率しました。が、LLMも完璧ではありません。それでも、RAGがあるのとないので、本当に天と地の差です。最大限活用するための工夫の1つを紹介しました。
metadataの意外な使い方を示せたのではないでしょうか。
同じような課題に直面した方の参考になれば幸いです。