BedrockのIaC化でつまづいた点

記事タイトルとURLをコピーする

はじめに

こんにちは、アプリケーションサービス本部ディベロップメントサービス3課の北出です。
今回は、Bedrock のナレッジベースを作成し、そこから検索し Amazon Nova Pro に検索結果もプロンプトに含めて渡すというのを IaC で作成したのですが、その際につまづいたポイントを共有しようと思います。 同じような部分で困っている方の一助になれば幸いです。

この記事はサーバーワークスAdvent Calendar 2025 の 17日目の記事です。

qiita.com

システム概要

今回説明するシステム構成はこのようなものになっています。

この構成の IaC を AWS CDK TypeScript で作成します。
厳密には VPC やカスタムリソースの Lambda も構成図にありますが省略します。

生成 AI の基盤モデルには Amazon Nova Pro を使います。

この構成を IaC 化する際につまづいたポイントを共有します。

つまづきポイント

Amazon Nova Pro のリージョン

Amazon Nova Pro を利用しようとしていたのですが、自分の検証環境では、ap-northeast-1 (東京)と us-east-1 (バージニア北部)だけが有効になっており、他のリージョンへのアクセスは拒否されるものとなっていました。
問題ないだろうと思って東京リージョンで進めていたのですが、いざ Amazon Nova Pro を実行するとエラーになり、原因を探るとリージョン制限によるものでした。

クロスリージョン推論

Bedrock では一部の基盤モデルに「クロスリージョン推論」というものが採用されています。
これは、最適なスループットになるように AWS 側が動的にリクエストをルーティングするもので、リクエスト内容や出力結果が送信元リージョン(今回は東京リージョン)以外にも送信される可能性があります。これはユーザー側で東京リージョンだけに絞ることができません。
クロスリージョン推論の詳細については こちら を参照してください。

Amazon Nova Pro はこのクロスリージョン推論となっており、東京リージョンから送信する場合は [ap-northeast-1, ap-northeast-2, ap-northeast-3, ap-south-1, ap-southeast-1, ap-southeast-2] もアクセス可能にする必要があります。
会社のガバナンスなどで、東京リージョンでのデータのやり取りしか許可できないといった場合は、別のモデルの利用を検討する必要がありますのでご注意ください。
各モデルのクロスリージョン推論での利用するリージョンについては こちら を参考にしてください。

もう一つ注意点として、IAM ロールなどで指定するモデルの ARN は arn:aws:bedrock:ap-northeast-1:123456789012:inference-profile/apac.amazon.nova-pro-v1:0 といったものになります。 foundation-model であったり、 apac.amazon.nova-pro-v1:0 といったように記述が少し変わりますのでご注意ください。ARN はマネジメントコンソールで 「Bedrock → クロスリージョン推論 → モデルを選択」とすることで、そのモデルの ARN を調べることができます。

Converse API と Invoke Model

Bedrock の API で基盤モデルにアクセスするのは主に InvokeModel API と Converse API があります。
これらの API の違いについては こちらのブログ が参考になります。
上記のブログにもあるように、Converse API が推奨されています。
以下は一例です。

import { ConverseCommand } from '@aws-sdk/client-bedrock-runtime';

command = new ConverseCommand({
  modelId: APP_CONFIG.ai.models.reviewModel,
  messages: [
    {
      role: 'user',
      content: [
        { text: prompt },
        {
          document: {
            format: fileType,
            name: sanitizedFileName,
            source: { 
              bytes: Buffer.from(content, 'base64')
            }
          }
        } as any
      ]
    }
  ],
  inferenceConfig: {
    maxTokens: APP_CONFIG.ai.inference.maxTokens,
    temperature: APP_CONFIG.ai.inference.temperature
  }
});

Bedrock で読み込むファイルの名前

Converse API で PDF や XLSX などのドキュメントを送る際に、ファイル名のルールは結構厳しく、こちらのドキュメント には以下のようにあります。

「英数字、空白文字(連続して 1 つまで)、ハイフン、括弧、角括弧」

