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..95fff5a8b
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/README.md
@@ -0,0 +1,215 @@
+# 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, AWS CDK synthesizes the table and its native `VectorIndexes` property; CloudFormation provisions the table and index together.
+
+### 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 AWS-managed KMS encryption, a native vector index, tenant partitioning, inline category filtering, and projected content attributes.
+
+The vector index is declared directly on `AWS::DynamoDB::Table` using its native [VectorIndexes property](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-dynamodb-table.html#cfn-dynamodb-table-vectorindexes). The pinned CDK version does not expose this property, so the stack uses `CfnTable.addPropertyOverride` with CloudFormation property names. Search-schema attributes are declared in the table’s `AttributeDefinitions`. CloudFormation manages the index lifecycle without a custom-resource provider or polling Lambda functions.
+
+## 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 stack creates one vector index on a new, empty on-demand table through CloudFormation. The table explicitly uses `TableEncryption.AWS_MANAGED` for encryption with the AWS-managed DynamoDB KMS key.
+
+### 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
+```
+
+The pinned CDK release may warn that `VectorIndexes` is an unexpected property during synthesis because its bundled validation schema predates CloudFormation support. The property override follows the current CloudFormation specification; this warning does not prevent synthesis.
+
+## Updating the vector index
+
+Vector index properties do not support in-place updates. CloudFormation supports creating or deleting only one vector index per stack operation. To replace an index, first add a differently named index while retaining the existing one and deploy; after the new index is ready, switch the application to it, then remove the old index in a separate deployment. Keep the application’s `VECTOR_INDEX_NAME`, the `SearchVectors` IAM resource, and the stack output aligned with the active index. See the [CloudFormation vector index reference](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-properties-dynamodb-table-vectorindex.html) before changing the schema.
+
+## Cleanup
+
+Delete the deployed resources:
+
+```bash
+npx cdk destroy
+```
+
+CloudFormation deletes the DynamoDB table and its vector index. The application Lambda log group is also removed by the stack’s `DESTROY` removal policy.
+
+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..ba68ddd91
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..6388cc010
--- /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.",
+ "The vector index is declared natively through the VectorIndexes property on the CloudFormation DynamoDB table resource. The pinned CDK version uses a property override to emit this declaration, and all search-schema attributes are included in the table attribute definitions. CloudFormation manages the table and index lifecycle without custom-resource Lambda functions or application-managed polling. The table explicitly uses an AWS-managed KMS key for encryption at rest. Application permissions remain 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 table, its vector index, and the application Lambda log group."
+ ]
+ },
+ "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/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..0af6f0a13
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/lib/vector-search-stack.ts
@@ -0,0 +1,148 @@
+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";
+
+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,
+ encryption: dynamodb.TableEncryption.AWS_MANAGED,
+ pointInTimeRecoverySpecification: {
+ pointInTimeRecoveryEnabled: true,
+ },
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
+ });
+
+ const cfnTable = table.node.defaultChild as dynamodb.CfnTable;
+ // The pinned CDK version predates the native CloudFormation VectorIndexes property.
+ cfnTable.addPropertyOverride("AttributeDefinitions", [
+ { AttributeName: "tenantId", AttributeType: "S" },
+ { AttributeName: "documentId", AttributeType: "S" },
+ { AttributeName: "category", AttributeType: "S" },
+ ]);
+ cfnTable.addPropertyOverride("VectorIndexes", [
+ {
+ IndexName: VECTOR_INDEX_NAME,
+ VectorAttribute: { AttributeName: "embedding" },
+ Dimensions: VECTOR_DIMENSIONS,
+ DistanceFunction: "COSINE",
+ SearchSchema: [
+ { AttributeName: "tenantId", SearchSchemaElementType: "HASH" },
+ { AttributeName: "category", SearchSchemaElementType: "INLINE_FILTER" },
+ ],
+ Projection: {
+ ProjectionType: "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: [`${table.tableArn}/index/${VECTOR_INDEX_NAME}`],
+ }),
+ );
+ 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: VECTOR_INDEX_NAME,
+ });
+ 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-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-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..1564579c2
--- /dev/null
+++ b/apigw-http-api-lambda-bedrock-dynamodb-vector-search-cdk/test/vector-search-stack.test.ts
@@ -0,0 +1,97 @@
+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("AWS::DynamoDB::Table", {
+ SSESpecification: { SSEEnabled: true },
+ AttributeDefinitions: [
+ { AttributeName: "tenantId", AttributeType: "S" },
+ { AttributeName: "documentId", AttributeType: "S" },
+ { AttributeName: "category", AttributeType: "S" },
+ ],
+ VectorIndexes: [{
+ IndexName: "document-embedding-index",
+ VectorAttribute: { AttributeName: "embedding" },
+ Dimensions: 1024,
+ DistanceFunction: "COSINE",
+ SearchSchema: [
+ { AttributeName: "tenantId", SearchSchemaElementType: "HASH" },
+ { AttributeName: "category", SearchSchemaElementType: "INLINE_FILTER" },
+ ],
+ 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", 1);
+ template.resourceCountIs("AWS::Lambda::Function", 1);
+ template.resourceCountIs("Custom::DynamoDBVectorIndex", 0);
+ template.hasResourceProperties("AWS::IAM::Policy", {
+ PolicyDocument: {
+ Statement: Match.arrayWith([
+ Match.objectLike({
+ Action: "dynamodb:SearchVectors",
+ Effect: "Allow",
+ Resource: {
+ "Fn::Join": ["", [
+ { "Fn::GetAtt": [stack.getLogicalId(
+ stack.node.findChild("Documents").node.defaultChild as cdk.CfnResource,
+ ), "Arn"] },
+ "/index/document-embedding-index",
+ ]],
+ },
+ }),
+ Match.objectLike({
+ Action: "bedrock:InvokeModel",
+ Effect: "Allow",
+ }),
+ ]),
+ },
+ });
+ const resources = Object.values(template.toJSON().Resources) as Array<{
+ Type: string;
+ Properties?: { PolicyDocument?: { Statement: Array<{ Action: string | string[] }> } };
+ }>;
+ const actions = resources
+ .filter((resource) => resource.Type === "AWS::IAM::Policy")
+ .flatMap((resource) => resource.Properties?.PolicyDocument?.Statement ?? [])
+ .flatMap((statement) => statement.Action);
+ expect(actions).not.toContain("dynamodb:UpdateTable");
+ expect(actions).not.toContain("dynamodb:DescribeTable");
+ });
+});
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"]
+}