0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GenUのバックエンド (CDK) 詳細解説 ⑥RagKnowledgeBaseStack スタックの解説

Last updated at Posted at 2025-03-20

はじめに

皆さん、こんにちは。

私は業務でデータ利活用基盤を取り扱っているため、dbtIceberg、そしてAWS GenUに取り組む必要があると考えています。特に AWS Japan Top Engineer として、GenUを扱い、その活用を広めることが責務だと感じています。

しかし、私はこれまで CloudFormation を好んで使っており、(逆張り思考も重なって)Cfn テンプレートをシンプルかつ汎用性・拡張性の高い形で作ることに注力してきました。そのため、改めてGenU の CDK コードを読もうとしても、なかなか理解が進みませんでした。

そこで、CDK を学びながら、その過程を記事としてまとめることにしました。

前回までのおさらい

前回までで、以下が完了しました。

GenU の CDK は最大で以下の 6 つの子スタックを作成します。

  • CloudFrontWafStack
  • RagKnowledgeBaseStack
  • AgentStack
  • GuardrailStack
  • GenerativeAiUseCasesStack ※メインスタック
  • DashboardStack

今回は GenU 内の RagKnowledgeBaseStack スタックを解説していきたいと思います。

RagKnowledgeBaseStack スタック

RagKnowledgeBaseStack は Bedrock ナレッジベースおよび OpenSearch Serverless
ベクトルストア、データソースである S3 のスタックです。
アーキテクチャ図でいうと、以下の赤枠の部分にあたります。

image.png

RagKnowledgeBaseStack の実体は packages/cdk/lib/rag-knowledge-base-stack.ts にあります。結構ボリュームが多いですが読んでいきましょう。

packages/cdk/lib/rag-knowledge-base-stack.ts
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as bedrock from 'aws-cdk-lib/aws-bedrock';
import * as oss from 'aws-cdk-lib/aws-opensearchserverless';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3Deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as iam from 'aws-cdk-lib/aws-iam';
import { ProcessedStackInput } from './stack-input';

const UUID = '339C5FED-A1B5-43B6-B40A-5E8E59E5734D';

// 以下が現状 Embedding model としてサポートされているモデル ID
// Dimension は最終的に Custom resource の props として渡すが
// 勝手に型が変換されてしまう Issue があるため、number ではなく string にしておく
// https://github.com/aws-cloudformation/cloudformation-coverage-roadmap/issues/1037
const MODEL_VECTOR_MAPPING: { [key: string]: string } = {
  'amazon.titan-embed-text-v1': '1536',
  'amazon.titan-embed-text-v2:0': '1024',
  'cohere.embed-multilingual-v3': '1024',
  'cohere.embed-english-v3': '1024',
};

// parsingConfiguration で PDF ファイルの中に埋め込まれている画像やグラフや表を読み取る機能がある。
// 読み取る際のプロンプトは任意のものが定義できる。以下に const として定義する。利用環境によってプロンプトを変更することで、より高い精度を期待できる。
// https://docs.aws.amazon.com/bedrock/latest/userguide/kb-chunking-parsing.html#kb-advanced-parsing
const PARSING_PROMPT = `ドキュメントに含まれる画像やグラフや表などの Image コンテンツからテキストを書き写して、コードブロックではないMarkdown構文で出力してください。以下の手順に従ってください:

1. 提供されたページを注意深く調べてください。

2. ページに存在するすべての要素を特定してください。これには見出し、本文、脚注、表、視覚化、キャプション、ページ番号などが含まれます。

3. Markdown構文のフォーマットを使用して出力してください :
- 見出し:主見出しには#、セクションには##、サブセクションには###など
- リスト:箇条書きには* または -、番号付きリストには1. 2. 3.
- 繰り返しは避けてください

4. 要素が Visualization の場合:
- 自然言語で詳細な説明を提供してください
- 説明を提供した後、Visualization 内のテキストは転写しないでください

5. 要素が表の場合:
- Markdownの表を作成し、すべての行が同じ列数を持つようにしてください
- セルの配置をできるだけ忠実に維持してください
- 表を複数の表に分割しないでください
- 結合されたセルが複数の行や列にまたがる場合、テキストを左上のセルに配置し、他のセルには ' ' を出力してください
- 列の区切りには | を使用し、ヘッダー行の区切りには |-|-| を使用してください
- セルに複数の項目がある場合、別々の行にリストしてください
- 表にサブヘッダーがある場合、サブヘッダーをヘッダーから別の行で分離してください

6. 要素が段落の場合:
- 各テキスト要素を表示されているとおりに正確に転写してください

7. 要素がヘッダー、フッター、脚注、ページ番号の場合:
- 各テキスト要素を表示されているとおりに正確に転写してください

出力例:

Y軸に「売上高($百万)」、X軸に「年」とラベル付けされた年間売上高を示す棒グラフ。グラフには2018年($12M)、2019年($18M)、2020年($8M)、2021年($22M)の棒がある。
図3:このグラフは年間売上高を百万ドル単位で示しています。2020年はCOVID-19パンデミックの影響で大幅に減少しました。

年次報告書
財務ハイライト
収益:$40M
利益:$12M
EPS:$1.25
| | 12月31日終了年度 | |

2021	2022
キャッシュフロー:
営業活動	$ 46,327	$ 46,752
投資活動	(58,154)	(37,601)
財務活動	6,291	9,718`;

const EMBEDDING_MODELS = Object.keys(MODEL_VECTOR_MAPPING);

interface OpenSearchServerlessIndexProps {
  collectionId: string;
  vectorIndexName: string;
  vectorField: string;
  metadataField: string;
  textField: string;
  vectorDimension: string;
}