これを見ていただくと、日本語はもちろん、アンダーバーなども含まれているとエラーになります。そのため、Converse API の実行前にファイル名を変換する処理を挟むのがおすすめです。
さきほどのコードでは sanitizedFileName が変換後のファイル名にあたります。
以下のコードのようにハッシュ化して確実にファイル名要件をパスできるようにします。

import { createHash } from 'crypto';

function sanitizeFileName(filePath: string, fileType: string): string {
  // ファイル名をMD5ハッシュ化
  const sanitized = createHash('md5').update(filePath).digest('hex');
  return sanitized;
}

Aurora Serverless ナレッジベースの構築

Bedrock のナレッジベースのベクトルデータベースは OpenSearch Serverless だとコストが高くなるため、PoC 目的で気軽には使うのが難しいです。
そのため、Aurora Serverless で ACU を低くすることでコストを抑えることができます。
※2025/12/02 の AWS re:Invent にて、S3 Vectors が一般提供開始されたため、気軽に使うには S3 Vectors を使うのがおすすめです。

Aurora Serverless をベクトルデータベースとして使うにあたり、こちらの手順 に従って設定をする必要があります。
この設定をする SDK などはなく、SDK だけでやろうとしたら、Aurora 構築 → 手動でデータべースに接続して設定 → ナレッジベース構築 といった手順になってしまいます。
そのため、「データベースに接続して設定」の部分をカスタムリソースにする必要があります。ソースコードは以下になります。

インフラ定義

export class AuroraKnowledgeBase extends Construct {

  constructor(scope: Construct, id: string, props: AuroraKnowledgeBaseProps) {
    super(scope, id);

    // Aurora Serverless v2 Cluster
    this.cluster = new rds.DatabaseCluster(this, 'AuroraCluster', {
      clusterIdentifier: `${props.envPrefix}-aurora-vector-knowledge-01`,
      engine: rds.DatabaseClusterEngine.auroraPostgres({
        version: rds.AuroraPostgresEngineVersion.VER_17_4
      }),
      writer: rds.ClusterInstance.serverlessV2('writer'),
      serverlessV2MinCapacity: 0.5,
      serverlessV2MaxCapacity: 2,
      vpc: props.vpc,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS
      },
      securityGroups: [this.auroraSecurityGroup],
      defaultDatabaseName: 'knowledgebase',
      credentials: rds.Credentials.fromGeneratedSecret('vectordb_admin', {
        secretName: `${props.envPrefix}-secret-aurora-master-01`
      }),
      enableDataApi: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      deletionProtection: false
    });

    // DB Setup Lambda(カスタムリソース)
    this.createDbSetupLambda(props);
  }

  private createDbSetupLambda(props: AuroraKnowledgeBaseProps): void {
    // DB Setup Lambda(カスタムリソース)
    const dbSetupLambda = new NodejsFunction(this, 'DbSetupLambda', {
      functionName: `${props.envPrefix}-lambda-db-setup-01`,
      entry: path.join(__dirname, '../../lambda/db-setup/db-setup.ts'),
      handler: 'handler',
      runtime: lambda.Runtime.NODEJS_22_X,
      timeout: cdk.Duration.minutes(5),
      memorySize: 256,
      vpc: props.vpc,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS
      },
      securityGroups: [props.lambdaSecurityGroup],
      environment: {
        CLUSTER_ARN: this.cluster.clusterArn,
        MASTER_SECRET_ARN: this.cluster.secret!.secretArn,
        BEDROCK_SECRET_ARN: this.bedrockUserSecret.secretArn,
        DATABASE_NAME: 'knowledgebase'
      }
    });

    // カスタムリソース
    const dbSetupProvider = new cr.Provider(this, 'DbSetupProvider', {
      onEventHandler: dbSetupLambda,
      logRetention: logs.RetentionDays.ONE_WEEK
    });

    // カスタムリソース(Aurora完全起動後に実行)
    const dbSetupCustomResource = new cdk.CustomResource(this, 'DbSetupCustomResource', {
      serviceToken: dbSetupProvider.serviceToken,
      properties: {
        ClusterArn: this.cluster.clusterArn,
        MasterSecretArn: this.cluster.secret!.secretArn,
        BedrockSecretArn: this.bedrockUserSecret.secretArn,
        DatabaseName: 'knowledgebase'
      }
    });

    // 明示的な依存関係を追加
    dbSetupCustomResource.node.addDependency(this.cluster);
    dbSetupCustomResource.node.addDependency(this.bedrockUserSecret);
  }
}

