diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/.gitignore b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/.gitignore
new file mode 100644
index 000000000..44cc92677
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+cdk.out/
+cdk-outputs.json
+*.js
+*.d.ts
+*.js.map
+!jest.config.js
+.DS_Store
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/README.md b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/README.md
new file mode 100644
index 000000000..c72bf3743
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/README.md
@@ -0,0 +1,214 @@
+# API Gateway HTTP API to Lambda, Bedrock, and DynamoDB vector search
+
+This pattern deploys a serverless semantic-search API. Clients ingest text documents and run natural-language searches through Amazon API Gateway. AWS Lambda generates embeddings with Amazon Bedrock and stores or searches those embeddings in a native Amazon DynamoDB vector index. The source content, metadata, and embedding remain together in one DynamoDB item.
+
+Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk
+
+Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage. See the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example.
+
+## Requirements
+
+* [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The identity used to deploy must be able to create the resources in this pattern.
+* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) installed and configured.
+* [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed.
+* [Node.js 22 or later](https://nodejs.org/en/download) installed.
+* [AWS CDK v2 prerequisites](https://docs.aws.amazon.com/cdk/v2/guide/prerequisites.html) completed, including a bootstrapped environment.
+* Access to invoke the Amazon Titan Text Embeddings V2 model (`amazon.titan-embed-text-v2:0`) in the deployment Region.
+
+## Architecture
+
+
+
+The diagram uses the official [AWS Architecture Icons](https://aws.amazon.com/architecture/icons/).
+
+### Flow
+
+1. A client sends a document to `POST /documents` or a natural-language query to `POST /search` through the API Gateway HTTP API.
+2. API Gateway passes the request to the vector-search Lambda function.
+3. Lambda invokes Amazon Titan Text Embeddings V2 to generate a normalized 1,024-dimensional vector.
+4. For document ingestion, Lambda stores the source content, metadata, and embedding together in DynamoDB with `PutItem`.
+5. For search, Lambda calls `SearchVectors` using the query embedding, required `tenantId` partition, optional `category` filter, and requested `topK`.
+6. DynamoDB returns projected document attributes ordered by cosine distance, where lower scores indicate closer semantic matches.
+7. During deployment, the CDK custom resource calls `UpdateTable` and polls `DescribeTable` until the vector index is active and backfilling is complete.
+
+### Resources
+
+- An Amazon API Gateway HTTP API with `POST /documents` and `POST /search` routes.
+- An AWS Lambda function that validates requests, invokes Bedrock, stores documents, and performs vector searches.
+- Amazon Bedrock with Amazon Titan Text Embeddings V2 for document and query embeddings.
+- An on-demand Amazon DynamoDB table with a native vector index, tenant partitioning, inline category filtering, and projected content attributes.
+- A CloudFormation custom-resource provider implemented with Lambda to create, monitor, replace, and delete the DynamoDB vector index.
+
+The CDK application also deploys a CloudFormation custom-resource provider to create the DynamoDB vector index and wait for asynchronous backfilling to finish. This deployment plumbing is required until DynamoDB vector indexes are available as native CDK/CloudFormation table properties.
+
+## How it works
+
+The table uses on-demand capacity, which is required for DynamoDB vector indexes. Its composite primary key uses `tenantId` as the partition key and `documentId` as the sort key, so different tenants can safely reuse document identifiers. The vector index projects only `title` and `content`; the table key and inline filter attributes are available automatically. The Lambda execution role can put items in this table, search only this vector index, and invoke only the selected Bedrock embedding model.
+
+The handler validates required fields, DynamoDB key byte limits, the `topK` range, and the Titan Text Embeddings V2 maximum input length before calling AWS services. Requests that fail validation return HTTP 400 without invoking Bedrock or DynamoDB.
+
+The HTTP API is intentionally unauthenticated to keep the integration focused. Add an authorizer and stricter CORS configuration before adapting this sample for production.
+
+## Deployment Instructions
+
+1. Clone the repository and change to the pattern directory:
+
+ ```bash
+ git clone https://github.com/aws-samples/serverless-patterns.git
+ cd serverless-patterns/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk
+ ```
+
+2. Install dependencies:
+
+ ```bash
+ npm install
+ ```
+
+3. Bootstrap the account and Region if necessary:
+
+ ```bash
+ npx cdk bootstrap
+ ```
+
+4. Deploy the stack:
+
+ ```bash
+ npx cdk deploy
+ ```
+
+5. Note the `ApiEndpoint`, `TableName`, `VectorIndexName`, and `VectorSearchFunctionName` stack outputs.
+
+The custom resource completes only after the vector index reports `ACTIVE` and `Backfilling` is false. It checks every 10 seconds and times out after 13 minutes. This bounded wait is intended for the new, empty table created by this pattern.
+
+### Optional CI/CD deployment pipeline
+
+For automated deployments, use short-lived credentials from your CI/CD provider's OpenID Connect integration instead of storing AWS access keys. Configure the deployment role and Region outside the repository, then run these stages:
+
+1. Check out the repository and configure Node.js 22.
+2. Install dependencies with `npm install`.
+3. Run `npm run build`, `npm test`, and `npm run synth` as validation gates.
+4. Assume the deployment role through OpenID Connect.
+5. Run `npx cdk deploy --require-approval never` only after the validation stages pass.
+
+Scope the deployment role to the CDK bootstrap resources and permissions required by this stack. Protect the deployment environment with branch rules and approvals appropriate to your organization. Do not commit account IDs, role ARNs, profiles, access keys, CDK output files, or API endpoints.
+
+## Testing
+
+Set the API endpoint from the deployment output:
+
+```bash
+export API_ENDPOINT="https://example.execute-api.us-east-1.amazonaws.com"
+```
+
+Ingest two sample documents:
+
+```bash
+curl -X POST "${API_ENDPOINT}/documents" \
+ -H 'content-type: application/json' \
+ -d '{
+ "documentId": "doc-1",
+ "title": "DynamoDB vector search",
+ "content": "Amazon DynamoDB stores vector embeddings alongside operational data and supports similarity search with the SearchVectors API.",
+ "tenantId": "tenant-1",
+ "category": "aws"
+ }'
+
+curl -X POST "${API_ENDPOINT}/documents" \
+ -H 'content-type: application/json' \
+ -d '{
+ "documentId": "doc-2",
+ "title": "AWS Lambda",
+ "content": "AWS Lambda runs event-driven code without provisioning or managing servers.",
+ "tenantId": "tenant-1",
+ "category": "aws"
+ }'
+```
+
+After a short delay for asynchronous table-to-index synchronization, run a semantic search:
+
+```bash
+curl -X POST "${API_ENDPOINT}/search" \
+ -H 'content-type: application/json' \
+ -d '{
+ "query": "How can I search embeddings without a separate vector database?",
+ "tenantId": "tenant-1",
+ "category": "aws",
+ "topK": 5
+ }'
+```
+
+The response contains the most similar projected documents and their cosine-distance scores:
+
+```json
+{
+ "query": "How can I search embeddings without a separate vector database?",
+ "results": [
+ {
+ "score": 0.12,
+ "documentId": "doc-1",
+ "title": "DynamoDB vector search",
+ "content": "Amazon DynamoDB stores vector embeddings alongside operational data and supports similarity search with the SearchVectors API.",
+ "category": "aws"
+ }
+ ]
+}
+```
+
+The `events` directory also contains complete Lambda test events. Invoke the function directly with the ingest event:
+
+```bash
+aws lambda invoke \
+ --function-name YOUR_VECTOR_SEARCH_FUNCTION_NAME \
+ --cli-binary-format raw-in-base64-out \
+ --payload fileb://events/ingest-event.json \
+ /tmp/ingest-output.json
+
+cat /tmp/ingest-output.json
+```
+
+Then invoke the search event after the item has propagated to the vector index:
+
+```bash
+aws lambda invoke \
+ --function-name YOUR_VECTOR_SEARCH_FUNCTION_NAME \
+ --cli-binary-format raw-in-base64-out \
+ --payload fileb://events/search-event.json \
+ /tmp/search-output.json
+
+cat /tmp/search-output.json
+```
+
+## Local validation
+
+```bash
+npm run build
+npm test
+npm run synth
+```
+
+## Updating the vector index
+
+Vector attribute, dimensions, distance function, search schema, and projection are immutable. If you change one of these settings, also change the index name. The custom resource creates and waits for the replacement index before CloudFormation deletes the old index. Backfilling a replacement on a table that has grown substantially can exceed the sample's 13-minute bounded wait; use a dedicated migration workflow for that case.
+
+## Cleanup
+
+Delete the deployed resources:
+
+```bash
+npx cdk destroy
+```
+
+The custom resource deletes the vector index before CloudFormation deletes the DynamoDB table.
+
+Confirm that no active stack with this name remains; the expected result is an empty array:
+
+```bash
+aws cloudformation list-stacks \
+ --query "StackSummaries[?StackName=='DynamoDbVectorSearchPatternStack' && StackStatus!='DELETE_COMPLETE']"
+```
+
+----
+
+Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+SPDX-License-Identifier: MIT-0
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/bin/app.ts b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/bin/app.ts
new file mode 100644
index 000000000..cade62a98
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/bin/app.ts
@@ -0,0 +1,9 @@
+#!/usr/bin/env node
+import * as cdk from "aws-cdk-lib";
+import { VectorSearchStack } from "../lib/vector-search-stack";
+
+const app = new cdk.App();
+
+new VectorSearchStack(app, "DynamoDbVectorSearchPatternStack", {
+ description: "Serverless semantic search with API Gateway, Lambda, Bedrock, and DynamoDB",
+});
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/cdk.json b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/cdk.json
new file mode 100644
index 000000000..a6700a2ff
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/cdk.json
@@ -0,0 +1,3 @@
+{
+ "app": "npx ts-node --prefer-ts-exts bin/app.ts"
+}
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/diagram.png b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/diagram.png
new file mode 100644
index 000000000..64eb30ec6
Binary files /dev/null and b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/diagram.png differ
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/events/ingest-event.json b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/events/ingest-event.json
new file mode 100644
index 000000000..dfceb8e71
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/events/ingest-event.json
@@ -0,0 +1,6 @@
+{
+ "version": "2.0",
+ "routeKey": "POST /documents",
+ "body": "{\"documentId\":\"doc-1\",\"title\":\"DynamoDB vector search\",\"content\":\"Amazon DynamoDB stores vector embeddings alongside operational data and supports similarity search with the SearchVectors API.\",\"tenantId\":\"tenant-1\",\"category\":\"aws\"}",
+ "isBase64Encoded": false
+}
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/events/search-event.json b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/events/search-event.json
new file mode 100644
index 000000000..94b38dcdc
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/events/search-event.json
@@ -0,0 +1,6 @@
+{
+ "version": "2.0",
+ "routeKey": "POST /search",
+ "body": "{\"query\":\"How can I search embeddings without a separate vector database?\",\"tenantId\":\"tenant-1\",\"category\":\"aws\",\"topK\":5}",
+ "isBase64Encoded": false
+}
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/example-pattern.json b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/example-pattern.json
new file mode 100644
index 000000000..b82bb8412
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/example-pattern.json
@@ -0,0 +1,68 @@
+{
+ "title": "API Gateway to Bedrock and DynamoDB vector search",
+ "description": "Build a semantic search API with API Gateway, Lambda, Bedrock embeddings, and a native DynamoDB vector index.",
+ "language": "TypeScript",
+ "level": "300",
+ "framework": "AWS CDK",
+ "introBox": {
+ "headline": "How it works",
+ "text": [
+ "This pattern deploys a serverless semantic-search API using Amazon API Gateway HTTP API, AWS Lambda, Amazon Bedrock, and Amazon DynamoDB. A single Lambda function handles two POST routes so that the sample remains focused on the service integration. The /documents route accepts text and metadata, invokes Amazon Titan Text Embeddings V2 to generate a normalized 1,024-dimensional embedding, and stores the source document, metadata, and vector together in one DynamoDB item. The /search route embeds a natural-language query with the same model and calls DynamoDB SearchVectors to retrieve semantically similar documents. Keeping operational attributes and vectors together removes the need to copy DynamoDB records to a separate vector database or maintain an external synchronization pipeline.",
+ "The on-demand DynamoDB table uses tenantId as its partition key and documentId as its sort key, so tenants can safely reuse document identifiers. The native vector index uses cosine distance, tenantId as its vector partition key, and category as an optional inline equality filter. Every search includes tenantId, limiting the vector space and supporting multi-tenancy. The index projects only title and content to control storage and response size. Results also include table keys and inline filter attributes, plus a cosine-distance score where lower values indicate closer semantic matches. The query accepts a configurable topK from 1 through 100 and defaults to five results.",
+ "Because DynamoDB vector indexes are not yet represented by native CDK or CloudFormation table properties, the application includes a bounded custom-resource provider. It calls UpdateTable to create or delete the vector index and polls DescribeTable until the index is ACTIVE and backfilling has completed. The provider is idempotent, uses table-scoped permissions, and protects existing indexes from unsupported in-place changes. Application permissions are also narrowly scoped: Lambda can put items only in the created table, call SearchVectors only on the created vector-index ARN, and invoke only the selected Bedrock embedding model. The HTTP API is deliberately unauthenticated for concise testing; production adaptations should add authorization and restrict CORS. CDK outputs expose the API endpoint, table, vector index, and function names for the supplied command-line and JSON tests. Stack deletion removes the vector index before deleting the DynamoDB table."
+ ]
+ },
+ "gitHub": {
+ "template": {
+ "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk",
+ "templateURL": "serverless-patterns/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk",
+ "projectFolder": "apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk",
+ "templateFile": "lib/vector-search-stack.ts"
+ }
+ },
+ "resources": {
+ "bullets": [
+ {
+ "text": "Using vector indexes in DynamoDB",
+ "link": "https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/VectorSearch.html"
+ },
+ {
+ "text": "Amazon DynamoDB now supports real-time vector search at any scale",
+ "link": "https://aws.amazon.com/blogs/aws/amazon-dynamodb-now-supports-real-time-vector-search-at-any-scale/"
+ },
+ {
+ "text": "Amazon Titan Text Embeddings models",
+ "link": "https://docs.aws.amazon.com/bedrock/latest/userguide/titan-embedding-models.html"
+ },
+ {
+ "text": "Working with HTTP APIs for API Gateway",
+ "link": "https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html"
+ }
+ ]
+ },
+ "deploy": {
+ "text": [
+ "npm install",
+ "npx cdk deploy"
+ ]
+ },
+ "testing": {
+ "text": [
+ "Use POST /documents to embed and store the supplied sample documents, then use POST /search to run the sample semantic query. See the README for curl commands and direct Lambda JSON events."
+ ]
+ },
+ "cleanup": {
+ "text": [
+ "Delete the stack: npx cdk destroy."
+ ]
+ },
+ "authors": [
+ {
+ "name": "Vidit Shah",
+ "image": "https://avatars.githubusercontent.com/u/80155713?v=4",
+ "bio": "Builder interested in serverless architecture, infrastructure as code, and generative AI patterns on AWS.",
+ "linkedin": "vidit-shah",
+ "twitter": "Vidit_210"
+ }
+ ]
+}
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/jest.config.js b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/jest.config.js
new file mode 100644
index 000000000..d56361e0e
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/jest.config.js
@@ -0,0 +1,8 @@
+module.exports = {
+ testEnvironment: "node",
+ roots: ["/test"],
+ testMatch: ["**/*.test.ts"],
+ transform: {
+ "^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json" }]
+ }
+};
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/lib/dynamodb-vector-index.ts b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/lib/dynamodb-vector-index.ts
new file mode 100644
index 000000000..1afc625fd
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/lib/dynamodb-vector-index.ts
@@ -0,0 +1,195 @@
+import * as path from "node:path";
+import * as cdk from "aws-cdk-lib";
+import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
+import * as lambda from "aws-cdk-lib/aws-lambda";
+import * as lambdaNodejs from "aws-cdk-lib/aws-lambda-nodejs";
+import * as logs from "aws-cdk-lib/aws-logs";
+import * as customResources from "aws-cdk-lib/custom-resources";
+import { Construct } from "constructs";
+
+export enum VectorDistanceFunction {
+ COSINE = "COSINE",
+ DOT_PRODUCT = "DOT_PRODUCT",
+ EUCLIDEAN = "EUCLIDEAN",
+}
+
+export enum VectorSearchSchemaElementType {
+ HASH = "HASH",
+ INLINE_FILTER = "INLINE_FILTER",
+}
+
+export enum VectorProjectionType {
+ ALL = "ALL",
+ INCLUDE = "INCLUDE",
+ KEYS_ONLY = "KEYS_ONLY",
+}
+
+export interface VectorSearchSchemaElement {
+ readonly attributeName: string;
+ readonly elementType: VectorSearchSchemaElementType;
+ readonly attributeType: dynamodb.AttributeType;
+}
+
+export interface VectorIndexProjection {
+ readonly projectionType: VectorProjectionType;
+ readonly nonKeyAttributes?: string[];
+}
+
+export interface DynamoDbVectorIndexProps {
+ readonly table: dynamodb.ITable;
+ readonly indexName: string;
+ readonly vectorAttributeName: string;
+ readonly dimensions: number;
+ readonly distanceFunction: VectorDistanceFunction;
+ readonly searchSchema?: VectorSearchSchemaElement[];
+ readonly projection?: VectorIndexProjection;
+}
+
+/**
+ * Adds a native vector index to a CDK-managed DynamoDB table.
+ *
+ * Vector index creation and backfill are asynchronous. The custom-resource
+ * provider waits until the index is ACTIVE and Backfilling is false.
+ */
+export class DynamoDbVectorIndex extends Construct {
+ public readonly indexArn: string;
+ public readonly indexName: string;
+
+ public constructor(scope: Construct, id: string, props: DynamoDbVectorIndexProps) {
+ super(scope, id);
+
+ validateProps(props);
+
+ this.indexName = props.indexName;
+ this.indexArn = `${props.table.tableArn}/index/${props.indexName}`;
+
+ const provider = getOrCreateProvider(cdk.Stack.of(this));
+ props.table.grant(provider.onEventHandler, "dynamodb:DescribeTable", "dynamodb:UpdateTable");
+
+ const projection = props.projection ?? {
+ projectionType: VectorProjectionType.KEYS_ONLY,
+ };
+
+ const resource = new cdk.CustomResource(this, "Resource", {
+ serviceToken: provider.serviceToken,
+ resourceType: "Custom::DynamoDBVectorIndex",
+ properties: {
+ TableName: props.table.tableName,
+ IndexName: props.indexName,
+ VectorAttributeName: props.vectorAttributeName,
+ Dimensions: props.dimensions,
+ DistanceFunction: props.distanceFunction,
+ SearchSchema: (props.searchSchema ?? []).map((element) => ({
+ AttributeName: element.attributeName,
+ SearchSchemaElementType: element.elementType,
+ AttributeType: toScalarAttributeType(element.attributeType),
+ })),
+ Projection: {
+ ProjectionType: projection.projectionType,
+ NonKeyAttributes: projection.nonKeyAttributes ?? [],
+ },
+ },
+ });
+
+ resource.node.addDependency(props.table);
+ }
+}
+
+class VectorIndexProvider extends Construct {
+ public readonly onEventHandler: lambdaNodejs.NodejsFunction;
+ public readonly serviceToken: string;
+
+ public constructor(scope: Construct, id: string) {
+ super(scope, id);
+
+ const entry = path.join(__dirname, "../src/vector-index-provider.ts");
+ const commonFunctionProps: Omit = {
+ entry,
+ runtime: lambda.Runtime.NODEJS_22_X,
+ timeout: cdk.Duration.minutes(14),
+ memorySize: 256,
+ bundling: {
+ bundleAwsSDK: true,
+ minify: true,
+ sourceMap: true,
+ },
+ };
+
+ this.onEventHandler = new lambdaNodejs.NodejsFunction(this, "OnEvent", {
+ ...commonFunctionProps,
+ handler: "onEvent",
+ logGroup: new logs.LogGroup(this, "OnEventLogs", {
+ retention: logs.RetentionDays.ONE_WEEK,
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
+ }),
+ });
+
+ const provider = new customResources.Provider(this, "Framework", {
+ onEventHandler: this.onEventHandler,
+ logGroup: new logs.LogGroup(this, "FrameworkLogs", {
+ retention: logs.RetentionDays.ONE_WEEK,
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
+ }),
+ });
+ this.serviceToken = provider.serviceToken;
+ }
+}
+
+function getOrCreateProvider(stack: cdk.Stack): VectorIndexProvider {
+ const providerId = "DynamoDbVectorIndexProvider";
+ const existing = stack.node.tryFindChild(providerId);
+ if (existing) {
+ return existing as VectorIndexProvider;
+ }
+ return new VectorIndexProvider(stack, providerId);
+}
+
+function toScalarAttributeType(attributeType: dynamodb.AttributeType): "S" | "N" | "B" {
+ switch (attributeType) {
+ case dynamodb.AttributeType.STRING:
+ return "S";
+ case dynamodb.AttributeType.NUMBER:
+ return "N";
+ case dynamodb.AttributeType.BINARY:
+ return "B";
+ }
+}
+
+function validateProps(props: DynamoDbVectorIndexProps): void {
+ if (!/^[A-Za-z0-9_.-]{3,255}$/.test(props.indexName)) {
+ throw new Error("indexName must be 3-255 characters and contain only letters, numbers, _, -, or .");
+ }
+ if (!props.vectorAttributeName) {
+ throw new Error("vectorAttributeName must not be empty");
+ }
+ if (!Number.isInteger(props.dimensions) || props.dimensions < 1 || props.dimensions > 4096) {
+ throw new Error("dimensions must be an integer between 1 and 4096");
+ }
+
+ const searchSchema = props.searchSchema ?? [];
+ const hashElements = searchSchema.filter(
+ (element) => element.elementType === VectorSearchSchemaElementType.HASH,
+ );
+ const inlineFilters = searchSchema.filter(
+ (element) => element.elementType === VectorSearchSchemaElementType.INLINE_FILTER,
+ );
+ if (hashElements.length > 1) {
+ throw new Error("A vector index can have at most one HASH search-schema element");
+ }
+ if (inlineFilters.length > 18) {
+ throw new Error("A vector index can have at most 18 INLINE_FILTER search-schema elements");
+ }
+ const attributeNames = searchSchema.map((element) => element.attributeName);
+ if (new Set(attributeNames).size !== attributeNames.length) {
+ throw new Error("Search-schema attribute names must be unique");
+ }
+
+ const projection = props.projection;
+ if (projection?.projectionType === VectorProjectionType.INCLUDE) {
+ if (!projection.nonKeyAttributes?.length) {
+ throw new Error("INCLUDE projection requires at least one nonKeyAttribute");
+ }
+ } else if (projection?.nonKeyAttributes?.length) {
+ throw new Error("nonKeyAttributes can only be supplied with an INCLUDE projection");
+ }
+}
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/lib/vector-search-stack.ts b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/lib/vector-search-stack.ts
new file mode 100644
index 000000000..4824ebd26
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/lib/vector-search-stack.ts
@@ -0,0 +1,153 @@
+import * as path from "node:path";
+import * as cdk from "aws-cdk-lib";
+import * as apigatewayv2 from "aws-cdk-lib/aws-apigatewayv2";
+import * as integrations from "aws-cdk-lib/aws-apigatewayv2-integrations";
+import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
+import * as iam from "aws-cdk-lib/aws-iam";
+import * as lambda from "aws-cdk-lib/aws-lambda";
+import * as lambdaNodejs from "aws-cdk-lib/aws-lambda-nodejs";
+import * as logs from "aws-cdk-lib/aws-logs";
+import { Construct } from "constructs";
+import {
+ DynamoDbVectorIndex,
+ VectorDistanceFunction,
+ VectorProjectionType,
+ VectorSearchSchemaElementType,
+} from "./dynamodb-vector-index";
+
+const EMBEDDING_MODEL_ID = "amazon.titan-embed-text-v2:0";
+const VECTOR_DIMENSIONS = 1024;
+const VECTOR_INDEX_NAME = "document-embedding-index";
+
+export class VectorSearchStack extends cdk.Stack {
+ public constructor(scope: Construct, id: string, props?: cdk.StackProps) {
+ super(scope, id, props);
+
+ const table = new dynamodb.Table(this, "Documents", {
+ partitionKey: {
+ name: "tenantId",
+ type: dynamodb.AttributeType.STRING,
+ },
+ sortKey: {
+ name: "documentId",
+ type: dynamodb.AttributeType.STRING,
+ },
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
+ pointInTimeRecoverySpecification: {
+ pointInTimeRecoveryEnabled: true,
+ },
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
+ });
+
+ const vectorIndex = new DynamoDbVectorIndex(this, "DocumentEmbeddingIndex", {
+ table,
+ indexName: VECTOR_INDEX_NAME,
+ vectorAttributeName: "embedding",
+ dimensions: VECTOR_DIMENSIONS,
+ distanceFunction: VectorDistanceFunction.COSINE,
+ searchSchema: [
+ {
+ attributeName: "tenantId",
+ elementType: VectorSearchSchemaElementType.HASH,
+ attributeType: dynamodb.AttributeType.STRING,
+ },
+ {
+ attributeName: "category",
+ elementType: VectorSearchSchemaElementType.INLINE_FILTER,
+ attributeType: dynamodb.AttributeType.STRING,
+ },
+ ],
+ projection: {
+ projectionType: VectorProjectionType.INCLUDE,
+ nonKeyAttributes: ["title", "content"],
+ },
+ });
+
+ const apiFunction = new lambdaNodejs.NodejsFunction(this, "VectorSearchFunction", {
+ entry: path.join(__dirname, "../src/vector-search-handler.ts"),
+ handler: "handler",
+ runtime: lambda.Runtime.NODEJS_22_X,
+ architecture: lambda.Architecture.ARM_64,
+ memorySize: 512,
+ timeout: cdk.Duration.seconds(30),
+ environment: {
+ TABLE_NAME: table.tableName,
+ VECTOR_INDEX_NAME,
+ EMBEDDING_MODEL_ID,
+ VECTOR_DIMENSIONS: String(VECTOR_DIMENSIONS),
+ },
+ logGroup: new logs.LogGroup(this, "VectorSearchFunctionLogs", {
+ retention: logs.RetentionDays.ONE_WEEK,
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
+ }),
+ bundling: {
+ bundleAwsSDK: true,
+ minify: true,
+ sourceMap: true,
+ },
+ });
+
+ table.grant(apiFunction, "dynamodb:PutItem");
+ apiFunction.addToRolePolicy(
+ new iam.PolicyStatement({
+ actions: ["dynamodb:SearchVectors"],
+ resources: [vectorIndex.indexArn],
+ }),
+ );
+ apiFunction.addToRolePolicy(
+ new iam.PolicyStatement({
+ actions: ["bedrock:InvokeModel"],
+ resources: [
+ this.formatArn({
+ service: "bedrock",
+ region: this.region,
+ account: "",
+ resource: "foundation-model",
+ resourceName: EMBEDDING_MODEL_ID,
+ }),
+ ],
+ }),
+ );
+
+ const httpApi = new apigatewayv2.HttpApi(this, "VectorSearchApi", {
+ description: "Ingest documents and run semantic search with DynamoDB vector indexes",
+ corsPreflight: {
+ allowHeaders: ["content-type"],
+ allowMethods: [apigatewayv2.CorsHttpMethod.POST],
+ allowOrigins: ["*"],
+ },
+ });
+ const integration = new integrations.HttpLambdaIntegration(
+ "VectorSearchIntegration",
+ apiFunction,
+ );
+
+ httpApi.addRoutes({
+ path: "/documents",
+ methods: [apigatewayv2.HttpMethod.POST],
+ integration,
+ });
+ httpApi.addRoutes({
+ path: "/search",
+ methods: [apigatewayv2.HttpMethod.POST],
+ integration,
+ });
+
+ new cdk.CfnOutput(this, "ApiEndpoint", {
+ description: "HTTP API base URL",
+ value: httpApi.apiEndpoint,
+ });
+ new cdk.CfnOutput(this, "TableName", {
+ description: "DynamoDB table containing documents and embeddings",
+ value: table.tableName,
+ });
+ new cdk.CfnOutput(this, "VectorIndexName", {
+ description: "DynamoDB vector index used by SearchVectors",
+ value: vectorIndex.indexName,
+ });
+ new cdk.CfnOutput(this, "VectorSearchFunctionName", {
+ description: "Lambda function backing both HTTP API routes",
+ value: apiFunction.functionName,
+ });
+ }
+}
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/package.json b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/package.json
new file mode 100644
index 000000000..ed8f45d46
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk",
+ "version": "1.0.0",
+ "private": true,
+ "engines": {
+ "node": ">=22"
+ },
+ "scripts": {
+ "build": "tsc --noEmit",
+ "test": "jest --runInBand",
+ "synth": "cdk synth"
+ },
+ "dependencies": {
+ "@aws-sdk/client-bedrock-runtime": "3.1106.0",
+ "@aws-sdk/client-dynamodb": "3.1106.0",
+ "@aws-sdk/util-dynamodb": "3.996.7",
+ "aws-cdk-lib": "2.263.0",
+ "constructs": "10.6.0"
+ },
+ "devDependencies": {
+ "@types/aws-lambda": "^8.10.152",
+ "@types/jest": "^29.5.14",
+ "@types/node": "^24.0.0",
+ "aws-cdk": "2.1135.1",
+ "aws-sdk-client-mock": "4.1.0",
+ "esbuild": "^0.25.0",
+ "jest": "^29.7.0",
+ "ts-jest": "^29.4.0",
+ "ts-node": "^10.9.2",
+ "typescript": "~5.9.0"
+ }
+}
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/src/vector-index-provider.ts b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/src/vector-index-provider.ts
new file mode 100644
index 000000000..c00a8c168
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/src/vector-index-provider.ts
@@ -0,0 +1,247 @@
+import {
+ AttributeDefinition,
+ CreateVectorIndexAction,
+ DescribeTableCommand,
+ DynamoDBClient,
+ Projection,
+ ResourceNotFoundException,
+ SearchSchemaElement,
+ UpdateTableCommand,
+ VectorIndexDescription,
+} from "@aws-sdk/client-dynamodb";
+import type { CloudFormationCustomResourceEvent } from "aws-lambda";
+
+const dynamodb = new DynamoDBClient({});
+
+interface VectorIndexProperties {
+ readonly TableName: string;
+ readonly IndexName: string;
+ readonly VectorAttributeName: string;
+ readonly Dimensions: number | string;
+ readonly DistanceFunction: "COSINE" | "DOT_PRODUCT" | "EUCLIDEAN";
+ readonly SearchSchema?: Array<{
+ readonly AttributeName: string;
+ readonly SearchSchemaElementType: "HASH" | "INLINE_FILTER";
+ readonly AttributeType: "S" | "N" | "B";
+ }>;
+ readonly Projection: {
+ readonly ProjectionType: "ALL" | "INCLUDE" | "KEYS_ONLY";
+ readonly NonKeyAttributes?: string[];
+ };
+ readonly ServiceToken?: string;
+}
+
+interface OnEventResponse {
+ readonly PhysicalResourceId: string;
+}
+
+export async function onEvent(event: CloudFormationCustomResourceEvent): Promise {
+ const properties = parseProperties(event.ResourceProperties);
+ const physicalResourceId = physicalId(properties);
+
+ if (event.RequestType === "Delete") {
+ await deleteIndexIfPresent(properties);
+ await waitForIndex(properties, false);
+ return { PhysicalResourceId: event.PhysicalResourceId ?? physicalResourceId };
+ }
+
+ if (event.RequestType === "Update") {
+ const oldProperties = parseProperties(event.OldResourceProperties);
+ const sameResource =
+ oldProperties.TableName === properties.TableName &&
+ oldProperties.IndexName === properties.IndexName;
+ if (sameResource && !sameConfiguration(oldProperties, properties)) {
+ throw new Error(
+ "DynamoDB vector-index dimensions, distance function, schema, vector attribute, and projection are immutable. " +
+ "Change indexName to replace the index safely.",
+ );
+ }
+ }
+
+ await createIndexIfNeeded(properties);
+ await waitForIndex(properties, true);
+ return { PhysicalResourceId: physicalResourceId };
+}
+
+async function createIndexIfNeeded(properties: VectorIndexProperties): Promise {
+ const table = await describeTable(properties.TableName);
+ const existing = table.VectorIndexes?.find((index) => index.IndexName === properties.IndexName);
+ if (existing) {
+ assertExistingIndexMatches(existing, properties);
+ return;
+ }
+
+ const attributeDefinitions: AttributeDefinition[] = (properties.SearchSchema ?? [])
+ .map((element) => ({
+ AttributeName: element.AttributeName,
+ AttributeType: element.AttributeType,
+ }));
+
+ const create: CreateVectorIndexAction = {
+ IndexName: properties.IndexName,
+ VectorAttribute: { AttributeName: properties.VectorAttributeName },
+ Dimensions: Number(properties.Dimensions),
+ DistanceFunction: properties.DistanceFunction,
+ Projection: toProjection(properties),
+ SearchSchema: toSearchSchema(properties),
+ };
+
+ await dynamodb.send(
+ new UpdateTableCommand({
+ TableName: properties.TableName,
+ // DynamoDB requires every SearchSchema attribute in this UpdateTable
+ // request, including attributes already used by the base table key.
+ AttributeDefinitions: attributeDefinitions.length ? attributeDefinitions : undefined,
+ VectorIndexUpdates: [{ Create: create }],
+ }),
+ );
+}
+
+async function waitForIndex(
+ properties: VectorIndexProperties,
+ shouldExist: boolean,
+): Promise {
+ const deadline = Date.now() + 13 * 60 * 1000;
+ while (Date.now() < deadline) {
+ const index = await findIndex(properties.TableName, properties.IndexName);
+ if (!shouldExist && !index) {
+ return;
+ }
+ if (shouldExist && index?.IndexStatus === "ACTIVE" && index.Backfilling !== true) {
+ return;
+ }
+ await delay(10_000);
+ }
+ throw new Error(
+ `Timed out waiting for vector index ${properties.IndexName} on table ${properties.TableName}`,
+ );
+}
+
+function delay(milliseconds: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
+}
+
+async function deleteIndexIfPresent(properties: VectorIndexProperties): Promise {
+ const index = await findIndex(properties.TableName, properties.IndexName);
+ if (!index) {
+ return;
+ }
+ if (index.IndexStatus === "DELETING") {
+ return;
+ }
+ await dynamodb.send(
+ new UpdateTableCommand({
+ TableName: properties.TableName,
+ VectorIndexUpdates: [{ Delete: { IndexName: properties.IndexName } }],
+ }),
+ );
+}
+
+async function findIndex(
+ tableName: string,
+ indexName: string,
+): Promise {
+ try {
+ const table = await describeTable(tableName);
+ return table.VectorIndexes?.find((index) => index.IndexName === indexName);
+ } catch (error) {
+ if (error instanceof ResourceNotFoundException) {
+ return undefined;
+ }
+ throw error;
+ }
+}
+
+async function describeTable(tableName: string) {
+ const response = await dynamodb.send(new DescribeTableCommand({ TableName: tableName }));
+ if (!response.Table) {
+ throw new Error(`DynamoDB did not return a description for table ${tableName}`);
+ }
+ return response.Table;
+}
+
+function assertExistingIndexMatches(
+ existing: VectorIndexDescription,
+ properties: VectorIndexProperties,
+): void {
+ const matches =
+ existing.VectorAttribute?.AttributeName === properties.VectorAttributeName &&
+ existing.Dimensions === Number(properties.Dimensions) &&
+ existing.DistanceFunction === properties.DistanceFunction &&
+ normalizedSchema(existing.SearchSchema) === normalizedSchema(toSearchSchema(properties)) &&
+ normalizedProjection(existing.Projection) === normalizedProjection(toProjection(properties));
+
+ if (!matches) {
+ throw new Error(
+ `Vector index ${properties.IndexName} already exists on ${properties.TableName} with a different configuration`,
+ );
+ }
+}
+
+function sameConfiguration(
+ oldProperties: VectorIndexProperties,
+ newProperties: VectorIndexProperties,
+): boolean {
+ return (
+ oldProperties.VectorAttributeName === newProperties.VectorAttributeName &&
+ Number(oldProperties.Dimensions) === Number(newProperties.Dimensions) &&
+ oldProperties.DistanceFunction === newProperties.DistanceFunction &&
+ normalizedSchema(toSearchSchema(oldProperties)) === normalizedSchema(toSearchSchema(newProperties)) &&
+ normalizedProjection(toProjection(oldProperties)) ===
+ normalizedProjection(toProjection(newProperties))
+ );
+}
+
+function toSearchSchema(properties: VectorIndexProperties): SearchSchemaElement[] | undefined {
+ if (!properties.SearchSchema?.length) {
+ return undefined;
+ }
+ return properties.SearchSchema.map((element) => ({
+ AttributeName: element.AttributeName,
+ SearchSchemaElementType: element.SearchSchemaElementType,
+ }));
+}
+
+function toProjection(properties: VectorIndexProperties): Projection {
+ return {
+ ProjectionType: properties.Projection.ProjectionType,
+ NonKeyAttributes:
+ properties.Projection.ProjectionType === "INCLUDE"
+ ? properties.Projection.NonKeyAttributes
+ : undefined,
+ };
+}
+
+function normalizedSchema(schema: SearchSchemaElement[] | undefined): string {
+ return JSON.stringify(
+ [...(schema ?? [])].sort((left, right) =>
+ (left.AttributeName ?? "").localeCompare(right.AttributeName ?? ""),
+ ),
+ );
+}
+
+function normalizedProjection(projection: Projection | undefined): string {
+ return JSON.stringify({
+ ProjectionType: projection?.ProjectionType,
+ NonKeyAttributes: [...(projection?.NonKeyAttributes ?? [])].sort(),
+ });
+}
+
+function physicalId(properties: VectorIndexProperties): string {
+ return `${properties.TableName}/index/${properties.IndexName}`;
+}
+
+function parseProperties(properties: Record): VectorIndexProperties {
+ const parsed = properties as unknown as VectorIndexProperties;
+ if (
+ !parsed.TableName ||
+ !parsed.IndexName ||
+ !parsed.VectorAttributeName ||
+ !parsed.Dimensions ||
+ !parsed.DistanceFunction ||
+ !parsed.Projection
+ ) {
+ throw new Error("The DynamoDB vector-index custom resource is missing required properties");
+ }
+ return parsed;
+}
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/src/vector-search-handler.ts b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/src/vector-search-handler.ts
new file mode 100644
index 000000000..9b8147e45
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/src/vector-search-handler.ts
@@ -0,0 +1,250 @@
+import {
+ BedrockRuntimeClient,
+ InvokeModelCommand,
+} from "@aws-sdk/client-bedrock-runtime";
+import {
+ DynamoDBClient,
+ PutItemCommand,
+ SearchVectorsCommand,
+} from "@aws-sdk/client-dynamodb";
+import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
+import type {
+ APIGatewayProxyEventV2,
+ APIGatewayProxyStructuredResultV2,
+} from "aws-lambda";
+
+const bedrock = new BedrockRuntimeClient({});
+const dynamodb = new DynamoDBClient({});
+const MAX_EMBEDDING_INPUT_CHARACTERS = 50_000;
+const MAX_TENANT_ID_BYTES = 2_048;
+const MAX_DOCUMENT_ID_BYTES = 1_024;
+const MAX_TITLE_CHARACTERS = 1_000;
+const MAX_CATEGORY_CHARACTERS = 256;
+
+interface DocumentRequest {
+ readonly documentId: string;
+ readonly title: string;
+ readonly content: string;
+ readonly tenantId: string;
+ readonly category: string;
+}
+
+interface SearchRequest {
+ readonly query: string;
+ readonly tenantId: string;
+ readonly category?: string;
+ readonly topK?: number;
+}
+
+export async function handler(
+ event: APIGatewayProxyEventV2,
+): Promise {
+ try {
+ if (event.routeKey === "POST /documents") {
+ return await ingestDocument(parseJsonBody(event));
+ }
+ if (event.routeKey === "POST /search") {
+ return await searchDocuments(parseJsonBody(event));
+ }
+ return jsonResponse(404, { message: "Route not found" });
+ } catch (error) {
+ if (error instanceof RequestValidationError) {
+ return jsonResponse(400, { message: error.message });
+ }
+ console.error("Request failed", error);
+ return jsonResponse(500, { message: "Internal server error" });
+ }
+}
+
+async function ingestDocument(body: unknown): Promise {
+ const document = validateDocument(body);
+ const embedding = await generateEmbedding(document.content);
+ const tableName = requiredEnvironmentVariable("TABLE_NAME");
+
+ await dynamodb.send(
+ new PutItemCommand({
+ TableName: tableName,
+ Item: marshall(
+ {
+ ...document,
+ embedding,
+ createdAt: new Date().toISOString(),
+ },
+ { removeUndefinedValues: true },
+ ),
+ }),
+ );
+
+ return jsonResponse(201, {
+ documentId: document.documentId,
+ message: "Document embedded and stored",
+ });
+}
+
+async function searchDocuments(body: unknown): Promise {
+ const request = validateSearch(body);
+ const embedding = await generateEmbedding(request.query);
+
+ const expressionAttributeNames: Record = {
+ "#tenantId": "tenantId",
+ };
+ const expressionAttributeValues = {
+ ":tenantId": { S: request.tenantId },
+ ...(request.category ? { ":category": { S: request.category } } : {}),
+ };
+ const conditions = ["#tenantId = :tenantId"];
+ if (request.category) {
+ expressionAttributeNames["#category"] = "category";
+ conditions.push("#category = :category");
+ }
+
+ const response = await dynamodb.send(
+ new SearchVectorsCommand({
+ TableName: requiredEnvironmentVariable("TABLE_NAME"),
+ IndexName: requiredEnvironmentVariable("VECTOR_INDEX_NAME"),
+ SearchVector: embedding.map((value) => ({ N: String(value) })),
+ TopK: request.topK ?? 5,
+ SearchConditionExpression: conditions.join(" AND "),
+ ExpressionAttributeNames: expressionAttributeNames,
+ ExpressionAttributeValues: expressionAttributeValues,
+ ProjectionExpression: "documentId, title, content, category",
+ }),
+ );
+
+ return jsonResponse(200, {
+ query: request.query,
+ results: (response.SearchResults ?? []).map((result) => ({
+ score: result.Score,
+ ...(result.Item ? unmarshall(result.Item) : {}),
+ })),
+ });
+}
+
+async function generateEmbedding(text: string): Promise {
+ const dimensions = Number(requiredEnvironmentVariable("VECTOR_DIMENSIONS"));
+ const response = await bedrock.send(
+ new InvokeModelCommand({
+ modelId: requiredEnvironmentVariable("EMBEDDING_MODEL_ID"),
+ contentType: "application/json",
+ accept: "application/json",
+ body: JSON.stringify({
+ inputText: text,
+ dimensions,
+ normalize: true,
+ }),
+ }),
+ );
+
+ const payload = JSON.parse(new TextDecoder().decode(response.body)) as {
+ embedding?: number[];
+ };
+ if (!payload.embedding || payload.embedding.length !== dimensions) {
+ throw new Error("The embedding model returned an unexpected vector size");
+ }
+ return payload.embedding;
+}
+
+function parseJsonBody(event: APIGatewayProxyEventV2): unknown {
+ if (!event.body) {
+ throw new RequestValidationError("Request body is required");
+ }
+ try {
+ const body = event.isBase64Encoded
+ ? Buffer.from(event.body, "base64").toString("utf8")
+ : event.body;
+ return JSON.parse(body) as unknown;
+ } catch {
+ throw new RequestValidationError("Request body must be valid JSON");
+ }
+}
+
+function validateDocument(value: unknown): DocumentRequest {
+ const body = requireObject(value);
+ return {
+ documentId: requireString(body, "documentId", { maxBytes: MAX_DOCUMENT_ID_BYTES }),
+ title: requireString(body, "title", { maxCharacters: MAX_TITLE_CHARACTERS }),
+ content: requireString(body, "content", {
+ maxCharacters: MAX_EMBEDDING_INPUT_CHARACTERS,
+ }),
+ tenantId: requireString(body, "tenantId", { maxBytes: MAX_TENANT_ID_BYTES }),
+ category: requireString(body, "category", { maxCharacters: MAX_CATEGORY_CHARACTERS }),
+ };
+}
+
+function validateSearch(value: unknown): SearchRequest {
+ const body = requireObject(value);
+ const topK = body.topK;
+ if (topK !== undefined && (!Number.isInteger(topK) || Number(topK) < 1 || Number(topK) > 100)) {
+ throw new RequestValidationError("topK must be an integer between 1 and 100");
+ }
+ return {
+ query: requireString(body, "query", {
+ maxCharacters: MAX_EMBEDDING_INPUT_CHARACTERS,
+ }),
+ tenantId: requireString(body, "tenantId", { maxBytes: MAX_TENANT_ID_BYTES }),
+ category: optionalString(body, "category", { maxCharacters: MAX_CATEGORY_CHARACTERS }),
+ topK: topK === undefined ? undefined : Number(topK),
+ };
+}
+
+function requireObject(value: unknown): Record {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new RequestValidationError("Request body must be a JSON object");
+ }
+ return value as Record;
+}
+
+interface StringConstraints {
+ readonly maxBytes?: number;
+ readonly maxCharacters?: number;
+}
+
+function requireString(
+ value: Record,
+ key: string,
+ constraints: StringConstraints = {},
+): string {
+ const result = value[key];
+ if (typeof result !== "string" || !result.trim()) {
+ throw new RequestValidationError(`${key} must be a non-empty string`);
+ }
+ const trimmed = result.trim();
+ if (constraints.maxCharacters && Array.from(trimmed).length > constraints.maxCharacters) {
+ throw new RequestValidationError(
+ `${key} must not exceed ${constraints.maxCharacters} characters`,
+ );
+ }
+ if (constraints.maxBytes && Buffer.byteLength(trimmed, "utf8") > constraints.maxBytes) {
+ throw new RequestValidationError(`${key} must not exceed ${constraints.maxBytes} UTF-8 bytes`);
+ }
+ return trimmed;
+}
+
+function optionalString(
+ value: Record,
+ key: string,
+ constraints: StringConstraints = {},
+): string | undefined {
+ if (value[key] === undefined) {
+ return undefined;
+ }
+ return requireString(value, key, constraints);
+}
+
+function requiredEnvironmentVariable(name: string): string {
+ const value = process.env[name];
+ if (!value) {
+ throw new Error(`Missing environment variable ${name}`);
+ }
+ return value;
+}
+
+function jsonResponse(statusCode: number, body: unknown): APIGatewayProxyStructuredResultV2 {
+ return {
+ statusCode,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ };
+}
+
+class RequestValidationError extends Error {}
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/test/vector-index-provider.test.ts b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/test/vector-index-provider.test.ts
new file mode 100644
index 000000000..765b5c8b4
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/test/vector-index-provider.test.ts
@@ -0,0 +1,149 @@
+import {
+ DescribeTableCommand,
+ DynamoDBClient,
+ UpdateTableCommand,
+} from "@aws-sdk/client-dynamodb";
+import type { CloudFormationCustomResourceEvent } from "aws-lambda";
+import { mockClient } from "aws-sdk-client-mock";
+import { onEvent } from "../src/vector-index-provider";
+
+const dynamodbMock = mockClient(DynamoDBClient);
+
+const resourceProperties = {
+ ServiceToken: "service-token",
+ TableName: "Documents",
+ IndexName: "embedding-index",
+ VectorAttributeName: "embedding",
+ Dimensions: 1024,
+ DistanceFunction: "COSINE",
+ SearchSchema: [
+ {
+ AttributeName: "tenantId",
+ SearchSchemaElementType: "HASH",
+ AttributeType: "S",
+ },
+ {
+ AttributeName: "category",
+ SearchSchemaElementType: "INLINE_FILTER",
+ AttributeType: "S",
+ },
+ ],
+ Projection: {
+ ProjectionType: "INCLUDE",
+ NonKeyAttributes: ["title"],
+ },
+};
+
+describe("vector index provider", () => {
+ beforeEach(() => dynamodbMock.reset());
+
+ test("includes every search-schema attribute definition when creating the index", async () => {
+ dynamodbMock
+ .on(DescribeTableCommand)
+ .resolvesOnce({
+ Table: {
+ TableName: "Documents",
+ AttributeDefinitions: [
+ { AttributeName: "tenantId", AttributeType: "S" },
+ { AttributeName: "documentId", AttributeType: "S" },
+ ],
+ VectorIndexes: [],
+ },
+ })
+ .resolves({
+ Table: {
+ TableName: "Documents",
+ VectorIndexes: [
+ { IndexName: "embedding-index", IndexStatus: "ACTIVE", Backfilling: false },
+ ],
+ },
+ });
+ dynamodbMock.on(UpdateTableCommand).resolves({});
+
+ const response = await onEvent(event("Create"));
+
+ expect(response.PhysicalResourceId).toBe("Documents/index/embedding-index");
+ const update = dynamodbMock.commandCalls(UpdateTableCommand)[0].args[0].input;
+ expect(update).toEqual({
+ TableName: "Documents",
+ AttributeDefinitions: [
+ { AttributeName: "tenantId", AttributeType: "S" },
+ { AttributeName: "category", AttributeType: "S" },
+ ],
+ VectorIndexUpdates: [
+ {
+ Create: {
+ IndexName: "embedding-index",
+ VectorAttribute: { AttributeName: "embedding" },
+ Dimensions: 1024,
+ DistanceFunction: "COSINE",
+ SearchSchema: [
+ {
+ AttributeName: "tenantId",
+ SearchSchemaElementType: "HASH",
+ },
+ {
+ AttributeName: "category",
+ SearchSchemaElementType: "INLINE_FILTER",
+ },
+ ],
+ Projection: {
+ ProjectionType: "INCLUDE",
+ NonKeyAttributes: ["title"],
+ },
+ },
+ },
+ ],
+ });
+ });
+
+ test("rejects an in-place immutable configuration change", async () => {
+ const updated = {
+ ...resourceProperties,
+ Dimensions: 1536,
+ };
+
+ await expect(
+ onEvent(event("Update", updated, resourceProperties)),
+ ).rejects.toThrow("Change indexName to replace the index safely");
+ expect(dynamodbMock.calls()).toHaveLength(0);
+ });
+
+ test("treats an index already being deleted as an idempotent delete", async () => {
+ dynamodbMock
+ .on(DescribeTableCommand)
+ .resolvesOnce({
+ Table: {
+ TableName: "Documents",
+ VectorIndexes: [{ IndexName: "embedding-index", IndexStatus: "DELETING" }],
+ },
+ })
+ .resolves({
+ Table: { TableName: "Documents", VectorIndexes: [] },
+ });
+
+ await expect(onEvent(event("Delete"))).resolves.toEqual({
+ PhysicalResourceId: "Documents/index/embedding-index",
+ });
+ expect(dynamodbMock.commandCalls(UpdateTableCommand)).toHaveLength(0);
+ });
+});
+
+function event(
+ requestType: "Create" | "Update" | "Delete",
+ properties: Record = resourceProperties,
+ oldProperties?: Record,
+): CloudFormationCustomResourceEvent {
+ return {
+ RequestType: requestType,
+ ServiceToken: "service-token",
+ ResponseURL: "https://example.com/response",
+ StackId: "stack-id",
+ RequestId: "request-id",
+ LogicalResourceId: "VectorIndex",
+ PhysicalResourceId: "Documents/index/embedding-index",
+ ResourceType: "Custom::DynamoDBVectorIndex",
+ ResourceProperties: properties,
+ OldResourceProperties: oldProperties,
+ } as CloudFormationCustomResourceEvent;
+}
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/test/vector-search-handler.test.ts b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/test/vector-search-handler.test.ts
new file mode 100644
index 000000000..87907dba5
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/test/vector-search-handler.test.ts
@@ -0,0 +1,176 @@
+import {
+ BedrockRuntimeClient,
+ InvokeModelCommand,
+ InvokeModelCommandOutput,
+} from "@aws-sdk/client-bedrock-runtime";
+import {
+ DynamoDBClient,
+ PutItemCommand,
+ SearchVectorsCommand,
+} from "@aws-sdk/client-dynamodb";
+import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
+import type { APIGatewayProxyEventV2 } from "aws-lambda";
+import { mockClient } from "aws-sdk-client-mock";
+import { handler } from "../src/vector-search-handler";
+
+const bedrockMock = mockClient(BedrockRuntimeClient);
+const dynamodbMock = mockClient(DynamoDBClient);
+
+describe("vector search handler", () => {
+ beforeEach(() => {
+ bedrockMock.reset();
+ dynamodbMock.reset();
+ process.env.TABLE_NAME = "Documents";
+ process.env.VECTOR_INDEX_NAME = "embedding-index";
+ process.env.EMBEDDING_MODEL_ID = "amazon.titan-embed-text-v2:0";
+ process.env.VECTOR_DIMENSIONS = "2";
+ bedrockMock.on(InvokeModelCommand).resolves({
+ body: Uint8Array.from(
+ Buffer.from(JSON.stringify({ embedding: [0.1, 0.2] })),
+ ) as unknown as InvokeModelCommandOutput["body"],
+ });
+ });
+
+ test("embeds and stores a document", async () => {
+ dynamodbMock.on(PutItemCommand).resolves({});
+
+ const response = await handler(
+ apiEvent("POST /documents", {
+ documentId: "doc-1",
+ title: "DynamoDB vector search",
+ content: "DynamoDB can search vectors alongside operational data.",
+ tenantId: "tenant-1",
+ category: "aws",
+ }),
+ );
+
+ expect(response.statusCode).toBe(201);
+ const put = dynamodbMock.commandCalls(PutItemCommand)[0].args[0].input;
+ expect(put.TableName).toBe("Documents");
+ expect(unmarshall(put.Item ?? {})).toMatchObject({
+ documentId: "doc-1",
+ tenantId: "tenant-1",
+ category: "aws",
+ embedding: [0.1, 0.2],
+ });
+ });
+
+ test("embeds a query and returns vector search results", async () => {
+ dynamodbMock.on(SearchVectorsCommand).resolves({
+ SearchResults: [
+ {
+ Score: 0.02,
+ Item: marshall({
+ documentId: "doc-1",
+ title: "DynamoDB vector search",
+ content: "DynamoDB can search vectors alongside operational data.",
+ tenantId: "tenant-1",
+ category: "aws",
+ }),
+ },
+ ],
+ });
+
+ const response = await handler(
+ apiEvent("POST /search", {
+ query: "How do I search embeddings?",
+ tenantId: "tenant-1",
+ category: "aws",
+ topK: 3,
+ }),
+ );
+
+ expect(response.statusCode).toBe(200);
+ expect(JSON.parse(response.body ?? "{}")).toMatchObject({
+ results: [{ documentId: "doc-1", score: 0.02 }],
+ });
+ const search = dynamodbMock.commandCalls(SearchVectorsCommand)[0].args[0].input;
+ expect(search).toMatchObject({
+ TableName: "Documents",
+ IndexName: "embedding-index",
+ TopK: 3,
+ SearchConditionExpression: "#tenantId = :tenantId AND #category = :category",
+ SearchVector: [{ N: "0.1" }, { N: "0.2" }],
+ ProjectionExpression: "documentId, title, content, category",
+ });
+ });
+
+ test("returns a validation response for an invalid topK", async () => {
+ const response = await handler(
+ apiEvent("POST /search", {
+ query: "query",
+ tenantId: "tenant-1",
+ topK: 101,
+ }),
+ );
+
+ expect(response.statusCode).toBe(400);
+ expect(dynamodbMock.calls()).toHaveLength(0);
+ expect(bedrockMock.calls()).toHaveLength(0);
+ });
+
+ test("rejects text that exceeds the embedding model character limit", async () => {
+ const response = await handler(
+ apiEvent("POST /documents", {
+ documentId: "doc-1",
+ title: "Oversized document",
+ content: "a".repeat(50_001),
+ tenantId: "tenant-1",
+ category: "aws",
+ }),
+ );
+
+ expect(response.statusCode).toBe(400);
+ expect(JSON.parse(response.body ?? "{}")).toEqual({
+ message: "content must not exceed 50000 characters",
+ });
+ expect(dynamodbMock.calls()).toHaveLength(0);
+ expect(bedrockMock.calls()).toHaveLength(0);
+ });
+
+ test("validates DynamoDB key sizes using UTF-8 bytes", async () => {
+ const response = await handler(
+ apiEvent("POST /search", {
+ query: "query",
+ tenantId: "é".repeat(1_025),
+ }),
+ );
+
+ expect(response.statusCode).toBe(400);
+ expect(JSON.parse(response.body ?? "{}")).toEqual({
+ message: "tenantId must not exceed 2048 UTF-8 bytes",
+ });
+ expect(dynamodbMock.calls()).toHaveLength(0);
+ expect(bedrockMock.calls()).toHaveLength(0);
+ });
+});
+
+function apiEvent(routeKey: string, body: unknown): APIGatewayProxyEventV2 {
+ return {
+ version: "2.0",
+ routeKey,
+ rawPath: routeKey.split(" ")[1],
+ rawQueryString: "",
+ headers: { "content-type": "application/json" },
+ requestContext: {
+ accountId: "test-account",
+ apiId: "api-id",
+ domainName: "example.execute-api.us-east-1.amazonaws.com",
+ domainPrefix: "example",
+ http: {
+ method: "POST",
+ path: routeKey.split(" ")[1],
+ protocol: "HTTP/1.1",
+ sourceIp: "127.0.0.1",
+ userAgent: "jest",
+ },
+ requestId: "request-id",
+ routeKey,
+ stage: "$default",
+ time: "10/Aug/2026:00:00:00 +0000",
+ timeEpoch: 0,
+ },
+ body: JSON.stringify(body),
+ isBase64Encoded: false,
+ };
+}
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/test/vector-search-stack.test.ts b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/test/vector-search-stack.test.ts
new file mode 100644
index 000000000..a857e7925
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/test/vector-search-stack.test.ts
@@ -0,0 +1,77 @@
+import * as cdk from "aws-cdk-lib";
+import { Match, Template } from "aws-cdk-lib/assertions";
+import { VectorSearchStack } from "../lib/vector-search-stack";
+
+describe("VectorSearchStack", () => {
+ test("creates the semantic search API and DynamoDB vector index", () => {
+ const app = new cdk.App();
+ const stack = new VectorSearchStack(app, "TestStack");
+ const template = Template.fromStack(stack);
+
+ template.hasResourceProperties("AWS::DynamoDB::Table", {
+ BillingMode: "PAY_PER_REQUEST",
+ KeySchema: [
+ { AttributeName: "tenantId", KeyType: "HASH" },
+ { AttributeName: "documentId", KeyType: "RANGE" },
+ ],
+ PointInTimeRecoverySpecification: {
+ PointInTimeRecoveryEnabled: true,
+ },
+ });
+ template.hasResourceProperties("Custom::DynamoDBVectorIndex", {
+ IndexName: "document-embedding-index",
+ VectorAttributeName: "embedding",
+ Dimensions: 1024,
+ DistanceFunction: "COSINE",
+ SearchSchema: [
+ {
+ AttributeName: "tenantId",
+ SearchSchemaElementType: "HASH",
+ AttributeType: "S",
+ },
+ {
+ AttributeName: "category",
+ SearchSchemaElementType: "INLINE_FILTER",
+ AttributeType: "S",
+ },
+ ],
+ Projection: {
+ ProjectionType: "INCLUDE",
+ NonKeyAttributes: ["title", "content"],
+ },
+ });
+ template.hasResourceProperties("AWS::Lambda::Function", {
+ Runtime: "nodejs22.x",
+ Architectures: ["arm64"],
+ Environment: {
+ Variables: Match.objectLike({
+ VECTOR_INDEX_NAME: "document-embedding-index",
+ EMBEDDING_MODEL_ID: "amazon.titan-embed-text-v2:0",
+ VECTOR_DIMENSIONS: "1024",
+ }),
+ },
+ });
+ template.hasResourceProperties("AWS::ApiGatewayV2::Route", {
+ RouteKey: "POST /documents",
+ });
+ template.hasResourceProperties("AWS::ApiGatewayV2::Route", {
+ RouteKey: "POST /search",
+ });
+ template.resourceCountIs("AWS::StepFunctions::StateMachine", 0);
+ template.resourceCountIs("AWS::Logs::LogGroup", 3);
+ template.hasResourceProperties("AWS::IAM::Policy", {
+ PolicyDocument: {
+ Statement: Match.arrayWith([
+ Match.objectLike({
+ Action: "dynamodb:SearchVectors",
+ Effect: "Allow",
+ }),
+ Match.objectLike({
+ Action: "bedrock:InvokeModel",
+ Effect: "Allow",
+ }),
+ ]),
+ },
+ });
+ });
+});
diff --git a/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/tsconfig.json b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/tsconfig.json
new file mode 100644
index 000000000..18488a4a9
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "commonjs",
+ "lib": ["ES2022"],
+ "declaration": true,
+ "strict": true,
+ "noImplicitAny": true,
+ "strictNullChecks": true,
+ "noImplicitThis": true,
+ "alwaysStrict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "esModuleInterop": true,
+ "inlineSourceMap": true,
+ "inlineSources": true,
+ "experimentalDecorators": true,
+ "strictPropertyInitialization": true,
+ "skipLibCheck": true,
+ "typeRoots": ["./node_modules/@types"]
+ },
+ "include": ["bin/**/*.ts", "lib/**/*.ts", "src/**/*.ts", "test/**/*.ts"],
+ "exclude": ["node_modules", "cdk.out"]
+}