class OpenSearchServerlessIndex extends Construct {
  public readonly customResourceHandler: lambda.IFunction;
  public readonly customResource: cdk.CustomResource;

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

    const customResourceHandler = new lambda.SingletonFunction(
      this,
      'OpenSearchServerlessIndex',
      {
        runtime: lambda.Runtime.NODEJS_LATEST,
        code: lambda.Code.fromAsset('custom-resources'),
        handler: 'oss-index.handler',
        uuid: UUID,
        lambdaPurpose: 'OpenSearchServerlessIndex',
        timeout: cdk.Duration.minutes(15),
      }
    );

    const customResource = new cdk.CustomResource(this, 'CustomResource', {
      serviceToken: customResourceHandler.functionArn,
      resourceType: 'Custom::OssIndex',
      properties: props,
    });

    this.customResourceHandler = customResourceHandler;
    this.customResource = customResource;
  }
}

export interface RagKnowledgeBaseStackProps extends StackProps {
  params: ProcessedStackInput;
  collectionName?: string;
  vectorIndexName?: string;
  vectorField?: string;
  metadataField?: string;
  textField?: string;
}

export class RagKnowledgeBaseStack extends Stack {
  public readonly knowledgeBaseId: string;
  public readonly dataSourceBucketName: string;

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

    const {
      env,
      embeddingModelId,
      ragKnowledgeBaseStandbyReplicas,
      ragKnowledgeBaseAdvancedParsing,
      ragKnowledgeBaseAdvancedParsingModelId,
    } = props.params;

    if (typeof embeddingModelId !== 'string') {
      throw new Error(
        'Knowledge Base RAG が有効になっていますが、embeddingModelId が指定されていません'
      );
    }

    if (!EMBEDDING_MODELS.includes(embeddingModelId)) {
      throw new Error(
        `embeddingModelId が無効な値です (有効な embeddingModelId ${EMBEDDING_MODELS})`
      );
    }

    const collectionName =
      props.collectionName ?? `generative-ai-use-cases-jp${env.toLowerCase()}`;
    const vectorIndexName =
      props.vectorIndexName ?? 'bedrock-knowledge-base-default';
    const vectorField =
      props.vectorField ?? 'bedrock-knowledge-base-default-vector';
    const textField = props.textField ?? 'AMAZON_BEDROCK_TEXT_CHUNK';
    const metadataField = props.metadataField ?? 'AMAZON_BEDROCK_METADATA';

    const knowledgeBaseRole = new iam.Role(this, 'KnowledgeBaseRole', {
      assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com'),
    });

    if (
      ragKnowledgeBaseAdvancedParsing &&
      typeof ragKnowledgeBaseAdvancedParsingModelId !== 'string'
    ) {
      throw new Error(
        'Knowledge Base RAG の Advanced Parsing が有効ですが、ragKnowledgeBaseAdvancedParsingModelId が指定されていないか、文字列ではありません'
      );
    }

    const collection = new oss.CfnCollection(this, 'Collection', {
      name: collectionName,
      description: 'GenU Collection',
      type: 'VECTORSEARCH',
      standbyReplicas: ragKnowledgeBaseStandbyReplicas ? 'ENABLED' : 'DISABLED',
    });

    const ossIndex = new OpenSearchServerlessIndex(this, 'OssIndex', {
      collectionId: collection.ref,
      vectorIndexName,
      vectorField,
      textField,
      metadataField,
      vectorDimension: MODEL_VECTOR_MAPPING[embeddingModelId],
    });