Lambda カスタムリソース

import { RDSDataClient, ExecuteStatementCommand } from '@aws-sdk/client-rds-data';
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import { APP_CONFIG } from '../../config/app-config';

const rdsData = new RDSDataClient({ region: APP_CONFIG.aws.region });
const secrets = new SecretsManagerClient({ region: APP_CONFIG.aws.region });

export const handler = async (event: any, context: any) => {
  console.log('Event:', JSON.stringify(event));
  
  const requestType = event.RequestType;
  const responseUrl = event.ResponseURL;
  const stackId = event.StackId;
  const requestId = event.RequestId;
  const logicalResourceId = event.LogicalResourceId;
  
  let responseStatus = 'FAILED';
  let responseReason = 'Error occurred during database setup';
  let physicalResourceId = event.PhysicalResourceId || `${stackId}-${logicalResourceId}`;
  
  try {
    if (requestType === 'Create' || requestType === 'Update') {
      const clusterArn = event.ResourceProperties.ClusterArn;
      const masterSecretArn = event.ResourceProperties.MasterSecretArn;
      const bedrockSecretArn = event.ResourceProperties.BedrockSecretArn;
      const databaseName = event.ResourceProperties.DatabaseName;
      
      // pgvector拡張の有効化
      await executeSql(clusterArn, masterSecretArn, databaseName,
        'CREATE EXTENSION IF NOT EXISTS vector;');
      
      // bedrock_userユーザー作成
      const bedrockSecret = await secrets.send(new GetSecretValueCommand({
        SecretId: bedrockSecretArn
      }));
      const bedrockCreds = JSON.parse(bedrockSecret.SecretString!);
      const password = bedrockCreds.password;
      const username = bedrockCreds.username;
      
      await executeSql(clusterArn, masterSecretArn, databaseName,
        `CREATE ROLE ${username} WITH PASSWORD '${password}' LOGIN;`);
      
      // bedrock_integrationスキーマ作成
      await executeSql(clusterArn, masterSecretArn, databaseName,
        'CREATE SCHEMA bedrock_integration;');
      
      // 権限付与
      await executeSql(clusterArn, masterSecretArn, databaseName,
        `GRANT ALL ON SCHEMA bedrock_integration TO ${username};`);
      
      // bedrock_kbテーブル作成(Titan V2: 1024次元)
      await executeSql(clusterArn, masterSecretArn, databaseName,
        `CREATE TABLE bedrock_integration.bedrock_kb (
          id uuid PRIMARY KEY, 
          embedding vector(1024), 
          chunks text, 
          metadata json
        );`);
      
      // テーブル権限付与
      await executeSql(clusterArn, masterSecretArn, databaseName,
        `GRANT ALL ON TABLE bedrock_integration.bedrock_kb TO ${username};`);
      
      // 必須インデックス作成(HNSW)
      await executeSql(clusterArn, masterSecretArn, databaseName,
        'CREATE INDEX ON bedrock_integration.bedrock_kb USING hnsw (embedding vector_cosine_ops);');
      
      // Bedrock必須: chunksカラムのGINインデックス(テキスト検索用)
      await executeSql(clusterArn, masterSecretArn, databaseName,
        'CREATE INDEX ON bedrock_integration.bedrock_kb USING gin (to_tsvector(\'simple\', chunks));');
      
      console.log('Database setup completed successfully');
      responseStatus = 'SUCCESS';
      responseReason = 'Database setup completed successfully';
    } else if (requestType === 'Delete') {
      // Delete時は何もしないが成功扱い
      console.log('Delete operation - no action required');
      responseStatus = 'SUCCESS';
      responseReason = 'Delete operation completed successfully';
    }
    
  } catch (error) {
    console.error('Error:', error);
    responseStatus = 'FAILED';
    responseReason = String(error);
  }
  
  // CloudFormationに必須レスポンス送信
  await sendResponse(responseUrl, {
    Status: responseStatus,
    Reason: responseReason,
    PhysicalResourceId: physicalResourceId,
    StackId: stackId,
    RequestId: requestId,
    LogicalResourceId: logicalResourceId,
    Data: { Message: responseReason }
  });
};