    ossIndex.customResourceHandler.addToRolePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        resources: [cdk.Token.asString(collection.getAtt('Arn'))],
        actions: ['aoss:APIAccessAll'],
      })
    );

    const accessPolicy = new oss.CfnAccessPolicy(this, 'AccessPolicy', {
      name: collectionName,
      policy: JSON.stringify([
        {
          Rules: [
            {
              Resource: [`collection/${collectionName}`],
              Permission: [
                'aoss:DescribeCollectionItems',
                'aoss:CreateCollectionItems',
                'aoss:UpdateCollectionItems',
              ],
              ResourceType: 'collection',
            },
            {
              Resource: [`index/${collectionName}/*`],
              Permission: [
                'aoss:UpdateIndex',
                'aoss:DescribeIndex',
                'aoss:ReadDocument',
                'aoss:WriteDocument',
                'aoss:CreateIndex',
                'aoss:DeleteIndex',
              ],
              ResourceType: 'index',
            },
          ],
          Principal: [
            knowledgeBaseRole.roleArn,
            ossIndex.customResourceHandler.role?.roleArn,
          ],
          Description: '',
        },
      ]),
      type: 'data',
    });

    const networkPolicy = new oss.CfnSecurityPolicy(this, 'NetworkPolicy', {
      name: collectionName,
      policy: JSON.stringify([
        {
          Rules: [
            {
              Resource: [`collection/${collectionName}`],
              ResourceType: 'collection',
            },
            {
              Resource: [`collection/${collectionName}`],
              ResourceType: 'dashboard',
            },
          ],
          AllowFromPublic: true,
        },
      ]),
      type: 'network',
    });

    const encryptionPolicy = new oss.CfnSecurityPolicy(
      this,
      'EncryptionPolicy',
      {
        name: collectionName,
        policy: JSON.stringify({
          Rules: [
            {
              Resource: [`collection/${collectionName}`],
              ResourceType: 'collection',
            },
          ],
          AWSOwnedKey: true,
        }),
        type: 'encryption',
      }
    );

    collection.node.addDependency(accessPolicy);
    collection.node.addDependency(networkPolicy);
    collection.node.addDependency(encryptionPolicy);

    const accessLogsBucket = new s3.Bucket(this, 'DataSourceAccessLogsBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      autoDeleteObjects: true,
      removalPolicy: RemovalPolicy.DESTROY,
      objectOwnership: s3.ObjectOwnership.OBJECT_WRITER,
      enforceSSL: true,
    });

    const dataSourceBucket = new s3.Bucket(this, 'DataSourceBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      autoDeleteObjects: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      objectOwnership: s3.ObjectOwnership.OBJECT_WRITER,
      serverAccessLogsBucket: accessLogsBucket,
      serverAccessLogsPrefix: 'AccessLogs/',
      enforceSSL: true,
    });

    knowledgeBaseRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        resources: ['*'],
        actions: ['bedrock:InvokeModel'],
      })
    );

    knowledgeBaseRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        resources: [cdk.Token.asString(collection.getAtt('Arn'))],
        actions: ['aoss:APIAccessAll'],
      })
    );

    knowledgeBaseRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        resources: [`arn:aws:s3:::${dataSourceBucket.bucketName}`],
        actions: ['s3:ListBucket'],
      })
    );

    knowledgeBaseRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        resources: [`arn:aws:s3:::${dataSourceBucket.bucketName}/*`],
        actions: ['s3:GetObject'],
      })
    );

    const knowledgeBase = new bedrock.CfnKnowledgeBase(this, 'KnowledgeBase', {
      name: collectionName,
      roleArn: knowledgeBaseRole.roleArn,
      knowledgeBaseConfiguration: {
        type: 'VECTOR',
        vectorKnowledgeBaseConfiguration: {
          embeddingModelArn: `arn:aws:bedrock:${this.region}::foundation-model/${embeddingModelId}`,
        },
      },
      storageConfiguration: {
        type: 'OPENSEARCH_SERVERLESS',
        opensearchServerlessConfiguration: {
          collectionArn: cdk.Token.asString(collection.getAtt('Arn')),
          fieldMapping: {
            metadataField,
            textField,
            vectorField,
          },
          vectorIndexName,
        },
      },
    });

    new bedrock.CfnDataSource(this, 'DataSource', {
      dataSourceConfiguration: {
        s3Configuration: {
          bucketArn: `arn:aws:s3:::${dataSourceBucket.bucketName}`,
          inclusionPrefixes: ['docs/'],
        },
        type: 'S3',
      },
      vectorIngestionConfiguration: {
        ...(ragKnowledgeBaseAdvancedParsing
          ? {
              // Advanced Parsing を有効化する場合のみ、parsingConfiguration を構成する
              parsingConfiguration: {
                parsingStrategy: 'BEDROCK_FOUNDATION_MODEL',
                bedrockFoundationModelConfiguration: {
                  modelArn: `arn:aws:bedrock:${this.region}::foundation-model/${ragKnowledgeBaseAdvancedParsingModelId}`,
                  parsingPrompt: {
                    parsingPromptText: PARSING_PROMPT,
                  },
                },
              },
            }
          : {}),
        // チャンク戦略を変更したい場合は、以下のコメントアウトを外して、各種パラメータを調整することで、環境に合わせた環境構築が可能です。
        // 以下の 4 種類のチャンク戦略が選択可能です。
        // - デフォルト (何も指定しない)
        // - セマンティックチャンク
        // - 階層チャンク
        // - 標準チャンク
        // 詳細は以下の Document を参照ください。
        // https://docs.aws.amazon.com/bedrock/latest/userguide/kb-chunking-parsing.html
        // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_bedrock.CfnDataSource.ChunkingConfigurationProperty.html
        //
        // セマンティックチャンク
        // chunkingConfiguration: {
        //   chunkingStrategy: 'SEMANTIC',
        //   semanticChunkingConfiguration: {
        //     maxTokens: 300,
        //     bufferSize: 0,
        //     breakpointPercentileThreshold: 95,
        //   },
        // },
        //
        // 階層チャンク
        // chunkingConfiguration: {
        //   chunkingStrategy: 'HIERARCHICAL',
        //   hierarchicalChunkingConfiguration: {
        //     levelConfigurations: [
        //       {
        //         maxTokens: 1500, // 親チャンクの Max Token サイズ
        //       },
        //       {
        //         maxTokens: 300, // 子チャンクの Max Token サイズ
        //       },
        //     ],
        //     overlapTokens: 60,
        //   },
        // },
        //
        // 標準チャンク
        // chunkingConfiguration: {
        //   chunkingStrategy: 'FIXED_SIZE',
        //   fixedSizeChunkingConfiguration: {
        //     maxTokens: 300,
        //     overlapPercentage: 10,
        //   },
        // },
      },
      knowledgeBaseId: knowledgeBase.ref,
      name: 's3-data-source',
    });

    knowledgeBase.addDependency(collection);
    knowledgeBase.node.addDependency(ossIndex.customResource);

    new s3Deploy.BucketDeployment(this, 'DeployDocs', {
      sources: [s3Deploy.Source.asset('./rag-docs')],
      destinationBucket: dataSourceBucket,
      // 以前の設定で同 Bucket にアクセスログが残っている可能性があるため、この設定は残す
      exclude: ['AccessLogs/*', 'logs*'],
    });

    this.knowledgeBaseId = knowledgeBase.ref;
    this.dataSourceBucketName = dataSourceBucket.bucketName;
  }
}

このスタックでは、以下のリソース群を作成しています。

  • Role
  • CfnCollection
  • OpenSearchServerlessIndex
  • CfnAccessPolicy
  • CfnSecurityPolicy 2 つ
  • Bucket 2 つ
  • CfnKnowledgeBase
  • CfnDataSource
  • BucketDeployment

上から 1 つずつ見ていきます。

RagKnowledgeBaseStack > Role リソース

Role は Bedrock ナレッジベースにアタッチするロールです。
以下のソースコードが Role の定義です。

packages/cdk/lib/rag-knowledge-base-stack.ts (抜粋)
    const knowledgeBaseRole = new iam.Role(this, 'KnowledgeBaseRole', {
      assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com'),
    });

    /* 中略 */

    knowledgeBaseRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        resources: ['*'],
        actions: ['bedrock:InvokeModel'],
      })
    );

    knowledgeBaseRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        resources: [cdk.Token.asString(collection.getAtt('Arn'))],
        actions: ['aoss:APIAccessAll'],
      })
    );

    knowledgeBaseRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        resources: [`arn:aws:s3:::${dataSourceBucket.bucketName}`],
        actions: ['s3:ListBucket'],
      })
    );

    knowledgeBaseRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        resources: [`arn:aws:s3:::${dataSourceBucket.bucketName}/*`],
        actions: ['s3:GetObject'],
      })
    );

この中では、以下の AWS リソースを生成しています。

  • KnowledgeBaseRole ロール
    • bedrock:InvokeModel の許可権限
      • 対象リソースは全リソース
    • aoss:APIAccessAll の許可権限
      • 対象リソースは OpenSearchServerless コレクション(後述)
    • s3:ListBucket の許可権限
      • 対象リソースは S3 の dataSourceBucket バケット(後述)
    • s3:GetObject の許可権限
      • 対象リソースは S3 の dataSourceBucket バケット(後述)

このロールを後述する CfnKnowledgeBase にアタッチすることで、ナレッジベースから Bedrock のモデル呼び出し (おそらく後述のプロンプトによるパース用)、OpenSearchServerless の API 呼び出し、S3 からのファイル取得が行えるようになります。

RagKnowledgeBaseStack > CfnCollection リソース

CfnCollection は OpenSearchServerless コレクションリソースです。
以下のソースコードが CfnCollection の定義です。

packages/cdk/lib/cloud-front-waf-stack.ts (抜粋)
    const {
      env,
      embeddingModelId,
      ragKnowledgeBaseStandbyReplicas,
      ragKnowledgeBaseAdvancedParsing,
      ragKnowledgeBaseAdvancedParsingModelId,
    } = props.params;

    /* 中略 */

    const collectionName =
      props.collectionName ?? `generative-ai-use-cases-jp${env.toLowerCase()}`;

    /* 中略 */

    const collection = new oss.CfnCollection(this, 'Collection', {
      name: collectionName,
      description: 'GenU Collection',
      type: 'VECTORSEARCH',
      standbyReplicas: ragKnowledgeBaseStandbyReplicas ? 'ENABLED' : 'DISABLED',
    });

この中では、以下の AWS リソースを生成しています。

  • generative-ai-use-cases-jp OpenSearchServerless コレクション
    • コレクションタイプは「ベクトル検索 (VECTORSEARCH) 」
    • レプリカはパラメータ ragKnowledgeBaseStandbyReplicas で有効/無効を設定

この OpenSearchServerless コレクションに対し、後述する分析用のインデックスを追加したり、アクセスポリシーやネットワークポリシーを追加していきます。

RagKnowledgeBaseStack > OpenSearchServerlessIndex リソース

OpenSearchServerlessIndex は OpenSearch Serverless のインデックスリソース (を作成するカスタムリソース) です。
以下のソースコードが OpenSearchServerlessIndex の定義です。

packages/cdk/lib/cloud-front-waf-stack.ts (抜粋)
// 以下が現状 Embedding model としてサポートされているモデル ID
// Dimension は最終的に Custom resource の props として渡すが
// 勝手に型が変換されてしまう Issue があるため、number ではなく string にしておく
// https://github.com/aws-cloudformation/cloudformation-coverage-roadmap/issues/1037
const MODEL_VECTOR_MAPPING: { [key: string]: string } = {
  'amazon.titan-embed-text-v1': '1536',
  'amazon.titan-embed-text-v2:0': '1024',
  'cohere.embed-multilingual-v3': '1024',
  'cohere.embed-english-v3': '1024',
};

/* 中略 */

class OpenSearchServerlessIndex extends Construct {
  public readonly customResourceHandler: lambda.IFunction;
  public readonly customResource: cdk.CustomResource;

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

    const customResourceHandler = new lambda.SingletonFunction(
      this,
      'OpenSearchServerlessIndex',
      {
        runtime: lambda.Runtime.NODEJS_LATEST,
        code: lambda.Code.fromAsset('custom-resources'),
        handler: 'oss-index.handler',
        uuid: UUID,
        lambdaPurpose: 'OpenSearchServerlessIndex',
        timeout: cdk.Duration.minutes(15),
      }
    );

    const customResource = new cdk.CustomResource(this, 'CustomResource', {
      serviceToken: customResourceHandler.functionArn,
      resourceType: 'Custom::OssIndex',
      properties: props,
    });