async function executeSql(clusterArn: string, secretArn: string, database: string, sql: string) {
  const response = await rdsData.send(new ExecuteStatementCommand({
    resourceArn: clusterArn,
    secretArn: secretArn,
    database: database,
    sql: sql
  }));
  console.log(`SQL executed: ${sql}`);
  return response;
}

async function sendResponse(url: string, responseBody: any) {
  const response = await fetch(url, {
    method: 'PUT',
    headers: {
      'Content-Type': '',
    },
    body: JSON.stringify(responseBody)
  });
  
  if (!response.ok) {
    throw new Error(`Failed to send response: ${response.status}`);
  }
  
  console.log('Response sent successfully');
}

これらのソースコードを参考に Aurora を構築したあとにナレッジベースの作成をする必要があります。
注意点として、Aurora Serverless の ACU の最小値は 0.5 以上にする必要があります。 0.0 ではナレッジベースの作成が失敗するので、コストを抑えたい場合は 0.5 以上でデプロイしたあとに 0.0 に修正する必要があります。

Lambda 関数での .pptx → .pdf 変換

ナレッジベースでは、.docx や .xlsx 形式はサポートされていますが、 .pptx はサポートされていません。参考

そのため、PowerPoint ファイルをナレッジベースのデータとして使いたい場合は別の形式に変換する必要があります。

markitdown というライブラリを使って .md に変換することは可能ですが、変換されるのはテキストのみになり、画像は実質破棄されます。
今回の記事では、画像含めたマルチモーダルなナレッジベースの作成はしませんでしたが、画像もデータソースとして扱いたい場合は画像も含めて変換する必要があります。

オープンソースで .pptx → .pdf に変換する方法として、LibreOffice を使う方法がありますが、Lambda レイヤーとして使うには必要な容量が大きくなります。

そのため今回は、LibreOffice をインストールした Docker イメージを作成し、コンテナイメージを使用した Lambda 関数を作成します。

LibreOffice をインストールした Lambda を手っ取り早く作成したい場合は、こちらの GitHub で、ECR のパブリックリポジトリに作成されているので使うことが可能です。

FROM public.ecr.aws/shelf/lambda-libreoffice-base:25.2-node22-x86_64

COPY handler.js ${LAMBDA_TASK_ROOT}/

CMD [ "handler.handler" ]

ちゃんと公式の LibreOffice からインストールしたい場合は、おなじ GitHub リポジトリにある、こちらの Dockerfile の手順が参考になります。 2025/12/14 時点では、25.2.5.2 が公式になかったので、25.2.7.2 への修正だけが必要で、あとはそのまま使えました。

おわりに

今回は Bedrock での生成 AI へのリクエストする基盤を AWS CDK TypeScript で作成した際に自分がつまづいたポイントを整理しました。
カスタムリソースを使うときはエラーハンドリングにかなり気を使わないと CloudFormation が自動タイムアウトする 1 時間待ちぼうけになってしまうため、気を付けてください。
RAG やナレッジベースを使いたいという方は多いかと思いますので、参考になれば幸いです。

北出 宏紀(執筆記事の一覧)

アプリケーションサービス本部ディベロップメントサービス3課

2024年9月中途入社です。