    this.customResourceHandler = customResourceHandler;
    this.customResource = customResource;
  }
}

    /* 中略 */

    const {
      env,
      embeddingModelId,
      ragKnowledgeBaseStandbyReplicas,
      ragKnowledgeBaseAdvancedParsing,
      ragKnowledgeBaseAdvancedParsingModelId,
    } = props.params;

    if (typeof embeddingModelId !== 'string') {
      throw new Error(
        'Knowledge Base RAG が有効になっていますが、embeddingModelId が指定されていません'
      );
    }

    if (!EMBEDDING_MODELS.includes(embeddingModelId)) {
      throw new Error(
        `embeddingModelId が無効な値です (有効な embeddingModelId ${EMBEDDING_MODELS})`
      );
    }

    /* 中略 */

    const vectorIndexName =
      props.vectorIndexName ?? 'bedrock-knowledge-base-default';
    const vectorField =
      props.vectorField ?? 'bedrock-knowledge-base-default-vector';
    const textField = props.textField ?? 'AMAZON_BEDROCK_TEXT_CHUNK';
    const metadataField = props.metadataField ?? 'AMAZON_BEDROCK_METADATA';

    /* 中略 */

    const ossIndex = new OpenSearchServerlessIndex(this, 'OssIndex', {
      collectionId: collection.ref,
      vectorIndexName,
      vectorField,
      textField,
      metadataField,
      vecotrDimension: MODEL_VECTOR_MAPPING[embeddingModelId],
    });

    ossIndex.customResourceHandler.addToRolePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        resources: [cdk.Token.asString(collection.getAtt('Arn'))],
        actions: ['aoss:APIAccessAll'],
      })
    );

この中では、以下の AWS リソースを生成しています。

  • OpenSearchServerlessIndex コンストラクト
    • SingletonFunction lambda 関数
      • コンストラクトとして部品化しているため、CDK 内で 1 つの Lambda 関数となるようSingletonFunction で定義されている
      • ソースコードは後述
      • addToRolePolicy にて OpenSearchServerless コレクションの API 呼び出しを許可
    • CDKのカスタムリソース
      • 上記の Lambda 関数を呼び出す
      • 呼び出し時のパラメータは以下の通り
        • collectionId: 前述の OpenSearchServerless コレクションのコレクション ID
        • vectorIndexName: パラメータ値。初期値は bedrock-knowledge-base-default
        • vectorField: パラメータ値。初期値は bedrock-knowledge-base-default-vector
        • textField: パラメータ値。初期値は AMAZON_BEDROCK_TEXT_CHUNK
        • metadataField: パラメータ値。初期値は AMAZON_BEDROCK_METADATA
        • vecotrDimension: MODEL_VECTOR_MAPPING[embeddingModelId] にてベクトル次元数を取得
          • embeddingModelId は必須かつ以下のいずれか
            • amazon.titan-embed-text-v1 => 1536 次元が設定される,
            • amazon.titan-embed-text-v2:0 => 1024 次元が設定される,
            • cohere.embed-multilingual-v3 => 1024 次元が設定される,
            • cohere.embed-english-v3 => 1024 次元が設定される,
OpenSearchServerlessIndex 関数の処理

この OpenSearchServerlessIndex 関数の実体は [packages/cdk/custom-resources/oss-index.js] にあります。

packages/cdk/custom-resources/oss-index.js
const { defaultProvider } = require('@aws-sdk/credential-provider-node');
const { Client } = require('@opensearch-project/opensearch');
const { AwsSigv4Signer } = require('@opensearch-project/opensearch/aws');

const sleep = (msec) => new Promise((resolve) => setTimeout(resolve, msec));

const updateStatus = async (event, status, reason, physicalResourceId) => {
  const body = JSON.stringify({
    Status: status,
    Reason: reason,
    PhysicalResourceId: physicalResourceId,
    StackId: event.StackId,
    RequestId: event.RequestId,
    LogicalResourceId: event.LogicalResourceId,
    NoEcho: false,
    Data: {},
  });

  const res = await fetch(event.ResponseURL, {
    method: 'PUT',
    body,
    headers: {
      'Content-Type': '',
      'Content-Length': body.length.toString(),
    },
  });

  // 失敗時の記録のために残す
  console.log(res);
  console.log(await res.text());
};

exports.handler = async (event, context) => {
  // 失敗時の記録のために残す
  console.log(event);

  const props = event.ResourceProperties;
  const collectionId = props.collectionId;
  const region = process.env.AWS_DEFAULT_REGION;
  const client = new Client({
    ...AwsSigv4Signer({
      region,
      service: 'aoss',
      getCredentials: () => {
        const credentialsProvider = defaultProvider();
        return credentialsProvider();
      },
    }),
    node: `https://${collectionId}.${region}.aoss.amazonaws.com`,
  });

  try {
    switch (event.RequestType) {
      case 'Create':
        await client.indices.create({
          index: props.vectorIndexName,
          body: {
            mappings: {
              properties: {
                [props.metadataField]: {
                  type: 'text',
                  index: false,
                },
                [props.textField]: {
                  type: 'text',
                  analyzer: 'custom_kuromoji_analyzer',
                },
                [props.vectorField]: {
                  type: 'knn_vector',
                  dimension: Number(props.vectorDimension),
                  method: {
                    engine: 'faiss',
                    space_type: 'l2',
                    name: 'hnsw',
                    parameters: {},
                  },
                },
              },
            },
            settings: {
              index: {
                knn: true,
                analysis: {
                  analyzer: {
                    custom_kuromoji_analyzer: {
                      type: 'custom',
                      tokenizer: 'kuromoji_tokenizer',
                      filter: [
                        'kuromoji_baseform',
                        'kuromoji_part_of_speech',
                        'kuromoji_stemmer',
                        'lowercase',
                        'ja_stop',
                      ],
                      char_filter: [
                        'kuromoji_iteration_mark',
                        'icu_normalizer',
                        'html_strip',
                      ],
                    },
                  },
                },
              },
            },
          },
        });
        await sleep(60 * 1000); // sleep 60s to confirm the creation
        await updateStatus(
          event,
          'SUCCESS',
          'Successfully created',
          props.vectorIndexName
        );
        break;
      case 'Update':
        await updateStatus(
          event,
          'SUCCESS',
          'Update operation is not supported',
          props.vectorIndexName
        );
        break;
      case 'Delete':
        const index = event.PhysicalResourceId;
        await client.indices.delete({
          index,
        });
        await updateStatus(event, 'SUCCESS', 'Successfully deleted', index);
        break;
    }
  } catch (e) {
    console.log('---- Error');
    console.log(e);

    const physicalResourceId =
      props.vectorIndexName || event.PhysicalResourceId;
    await updateStatus(event, 'FAILED', e.message, physicalResourceId);
  }
};

この Lambda 関数では AWS SDK を用いて OpenSearch Serverless 用のインデックスを作成しています。

  • Amazon OpenSearch Serverless クライアントの作成
  • スタック操作 event.RequestType によって以下の処理を実行
    • リソース作成: Create
      • opensearch-js を利用してインデックスを作成
        • パラメータの vectorIndexName, metadataField, textField, vectorField, vecotrDimension を元に mappings を設定
        • settings で日本語のトークナイザーとしてよく使われるkuromoji_tokenizerをカスタマイズ指定
          • filter: kuromoji_baseform, kuromoji_part_of_speech, kuromoji_stemmer, lowercase, ja_stop
          • char_filter: kuromoji_iteration_mark, icu_normalizer, html_strip
      • 60 秒待つ
      • ステータスを SUCCESS, メッセージを Successfully created に更新
    • リソース更新: Update
      • 何もしない
      • ステータスを SUCCESS, メッセージを Update operation is not supported に更新
    • リソース削除: Delete
      • リソースを削除
      • ステータスを SUCCESS, メッセージを Successfully deleted に更新

これを見ると、Create の場合、作成が終わっていても終わっていなくても 60 秒後にSUCCESSを返すようです。

RagKnowledgeBaseStack > CfnAccessPolicy リソース

CfnAccessPolicy は、OpenSearch Serverless のデータアクセスポリシーです。
以下のソースコードが CfnAccessPolicy の定義です。

packages/cdk/lib/cloud-front-waf-stack.ts (抜粋)
    const accessPolicy = new oss.CfnAccessPolicy(this, 'AccessPolicy', {
      name: collectionName,
      policy: JSON.stringify([
        {
          Rules: [
            {
              Resource: [`collection/${collectionName}`],
              Permission: [
                'aoss:DescribeCollectionItems',
                'aoss:CreateCollectionItems',
                'aoss:UpdateCollectionItems',
              ],
              ResourceType: 'collection',
            },
            {
              Resource: [`index/${collectionName}/*`],
              Permission: [
                'aoss:UpdateIndex',
                'aoss:DescribeIndex',
                'aoss:ReadDocument',
                'aoss:WriteDocument',
                'aoss:CreateIndex',
                'aoss:DeleteIndex',
              ],
              ResourceType: 'index',
            },
          ],
          Principal: [
            knowledgeBaseRole.roleArn,
            ossIndex.customResourceHandler.role?.roleArn,
          ],
          Description: '',
        },
      ]),
      type: 'data',
    });

    /* 中略 */

    collection.node.addDependency(accessPolicy);

この中では、サポートされているポリシーのアクセス許可のうち、DeleteCollectionItemsを除く以下のポリシーが許可されています。

  • OpenSearch Serverless コレクションへのアイテム登録/更新/削除
  • OpenSearch Serverless インデックスの登録/更新/削除

また、OpenSearch Serverless コレクションはこのアクセスポリシーに依存すること (アクセスポリシー作成後にコレクションを作成すること) が明示されています。

RagKnowledgeBaseStack > CfnSecurityPolicy リソース

CfnSecurityPolicy は、OpenSearch Serverless のネットワークポリシー、暗号化ポリシーを設定しています。
以下のソースコードが CfnSecurityPolicy の定義です。

packages/cdk/lib/cloud-front-waf-stack.ts (抜粋)
    const networkPolicy = new oss.CfnSecurityPolicy(this, 'NetworkPolicy', {
      name: collectionName,
      policy: JSON.stringify([
        {
          Rules: [
            {
              Resource: [`collection/${collectionName}`],
              ResourceType: 'collection',
            },
            {
              Resource: [`collection/${collectionName}`],
              ResourceType: 'dashboard',
            },
          ],
          AllowFromPublic: true,
        },
      ]),
      type: 'network',
    });

    const encryptionPolicy = new oss.CfnSecurityPolicy(
      this,
      'EncryptionPolicy',
      {
        name: collectionName,
        policy: JSON.stringify({
          Rules: [
            {
              Resource: [`collection/${collectionName}`],
              ResourceType: 'collection',
            },
          ],
          AWSOwnedKey: true,
        }),
        type: 'encryption',
      }
    );

    /* 中略 */

    collection.node.addDependency(networkPolicy);
    collection.node.addDependency(encryptionPolicy);

この中では、OpenSearch Serverless のネットワークポリシー暗号化ポリシーを生成しています。

  • ネットワークポリシー
    • dashboard, collection 共にパブリックアクセスを許可
  • 暗号化ポリシー
    • AWS マネージドキーを使用して collection を暗号化

また、アクセスポリシー同様、OpenSearch Serverless コレクションはこれらのポリシーに依存することが明示されています。

RagKnowledgeBaseStack > Bucket リソース

Bucket はデータソースおよびアクセスログ保管用の S3 バケットリソースです。
以下のソースコードが Bucket の定義です。

packages/cdk/lib/cloud-front-waf-stack.ts (抜粋)
    const accessLogsBucket = new s3.Bucket(this, 'DataSourceAccessLogsBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      autoDeleteObjects: true,
      removalPolicy: RemovalPolicy.DESTROY,
      objectOwnership: s3.ObjectOwnership.OBJECT_WRITER,
      enforceSSL: true,
    });

    const dataSourceBucket = new s3.Bucket(this, 'DataSourceBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      autoDeleteObjects: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      objectOwnership: s3.ObjectOwnership.OBJECT_WRITER,
      serverAccessLogsBucket: accessLogsBucket,
      serverAccessLogsPrefix: 'AccessLogs/',
      enforceSSL: true,
    });

この中では、以下の S3 バケットを生成しています。

  • アクセスログ保管用バケット
    • パブリックアクセス: ブロック
    • 暗号化: AWS マネージドキーでの暗号化
    • 削除ポリシー: バケットを削除 (バケットに含まれるオブジェクトも削除)
    • オブジェクトの所有者: オブジェクトをアップロードしたアカウント
    • SSL アクセス: 強制
  • データソースバケット
    • パブリックアクセス: ブロック
    • 暗号化: AWS マネージドキーでの暗号化
    • 削除ポリシー: バケットを削除 (バケットに含まれるオブジェクトも削除)
    • オブジェクトの所有者: オブジェクトをアップロードしたアカウント
    • アクセスログ: アクセスログ保管用バケットの AccessLogs/ 配下を指定
    • SSL アクセス: 強制

また、前述のロールの指定により、 Bedrock ナレッジベースはデータソース S3 バケットに対し、オブジェクトの取得を許可しています。

RagKnowledgeBaseStack > CfnKnowledgeBase リソース

CfnKnowledgeBase は Bedrock ナレッジベースのリソースです。
以下のソースコードが CfnKnowledgeBase の定義です。

packages/cdk/lib/cloud-front-waf-stack.ts (抜粋)
    const knowledgeBase = new bedrock.CfnKnowledgeBase(this, 'KnowledgeBase', {
      name: collectionName,
      roleArn: knowledgeBaseRole.roleArn,
      knowledgeBaseConfiguration: {
        type: 'VECTOR',
        vectorKnowledgeBaseConfiguration: {
          embeddingModelArn: `arn:aws:bedrock:${this.region}::foundation-model/${embeddingModelId}`,
        },
      },
      storageConfiguration: {
        type: 'OPENSEARCH_SERVERLESS',
        opensearchServerlessConfiguration: {
          collectionArn: cdk.Token.asString(collection.getAtt('Arn')),
          fieldMapping: {
            metadataField,
            textField,
            vectorField,
          },
          vectorIndexName,
        },
      },
    });

    /* 中略 */

    knowledgeBase.addDependency(collection);
    knowledgeBase.node.addDependency(ossIndex.customResource);

この中では、以下の Bedrock ナレッジベースを生成しています。

  • 名前は OpenSearch Serverless のコレクションと同名
  • 前述の Bedrock ナレッジベース用ロールを指定
  • 変換するデータのタイプは ベクトル を指定
  • 生成 AI のモデルは前述の通りパラメータの embeddingModelId で指定する (必須かつ以下のいずれか)
    • amazon.titan-embed-text-v1
    • amazon.titan-embed-text-v2:0
    • cohere.embed-multilingual-v3
    • cohere.embed-english-v3
  • ストレージに前述の OpenSearch Serverless コレクションを指定

また、Bedrock ナレッジベースは、OpenSearch Serverless コレクション、および、インデックス (を作成する Lambda 関数カスタムリソース) に依存させることが明示されています。

RagKnowledgeBaseStack > CfnDataSource リソース

CfnDataSource は Bedrock データソースのリソースです。
Bedrock ナレッジベースがベクトルストアのリソースであることに対し、Bedrock データソースは外部データをナレッジベースに取り込む際の設定を定義します。

以下のソースコードが CfnDataSource の定義です。

packages/cdk/lib/cloud-front-waf-stack.ts (抜粋)
    new bedrock.CfnDataSource(this, 'DataSource', {
      dataSourceConfiguration: {
        s3Configuration: {
          bucketArn: `arn:aws:s3:::${dataSourceBucket.bucketName}`,
          inclusionPrefixes: ['docs/'],
        },
        type: 'S3',
      },
      vectorIngestionConfiguration: {
        ...(ragKnowledgeBaseAdvancedParsing
          ? {
              // Advanced Parsing を有効化する場合のみ、parsingConfiguration を構成する
              parsingConfiguration: {
                parsingStrategy: 'BEDROCK_FOUNDATION_MODEL',
                bedrockFoundationModelConfiguration: {
                  modelArn: `arn:aws:bedrock:${this.region}::foundation-model/${ragKnowledgeBaseAdvancedParsingModelId}`,
                  parsingPrompt: {
                    parsingPromptText: PARSING_PROMPT,
                  },
                },
              },
            }
          : {}),
        // チャンク戦略を変更したい場合は、以下のコメントアウトを外して、各種パラメータを調整することで、環境に合わせた環境構築が可能です。
        // 以下の 4 種類のチャンク戦略が選択可能です。
        // - デフォルト (何も指定しない)
        // - セマンティックチャンク
        // - 階層チャンク
        // - 標準チャンク
        // 詳細は以下の Document を参照ください。
        // https://docs.aws.amazon.com/bedrock/latest/userguide/kb-chunking-parsing.html
        // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_bedrock.CfnDataSource.ChunkingConfigurationProperty.html
        //
        // セマンティックチャンク
        // chunkingConfiguration: {
        //   chunkingStrategy: 'SEMANTIC',
        //   semanticChunkingConfiguration: {
        //     maxTokens: 300,
        //     bufferSize: 0,
        //     breakpointPercentileThreshold: 95,
        //   },
        // },
        //
        // 階層チャンク
        // chunkingConfiguration: {
        //   chunkingStrategy: 'HIERARCHICAL',
        //   hierarchicalChunkingConfiguration: {
        //     levelConfigurations: [
        //       {
        //         maxTokens: 1500, // 親チャンクの Max Token サイズ
        //       },
        //       {
        //         maxTokens: 300, // 子チャンクの Max Token サイズ
        //       },
        //     ],
        //     overlapTokens: 60,
        //   },
        // },
        //
        // 標準チャンク
        // chunkingConfiguration: {
        //   chunkingStrategy: 'FIXED_SIZE',
        //   fixedSizeChunkingConfiguration: {
        //     maxTokens: 300,
        //     overlapPercentage: 10,
        //   },
        // },
      },
      knowledgeBaseId: knowledgeBase.ref,
      name: 's3-data-source',
    });

この中では、以下の Bedrock データソースを生成しています。

  • 名前は固定値 s3-data-source
  • データソースとして前述のデータソース S3 バケットを指定 (パスプレフィックスは docs/)
  • データソースを取り込む際のチャンク戦略を指定
    • パラメータで ragKnowledgeBaseAdvancedParsingtrue に指定すると、anthropic.claude-3-sonnet-20240229-v1:0 モデルを使用したプロンプトでパース指示が可能 (packages/cdk/lib/stack-input.ts)
    • ragKnowledgeBaseAdvancedParsing のデフォルトは false (packages/cdk/lib/stack-input.ts)
    • その他、チャンク戦略を変更する場合のサンプルコードがコメント内に記載されている
      • セマンティックチャンク
      • 階層チャンク
      • 標準チャンク
  • ナレッジベース: 前述の Bedrock ナレッジベースの ナレッジベース ID を指定
データソースのパース指示用プロンプト

ragKnowledgeBaseAdvancedParsingtrue に指定した場合、デフォルトでは以下のパース指示プロンプトが設定されます。コメントにも記載のある通り、利用環境によってプロンプトを変えることで精度向上が期待できます。

packages/cdk/lib/cloud-front-waf-stack.ts (抜粋)
// parsingConfiguration で PDF ファイルの中に埋め込まれている画像やグラフや表を読み取る機能がある。
// 読み取る際のプロンプトは任意のものが定義できる。以下に const として定義する。利用環境によってプロンプトを変更することで、より高い精度を期待できる。
// https://docs.aws.amazon.com/bedrock/latest/userguide/kb-chunking-parsing.html#kb-advanced-parsing
const PARSING_PROMPT = `ドキュメントに含まれる画像やグラフや表などの Image コンテンツからテキストを書き写して、コードブロックではないMarkdown構文で出力してください。以下の手順に従ってください:

1. 提供されたページを注意深く調べてください。

2. ページに存在するすべての要素を特定してください。これには見出し、本文、脚注、表、視覚化、キャプション、ページ番号などが含まれます。

3. Markdown構文のフォーマットを使用して出力してください :
- 見出し:主見出しには#、セクションには##、サブセクションには###など
- リスト:箇条書きには* または -、番号付きリストには1. 2. 3.
- 繰り返しは避けてください

4. 要素が Visualization の場合:
- 自然言語で詳細な説明を提供してください
- 説明を提供した後、Visualization 内のテキストは転写しないでください

5. 要素が表の場合:
- Markdownの表を作成し、すべての行が同じ列数を持つようにしてください
- セルの配置をできるだけ忠実に維持してください
- 表を複数の表に分割しないでください
- 結合されたセルが複数の行や列にまたがる場合、テキストを左上のセルに配置し、他のセルには ' ' を出力してください
- 列の区切りには | を使用し、ヘッダー行の区切りには |-|-| を使用してください
- セルに複数の項目がある場合、別々の行にリストしてください
- 表にサブヘッダーがある場合、サブヘッダーをヘッダーから別の行で分離してください

6. 要素が段落の場合:
- 各テキスト要素を表示されているとおりに正確に転写してください

7. 要素がヘッダー、フッター、脚注、ページ番号の場合:
- 各テキスト要素を表示されているとおりに正確に転写してください

出力例:

Y軸に「売上高($百万)」、X軸に「年」とラベル付けされた年間売上高を示す棒グラフ。グラフには2018年($12M)、2019年($18M)、2020年($8M)、2021年($22M)の棒がある。
図3:このグラフは年間売上高を百万ドル単位で示しています。2020年はCOVID-19パンデミックの影響で大幅に減少しました。

年次報告書
財務ハイライト
収益:$40M
利益:$12M
EPS:$1.25
| | 12月31日終了年度 | |

2021	2022
キャッシュフロー:
営業活動	$ 46,327	$ 46,752
投資活動	(58,154)	(37,601)
財務活動	6,291	9,718`;

RagKnowledgeBaseStack > BucketDeployment コンストラクト

BucketDeployment は S3 バケットへのデプロイ (ファイルアップロード) を指定します。
以下のソースコードが BucketDeployment の定義です。

packages/cdk/lib/cloud-front-waf-stack.ts (抜粋)
    new s3Deploy.BucketDeployment(this, 'DeployDocs', {
      sources: [s3Deploy.Source.asset('./rag-docs')],
      destinationBucket: dataSourceBucket,
      // 以前の設定で同 Bucket にアクセスログが残っている可能性があるため、この設定は残す
      exclude: ['AccessLogs/*', 'logs*'],
    });

この中では、データソース S3 バケットに、./rag-docs 配下のローカルファイルをアップロードすることを指定しています。誤ってログ用のフォルダにファイルを送信しないような設定も入っています。

以上です。
今回は RAG の設定ということもあり、相当長くなりました。
次回は GenU 内の WebSearchAgentStack スタックを解説していきたいと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?