From 4985a9caa83ebedcaf64cc5d8a9c169527524471 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Mon, 8 Jun 2026 10:57:47 +0100 Subject: [PATCH 01/12] feat(opensearch-nextgen): add API Gateway to Lambda to OpenSearch Serverless NextGen pattern --- .../.cfnlintrc | 5 + .../.checkov.yaml | 10 + .../.gitignore | 49 ++ .../README.md | 158 +++++ .../example-pattern.json | 64 ++ .../images/architecture.drawio | 55 ++ .../images/architecture.png | Bin 0 -> 52867 bytes .../images/search-acu-scaling.png | Bin 0 -> 120681 bytes .../nextgen_collection_group/app.py | 97 +++ .../nextgen_collection_group/requirements.txt | 2 + .../custom_resources/setup_pipeline/app.py | 260 ++++++++ .../setup_pipeline/requirements.txt | 1 + .../lambda/delete_documents/app.py | 63 ++ .../lambda/delete_documents/requirements.txt | 1 + .../lambda/index_documents/app.py | 105 +++ .../lambda/index_documents/requirements.txt | 1 + .../lambda/search/app.py | 159 +++++ .../lambda/search/requirements.txt | 1 + .../layers/opensearch_client/__init__.py | 0 .../opensearch_client/opensearch_client.py | 33 + .../layers/opensearch_client/requirements.txt | 3 + .../mise.toml | 93 +++ .../requirements.txt | 1 + .../template.yaml | 630 ++++++++++++++++++ .../tests/__init__.py | 0 .../tests/integration/__init__.py | 0 .../tests/integration/test_data.json | 54 ++ .../tests/integration/test_search.py | 262 ++++++++ .../tests/requirements.txt | 5 + .../tests/unit/__init__.py | 0 .../tests/unit/conftest.py | 64 ++ .../tests/unit/test_collection_group.py | 151 +++++ .../tests/unit/test_delete_documents.py | 121 ++++ .../tests/unit/test_index_documents.py | 173 +++++ .../tests/unit/test_search.py | 171 +++++ .../tests/unit/test_setup_pipeline.py | 188 ++++++ 36 files changed, 2980 insertions(+) create mode 100644 apigw-lambda-opensearch-serverless-nextgen/.cfnlintrc create mode 100644 apigw-lambda-opensearch-serverless-nextgen/.checkov.yaml create mode 100644 apigw-lambda-opensearch-serverless-nextgen/.gitignore create mode 100644 apigw-lambda-opensearch-serverless-nextgen/README.md create mode 100644 apigw-lambda-opensearch-serverless-nextgen/example-pattern.json create mode 100644 apigw-lambda-opensearch-serverless-nextgen/images/architecture.drawio create mode 100644 apigw-lambda-opensearch-serverless-nextgen/images/architecture.png create mode 100644 apigw-lambda-opensearch-serverless-nextgen/images/search-acu-scaling.png create mode 100644 apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/nextgen_collection_group/app.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/nextgen_collection_group/requirements.txt create mode 100644 apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/setup_pipeline/app.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/setup_pipeline/requirements.txt create mode 100644 apigw-lambda-opensearch-serverless-nextgen/lambda/delete_documents/app.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/lambda/delete_documents/requirements.txt create mode 100644 apigw-lambda-opensearch-serverless-nextgen/lambda/index_documents/app.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/lambda/index_documents/requirements.txt create mode 100644 apigw-lambda-opensearch-serverless-nextgen/lambda/search/app.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/lambda/search/requirements.txt create mode 100644 apigw-lambda-opensearch-serverless-nextgen/layers/opensearch_client/__init__.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/layers/opensearch_client/opensearch_client.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/layers/opensearch_client/requirements.txt create mode 100644 apigw-lambda-opensearch-serverless-nextgen/mise.toml create mode 100644 apigw-lambda-opensearch-serverless-nextgen/requirements.txt create mode 100644 apigw-lambda-opensearch-serverless-nextgen/template.yaml create mode 100644 apigw-lambda-opensearch-serverless-nextgen/tests/__init__.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/tests/integration/__init__.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_data.json create mode 100644 apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/tests/requirements.txt create mode 100644 apigw-lambda-opensearch-serverless-nextgen/tests/unit/__init__.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/tests/unit/conftest.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_collection_group.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_delete_documents.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_index_documents.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_search.py create mode 100644 apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_setup_pipeline.py diff --git a/apigw-lambda-opensearch-serverless-nextgen/.cfnlintrc b/apigw-lambda-opensearch-serverless-nextgen/.cfnlintrc new file mode 100644 index 000000000..641a4b26f --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/.cfnlintrc @@ -0,0 +1,5 @@ +configure_rules: + E3030: + # python3.14 is valid but not yet in cfn-lint's schema + exceptions: + - python3.14 diff --git a/apigw-lambda-opensearch-serverless-nextgen/.checkov.yaml b/apigw-lambda-opensearch-serverless-nextgen/.checkov.yaml new file mode 100644 index 000000000..a3176031b --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/.checkov.yaml @@ -0,0 +1,10 @@ +# Checkov suppressions for sample application +# These controls are intentionally skipped as this is a demonstration/sample project. +# Production workloads should implement these controls. + +skip-checks: + - CKV_AWS_115 # Lambda reserved concurrency — not required for sample application + - CKV_AWS_116 # Lambda DLQ — not required for sample application with synchronous API handlers + - CKV_AWS_117 # Lambda in VPC — not required for sample application + - CKV_AWS_158 # CloudWatch LogGroup KMS encryption — not required for sample application log data + - CKV_AWS_173 # Lambda env var encryption — no secrets stored, only configuration values in sample application diff --git a/apigw-lambda-opensearch-serverless-nextgen/.gitignore b/apigw-lambda-opensearch-serverless-nextgen/.gitignore new file mode 100644 index 000000000..75700a25c --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/.gitignore @@ -0,0 +1,49 @@ +# AWS SAM +.aws-sam/ +packaged.yaml + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +*.egg +dist/ +build/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment variables +.env +.env.local + +# Coverage & testing +htmlcov/ +.coverage +.coverage.* +.pytest_cache/ +.mypy_cache/ + +# Distribution +*.whl + +# Local Config +mise.local.toml +.kiro +blog/ \ No newline at end of file diff --git a/apigw-lambda-opensearch-serverless-nextgen/README.md b/apigw-lambda-opensearch-serverless-nextgen/README.md new file mode 100644 index 000000000..ef1a43e37 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/README.md @@ -0,0 +1,158 @@ +# Amazon API Gateway to AWS Lambda to Amazon OpenSearch Serverless NextGen + +This pattern deploys a serverless semantic search API using Amazon API Gateway, AWS Lambda, and Amazon OpenSearch Serverless with the NextGen architecture. Both Lambda and OpenSearch scale independently to zero when idle, resulting in zero baseline compute cost. + +Learn more about this pattern at Serverless Land Patterns: << Add the live URL here >> + +Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please 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 IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +* [AWS CLI installed and configured](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) +* [Git installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +* [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) installed +* [Python 3.14](https://www.python.org/downloads/) + + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + ``` + git clone https://github.com/aws-samples/serverless-patterns + ``` +1. Change directory to the pattern directory: + ``` + cd apigw-lambda-opensearch-serverless-nextgen + ``` +1. Build the application: + ``` + sam build + ``` +1. Deploy the application: + ``` + sam deploy --guided + ``` +1. During the prompts: + * Enter a stack name + * Enter the desired AWS Region + * Accept the default parameter values or customize them + * Allow SAM CLI to create IAM roles with the required permissions + + Once you have run `sam deploy --guided` mode once and saved arguments to a configuration file (samconfig.toml), you can use `sam deploy` in future to use these defaults. + +1. Note the outputs from the SAM deployment process. These contain the API Gateway endpoint URLs for search, index, and delete operations. + +## How it works + +![Architecture diagram](images/architecture.png) + +Figure 1 - Architecture + +This pattern creates a REST API backed by three Lambda functions that interact with an OpenSearch Serverless NextGen collection configured for vector search: + +1. The client sends an HTTPS request (SigV4-signed) to Amazon API Gateway. +2. API Gateway routes the request to the appropriate Lambda function based on path: Search (`POST /search`), Index (`POST /index`), or Delete (`DELETE /documents`). +3. The Lambda function calls the OpenSearch Serverless collection — performing a neural/lexical/hybrid query, bulk indexing via the ingest pipeline, or a bulk delete. +4. For semantic and hybrid search, and during document indexing, the OpenSearch ML model calls Amazon Bedrock Titan Embed Text V2 to generate 1024-dimensional embeddings server-side. +5. For hybrid search, the search pipeline applies min-max score normalization to combine BM25 (lexical) and k-NN (semantic) results with configurable weights (0.3 lexical / 0.7 semantic). + +The OpenSearch collection lives inside a NextGen collection group, which enables scale-to-zero behavior. When idle, both indexing and search OCUs (OpenSearch Compute Units) drop to zero. When a request arrives, capacity provisions in approximately 10 seconds. Requests are queued (not dropped) during this window. + +Key architectural decisions: + +- **IAM authorization** on API Gateway — all endpoints require SigV4-signed requests. +- **Server-side embeddings** — OpenSearch handles all embedding generation via its ML model connector to Bedrock. Lambda functions send and receive plain text only. +- **Hybrid search with score normalization** — A search pipeline applies min-max normalization to combine BM25 (lexical) and k-NN (semantic) scores with configurable weights (0.3 lexical / 0.7 semantic). +- **Custom resources** — The NextGen collection group and ML pipeline setup use Lambda-backed custom resources since CloudFormation doesn't yet natively support the `Generation` parameter. + +## Testing + +Install the test dependencies: + +```bash +pip install -r tests/requirements.txt +``` + +### Unit tests + +Run the unit tests (no deployed stack or AWS credentials required): + +```bash +pytest tests/unit/ -v +``` + +### Integration tests + +The repository includes integration tests that exercise all three search modes against a 50-product outdoor equipment catalog: + +```bash +# Run integration tests (requires a deployed stack) +pytest tests/integration/ -v -s +``` + +The tests demonstrate semantic understanding: `"shoes for the beach"` matches "Summer Beach Sandals" (no keyword overlap), `"charging phone while camping"` matches "Solar Power Bank" (intent matching), and hybrid mode combines both signals for queries like `"waterproof bag for kayaking"` → "Dry Bag 20L". + +### Manual testing with awscurl + +Install the project dependencies (includes `awscurl`): + +```bash +pip install -r requirements.txt +``` + +Set your stack name and region: + +```bash +STACK_NAME="your-stack-name" +AWS_REGION="your-region" +``` + +Index a document: + +```bash +awscurl --service execute-api --region $AWS_REGION -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "documents": [{ + "id": "doc-1", + "title": "OpenSearch Serverless NextGen", + "content": "The next generation architecture scales to zero and provisions in seconds." + }] + }' \ + "$(aws cloudformation describe-stacks --stack-name $STACK_NAME --region $AWS_REGION --query 'Stacks[0].Outputs[?OutputKey==`IndexApiUrl`].OutputValue' --output text)" +``` + +Search for it: + +```bash +awscurl --service execute-api --region $AWS_REGION -X POST \ + -H "Content-Type: application/json" \ + -d '{"query": "serverless scaling", "mode": "hybrid"}' \ + "$(aws cloudformation describe-stacks --stack-name $STACK_NAME --region $AWS_REGION --query 'Stacks[0].Outputs[?OutputKey==`SearchApiUrl`].OutputValue' --output text)" +``` + +Delete a document: + +```bash +awscurl --service execute-api --region $AWS_REGION -X DELETE \ + -H "Content-Type: application/json" \ + -d '{"ids": ["doc-1"]}' \ + "$(aws cloudformation describe-stacks --stack-name $STACK_NAME --region $AWS_REGION --query 'Stacks[0].Outputs[?OutputKey==`DeleteApiUrl`].OutputValue' --output text)" +``` + +> **Note:** The first request after an idle period takes approximately 10 seconds while OpenSearch provisions compute from zero. Subsequent requests respond at normal latency. + +## Cleanup + +1. Delete the stack: + ```bash + sam delete --stack-name STACK_NAME + ``` + + This removes all resources including the OpenSearch collection, collection group, security policies, and Lambda functions. + +---- +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/apigw-lambda-opensearch-serverless-nextgen/example-pattern.json b/apigw-lambda-opensearch-serverless-nextgen/example-pattern.json new file mode 100644 index 000000000..f7ce56447 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/example-pattern.json @@ -0,0 +1,64 @@ +{ + "title": "API Gateway to Lambda to OpenSearch Serverless NextGen", + "description": "Deploy a serverless semantic search API with zero baseline compute cost using Lambda and OpenSearch Serverless NextGen (scale-to-zero).", + "language": "Python", + "level": "300", + "framework": "SAM", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern deploys an API Gateway REST API backed by three Lambda functions that perform semantic, lexical, and hybrid search against an OpenSearch Serverless NextGen collection.", + "OpenSearch Serverless NextGen scales compute to zero when idle and provisions in approximately 10 seconds when traffic arrives. Combined with Lambda's own scale-to-zero, the entire stack incurs zero compute cost when not in use.", + "Embeddings are generated server-side by an OpenSearch ML model connected to Amazon Bedrock Titan Embed Text V2 — Lambda functions send and receive plain text only.", + "A hybrid search pipeline applies min-max score normalization to combine BM25 (lexical) and k-NN (semantic) results with configurable weights." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/apigw-lambda-opensearch-serverless-nextgen", + "templateURL": "serverless-patterns/apigw-lambda-opensearch-serverless-nextgen", + "projectFolder": "apigw-lambda-opensearch-serverless-nextgen", + "templateFile": "template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "Amazon OpenSearch Serverless", + "link": "https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless.html" + }, + { + "text": "OpenSearch neural search", + "link": "https://opensearch.org/docs/latest/search-plugins/neural-search/" + }, + { + "text": "Amazon Bedrock Titan Embeddings", + "link": "https://docs.aws.amazon.com/bedrock/latest/userguide/titan-embedding-models.html" + } + ] + }, + "deploy": { + "text": [ + "sam build", + "sam deploy --guided" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: sam delete --stack-name STACK_NAME." + ] + }, + "authors": [ + { + "name": "Pete Davis", + "image": "https://github.com/pjdavis-aws.png", + "bio": "Senior Partner Solution Architect at AWS", + "linkedin": "peter-davis-2676585" + } + ] +} diff --git a/apigw-lambda-opensearch-serverless-nextgen/images/architecture.drawio b/apigw-lambda-opensearch-serverless-nextgen/images/architecture.drawio new file mode 100644 index 000000000..007d80c1b --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/images/architecture.drawio @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apigw-lambda-opensearch-serverless-nextgen/images/architecture.png b/apigw-lambda-opensearch-serverless-nextgen/images/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..94226166c6eb896315569aab7c8917c034f5a783 GIT binary patch literal 52867 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 literal 0 HcmV?d00001 diff --git a/apigw-lambda-opensearch-serverless-nextgen/images/search-acu-scaling.png b/apigw-lambda-opensearch-serverless-nextgen/images/search-acu-scaling.png new file mode 100644 index 0000000000000000000000000000000000000000..aa7a4b00416b7b38b7047b37c30bbc17134c318e GIT binary patch literal 120681 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�vH^f!}Dgj2qFDZVz1o#9ZTItc}ukoI7!z& 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.43.17 diff --git a/apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/setup_pipeline/app.py b/apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/setup_pipeline/app.py new file mode 100644 index 000000000..d58e49b1e --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/setup_pipeline/app.py @@ -0,0 +1,260 @@ +"""Custom Resource handler to set up ML model and pipelines at deploy time. + +Creates: +- A Bedrock Titan V2 ML connector +- A registered and deployed ML model +- An ingest pipeline with text_embedding processor +- A hybrid search pipeline with min-max normalization + +Properties: + CollectionEndpoint: The AOSS collection endpoint URL + ModelRoleArn: IAM role ARN for OpenSearch ML to call Bedrock + EmbeddingModelId: Bedrock model ID (e.g. amazon.titan-embed-text-v2:0) + EmbeddingDimension: Vector dimension (e.g. 1024) +""" + +from __future__ import annotations + +import logging +import os +import time + +from opensearch_client import get_client +from crhelper import CfnResource + +logger = logging.getLogger(__name__) + +helper = CfnResource(json_logging=True, log_level="INFO", sleep_on_delete=30) + +SEARCH_PIPELINE_NAME = "hybrid-search-pipeline" +INGEST_PIPELINE_NAME = "embedding-ingest-pipeline" +REGION = os.environ.get("AWS_REGION") + + +def _wait_for_model_deployed(client, model_id, max_attempts=30, delay=5): + """Poll model status until deployed or timeout.""" + for _ in range(max_attempts): + resp = client.transport.perform_request( + "GET", f"/_plugins/_ml/models/{model_id}" + ) + status = resp.get("model_state") + if status == "DEPLOYED": + return True + if status in ("DEPLOY_FAILED", "REGISTER_FAILED"): + raise RuntimeError(f"Model {model_id} failed with state: {status}") + time.sleep(delay) + raise TimeoutError(f"Model {model_id} did not reach DEPLOYED state") + + +def _retry_with_backoff(func, max_attempts=6, initial_delay=10): + """Retry a function with exponential backoff for auth propagation.""" + delay = initial_delay + last_error = None + for attempt in range(max_attempts): + try: + return func() + except Exception as e: + last_error = e + error_str = str(e) + # Only retry on authorization/forbidden errors (policy propagation) + if "403" in error_str or "Forbidden" in error_str or "Authorization" in error_str: + logger.warning( + "Authorization error on attempt %d/%d, retrying in %ds: %s", + attempt + 1, max_attempts, delay, error_str, + ) + time.sleep(delay) + delay = min(delay * 2, 60) + else: + raise + raise last_error + + +def _setup_ml_and_pipelines(event): + """Core logic shared by create and update handlers.""" + props = event["ResourceProperties"] + endpoint = props["CollectionEndpoint"] + model_role_arn = props["ModelRoleArn"] + embedding_model_id = props.get("EmbeddingModelId", "amazon.titan-embed-text-v2:0") + embedding_dimension = int(props.get("EmbeddingDimension", "1024")) + + client = get_client(endpoint, REGION) + + # Step 1: Create the Bedrock connector (with retry for policy propagation) + connector_body = { + "name": "Bedrock Titan Embeddings V2", + "description": "Connector for Amazon Bedrock Titan Text Embeddings V2", + "version": "1.0", + "protocol": "aws_sigv4", + "credential": { + "roleArn": model_role_arn, + }, + "parameters": { + "region": REGION, + "service_name": "bedrock", + "model": embedding_model_id, + }, + "actions": [ + { + "action_type": "predict", + "method": "POST", + "headers": {"content-type": "application/json"}, + "url": f"https://bedrock-runtime.{REGION}.amazonaws.com/model/{embedding_model_id}/invoke", + "request_body": '{"inputText": "${parameters.inputText}", "dimensions": ' + + str(embedding_dimension) + + ', "normalize": true}', + "pre_process_function": "connector.pre_process.bedrock.embedding", + "post_process_function": "connector.post_process.bedrock.embedding", + } + ], + } + + connector_resp = _retry_with_backoff( + lambda: client.transport.perform_request( + "POST", "/_plugins/_ml/connectors/_create", body=connector_body + ) + ) + connector_id = connector_resp["connector_id"] + logger.info("Created connector: %s", connector_id) + + # Step 2: Register the model + register_body = { + "name": "Bedrock Titan Embed V2", + "function_name": "remote", + "description": "Titan Text Embeddings V2 via Bedrock connector", + "connector_id": connector_id, + } + + register_resp = client.transport.perform_request( + "POST", "/_plugins/_ml/models/_register", body=register_body + ) + model_id = register_resp["model_id"] + logger.info("Registered model: %s", model_id) + + # Step 3: Deploy the model + client.transport.perform_request( + "POST", f"/_plugins/_ml/models/{model_id}/_deploy" + ) + logger.info("Deploy initiated for model: %s", model_id) + + # Wait for deployment + _wait_for_model_deployed(client, model_id) + logger.info("Model deployed successfully: %s", model_id) + + # Step 4: Create the ingest pipeline with text_embedding processor + ingest_pipeline_body = { + "description": "Ingest pipeline that generates embeddings via Bedrock Titan V2", + "processors": [ + { + "text_embedding": { + "model_id": model_id, + "field_map": { + "embedding_text": "embedding", + }, + } + } + ], + } + + client.transport.perform_request( + "PUT", + f"/_ingest/pipeline/{INGEST_PIPELINE_NAME}", + body=ingest_pipeline_body, + ) + logger.info("Created ingest pipeline: %s", INGEST_PIPELINE_NAME) + + # Step 5: Create/update the search pipeline with normalization + search_pipeline_body = { + "description": "Normalization pipeline for hybrid search", + "phase_results_processors": [ + { + "normalization-processor": { + "normalization": {"technique": "min_max"}, + "combination": { + "technique": "arithmetic_mean", + "parameters": {"weights": [0.3, 0.7]}, + }, + } + } + ], + } + + client.transport.perform_request( + "PUT", + f"/_search/pipeline/{SEARCH_PIPELINE_NAME}", + body=search_pipeline_body, + ) + logger.info("Created search pipeline: %s", SEARCH_PIPELINE_NAME) + + # Store outputs for !GetAtt + helper.Data["SearchPipeline"] = SEARCH_PIPELINE_NAME + helper.Data["IngestPipeline"] = INGEST_PIPELINE_NAME + helper.Data["ModelId"] = model_id + helper.Data["ConnectorId"] = connector_id + + return f"{connector_id}/{model_id}" + + +@helper.create +def on_create(event, _context): + """Create ML connector, model, ingest pipeline, and search pipeline.""" + return _setup_ml_and_pipelines(event) + + +@helper.update +def on_update(event, _context): + """Update recreates all ML resources and pipelines.""" + return _setup_ml_and_pipelines(event) + + +@helper.delete +def on_delete(event, _context): + """Delete pipelines and ML resources on stack deletion.""" + props = event["ResourceProperties"] + endpoint = props["CollectionEndpoint"] + + client = get_client(endpoint, REGION) + + # Delete ingest pipeline + try: + client.transport.perform_request( + "DELETE", f"/_ingest/pipeline/{INGEST_PIPELINE_NAME}" + ) + except Exception: + logger.warning("Failed to delete ingest pipeline", exc_info=True) + + # Delete search pipeline + try: + client.transport.perform_request( + "DELETE", f"/_search/pipeline/{SEARCH_PIPELINE_NAME}" + ) + except Exception: + logger.warning("Failed to delete search pipeline", exc_info=True) + + # Undeploy and delete model (best effort from physical resource ID) + physical_id = event.get("PhysicalResourceId", "") + if "/" in physical_id: + connector_id, model_id = physical_id.split("/", 1) + try: + client.transport.perform_request( + "POST", f"/_plugins/_ml/models/{model_id}/_undeploy" + ) + time.sleep(5) + client.transport.perform_request( + "DELETE", f"/_plugins/_ml/models/{model_id}" + ) + except Exception: + logger.warning("Failed to delete model %s", model_id, exc_info=True) + + try: + client.transport.perform_request( + "DELETE", f"/_plugins/_ml/connectors/{connector_id}" + ) + except Exception: + logger.warning( + "Failed to delete connector %s", connector_id, exc_info=True + ) + + +def lambda_handler(event, context): + """Main Lambda handler — delegates to crhelper.""" + helper(event, context) diff --git a/apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/setup_pipeline/requirements.txt b/apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/setup_pipeline/requirements.txt new file mode 100644 index 000000000..832072fd1 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/setup_pipeline/requirements.txt @@ -0,0 +1 @@ +crhelper diff --git a/apigw-lambda-opensearch-serverless-nextgen/lambda/delete_documents/app.py b/apigw-lambda-opensearch-serverless-nextgen/lambda/delete_documents/app.py new file mode 100644 index 000000000..73557202b --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/lambda/delete_documents/app.py @@ -0,0 +1,63 @@ +"""Lambda handler for deleting documents from OpenSearch Serverless NextGen.""" + +import json +import os + +from aws_lambda_powertools import Logger, Tracer +from opensearch_client import get_client + +logger = Logger() +tracer = Tracer() + +INDEX_NAME = os.environ.get("INDEX_NAME", "documents") +COLLECTION_ENDPOINT = os.environ["COLLECTION_ENDPOINT"] +REGION = os.environ.get("AWS_REGION", "eu-west-1") + + +@tracer.capture_lambda_handler +@logger.inject_lambda_context +def handler(event, context): + """Delete one or more documents by ID. + + Expected request body: + { + "ids": ["doc-1", "doc-2", ...] + } + """ + try: + body = json.loads(event.get("body", "{}")) + doc_ids = body.get("ids", []) + + if not doc_ids: + return { + "statusCode": 400, + "body": json.dumps({"error": "No document IDs provided"}), + } + + client = get_client(COLLECTION_ENDPOINT, REGION) + + bulk_body = [] + for doc_id in doc_ids: + bulk_body.append({"delete": {"_index": INDEX_NAME, "_id": doc_id}}) + + response = client.bulk(body=bulk_body) + + logger.info("Deleted documents", extra={"count": len(doc_ids), "errors": response.get("errors", False)}) + + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps( + { + "message": f"Deleted {len(doc_ids)} document(s)", + "errors": response.get("errors", False), + } + ), + } + + except Exception as e: + logger.exception("Error deleting documents") + return { + "statusCode": 500, + "body": json.dumps({"error": str(e)}), + } diff --git a/apigw-lambda-opensearch-serverless-nextgen/lambda/delete_documents/requirements.txt b/apigw-lambda-opensearch-serverless-nextgen/lambda/delete_documents/requirements.txt new file mode 100644 index 000000000..bd9f77520 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/lambda/delete_documents/requirements.txt @@ -0,0 +1 @@ +# Dependencies provided by OpenSearchClientLayer diff --git a/apigw-lambda-opensearch-serverless-nextgen/lambda/index_documents/app.py b/apigw-lambda-opensearch-serverless-nextgen/lambda/index_documents/app.py new file mode 100644 index 000000000..1148b9707 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/lambda/index_documents/app.py @@ -0,0 +1,105 @@ +"""Lambda handler for indexing documents into OpenSearch Serverless NextGen. + +Documents are indexed with a text field that the OpenSearch ingest pipeline +automatically converts to embeddings via Bedrock Titan V2. No client-side +embedding generation is needed. +""" + +import json +import os + +from aws_lambda_powertools import Logger, Tracer +from opensearch_client import get_client + +logger = Logger() +tracer = Tracer() + +INDEX_NAME = os.environ.get("INDEX_NAME", "documents") +COLLECTION_ENDPOINT = os.environ["COLLECTION_ENDPOINT"] +INGEST_PIPELINE = os.environ.get("INGEST_PIPELINE", "embedding-ingest-pipeline") +REGION = os.environ.get("AWS_REGION", "eu-west-1") + + +@tracer.capture_lambda_handler +@logger.inject_lambda_context +def handler(event, context): + """Index one or more documents. + + Expected request body: + { + "documents": [ + { + "id": "doc-1", + "title": "Example Document", + "content": "Full text content..." + } + ] + } + + Embeddings are generated automatically by the OpenSearch ingest pipeline. + """ + try: + body = json.loads(event.get("body", "{}")) + documents = body.get("documents", []) + + if not documents: + return { + "statusCode": 400, + "body": json.dumps({"error": "No documents provided"}), + } + + client = get_client(COLLECTION_ENDPOINT, REGION) + + # Build bulk request — the ingest pipeline handles embedding generation + bulk_body = [] + for doc in documents: + doc_id = doc.get("id") + title = doc.get("title", "") + content = doc.get("content", "") + + # Combine title and content for the embedding source field + embedding_text = f"{title}. {content}" if title else content + + bulk_body.append({"index": {"_index": INDEX_NAME, "_id": doc_id}}) + bulk_body.append( + { + "title": title, + "content": content, + "embedding_text": embedding_text, + } + ) + + # Use the ingest pipeline to auto-generate embeddings + response = client.bulk(body=bulk_body, pipeline=INGEST_PIPELINE) + + # Extract individual item errors for debugging + item_errors = [] + if response.get("errors"): + for item in response.get("items", []): + for action, result in item.items(): + if "error" in result: + item_errors.append({"id": result.get("_id"), "error": result["error"]}) + + logger.info("Indexed documents", extra={ + "count": len(documents), + "errors": response.get("errors", False), + }) + + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps( + { + "message": f"Indexed {len(documents)} document(s)", + "errors": response.get("errors", False), + "item_errors": item_errors[:5] if item_errors else [], + } + ), + } + + except Exception as e: + logger.exception("Error indexing documents") + return { + "statusCode": 500, + "body": json.dumps({"error": str(e)}), + } diff --git a/apigw-lambda-opensearch-serverless-nextgen/lambda/index_documents/requirements.txt b/apigw-lambda-opensearch-serverless-nextgen/lambda/index_documents/requirements.txt new file mode 100644 index 000000000..bd9f77520 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/lambda/index_documents/requirements.txt @@ -0,0 +1 @@ +# Dependencies provided by OpenSearchClientLayer diff --git a/apigw-lambda-opensearch-serverless-nextgen/lambda/search/app.py b/apigw-lambda-opensearch-serverless-nextgen/lambda/search/app.py new file mode 100644 index 000000000..9bdc17b37 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/lambda/search/app.py @@ -0,0 +1,159 @@ +"""Lambda handler for search against OpenSearch Serverless NextGen. + +Supports three search modes: +- semantic: neural search using OpenSearch ML model (Bedrock Titan V2) +- lexical: BM25 full-text match query with fuzzy matching +- hybrid: combines both with score normalization pipeline + +Embeddings are generated server-side by the OpenSearch ML model — no +client-side embedding generation is needed. +""" + +import json +import os + +from aws_lambda_powertools import Logger, Tracer +from opensearch_client import get_client + +logger = Logger() +tracer = Tracer() + +INDEX_NAME = os.environ.get("INDEX_NAME", "documents") +COLLECTION_ENDPOINT = os.environ["COLLECTION_ENDPOINT"] +MODEL_ID = os.environ["MODEL_ID"] +REGION = os.environ.get("AWS_REGION", "eu-west-1") + + +@tracer.capture_lambda_handler +@logger.inject_lambda_context +def handler(event, context): + """Perform search in the specified mode. + + Expected request body: + { + "query": "search terms", + "mode": "semantic" | "lexical" | "hybrid", // optional, default "semantic" + "size": 5 // optional, default 5 + } + """ + try: + body = json.loads(event.get("body", "{}")) + query_text = body.get("query") + mode = body.get("mode", "semantic") + size = body.get("size", 5) + + if not query_text: + return { + "statusCode": 400, + "body": json.dumps({"error": "'query' is required"}), + } + + if mode not in ("semantic", "lexical", "hybrid"): + return { + "statusCode": 400, + "body": json.dumps({"error": "mode must be 'semantic', 'lexical', or 'hybrid'"}), + } + + client = get_client(COLLECTION_ENDPOINT, REGION) + search_body = _build_query(query_text, mode, size) + + params = {} + if mode == "hybrid": + params["search_pipeline"] = "hybrid-search-pipeline" + + response = client.search(index=INDEX_NAME, body=search_body, params=params) + + hits = [ + { + "id": hit["_id"], + "score": hit["_score"], + "title": hit["_source"].get("title"), + "content": hit["_source"].get("content"), + } + for hit in response["hits"]["hits"] + ] + + logger.info("Search completed", extra={ + "mode": mode, + "total_hits": response["hits"]["total"]["value"], + }) + + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps( + { + "results": hits, + "total": response["hits"]["total"]["value"], + "mode": mode, + } + ), + } + + except Exception as e: + logger.exception("Error performing search") + return { + "statusCode": 500, + "body": json.dumps({"error": str(e)}), + } + + +def _build_query(query_text, mode, size): + """Build the OpenSearch query body based on mode.""" + if mode == "hybrid": + return _build_hybrid_query(query_text, size) + + if mode == "lexical": + return { + "size": size, + "query": { + "multi_match": { + "query": query_text, + "fields": ["title^2", "content"], + "fuzziness": 1, + } + }, + } + + # semantic — uses neural query (OpenSearch generates embedding server-side) + return { + "size": size, + "min_score": 0.55, + "query": { + "neural": { + "embedding": { + "query_text": query_text, + "model_id": MODEL_ID, + "k": size, + } + } + }, + } + + +def _build_hybrid_query(query_text, size): + """Build a hybrid query combining lexical and neural search.""" + return { + "size": size, + "query": { + "hybrid": { + "queries": [ + { + "multi_match": { + "query": query_text, + "fields": ["title^2", "content"], + } + }, + { + "neural": { + "embedding": { + "query_text": query_text, + "model_id": MODEL_ID, + "k": size, + } + } + }, + ] + } + }, + } diff --git a/apigw-lambda-opensearch-serverless-nextgen/lambda/search/requirements.txt b/apigw-lambda-opensearch-serverless-nextgen/lambda/search/requirements.txt new file mode 100644 index 000000000..bd9f77520 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/lambda/search/requirements.txt @@ -0,0 +1 @@ +# Dependencies provided by OpenSearchClientLayer diff --git a/apigw-lambda-opensearch-serverless-nextgen/layers/opensearch_client/__init__.py b/apigw-lambda-opensearch-serverless-nextgen/layers/opensearch_client/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apigw-lambda-opensearch-serverless-nextgen/layers/opensearch_client/opensearch_client.py b/apigw-lambda-opensearch-serverless-nextgen/layers/opensearch_client/opensearch_client.py new file mode 100644 index 000000000..718cbd87f --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/layers/opensearch_client/opensearch_client.py @@ -0,0 +1,33 @@ +"""Shared OpenSearch Serverless client configuration.""" + +import boto3 +from opensearchpy import OpenSearch, RequestsHttpConnection +from requests_aws4auth import AWS4Auth + + +def get_client(endpoint: str, region: str) -> OpenSearch: + """Create an OpenSearch client authenticated with SigV4 for AOSS. + + Args: + endpoint: The collection endpoint URL (https://...). + region: AWS region code (e.g. eu-west-1). + """ + host = endpoint.replace("https://", "").rstrip("/") + + credentials = boto3.Session().get_credentials() + auth = AWS4Auth( + credentials.access_key, + credentials.secret_key, + region, + "aoss", + session_token=credentials.token, + ) + + return OpenSearch( + hosts=[{"host": host, "port": 443}], + http_auth=auth, + use_ssl=True, + verify_certs=True, + connection_class=RequestsHttpConnection, + timeout=25, + ) diff --git a/apigw-lambda-opensearch-serverless-nextgen/layers/opensearch_client/requirements.txt b/apigw-lambda-opensearch-serverless-nextgen/layers/opensearch_client/requirements.txt new file mode 100644 index 000000000..9824a37fd --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/layers/opensearch_client/requirements.txt @@ -0,0 +1,3 @@ +opensearch-py>=2.8.0 +requests-aws4auth>=1.3.2 +requests>=2.34.2 diff --git a/apigw-lambda-opensearch-serverless-nextgen/mise.toml b/apigw-lambda-opensearch-serverless-nextgen/mise.toml new file mode 100644 index 000000000..90d0ae997 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/mise.toml @@ -0,0 +1,93 @@ +[env] +_.python.venv = { path = ".venv", create = true } +AWS_REGION = "eu-west-1" +STACK_NAME = "lambda-aoss-nextgen" +ENABLE_API_ACCESS_LOGS = "false" +COLLECTION_GROUP_NAME = "{{exec(command='echo cg-$STACK_NAME | tr A-Z a-z')}}" +COLLECTION_NAME = "{{exec(command='echo col-$STACK_NAME | tr A-Z a-z')}}" + +[tools] +python = "3.14" +uv = "latest" +aws-sam = "latest" + +[tasks] + +[tasks.init] +description = "Install all Lambda, layer, and test dependencies into the local .venv" +run = """ +find lambda layers tests -name 'requirements.txt' | while read -r req; do + echo "Installing ${req}..." + uv pip install --upgrade -r "${req}" +done +echo "Installing ./requirements.txt" +uv pip install --upgrade -r requirements.txt +""" + +[tasks.clean] +description = "Remove all generated and temporary files" +run = """ +rm -rf .aws-sam/ .pytest_cache/ __pycache__/ .venv/ +find . -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true +find . -type f -name '*.py[cod]' -delete 2>/dev/null || true +""" + +[tasks."sam:validate"] +description = "Validate the SAM template against AWS CloudFormation rules and lint for best practices" +run = "sam validate --lint" + +[tasks."sam:build"] +description = "Build the SAM application artifacts (validates template first)" +depends = "sam:validate" +run = "sam build" + +[tasks."sam:deploy"] +description = "Deploy the SAM application to AWS without requiring changeset confirmation (builds first)" +depends = "sam:build" +run = """ + sam deploy --stack-name ${STACK_NAME} \ + --parameter-overrides EnableApiAccessLogs=${ENABLE_API_ACCESS_LOGS} \ + CollectionGroupName=${COLLECTION_GROUP_NAME} \ + CollectionName=${COLLECTION_NAME} +""" + +[tasks."sam:sync"] +description = "Watch for file changes and automatically sync code and infra to AWS" +run = """sam sync --stack-name ${STACK_NAME} \ + --watch \ + --parameter-overrides EnableApiAccessLogs=${ENABLE_API_ACCESS_LOGS} \ + CollectionGroupName=${COLLECTION_GROUP_NAME} \ + CollectionName=${COLLECTION_NAME} +""" + +[tasks."sam:delete"] +description = "Delete the SAM stack and all associated resources from AWS" +depends_post = "clean:loggroups" +run = "sam delete --no-prompts --stack-name ${STACK_NAME}" + +[tasks."clean:loggroups"] +description = "Delete log groups that may have been recreated as part of the delete process" +run = """ +aws logs describe-log-groups \ + --log-group-name-prefix "/aws/lambda/${STACK_NAME}-" \ + --query 'logGroups[].logGroupName' --output text \ + | tr '\t' '\n' \ + | while read -r lg; do + [ -z "${lg}" ] && continue + echo "Deleting log group ${lg}" + aws logs delete-log-group --log-group-name "${lg}" + done +""" + +[tasks."test:unit"] +description = "Run unit tests" +run = "pytest tests/unit/ -v" + +[tasks."test:integration"] +description = "Run integration tests against the deployed stack" +run = "pytest tests/integration/ -v -s" + +[tasks.test] +description = "Run all tests (unit and integration)" +depends = "test:unit" +depends_post = "test:integration" diff --git a/apigw-lambda-opensearch-serverless-nextgen/requirements.txt b/apigw-lambda-opensearch-serverless-nextgen/requirements.txt new file mode 100644 index 000000000..8e9837f55 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/requirements.txt @@ -0,0 +1 @@ +awscurl>=0.32 diff --git a/apigw-lambda-opensearch-serverless-nextgen/template.yaml b/apigw-lambda-opensearch-serverless-nextgen/template.yaml new file mode 100644 index 000000000..4cbf3e06a --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/template.yaml @@ -0,0 +1,630 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + Serverless Search API — Lambda + OpenSearch Serverless NextGen. + Demonstrates zero-baseline cost with both Lambda and OpenSearch scaling to zero when idle. + +Parameters: + CollectionName: + Type: String + Default: semantic-search + Description: Name of the OpenSearch Serverless collection + AllowedPattern: '^[a-z][a-z0-9-]{2,27}$' + + CollectionGroupName: + Type: String + Default: semantic-search-cg + Description: Name of the OpenSearch Serverless collection group + AllowedPattern: '^[a-z][a-z0-9-]{2,31}$' + + LogRetentionDays: + Type: Number + Default: 7 + Description: Number of days to retain CloudWatch log events + AllowedValues: [1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653] + + EnableApiAccessLogs: + Type: String + Default: 'false' + Description: > + Enable API Gateway access logging. Prerequisite: the account-level API Gateway + CloudWatch Logs role must already be configured before setting this to true. + AllowedValues: ['true', 'false'] + +Conditions: + ApiAccessLogsEnabled: !Equals [!Ref EnableApiAccessLogs, 'true'] + +Globals: + Function: + Timeout: 30 + MemorySize: 256 + Runtime: python3.14 + Architectures: + - arm64 + Tracing: Active + Environment: + Variables: + POWERTOOLS_SERVICE_NAME: opensearch-nextgen + POWERTOOLS_METRICS_NAMESPACE: opensearch-nextgen + Layers: + - !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPythonV3-python314-arm64:33 + +Resources: + # ============================================================ + # API Gateway + # ============================================================ + + SearchApi: + Type: AWS::Serverless::Api + Metadata: + cfn_nag: + rules_to_suppress: + - id: W64 + reason: "Sample application — usage plan not required for demonstration purposes" + - id: W68 + reason: "Sample application — usage plan not required for demonstration purposes" + - id: W69 + reason: "Sample application — access logging controlled by EnableApiAccessLogs parameter" + Properties: + StageName: Prod + TracingEnabled: true + Auth: + DefaultAuthorizer: AWS_IAM + AccessLogSetting: !If + - ApiAccessLogsEnabled + - DestinationArn: !GetAtt ApiAccessLogGroup.Arn + Format: '{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","caller":"$context.identity.caller","user":"$context.identity.user","requestTime":"$context.requestTime","httpMethod":"$context.httpMethod","resourcePath":"$context.resourcePath","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength"}' + - !Ref AWS::NoValue + + # ============================================================ + # API Gateway Access Logging (conditional) + # Prerequisite: The account-level API Gateway CloudWatch Logs role must be configured + # before enabling. Set via AWS Console (API Gateway > Settings) or a separate stack. + # ============================================================ + + ApiAccessLogGroup: + Type: AWS::Logs::LogGroup + Condition: ApiAccessLogsEnabled + DeletionPolicy: Delete + UpdateReplacePolicy: Delete + Metadata: + cfn_nag: + rules_to_suppress: + - id: W84 + reason: "Sample application — KMS encryption not required for demonstration log data" + Properties: + LogGroupName: !Sub /aws/apigateway/${AWS::StackName}-access-logs + RetentionInDays: !Ref LogRetentionDays + + # ============================================================ + # Shared Lambda Layer + # ============================================================ + + OpenSearchClientLayer: + Type: AWS::Serverless::LayerVersion + DeletionPolicy: Delete + UpdateReplacePolicy: Delete + Properties: + LayerName: opensearch-client + Description: OpenSearch Serverless client with SigV4 auth + ContentUri: layers/opensearch_client/ + CompatibleRuntimes: + - python3.14 + CompatibleArchitectures: + - arm64 + Metadata: + BuildMethod: python3.14 + BuildArchitecture: arm64 + + # ============================================================ + # Custom Resource — NextGen Collection Group + # ============================================================ + + CollectionGroupFunctionLogGroup: + Type: AWS::Logs::LogGroup + DeletionPolicy: Delete + UpdateReplacePolicy: Delete + Metadata: + cfn_nag: + rules_to_suppress: + - id: W84 + reason: "Sample application — KMS encryption not required for demonstration log data" + Properties: + LogGroupName: !Sub /aws/lambda/${AWS::StackName}-CollectionGroupFunction + RetentionInDays: !Ref LogRetentionDays + + CollectionGroupFunction: + Type: AWS::Serverless::Function + Metadata: + cfn_nag: + rules_to_suppress: + - id: W89 + reason: "Sample application — VPC deployment not required for demonstration purposes" + - id: W92 + reason: "Sample application — reserved concurrency not required for demonstration purposes" + Properties: + Handler: app.lambda_handler + CodeUri: lambda/custom_resources/nextgen_collection_group/ + Description: Custom resource handler for NextGen collection group + Timeout: 60 + LoggingConfig: + LogGroup: !Ref CollectionGroupFunctionLogGroup + Policies: + - Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - aoss:CreateCollectionGroup + - aoss:UpdateCollectionGroup + - aoss:DeleteCollectionGroup + Resource: !Sub 'arn:${AWS::Partition}:aoss:${AWS::Region}:${AWS::AccountId}:collection-group/*' + - Effect: Allow + Action: + - aoss:BatchGetCollectionGroup + Resource: !Sub 'arn:${AWS::Partition}:aoss:${AWS::Region}:${AWS::AccountId}:collection-group/*' + Environment: + Variables: + POWERTOOLS_SERVICE_NAME: opensearch-nextgen-cr + + CollectionGroup: + Type: AWS::CloudFormation::CustomResource + Properties: + ServiceToken: !GetAtt CollectionGroupFunction.Arn + ServiceTimeout: '120' + Name: !Ref CollectionGroupName + Description: Collection group for semantic search sample — NextGen with scale-to-zero + MaxIndexingCapacityInOCU: '8' + MaxSearchCapacityInOCU: '8' + + # ============================================================ + # OpenSearch Serverless — Security Policies & Collection + # ============================================================ + + EncryptionPolicy: + Type: AWS::OpenSearchServerless::SecurityPolicy + Properties: + Name: !Sub '${AWS::StackName}-enc' + Type: encryption + Description: Encryption policy for semantic search collection + Policy: !Sub | + { + "Rules": [ + { + "ResourceType": "collection", + "Resource": ["collection/${CollectionName}"] + } + ], + "AWSOwnedKey": true + } + + NetworkPolicy: + Type: AWS::OpenSearchServerless::SecurityPolicy + Properties: + Name: !Sub '${AWS::StackName}-net' + Type: network + Description: Network policy — public access with semantic enrichment service access + Policy: !Sub | + [ + { + "Rules": [ + { + "ResourceType": "collection", + "Resource": ["collection/${CollectionName}"] + }, + { + "ResourceType": "dashboard", + "Resource": ["collection/${CollectionName}"] + } + ], + "AllowFromPublic": true + }, + { + "Rules": [ + { + "ResourceType": "collection", + "Resource": ["collection/${CollectionName}"] + } + ], + "AllowFromPublic": false, + "SourceServices": ["aoss.amazonaws.com"] + } + ] + + DataAccessPolicy: + Type: AWS::OpenSearchServerless::AccessPolicy + Properties: + Name: !Sub '${AWS::StackName}-access' + Type: data + Description: Data access policy for Lambda function role + Policy: !Sub + - | + [ + { + "Description": "Search read and ML execute access", + "Rules": [{"ResourceType": "index", "Resource": ["index/${CollectionName}/*"], "Permission": ["aoss:DescribeIndex", "aoss:ReadDocument"]}, {"ResourceType": "model", "Resource": ["model/${CollectionName}/*"], "Permission": ["aoss:DescribeMLResource", "aoss:ExecuteMLResource"]}], + "Principal": ["${SearchRoleArn}"] + }, + { + "Description": "Index write access", + "Rules": [{"ResourceType": "index", "Resource": ["index/${CollectionName}/*"], "Permission": ["aoss:CreateIndex", "aoss:DescribeIndex", "aoss:UpdateIndex", "aoss:ReadDocument", "aoss:WriteDocument"]}], + "Principal": ["${IndexRoleArn}"] + }, + { + "Description": "Delete write access", + "Rules": [{"ResourceType": "index", "Resource": ["index/${CollectionName}/*"], "Permission": ["aoss:DescribeIndex", "aoss:WriteDocument"]}], + "Principal": ["${DeleteRoleArn}"] + }, + { + "Description": "Index pipeline and ML full access", + "Rules": [{"ResourceType": "collection", "Resource": ["collection/${CollectionName}"], "Permission": ["aoss:CreateCollectionItems", "aoss:DescribeCollectionItems", "aoss:UpdateCollectionItems"]}, {"ResourceType": "model", "Resource": ["model/${CollectionName}/*"], "Permission": ["aoss:CreateMLResource", "aoss:DescribeMLResource", "aoss:UpdateMLResource", "aoss:DeleteMLResource", "aoss:ExecuteMLResource"]}], + "Principal": ["${IndexRoleArn}"] + }, + { + "Description": "Admin full access", + "Rules": [{"ResourceType": "index", "Resource": ["index/${CollectionName}/*"], "Permission": ["aoss:CreateIndex", "aoss:DescribeIndex", "aoss:UpdateIndex", "aoss:DeleteIndex", "aoss:ReadDocument", "aoss:WriteDocument"]}, {"ResourceType": "model", "Resource": ["model/${CollectionName}/*"], "Permission": ["aoss:CreateMLResource", "aoss:DescribeMLResource", "aoss:UpdateMLResource", "aoss:DeleteMLResource", "aoss:ExecuteMLResource"]}], + "Principal": ["arn:aws:iam::${AWS::AccountId}:role/Admin"] + } + ] + - SearchRoleArn: !GetAtt SearchFunctionRole.Arn + IndexRoleArn: !GetAtt IndexFunctionRole.Arn + DeleteRoleArn: !GetAtt DeleteFunctionRole.Arn + + Collection: + Type: AWS::OpenSearchServerless::Collection + DependsOn: + - EncryptionPolicy + - NetworkPolicy + - DataAccessPolicy + - CollectionGroup + Properties: + Name: !Ref CollectionName + Type: VECTORSEARCH + Description: NextGen vector search collection for semantic search API + CollectionGroupName: !Ref CollectionGroupName + + # ============================================================ + # ML Model Role — allows OpenSearch ML to invoke Bedrock + # ============================================================ + + OpenSearchMLRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - opensearchservice.amazonaws.com + - ml.opensearchservice.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: BedrockInvoke + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + Resource: !Sub 'arn:aws:bedrock:${AWS::Region}::foundation-model/amazon.titan-embed-text-v2:0' + + # ============================================================ + # Index & Pipeline Setup (runs at deploy time) + # ============================================================ + + SetupPipelineFunctionLogGroup: + Type: AWS::Logs::LogGroup + DeletionPolicy: Delete + UpdateReplacePolicy: Delete + Metadata: + cfn_nag: + rules_to_suppress: + - id: W84 + reason: "Sample application — KMS encryption not required for demonstration log data" + Properties: + LogGroupName: !Sub /aws/lambda/${AWS::StackName}-SetupPipelineFunction + RetentionInDays: !Ref LogRetentionDays + + SetupPipelineFunction: + Type: AWS::Serverless::Function + Metadata: + cfn_nag: + rules_to_suppress: + - id: W89 + reason: "Sample application — VPC deployment not required for demonstration purposes" + - id: W92 + reason: "Sample application — reserved concurrency not required for demonstration purposes" + Properties: + Handler: app.lambda_handler + CodeUri: lambda/custom_resources/setup_pipeline/ + Description: Creates ML model, ingest pipeline, and search pipeline at deploy time + Timeout: 300 + Role: !GetAtt IndexFunctionRole.Arn + LoggingConfig: + LogGroup: !Ref SetupPipelineFunctionLogGroup + Layers: + - !Ref OpenSearchClientLayer + Environment: + Variables: + POWERTOOLS_SERVICE_NAME: opensearch-nextgen-setup + + VectorIndex: + Type: AWS::OpenSearchServerless::Index + Properties: + CollectionEndpoint: !GetAtt Collection.CollectionEndpoint + IndexName: documents + Mappings: + Properties: + title: + Type: text + content: + Type: text + embedding_text: + Type: text + embedding: + Type: knn_vector + Dimension: 1024 + Method: + Name: hnsw + SpaceType: cosinesimil + Settings: + Index: + Knn: true + + SetupSearchPipeline: + Type: AWS::CloudFormation::CustomResource + DependsOn: VectorIndex + Properties: + ServiceToken: !GetAtt SetupPipelineFunction.Arn + ServiceTimeout: '300' + CollectionEndpoint: !GetAtt Collection.CollectionEndpoint + ModelRoleArn: !GetAtt OpenSearchMLRole.Arn + EmbeddingModelId: amazon.titan-embed-text-v2:0 + EmbeddingDimension: '1024' + + # ============================================================ + # Lambda Functions + # ============================================================ + + SearchFunctionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess + Policies: + - PolicyName: OpenSearchServerlessRead + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - aoss:APIAccessAll + Resource: !Sub 'arn:aws:aoss:${AWS::Region}:${AWS::AccountId}:collection/*' + + SearchFunctionLogGroup: + Type: AWS::Logs::LogGroup + DeletionPolicy: Delete + UpdateReplacePolicy: Delete + Metadata: + cfn_nag: + rules_to_suppress: + - id: W84 + reason: "Sample application — KMS encryption not required for demonstration log data" + Properties: + LogGroupName: !Sub /aws/lambda/${AWS::StackName}-SearchFunction + RetentionInDays: !Ref LogRetentionDays + + SearchFunction: + Type: AWS::Serverless::Function + Metadata: + cfn_nag: + rules_to_suppress: + - id: W89 + reason: "Sample application — VPC deployment not required for demonstration purposes" + - id: W92 + reason: "Sample application — reserved concurrency not required for demonstration purposes" + Properties: + Handler: app.handler + CodeUri: lambda/search/ + Description: Performs semantic, lexical, and hybrid search (neural queries via OpenSearch ML) + Role: !GetAtt SearchFunctionRole.Arn + LoggingConfig: + LogGroup: !Ref SearchFunctionLogGroup + Layers: + - !Ref OpenSearchClientLayer + Environment: + Variables: + COLLECTION_ENDPOINT: !GetAtt Collection.CollectionEndpoint + COLLECTION_NAME: !Ref CollectionName + INDEX_NAME: documents + MODEL_ID: !GetAtt SetupSearchPipeline.ModelId + Events: + SearchApi: + Type: Api + Properties: + RestApiId: !Ref SearchApi + Path: /search + Method: POST + + IndexFunctionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess + Policies: + - PolicyName: OpenSearchServerlessWrite + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - aoss:APIAccessAll + - aoss:CreateIndex + - aoss:GetIndex + Resource: !Sub 'arn:aws:aoss:${AWS::Region}:${AWS::AccountId}:collection/*' + - PolicyName: PassMLRole + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - iam:PassRole + Resource: !GetAtt OpenSearchMLRole.Arn + + IndexFunctionLogGroup: + Type: AWS::Logs::LogGroup + DeletionPolicy: Delete + UpdateReplacePolicy: Delete + Metadata: + cfn_nag: + rules_to_suppress: + - id: W84 + reason: "Sample application — KMS encryption not required for demonstration log data" + Properties: + LogGroupName: !Sub /aws/lambda/${AWS::StackName}-IndexFunction + RetentionInDays: !Ref LogRetentionDays + + IndexFunction: + Type: AWS::Serverless::Function + Metadata: + cfn_nag: + rules_to_suppress: + - id: W89 + reason: "Sample application — VPC deployment not required for demonstration purposes" + - id: W92 + reason: "Sample application — reserved concurrency not required for demonstration purposes" + Properties: + Handler: app.handler + CodeUri: lambda/index_documents/ + Description: Indexes documents with text (embeddings generated by OpenSearch ML) + Timeout: 120 + Role: !GetAtt IndexFunctionRole.Arn + LoggingConfig: + LogGroup: !Ref IndexFunctionLogGroup + Layers: + - !Ref OpenSearchClientLayer + Environment: + Variables: + COLLECTION_ENDPOINT: !GetAtt Collection.CollectionEndpoint + COLLECTION_NAME: !Ref CollectionName + INDEX_NAME: documents + INGEST_PIPELINE: !GetAtt SetupSearchPipeline.IngestPipeline + Events: + IndexApi: + Type: Api + Properties: + RestApiId: !Ref SearchApi + Path: /index + Method: POST + + DeleteFunctionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess + Policies: + - PolicyName: OpenSearchServerlessDelete + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - aoss:APIAccessAll + Resource: !Sub 'arn:aws:aoss:${AWS::Region}:${AWS::AccountId}:collection/*' + + DeleteFunctionLogGroup: + Type: AWS::Logs::LogGroup + DeletionPolicy: Delete + UpdateReplacePolicy: Delete + Metadata: + cfn_nag: + rules_to_suppress: + - id: W84 + reason: "Sample application — KMS encryption not required for demonstration log data" + Properties: + LogGroupName: !Sub /aws/lambda/${AWS::StackName}-DeleteFunction + RetentionInDays: !Ref LogRetentionDays + + DeleteFunction: + Type: AWS::Serverless::Function + Metadata: + cfn_nag: + rules_to_suppress: + - id: W89 + reason: "Sample application — VPC deployment not required for demonstration purposes" + - id: W92 + reason: "Sample application — reserved concurrency not required for demonstration purposes" + Properties: + Handler: app.handler + CodeUri: lambda/delete_documents/ + Description: Deletes documents by ID from the index + Role: !GetAtt DeleteFunctionRole.Arn + LoggingConfig: + LogGroup: !Ref DeleteFunctionLogGroup + Layers: + - !Ref OpenSearchClientLayer + Environment: + Variables: + COLLECTION_ENDPOINT: !GetAtt Collection.CollectionEndpoint + COLLECTION_NAME: !Ref CollectionName + INDEX_NAME: documents + Events: + DeleteApi: + Type: Api + Properties: + RestApiId: !Ref SearchApi + Path: /documents + Method: DELETE + +Outputs: + SearchApiUrl: + Description: API Gateway endpoint URL for the search function + Value: !Sub 'https://${SearchApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/search' + + IndexApiUrl: + Description: API Gateway endpoint URL for the index function + Value: !Sub 'https://${SearchApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/index' + + DeleteApiUrl: + Description: API Gateway endpoint URL for the delete function + Value: !Sub 'https://${SearchApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/documents' + + CollectionEndpoint: + Description: OpenSearch Serverless collection endpoint + Value: !GetAtt Collection.CollectionEndpoint + + CollectionArn: + Description: OpenSearch Serverless collection ARN + Value: !GetAtt Collection.Arn + + CollectionGroupId: + Description: NextGen Collection Group ID + Value: !GetAtt CollectionGroup.Id + + MLModelId: + Description: OpenSearch ML model ID for neural search + Value: !GetAtt SetupSearchPipeline.ModelId diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/__init__.py b/apigw-lambda-opensearch-serverless-nextgen/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/integration/__init__.py b/apigw-lambda-opensearch-serverless-nextgen/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_data.json b/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_data.json new file mode 100644 index 000000000..65f1aacb3 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_data.json @@ -0,0 +1,54 @@ +{ + "documents": [ + {"id": "prod-01", "title": "Waterproof Hiking Boots", "content": "Durable leather boots with Gore-Tex lining for all-weather trail use. Vibram sole provides excellent grip on wet rock and muddy paths."}, + {"id": "prod-02", "title": "Summer Beach Sandals", "content": "Lightweight open-toe sandals with quick-dry straps. Perfect for shoreline walks and water activities at the coast."}, + {"id": "prod-03", "title": "Running Sneakers Pro", "content": "Breathable mesh upper with responsive foam cushioning. Designed for long-distance road running and marathon training."}, + {"id": "prod-04", "title": "Wool Winter Socks", "content": "Merino wool blend socks with reinforced heel and toe. Moisture-wicking and thermal insulation keep feet warm in cold weather."}, + {"id": "prod-05", "title": "Portable Bluetooth Speaker", "content": "Waterproof wireless speaker with 12-hour battery life. Rich bass and 360-degree sound for outdoor gatherings and pool parties."}, + {"id": "prod-06", "title": "Noise-Cancelling Headphones", "content": "Over-ear headphones with active noise cancellation. 30-hour battery and premium memory foam ear cushions for long flights."}, + {"id": "prod-07", "title": "Camping Hammock", "content": "Ultralight nylon hammock for relaxing outdoors between trees. Integrated mosquito net and supports up to 200kg. Packs down to fist size for easy carrying on hikes and camping trips."}, + {"id": "prod-08", "title": "Stainless Steel Water Bottle", "content": "Double-wall vacuum insulated bottle for hydration on the go. Keeps drinks cold 24 hours or hot 12 hours. BPA-free and leak-proof."}, + {"id": "prod-09", "title": "Yoga Mat Premium", "content": "Non-slip natural rubber mat with alignment markers. Extra thick 6mm cushioning protects joints during floor exercises and stretching."}, + {"id": "prod-10", "title": "Cycling Helmet", "content": "Aerodynamic road cycling helmet with MIPS brain protection system. Lightweight polycarbonate shell with 14 ventilation channels."}, + {"id": "prod-11", "title": "Camping Tent 3-Person", "content": "Freestanding dome tent with waterproof rainfly. Sets up in under 5 minutes. Two vestibules provide gear storage space for weekend trips."}, + {"id": "prod-12", "title": "LED Head Torch", "content": "Rechargeable headlamp with 800 lumens and red night-vision mode. Lightweight at 75g with adjustable beam angle for trail running after dark."}, + {"id": "prod-13", "title": "Insulated Ski Jacket", "content": "Down-filled jacket with 10,000mm waterproof rating. Helmet-compatible hood and powder skirt keep snow out during alpine skiing."}, + {"id": "prod-14", "title": "Fitness Tracker Watch", "content": "Heart rate monitor with GPS tracking and sleep analysis. Tracks steps, calories, and workouts. Water-resistant to 50 meters."}, + {"id": "prod-15", "title": "Climbing Rope 60m", "content": "Dynamic single rope rated for lead climbing. Dry-treated sheath resists moisture absorption. 9.8mm diameter balances handling and durability."}, + {"id": "prod-16", "title": "Kayak Paddle Carbon", "content": "Lightweight carbon fibre paddle with adjustable ferrule. Dihedral blade design reduces flutter for efficient forward paddling on lakes and rivers."}, + {"id": "prod-17", "title": "Compression Running Tights", "content": "Graduated compression leggings improve blood circulation during runs. Reflective details for visibility and zip pocket for keys and phone."}, + {"id": "prod-18", "title": "Dry Bag 20L", "content": "Roll-top waterproof bag keeps belongings dry during kayaking, rafting, and beach trips. Welded seams and transparent window panel."}, + {"id": "prod-19", "title": "Trail Mix Energy Bars", "content": "Pack of 12 nut and seed bars with dark chocolate chips. High protein snack for hiking and endurance sports. No artificial flavours."}, + {"id": "prod-20", "title": "Polarised Sunglasses", "content": "UV400 polarised lenses reduce glare from water and snow. Lightweight frame with non-slip nose pads for cycling and fishing."}, + {"id": "prod-21", "title": "Foam Roller Recovery", "content": "High-density EVA foam roller for deep tissue massage and muscle recovery after intense workouts. Textured surface targets trigger points."}, + {"id": "prod-22", "title": "Snorkel Mask Full-Face", "content": "180-degree panoramic viewing with anti-fog design. Dry-top snorkel prevents water entry. GoPro mount for underwater photography."}, + {"id": "prod-23", "title": "Trekking Poles Pair", "content": "Adjustable aluminium trekking poles with cork grips. Shock-absorbing tips reduce knee strain on steep descents and long hikes."}, + {"id": "prod-24", "title": "Solar Power Bank", "content": "20,000mAh portable charger with built-in solar panel. Dual USB outputs charge phone and tablet simultaneously while camping off-grid."}, + {"id": "prod-25", "title": "Wetsuit 3mm Full", "content": "Neoprene full-body wetsuit for surfing and open-water swimming. Sealed seams and back zip entry. Provides warmth in waters down to 15°C."}, + {"id": "prod-26", "title": "Resistance Bands Set", "content": "Set of 5 latex bands in graduated strengths for home workouts. Includes door anchor, ankle straps, and carrying bag for travel fitness."}, + {"id": "prod-27", "title": "Mountain Bike Gloves", "content": "Padded gel cycling gloves with touchscreen-compatible fingertips. Breathable mesh back and silicone grip palm for handlebar control."}, + {"id": "prod-28", "title": "Camping Stove Portable", "content": "Compact gas canister stove that boils water in 3 minutes. Foldable legs and piezo ignition. Weighs just 350g for lightweight backpacking."}, + {"id": "prod-29", "title": "Swim Goggles Racing", "content": "Low-profile competitive swimming goggles with mirrored lenses. Adjustable nose bridge and silicone gasket seal prevent leaking during laps."}, + {"id": "prod-30", "title": "Down Sleeping Bag", "content": "Lightweight 800-fill goose down mummy bag rated to -10°C. Compression sack included for compact packing on multi-day treks."}, + {"id": "prod-31", "title": "Basketball Indoor/Outdoor", "content": "Official size 7 composite leather basketball with deep channel design for superior grip. Suitable for both indoor courts and concrete playgrounds."}, + {"id": "prod-32", "title": "Skateboard Complete", "content": "Canadian maple deck with 52mm wheels and ABEC-7 bearings. Concave shape suits tricks and street skating for beginners and intermediates."}, + {"id": "prod-33", "title": "GPS Handheld Navigator", "content": "Rugged outdoor GPS device with topographic maps and 16-hour battery. Barometric altimeter and electronic compass for backcountry navigation."}, + {"id": "prod-34", "title": "Protein Shaker Bottle", "content": "700ml leak-proof shaker with wire whisk ball for smooth protein shakes. BPA-free tritan plastic and measurement markings on the side."}, + {"id": "prod-35", "title": "Surfboard Shortboard 6ft", "content": "High-performance epoxy shortboard with thruster fin setup. Rounded pin tail for quick turns on steep hollow waves."}, + {"id": "prod-36", "title": "Hiking Backpack 45L", "content": "Multi-day trekking pack with adjustable torso length and padded hip belt. Rain cover included. Multiple compartments for organised packing."}, + {"id": "prod-37", "title": "Table Tennis Set", "content": "Retractable net with two paddles and three balls. Clamps to any table up to 2 inches thick for instant ping pong games at home or office."}, + {"id": "prod-38", "title": "Ski Goggles Anti-Fog", "content": "Dual-lens ski goggles with magnetic lens swap system. OTG design fits over prescription glasses. Helmet-compatible adjustable strap."}, + {"id": "prod-39", "title": "Jump Rope Speed", "content": "Ball-bearing speed rope with adjustable steel cable. Lightweight handles with foam grip. Ideal for double-unders and HIIT cardio training."}, + {"id": "prod-40", "title": "Fishing Rod Telescopic", "content": "Carbon fibre telescopic rod that collapses to 45cm for travel. Medium action suits freshwater lake and river fishing for trout and bass."}, + {"id": "prod-41", "title": "Climbing Chalk Bag", "content": "Drawstring chalk bag with fleece lining and brush holder loop. Belt attachment and zippered pocket for keys. Essential for bouldering."}, + {"id": "prod-42", "title": "Inflatable Stand-Up Paddleboard", "content": "All-round SUP board that inflates to 15 PSI in 5 minutes. Non-slip deck pad and bungee storage for calm lake paddling and coastal touring."}, + {"id": "prod-43", "title": "Weightlifting Belt Leather", "content": "10mm thick genuine leather powerlifting belt with single prong buckle. Provides lumbar support for heavy squats and deadlifts."}, + {"id": "prod-44", "title": "Badminton Racket Pair", "content": "Two lightweight graphite rackets with carrying case and three shuttlecocks. Pre-strung at 22 lbs tension for recreational garden play."}, + {"id": "prod-45", "title": "Bike Repair Multi-Tool", "content": "16-function cycling multi-tool with allen keys, screwdrivers, chain breaker, and tyre levers. Compact enough to fit in a jersey pocket."}, + {"id": "prod-46", "title": "Scuba Diving Fins", "content": "Open-heel adjustable fins with channel thrust blade design. Spring heel straps for easy donning. Suitable for warm and cold water diving."}, + {"id": "prod-47", "title": "Boxing Gloves 12oz", "content": "Multi-layer foam padding with ventilated palm and secure velcro wrist closure. Durable synthetic leather for bag work and sparring."}, + {"id": "prod-48", "title": "Thermal Base Layer", "content": "Merino wool and polyester blend long-sleeve top. Flat-lock seams prevent chafing. Regulates body temperature for skiing and winter hiking."}, + {"id": "prod-49", "title": "Frisbee Golf Disc Set", "content": "Set of 3 discs — driver, midrange, and putter — for disc golf courses. Durable plastic in high-visibility colours with flight ratings printed."}, + {"id": "prod-50", "title": "Action Camera Waterproof", "content": "4K video at 60fps with electronic image stabilisation. Waterproof to 10m without housing. Voice control and WiFi live preview on phone."} + ] +} diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py b/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py new file mode 100644 index 000000000..fd84322c4 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py @@ -0,0 +1,262 @@ +"""Integration tests for the search API. + +Tests semantic, lexical, and hybrid search modes against a 50-product +catalog to demonstrate how each mode handles different query types. + +Requirements: + - Stack must be deployed (mise run sam:deploy) + - STACK_NAME and AWS_REGION env vars are read from mise.toml + +Usage: + mise run test:integration +""" + +import json +import os +import time +from pathlib import Path + +import boto3 +import pytest +import requests +from requests_aws4auth import AWS4Auth + +STACK_NAME = os.environ.get("STACK_NAME", "lambda-opensearch-nextgen") +REGION = os.environ.get("AWS_REGION", "eu-west-1") + + +def _get_auth(): + """Get SigV4 auth for API Gateway IAM authorization.""" + credentials = boto3.Session().get_credentials().get_frozen_credentials() + return AWS4Auth( + credentials.access_key, + credentials.secret_key, + REGION, + "execute-api", + session_token=credentials.token, + ) + + +@pytest.fixture(scope="module") +def api_urls(): + """Get API URLs from CloudFormation stack outputs.""" + cfn = boto3.client("cloudformation", region_name=REGION) + response = cfn.describe_stacks(StackName=STACK_NAME) + outputs = { + o["OutputKey"]: o["OutputValue"] + for o in response["Stacks"][0]["Outputs"] + } + return { + "index": outputs["IndexApiUrl"], + "search": outputs["SearchApiUrl"], + "delete": outputs["DeleteApiUrl"], + } + + +@pytest.fixture(scope="module") +def seed_data(api_urls): + """Index test documents and wait for indexing to complete.""" + test_data_path = Path(__file__).parent / "test_data.json" + with open(test_data_path) as f: + payload = json.load(f) + + response = requests.post(api_urls["index"], json=payload, auth=_get_auth(), timeout=120) + assert response.status_code == 200, f"Failed to index: {response.text}" + + result = response.json() + assert result["errors"] is False, f"Bulk index had errors: {result}" + + # Wait for vector index to build + time.sleep(10) + + yield payload["documents"] + + # Cleanup — delete test documents via the API + doc_ids = [doc["id"] for doc in payload["documents"]] + requests.delete( + api_urls["delete"], + json={"ids": doc_ids}, + auth=_get_auth(), + timeout=30, + ) + + +def search(api_url, query, mode="semantic", size=5): + """Helper to perform a search request.""" + response = requests.post( + api_url, + json={"query": query, "mode": mode, "size": size}, + auth=_get_auth(), + timeout=30, + ) + assert response.status_code == 200, f"Search failed: {response.text}" + result = response.json() + + # Print query and results for demonstration + print(f"\n [{mode}] Query: \"{query}\"") + print(f" Results:") + for hit in result["results"]: + print(f" [{hit['score']:.4f}] {hit['title']}") + if not result["results"]: + print(" (no results)") + + return result + + +class TestSemanticSearch: + """Tests that demonstrate semantic (vector) matching.""" + + def test_synonym_matching(self, api_urls, seed_data): + """'shoes for the beach' should match Beach Sandals.""" + result = search(api_urls["search"], "shoes for the beach") + ids = [hit["id"] for hit in result["results"]] + assert "prod-02" in ids + + def test_conceptual_matching(self, api_urls, seed_data): + """'something to keep my feet warm' should match Wool Winter Socks.""" + result = search(api_urls["search"], "something to keep my feet warm") + ids = [hit["id"] for hit in result["results"]] + assert "prod-04" in ids + + def test_cross_domain_inference(self, api_urls, seed_data): + """'footwear for rainy trails' should match Waterproof Hiking Boots.""" + result = search(api_urls["search"], "footwear for rainy trails") + ids = [hit["id"] for hit in result["results"]] + assert "prod-01" in ids + + def test_abstract_activity(self, api_urls, seed_data): + """'relaxing outdoors' should match Camping Hammock.""" + result = search(api_urls["search"], "relaxing outdoors") + ids = [hit["id"] for hit in result["results"]] + assert "prod-07" in ids + + def test_british_english_synonyms(self, api_urls, seed_data): + """'jogging trainers' should match Running Sneakers Pro.""" + result = search(api_urls["search"], "jogging trainers") + ids = [hit["id"] for hit in result["results"]] + assert "prod-03" in ids + + def test_concept_mapping(self, api_urls, seed_data): + """'staying hydrated while exercising' should match Water Bottle.""" + result = search(api_urls["search"], "staying hydrated while exercising") + ids = [hit["id"] for hit in result["results"]] + assert "prod-08" in ids + + def test_activity_to_equipment(self, api_urls, seed_data): + """'recording my surf session' should match Action Camera.""" + result = search(api_urls["search"], "recording my surf session") + ids = [hit["id"] for hit in result["results"]] + assert "prod-50" in ids + + def test_problem_to_solution(self, api_urls, seed_data): + """'reduce knee strain hiking' should match Trekking Poles.""" + result = search(api_urls["search"], "reduce knee strain hiking") + ids = [hit["id"] for hit in result["results"]] + assert "prod-23" in ids + + def test_use_case_matching(self, api_urls, seed_data): + """'charging phone while camping' should match Solar Power Bank.""" + result = search(api_urls["search"], "charging phone while camping") + ids = [hit["id"] for hit in result["results"]] + assert "prod-24" in ids + + +class TestLexicalSearch: + """Tests that demonstrate lexical (BM25 + fuzzy) matching.""" + + def test_exact_keyword(self, api_urls, seed_data): + """'bluetooth speaker' should match the Bluetooth Speaker product.""" + result = search(api_urls["search"], "bluetooth speaker", mode="lexical") + ids = [hit["id"] for hit in result["results"]] + assert "prod-05" in ids + + def test_fuzzy_typo_tolerance(self, api_urls, seed_data): + """'headpohnes' (typo) should still match Headphones via fuzziness=1.""" + result = search(api_urls["search"], "headpohnes", mode="lexical") + ids = [hit["id"] for hit in result["results"]] + assert "prod-06" in ids + + def test_partial_product_name(self, api_urls, seed_data): + """'kayak paddle' should match the carbon kayak paddle.""" + result = search(api_urls["search"], "kayak paddle", mode="lexical") + ids = [hit["id"] for hit in result["results"]] + assert "prod-16" in ids + + def test_specific_attribute(self, api_urls, seed_data): + """'gore-tex' should match the hiking boots.""" + result = search(api_urls["search"], "gore-tex", mode="lexical") + ids = [hit["id"] for hit in result["results"]] + assert "prod-01" in ids + + def test_lexical_fails_on_synonyms(self, api_urls, seed_data): + """'trainers' should NOT match 'sneakers' in pure lexical mode. + + This demonstrates the limitation of keyword matching — it can't + bridge vocabulary gaps that semantic search handles. + """ + result = search(api_urls["search"], "trainers", mode="lexical") + ids = [hit["id"] for hit in result["results"]] + # Running Sneakers should NOT appear because 'trainers' != 'sneakers' + assert "prod-03" not in ids + + +class TestSearchModeComparison: + """Tests that compare semantic vs lexical to illustrate differences.""" + + def test_semantic_finds_what_lexical_misses(self, api_urls, seed_data): + """'trainers' finds Running Sneakers semantically but not lexically.""" + semantic = search(api_urls["search"], "trainers", mode="semantic") + lexical = search(api_urls["search"], "trainers", mode="lexical") + + semantic_ids = [hit["id"] for hit in semantic["results"]] + lexical_ids = [hit["id"] for hit in lexical["results"]] + + assert "prod-03" in semantic_ids # Semantic understands trainers = sneakers + assert "prod-03" not in lexical_ids # Lexical can't bridge the gap + + def test_lexical_precision_on_brand_terms(self, api_urls, seed_data): + """'MIPS' should precisely match cycling helmet via lexical.""" + result = search(api_urls["search"], "MIPS", mode="lexical") + ids = [hit["id"] for hit in result["results"]] + assert "prod-10" in ids + + def test_lexical_handles_model_numbers(self, api_urls, seed_data): + """'4K 60fps' should match action camera via lexical.""" + result = search(api_urls["search"], "4K 60fps", mode="lexical") + ids = [hit["id"] for hit in result["results"]] + assert "prod-50" in ids + + def test_semantic_handles_natural_language(self, api_urls, seed_data): + """'something to play at the office' should match Table Tennis.""" + result = search(api_urls["search"], "something to play at the office") + ids = [hit["id"] for hit in result["results"]] + assert "prod-37" in ids + + +class TestHybridSearch: + """Tests that demonstrate hybrid (lexical + semantic) with normalization.""" + + def test_hybrid_boosts_exact_match(self, api_urls, seed_data): + """'camping tent' should match the tent product in hybrid mode.""" + result = search(api_urls["search"], "camping tent", mode="hybrid") + ids = [hit["id"] for hit in result["results"]] + assert "prod-11" in ids + + def test_hybrid_finds_synonyms_and_keywords(self, api_urls, seed_data): + """'waterproof bag for kayaking' matches Dry Bag via both modes.""" + result = search(api_urls["search"], "waterproof bag for kayaking", mode="hybrid") + ids = [hit["id"] for hit in result["results"]] + assert "prod-18" in ids + + def test_hybrid_handles_typo(self, api_urls, seed_data): + """'skiign goggles' (typo) should still find ski goggles.""" + result = search(api_urls["search"], "ski goggles", mode="hybrid") + ids = [hit["id"] for hit in result["results"]] + assert "prod-38" in ids + + def test_hybrid_multi_intent(self, api_urls, seed_data): + """'swimming equipment' should return swim-related products.""" + result = search(api_urls["search"], "swimming equipment", mode="hybrid") + ids = [hit["id"] for hit in result["results"]] + swim_products = {"prod-22", "prod-25", "prod-29"} + assert len(swim_products & set(ids)) >= 1 diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/requirements.txt b/apigw-lambda-opensearch-serverless-nextgen/tests/requirements.txt new file mode 100644 index 000000000..8226d697b --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/requirements.txt @@ -0,0 +1,5 @@ +pytest>=8.0 +requests>=2.34.2 +requests-aws4auth>=1.3.2 +boto3>=1.43 +aws-lambda-powertools[tracer]>=3.0 diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/unit/__init__.py b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/unit/conftest.py b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/conftest.py new file mode 100644 index 000000000..fbbc58ddb --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/conftest.py @@ -0,0 +1,64 @@ +"""Unit test configuration — sets up isolated environment before any imports.""" + +import os +import sys +from dataclasses import dataclass + +import pytest + +# Prevent any real AWS service calls — fake credentials and region +_REGION = os.environ.get("AWS_REGION", "eu-west-1") + +os.environ["AWS_ACCESS_KEY_ID"] = "testing" +os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" +os.environ["AWS_SECURITY_TOKEN"] = "testing" +os.environ["AWS_SESSION_TOKEN"] = "testing" +os.environ.setdefault("AWS_DEFAULT_REGION", _REGION) +os.environ.setdefault("AWS_REGION", _REGION) + +# Disable X-Ray tracing so Tracer uses a no-op provider +os.environ["POWERTOOLS_TRACE_DISABLED"] = "true" +os.environ["POWERTOOLS_SERVICE_NAME"] = "test" + +# Lambda function env vars +os.environ["COLLECTION_ENDPOINT"] = "https://test-collection.eu-west-1.aoss.amazonaws.com" +os.environ["COLLECTION_NAME"] = "semantic-search" +os.environ["INDEX_NAME"] = "documents" +os.environ["MODEL_ID"] = "test-model-id" +os.environ["INGEST_PIPELINE"] = "embedding-ingest-pipeline" + +# Add the layer source to the path so Lambda functions can import opensearch_client +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "layers", "opensearch_client")) + + +@dataclass +class FakeLambdaContext: + """Minimal Lambda context for Powertools inject_lambda_context.""" + + function_name: str = "test-function" + memory_limit_in_mb: int = 256 + invoked_function_arn: str = f"arn:aws:lambda:{_REGION}:123456789012:function:test" + aws_request_id: str = "test-request-id" + + +@pytest.fixture +def lambda_context(): + """Provide a fake Lambda context for Powertools.""" + return FakeLambdaContext() + + +@pytest.fixture +def apigw_event(): + """Build a minimal API Gateway proxy event.""" + def _make(body=None, method="POST", path="/"): + return { + "body": body if isinstance(body, str) else __import__("json").dumps(body or {}), + "httpMethod": method, + "path": path, + "requestContext": { + "requestId": "test-request-id", + "stage": "Prod", + }, + "headers": {"Content-Type": "application/json"}, + } + return _make diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_collection_group.py b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_collection_group.py new file mode 100644 index 000000000..de82ac874 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_collection_group.py @@ -0,0 +1,151 @@ +"""Unit tests for the NextGen collection group custom resource handler.""" + +import sys +import os +import importlib.util +from unittest.mock import patch, MagicMock + +_cg_path = os.path.join(os.path.dirname(__file__), "..", "..", "lambda", "custom_resources", "nextgen_collection_group", "app.py") +_spec = importlib.util.spec_from_file_location("cg_app", _cg_path) +cg_app = importlib.util.module_from_spec(_spec) +sys.modules["cg_app"] = cg_app +_spec.loader.exec_module(cg_app) + +on_create = cg_app.on_create +on_update = cg_app.on_update +on_delete = cg_app.on_delete + + +def _make_event(props=None, physical_id=None): + """Build a minimal CloudFormation custom resource event.""" + event = { + "ResourceProperties": props or { + "Name": "test-collection-group", + "Description": "Test group", + "MaxIndexingCapacityInOCU": "8", + "MaxSearchCapacityInOCU": "8", + }, + } + if physical_id: + event["PhysicalResourceId"] = physical_id + return event + + +@patch("cg_app.helper") +@patch("cg_app.client") +def test_create_calls_api_with_correct_params(mock_client, mock_helper, lambda_context): + """Create handler calls create_collection_group with NextGen generation.""" + mock_client.create_collection_group.return_value = { + "createCollectionGroupDetail": { + "id": "abc123def456", + "arn": "arn:aws:aoss:eu-west-1:123456789012:collection-group/abc123def456", + "name": "test-collection-group", + } + } + + result = on_create(_make_event(), lambda_context) + + mock_client.create_collection_group.assert_called_once_with( + name="test-collection-group", + standbyReplicas="ENABLED", + generation="NEXTGEN", + description="Test group", + capacityLimits={ + "maxIndexingCapacityInOCU": 8, + "maxSearchCapacityInOCU": 8, + }, + ) + assert result == "abc123def456" + + +@patch("cg_app.helper") +@patch("cg_app.client") +def test_create_sets_helper_data(mock_client, mock_helper, lambda_context): + """Create handler populates helper.Data with outputs.""" + mock_helper.Data = {} + mock_client.create_collection_group.return_value = { + "createCollectionGroupDetail": { + "id": "abc123def456", + "arn": "arn:aws:aoss:eu-west-1:123456789012:collection-group/abc123def456", + "name": "test-collection-group", + } + } + + on_create(_make_event(), lambda_context) + + assert mock_helper.Data["Id"] == "abc123def456" + assert mock_helper.Data["Generation"] == "NEXTGEN" + assert mock_helper.Data["Name"] == "test-collection-group" + + +@patch("cg_app.helper") +@patch("cg_app.client") +def test_update_calls_api_with_physical_id(mock_client, mock_helper, lambda_context): + """Update handler uses the physical resource ID to update.""" + event = _make_event(physical_id="abc123def456") + + on_update(event, lambda_context) + + mock_client.update_collection_group.assert_called_once_with( + id="abc123def456", + description="Test group", + capacityLimits={ + "maxIndexingCapacityInOCU": 8, + "maxSearchCapacityInOCU": 8, + }, + ) + + +@patch("cg_app.helper") +@patch("cg_app.client") +def test_delete_calls_api(mock_client, mock_helper, lambda_context): + """Delete handler calls delete_collection_group.""" + event = _make_event(physical_id="abc123def456") + + on_delete(event, lambda_context) + + mock_client.delete_collection_group.assert_called_once_with(id="abc123def456") + + +@patch("cg_app.helper") +@patch("cg_app.client") +def test_delete_skips_invalid_physical_id(mock_client, mock_helper, lambda_context): + """Delete handler skips deletion when physical ID looks like a CFN logical ID.""" + event = _make_event(physical_id="CollectionGroup-ABCDEF") + + on_delete(event, lambda_context) + + mock_client.delete_collection_group.assert_not_called() + + +@patch("cg_app.helper") +@patch("cg_app.client") +def test_delete_handles_not_found(mock_client, mock_helper, lambda_context): + """Delete handler gracefully handles ResourceNotFoundException.""" + mock_client.exceptions.ResourceNotFoundException = type("ResourceNotFoundException", (Exception,), {}) + mock_client.delete_collection_group.side_effect = mock_client.exceptions.ResourceNotFoundException() + + event = _make_event(physical_id="abc123def456") + + # Should not raise + on_delete(event, lambda_context) + + +@patch("cg_app.helper") +@patch("cg_app.client") +def test_create_default_capacity(mock_client, mock_helper, lambda_context): + """Create uses default capacity of 2 when not specified.""" + mock_client.create_collection_group.return_value = { + "createCollectionGroupDetail": { + "id": "abc123", + "arn": "arn:aws:aoss:eu-west-1:123456789012:collection-group/abc123", + "name": "minimal", + } + } + + event = _make_event(props={"Name": "minimal"}) + on_create(event, lambda_context) + + call_kwargs = mock_client.create_collection_group.call_args[1] + assert call_kwargs["capacityLimits"]["maxIndexingCapacityInOCU"] == 2 + assert call_kwargs["capacityLimits"]["maxSearchCapacityInOCU"] == 2 diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_delete_documents.py b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_delete_documents.py new file mode 100644 index 000000000..dc132ffbc --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_delete_documents.py @@ -0,0 +1,121 @@ +"""Unit tests for the delete documents Lambda handler.""" + +import json +import sys +import os +import importlib.util +from unittest.mock import patch, MagicMock + +_delete_path = os.path.join(os.path.dirname(__file__), "..", "..", "lambda", "delete_documents", "app.py") +_spec = importlib.util.spec_from_file_location("delete_app", _delete_path) +delete_app = importlib.util.module_from_spec(_spec) +sys.modules["delete_app"] = delete_app +_spec.loader.exec_module(delete_app) + +handler = delete_app.handler + + +@patch("delete_app.get_client") +def test_delete_single_document(mock_get_client, apigw_event, lambda_context): + """Deleting a single document calls bulk with correct delete action.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.bulk.return_value = {"errors": False, "items": []} + + event = apigw_event(body={"ids": ["doc-1"]}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert "1 document" in body["message"] + assert body["errors"] is False + + +@patch("delete_app.get_client") +def test_delete_multiple_documents(mock_get_client, apigw_event, lambda_context): + """Deleting multiple documents sends all in a single bulk request.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.bulk.return_value = {"errors": False, "items": []} + + event = apigw_event(body={"ids": ["doc-1", "doc-2", "doc-3"]}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert "3 document" in body["message"] + + bulk_body = mock_client.bulk.call_args[1]["body"] + assert len(bulk_body) == 3 + assert all(item.get("delete") for item in bulk_body) + + +@patch("delete_app.get_client") +def test_delete_bulk_body_contains_correct_ids(mock_get_client, apigw_event, lambda_context): + """Bulk request contains the correct document IDs and index.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.bulk.return_value = {"errors": False, "items": []} + + event = apigw_event(body={"ids": ["doc-a", "doc-b"]}) + handler(event, lambda_context) + + bulk_body = mock_client.bulk.call_args[1]["body"] + assert bulk_body[0] == {"delete": {"_index": "documents", "_id": "doc-a"}} + assert bulk_body[1] == {"delete": {"_index": "documents", "_id": "doc-b"}} + + +@patch("delete_app.get_client") +def test_empty_ids_returns_400(mock_get_client, apigw_event, lambda_context): + """Empty ids array returns 400 without calling OpenSearch.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + event = apigw_event(body={"ids": []}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 400 + assert "no document" in json.loads(result["body"])["error"].lower() + mock_client.bulk.assert_not_called() + + +@patch("delete_app.get_client") +def test_missing_ids_key_returns_400(mock_get_client, apigw_event, lambda_context): + """Missing ids key returns 400.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + event = apigw_event(body={"documents": ["doc-1"]}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 400 + mock_client.bulk.assert_not_called() + + +@patch("delete_app.get_client") +def test_bulk_errors_reported(mock_get_client, apigw_event, lambda_context): + """Bulk errors from OpenSearch are surfaced in the response.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.bulk.return_value = {"errors": True, "items": []} + + event = apigw_event(body={"ids": ["doc-1"]}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert body["errors"] is True + + +@patch("delete_app.get_client") +def test_opensearch_error_returns_500(mock_get_client, apigw_event, lambda_context): + """OpenSearch client exception returns 500.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.bulk.side_effect = Exception("Service unavailable") + + event = apigw_event(body={"ids": ["doc-1"]}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 500 + assert "Service unavailable" in json.loads(result["body"])["error"] diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_index_documents.py b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_index_documents.py new file mode 100644 index 000000000..9a65cfb42 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_index_documents.py @@ -0,0 +1,173 @@ +"""Unit tests for the index documents Lambda handler.""" + +import json +import sys +import os +import importlib.util +from unittest.mock import patch, MagicMock + +_index_path = os.path.join(os.path.dirname(__file__), "..", "..", "lambda", "index_documents", "app.py") +_spec = importlib.util.spec_from_file_location("index_app", _index_path) +index_app = importlib.util.module_from_spec(_spec) +sys.modules["index_app"] = index_app +_spec.loader.exec_module(index_app) + +handler = index_app.handler + + +@patch("index_app.get_client") +def test_index_single_document(mock_get_client, apigw_event, lambda_context): + """Indexing a single document calls bulk with correct structure.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.bulk.return_value = {"errors": False, "items": []} + + event = apigw_event(body={ + "documents": [{"id": "doc-1", "title": "Test", "content": "Content"}] + }) + result = handler(event, lambda_context) + + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert "1 document" in body["message"] + assert body["errors"] is False + + +@patch("index_app.get_client") +def test_index_multiple_documents(mock_get_client, apigw_event, lambda_context): + """Indexing multiple documents sends all in a single bulk request.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.bulk.return_value = {"errors": False, "items": []} + + docs = [ + {"id": f"doc-{i}", "title": f"Title {i}", "content": f"Content {i}"} + for i in range(5) + ] + event = apigw_event(body={"documents": docs}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert "5 document" in body["message"] + + # Verify bulk was called with 10 items (action + source for each doc) + bulk_body = mock_client.bulk.call_args[1]["body"] + assert len(bulk_body) == 10 + + +@patch("index_app.get_client") +def test_index_uses_ingest_pipeline(mock_get_client, apigw_event, lambda_context): + """Bulk index request specifies the ingest pipeline for embedding generation.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.bulk.return_value = {"errors": False, "items": []} + + event = apigw_event(body={ + "documents": [{"id": "doc-1", "title": "Test", "content": "Content"}] + }) + handler(event, lambda_context) + + call_kwargs = mock_client.bulk.call_args[1] + assert call_kwargs["pipeline"] == "embedding-ingest-pipeline" + + +@patch("index_app.get_client") +def test_embedding_text_combines_title_and_content(mock_get_client, apigw_event, lambda_context): + """The embedding_text field concatenates title and content.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.bulk.return_value = {"errors": False, "items": []} + + event = apigw_event(body={ + "documents": [{"id": "doc-1", "title": "My Title", "content": "My content"}] + }) + handler(event, lambda_context) + + bulk_body = mock_client.bulk.call_args[1]["body"] + doc_body = bulk_body[1] + assert doc_body["embedding_text"] == "My Title. My content" + + +@patch("index_app.get_client") +def test_embedding_text_without_title(mock_get_client, apigw_event, lambda_context): + """When title is empty, embedding_text is just the content.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.bulk.return_value = {"errors": False, "items": []} + + event = apigw_event(body={ + "documents": [{"id": "doc-1", "title": "", "content": "Only content"}] + }) + handler(event, lambda_context) + + bulk_body = mock_client.bulk.call_args[1]["body"] + doc_body = bulk_body[1] + assert doc_body["embedding_text"] == "Only content" + + +@patch("index_app.get_client") +def test_empty_documents_returns_400(mock_get_client, apigw_event, lambda_context): + """Empty documents array returns 400 without calling OpenSearch.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + event = apigw_event(body={"documents": []}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 400 + assert "no documents" in json.loads(result["body"])["error"].lower() + mock_client.bulk.assert_not_called() + + +@patch("index_app.get_client") +def test_no_documents_key_returns_400(mock_get_client, apigw_event, lambda_context): + """Missing documents key returns 400.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + event = apigw_event(body={"data": "something"}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 400 + mock_client.bulk.assert_not_called() + + +@patch("index_app.get_client") +def test_bulk_errors_reported_in_response(mock_get_client, apigw_event, lambda_context): + """Bulk errors from OpenSearch are surfaced in the response.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.bulk.return_value = { + "errors": True, + "items": [ + {"index": {"_id": "doc-1", "error": {"type": "mapper_parsing_exception", "reason": "bad field"}}} + ], + } + + event = apigw_event(body={ + "documents": [{"id": "doc-1", "title": "Test", "content": "Content"}] + }) + result = handler(event, lambda_context) + + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert body["errors"] is True + assert len(body["item_errors"]) == 1 + assert body["item_errors"][0]["id"] == "doc-1" + + +@patch("index_app.get_client") +def test_opensearch_error_returns_500(mock_get_client, apigw_event, lambda_context): + """OpenSearch client exception returns 500.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.bulk.side_effect = Exception("Connection refused") + + event = apigw_event(body={ + "documents": [{"id": "doc-1", "title": "Test", "content": "Content"}] + }) + result = handler(event, lambda_context) + + assert result["statusCode"] == 500 + assert "Connection refused" in json.loads(result["body"])["error"] diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_search.py b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_search.py new file mode 100644 index 000000000..4dd15680c --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_search.py @@ -0,0 +1,171 @@ +"""Unit tests for the search Lambda handler.""" + +import json +import sys +import os +import importlib.util +from unittest.mock import patch, MagicMock + +# Import the search handler from its specific path +_search_path = os.path.join(os.path.dirname(__file__), "..", "..", "lambda", "search", "app.py") +_spec = importlib.util.spec_from_file_location("search_app", _search_path) +search_app = importlib.util.module_from_spec(_spec) +sys.modules["search_app"] = search_app +_spec.loader.exec_module(search_app) + +handler = search_app.handler + + +def _opensearch_response(hits, total=None): + """Build a mock OpenSearch search response.""" + if total is None: + total = len(hits) + return { + "hits": { + "total": {"value": total, "relation": "eq"}, + "hits": [ + { + "_id": h["id"], + "_score": h.get("score", 0.9), + "_source": {"title": h.get("title", ""), "content": h.get("content", "")}, + } + for h in hits + ], + } + } + + +@patch("search_app.get_client") +def test_semantic_search_returns_results(mock_get_client, apigw_event, lambda_context): + """Semantic search with valid query returns formatted results.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.search.return_value = _opensearch_response( + [{"id": "doc-1", "score": 0.92, "title": "Test Doc", "content": "Test content"}] + ) + + event = apigw_event(body={"query": "test query", "mode": "semantic"}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert body["mode"] == "semantic" + assert len(body["results"]) == 1 + assert body["results"][0]["id"] == "doc-1" + assert body["results"][0]["score"] == 0.92 + + +@patch("search_app.get_client") +def test_semantic_is_default_mode(mock_get_client, apigw_event, lambda_context): + """When no mode is specified, defaults to semantic.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.search.return_value = _opensearch_response([]) + + event = apigw_event(body={"query": "test"}) + result = handler(event, lambda_context) + + body = json.loads(result["body"]) + assert body["mode"] == "semantic" + + +@patch("search_app.get_client") +def test_lexical_search_uses_multi_match(mock_get_client, apigw_event, lambda_context): + """Lexical mode sends a multi_match query with fuzzy matching.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.search.return_value = _opensearch_response([]) + + event = apigw_event(body={"query": "hiking boots", "mode": "lexical"}) + handler(event, lambda_context) + + call_kwargs = mock_client.search.call_args[1] + assert "multi_match" in json.dumps(call_kwargs["body"]) + + +@patch("search_app.get_client") +def test_hybrid_search_uses_search_pipeline(mock_get_client, apigw_event, lambda_context): + """Hybrid mode passes search_pipeline param to OpenSearch.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.search.return_value = _opensearch_response([]) + + event = apigw_event(body={"query": "waterproof bag", "mode": "hybrid"}) + handler(event, lambda_context) + + call_kwargs = mock_client.search.call_args[1] + assert call_kwargs["params"]["search_pipeline"] == "hybrid-search-pipeline" + + +@patch("search_app.get_client") +def test_hybrid_search_combines_lexical_and_neural(mock_get_client, apigw_event, lambda_context): + """Hybrid query body contains both multi_match and neural queries.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.search.return_value = _opensearch_response([]) + + event = apigw_event(body={"query": "camping gear", "mode": "hybrid"}) + handler(event, lambda_context) + + search_body = mock_client.search.call_args[1]["body"] + assert "hybrid" in search_body["query"] + queries = search_body["query"]["hybrid"]["queries"] + query_str = json.dumps(queries) + assert "multi_match" in query_str + assert "neural" in query_str + + +@patch("search_app.get_client") +def test_custom_size_parameter(mock_get_client, apigw_event, lambda_context): + """Size parameter controls the number of results requested.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.search.return_value = _opensearch_response([]) + + event = apigw_event(body={"query": "test", "size": 10}) + handler(event, lambda_context) + + search_body = mock_client.search.call_args[1]["body"] + assert search_body["size"] == 10 + + +@patch("search_app.get_client") +def test_missing_query_returns_400(mock_get_client, apigw_event, lambda_context): + """Missing query field returns 400 without calling OpenSearch.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + event = apigw_event(body={"mode": "semantic"}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 400 + assert "query" in json.loads(result["body"])["error"].lower() + mock_client.search.assert_not_called() + + +@patch("search_app.get_client") +def test_invalid_mode_returns_400(mock_get_client, apigw_event, lambda_context): + """Invalid mode value returns 400.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + event = apigw_event(body={"query": "test", "mode": "invalid"}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 400 + assert "mode" in json.loads(result["body"])["error"].lower() + mock_client.search.assert_not_called() + + +@patch("search_app.get_client") +def test_opensearch_error_returns_500(mock_get_client, apigw_event, lambda_context): + """OpenSearch client exception returns 500 with error message.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.search.side_effect = Exception("Connection timeout") + + event = apigw_event(body={"query": "test"}) + result = handler(event, lambda_context) + + assert result["statusCode"] == 500 + assert "Connection timeout" in json.loads(result["body"])["error"] diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_setup_pipeline.py b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_setup_pipeline.py new file mode 100644 index 000000000..1faee5923 --- /dev/null +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/unit/test_setup_pipeline.py @@ -0,0 +1,188 @@ +"""Unit tests for the setup pipeline custom resource handler.""" + +import sys +import os +import importlib.util +from unittest.mock import patch, MagicMock + +import pytest + +_setup_path = os.path.join(os.path.dirname(__file__), "..", "..", "lambda", "custom_resources", "setup_pipeline", "app.py") +_spec = importlib.util.spec_from_file_location("setup_app", _setup_path) +setup_app = importlib.util.module_from_spec(_spec) +sys.modules["setup_app"] = setup_app +_spec.loader.exec_module(setup_app) + +on_create = setup_app.on_create +on_delete = setup_app.on_delete +_wait_for_model_deployed = setup_app._wait_for_model_deployed +_retry_with_backoff = setup_app._retry_with_backoff + + +def _make_event(physical_id=None): + """Build a minimal CloudFormation custom resource event.""" + event = { + "ResourceProperties": { + "CollectionEndpoint": "https://test.eu-west-1.aoss.amazonaws.com", + "ModelRoleArn": "arn:aws:iam::123456789012:role/MLRole", + "EmbeddingModelId": "amazon.titan-embed-text-v2:0", + "EmbeddingDimension": "1024", + }, + } + if physical_id: + event["PhysicalResourceId"] = physical_id + return event + + +@patch("setup_app.helper") +@patch("setup_app.get_client") +def test_create_registers_connector_and_model(mock_get_client, mock_helper, lambda_context): + """Create handler creates connector, registers model, deploys, and creates pipelines.""" + mock_helper.Data = {} + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + mock_client.transport.perform_request.side_effect = [ + # Step 1: Create connector + {"connector_id": "conn-123"}, + # Step 2: Register model + {"model_id": "model-456"}, + # Step 3: Deploy model + {}, + # Step 3b: Wait for deploy (poll) + {"model_state": "DEPLOYED"}, + # Step 4: Create ingest pipeline + {"acknowledged": True}, + # Step 5: Create search pipeline + {"acknowledged": True}, + ] + + result = on_create(_make_event(), lambda_context) + + assert result == "conn-123/model-456" + assert mock_helper.Data["ModelId"] == "model-456" + assert mock_helper.Data["ConnectorId"] == "conn-123" + assert mock_helper.Data["IngestPipeline"] == "embedding-ingest-pipeline" + assert mock_helper.Data["SearchPipeline"] == "hybrid-search-pipeline" + + +@patch("setup_app.helper") +@patch("setup_app.get_client") +def test_create_connector_body_contains_bedrock_config(mock_get_client, mock_helper, lambda_context): + """Connector body includes correct Bedrock model and role ARN.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + mock_client.transport.perform_request.side_effect = [ + {"connector_id": "conn-123"}, + {"model_id": "model-456"}, + {}, + {"model_state": "DEPLOYED"}, + {"acknowledged": True}, + {"acknowledged": True}, + ] + + on_create(_make_event(), lambda_context) + + # First call is the connector creation + first_call = mock_client.transport.perform_request.call_args_list[0] + assert first_call[0][0] == "POST" + assert "/_plugins/_ml/connectors/_create" in first_call[0][1] + connector_body = first_call[1]["body"] + assert connector_body["credential"]["roleArn"] == "arn:aws:iam::123456789012:role/MLRole" + + +@patch("setup_app.helper") +@patch("setup_app.get_client") +def test_delete_cleans_up_resources(mock_get_client, mock_helper, lambda_context): + """Delete handler removes pipelines, undeploys model, and deletes connector.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.transport.perform_request.return_value = {} + + event = _make_event(physical_id="conn-123/model-456") + on_delete(event, lambda_context) + + calls = mock_client.transport.perform_request.call_args_list + methods_and_paths = [(c[0][0], c[0][1]) for c in calls] + + assert ("DELETE", "/_ingest/pipeline/embedding-ingest-pipeline") in methods_and_paths + assert ("DELETE", "/_search/pipeline/hybrid-search-pipeline") in methods_and_paths + assert ("POST", "/_plugins/_ml/models/model-456/_undeploy") in methods_and_paths + assert ("DELETE", "/_plugins/_ml/models/model-456") in methods_and_paths + assert ("DELETE", "/_plugins/_ml/connectors/conn-123") in methods_and_paths + + +@patch("setup_app.helper") +@patch("setup_app.get_client") +def test_delete_handles_pipeline_errors_gracefully(mock_get_client, mock_helper, lambda_context): + """Delete handler continues even if pipeline deletion fails.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.transport.perform_request.side_effect = Exception("Not found") + + event = _make_event(physical_id="conn-123/model-456") + + # Should not raise + on_delete(event, lambda_context) + + +def test_wait_for_model_deployed_success(): + """_wait_for_model_deployed returns True when model reaches DEPLOYED.""" + mock_client = MagicMock() + mock_client.transport.perform_request.side_effect = [ + {"model_state": "REGISTERING"}, + {"model_state": "DEPLOYING"}, + {"model_state": "DEPLOYED"}, + ] + + result = _wait_for_model_deployed(mock_client, "model-1", max_attempts=5, delay=0) + assert result is True + + +def test_wait_for_model_deployed_failure(): + """_wait_for_model_deployed raises on DEPLOY_FAILED.""" + mock_client = MagicMock() + mock_client.transport.perform_request.return_value = {"model_state": "DEPLOY_FAILED"} + + with pytest.raises(RuntimeError, match="DEPLOY_FAILED"): + _wait_for_model_deployed(mock_client, "model-1", max_attempts=3, delay=0) + + +def test_wait_for_model_deployed_timeout(): + """_wait_for_model_deployed raises TimeoutError if model doesn't deploy.""" + mock_client = MagicMock() + mock_client.transport.perform_request.return_value = {"model_state": "DEPLOYING"} + + with pytest.raises(TimeoutError): + _wait_for_model_deployed(mock_client, "model-1", max_attempts=2, delay=0) + + +def test_retry_with_backoff_succeeds_on_first_try(): + """_retry_with_backoff returns immediately when function succeeds.""" + result = _retry_with_backoff(lambda: "success", max_attempts=3, initial_delay=0) + assert result == "success" + + +def test_retry_with_backoff_retries_on_403(): + """_retry_with_backoff retries on 403 Forbidden errors.""" + attempts = {"count": 0} + + def flaky(): + attempts["count"] += 1 + if attempts["count"] < 3: + raise Exception("403 Forbidden") + return "ok" + + result = _retry_with_backoff(flaky, max_attempts=5, initial_delay=0) + assert result == "ok" + assert attempts["count"] == 3 + + +def test_retry_with_backoff_raises_non_auth_errors(): + """_retry_with_backoff does not retry non-authorization errors.""" + def always_fails(): + raise ValueError("Something else broke") + + with pytest.raises(ValueError, match="Something else broke"): + _retry_with_backoff(always_fails, max_attempts=3, initial_delay=0) From 087c1666f2cb83f50fb2febb9f34919578673d87 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Mon, 8 Jun 2026 11:14:18 +0100 Subject: [PATCH 02/12] docs(opensearch-nextgen): fix service naming and streamline README --- .../README.md | 20 +++++++------------ .../example-pattern.json | 8 ++++---- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/apigw-lambda-opensearch-serverless-nextgen/README.md b/apigw-lambda-opensearch-serverless-nextgen/README.md index ef1a43e37..14ca92b33 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/README.md +++ b/apigw-lambda-opensearch-serverless-nextgen/README.md @@ -1,8 +1,8 @@ # Amazon API Gateway to AWS Lambda to Amazon OpenSearch Serverless NextGen -This pattern deploys a serverless semantic search API using Amazon API Gateway, AWS Lambda, and Amazon OpenSearch Serverless with the NextGen architecture. Both Lambda and OpenSearch scale independently to zero when idle, resulting in zero baseline compute cost. +This pattern deploys a serverless semantic search API using Amazon API Gateway, AWS Lambda, and Amazon OpenSearch Serverless with the NextGen architecture. All three services operate on a pay-per-use model with no minimum baseline cost, meaning the entire stack incurs zero compute charges when idle. -Learn more about this pattern at Serverless Land Patterns: << Add the live URL here >> +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/apigw-lambda-opensearch-serverless-nextgen Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please 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. @@ -23,7 +23,7 @@ Important: this application uses various AWS services and there are costs associ ``` 1. Change directory to the pattern directory: ``` - cd apigw-lambda-opensearch-serverless-nextgen + cd serverless-patterns/apigw-lambda-opensearch-serverless-nextgen ``` 1. Build the application: ``` @@ -41,7 +41,6 @@ Important: this application uses various AWS services and there are costs associ Once you have run `sam deploy --guided` mode once and saved arguments to a configuration file (samconfig.toml), you can use `sam deploy` in future to use these defaults. -1. Note the outputs from the SAM deployment process. These contain the API Gateway endpoint URLs for search, index, and delete operations. ## How it works @@ -51,20 +50,15 @@ Figure 1 - Architecture This pattern creates a REST API backed by three Lambda functions that interact with an OpenSearch Serverless NextGen collection configured for vector search: -1. The client sends an HTTPS request (SigV4-signed) to Amazon API Gateway. +1. The client sends an HTTPS request (SigV4-signed) to Amazon API Gateway with IAM authorization. 2. API Gateway routes the request to the appropriate Lambda function based on path: Search (`POST /search`), Index (`POST /index`), or Delete (`DELETE /documents`). -3. The Lambda function calls the OpenSearch Serverless collection — performing a neural/lexical/hybrid query, bulk indexing via the ingest pipeline, or a bulk delete. -4. For semantic and hybrid search, and during document indexing, the OpenSearch ML model calls Amazon Bedrock Titan Embed Text V2 to generate 1024-dimensional embeddings server-side. +3. The Lambda function calls the OpenSearch Serverless collection — performing a neural/lexical/hybrid query, bulk indexing via the ingest pipeline, or a bulk delete. Lambda functions send and receive plain text only; no client-side embedding generation is needed. +4. For semantic and hybrid search, and during document indexing, the OpenSearch ML model calls Amazon Bedrock (Amazon Titan Text Embeddings V2) to generate 1024-dimensional embeddings server-side. 5. For hybrid search, the search pipeline applies min-max score normalization to combine BM25 (lexical) and k-NN (semantic) results with configurable weights (0.3 lexical / 0.7 semantic). The OpenSearch collection lives inside a NextGen collection group, which enables scale-to-zero behavior. When idle, both indexing and search OCUs (OpenSearch Compute Units) drop to zero. When a request arrives, capacity provisions in approximately 10 seconds. Requests are queued (not dropped) during this window. -Key architectural decisions: - -- **IAM authorization** on API Gateway — all endpoints require SigV4-signed requests. -- **Server-side embeddings** — OpenSearch handles all embedding generation via its ML model connector to Bedrock. Lambda functions send and receive plain text only. -- **Hybrid search with score normalization** — A search pipeline applies min-max normalization to combine BM25 (lexical) and k-NN (semantic) scores with configurable weights (0.3 lexical / 0.7 semantic). -- **Custom resources** — The NextGen collection group and ML pipeline setup use Lambda-backed custom resources since CloudFormation doesn't yet natively support the `Generation` parameter. +The NextGen collection group is created using a Lambda-backed custom resources since CloudFormation doesn't yet natively support the `Generation` parameter. ## Testing diff --git a/apigw-lambda-opensearch-serverless-nextgen/example-pattern.json b/apigw-lambda-opensearch-serverless-nextgen/example-pattern.json index f7ce56447..4cb89cec1 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/example-pattern.json +++ b/apigw-lambda-opensearch-serverless-nextgen/example-pattern.json @@ -7,9 +7,9 @@ "introBox": { "headline": "How it works", "text": [ - "This pattern deploys an API Gateway REST API backed by three Lambda functions that perform semantic, lexical, and hybrid search against an OpenSearch Serverless NextGen collection.", - "OpenSearch Serverless NextGen scales compute to zero when idle and provisions in approximately 10 seconds when traffic arrives. Combined with Lambda's own scale-to-zero, the entire stack incurs zero compute cost when not in use.", - "Embeddings are generated server-side by an OpenSearch ML model connected to Amazon Bedrock Titan Embed Text V2 — Lambda functions send and receive plain text only.", + "This pattern deploys an Amazon API Gateway REST API backed by three AWS Lambda functions that perform semantic, lexical, and hybrid search against an Amazon OpenSearch Serverless NextGen collection.", + "Amazon OpenSearch Serverless NextGen scales compute to zero when idle and provisions in approximately 10 seconds when traffic arrives. Combined with Lambda's own scale-to-zero, the entire stack incurs zero compute cost when not in use.", + "Embeddings are generated server-side by an OpenSearch ML model connected to Amazon Bedrock (Amazon Titan Text Embeddings V2) — Lambda functions send and receive plain text only.", "A hybrid search pipeline applies min-max score normalization to combine BM25 (lexical) and k-NN (semantic) results with configurable weights." ] }, @@ -32,7 +32,7 @@ "link": "https://opensearch.org/docs/latest/search-plugins/neural-search/" }, { - "text": "Amazon Bedrock Titan Embeddings", + "text": "Amazon Titan Text Embeddings V2", "link": "https://docs.aws.amazon.com/bedrock/latest/userguide/titan-embedding-models.html" } ] From 2b58a234a972098a32fe76ca1343ffe8af82a136 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Mon, 8 Jun 2026 11:43:27 +0100 Subject: [PATCH 03/12] fix(opensearch-nextgen): address scan findings for service naming and cleanup --- apigw-lambda-opensearch-serverless-nextgen/README.md | 8 +++++--- .../lambda/custom_resources/setup_pipeline/app.py | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apigw-lambda-opensearch-serverless-nextgen/README.md b/apigw-lambda-opensearch-serverless-nextgen/README.md index 14ca92b33..21a426029 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/README.md +++ b/apigw-lambda-opensearch-serverless-nextgen/README.md @@ -1,6 +1,6 @@ # Amazon API Gateway to AWS Lambda to Amazon OpenSearch Serverless NextGen -This pattern deploys a serverless semantic search API using Amazon API Gateway, AWS Lambda, and Amazon OpenSearch Serverless with the NextGen architecture. All three services operate on a pay-per-use model with no minimum baseline cost, meaning the entire stack incurs zero compute charges when idle. +This pattern deploys a serverless semantic search API using Amazon API Gateway, AWS Lambda, and Amazon OpenSearch Serverless with the NextGen architecture. All three services operate on a pay-per-use model with no minimum baseline cost, meaning the entire stack incurs zero compute charges when idle. You pay only for storage of indexed data. Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/apigw-lambda-opensearch-serverless-nextgen @@ -48,7 +48,7 @@ Important: this application uses various AWS services and there are costs associ Figure 1 - Architecture -This pattern creates a REST API backed by three Lambda functions that interact with an OpenSearch Serverless NextGen collection configured for vector search: +This pattern creates a REST API backed by three AWS Lambda functions that interact with an OpenSearch Serverless NextGen collection configured for vector search: 1. The client sends an HTTPS request (SigV4-signed) to Amazon API Gateway with IAM authorization. 2. API Gateway routes the request to the appropriate Lambda function based on path: Search (`POST /search`), Index (`POST /index`), or Delete (`DELETE /documents`). @@ -139,12 +139,14 @@ awscurl --service execute-api --region $AWS_REGION -X DELETE \ ## Cleanup +> **Warning:** This will permanently delete all indexed documents in the OpenSearch collection. Back up any data you need to retain before proceeding. + 1. Delete the stack: ```bash sam delete --stack-name STACK_NAME ``` - This removes all resources including the OpenSearch collection, collection group, security policies, and Lambda functions. + This removes all resources including the API Gateway, Lambda functions, OpenSearch collection, collection group, security policies, IAM roles, and CloudWatch log groups. ---- Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/setup_pipeline/app.py b/apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/setup_pipeline/app.py index d58e49b1e..5a2ca39c0 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/setup_pipeline/app.py +++ b/apigw-lambda-opensearch-serverless-nextgen/lambda/custom_resources/setup_pipeline/app.py @@ -1,7 +1,7 @@ """Custom Resource handler to set up ML model and pipelines at deploy time. Creates: -- A Bedrock Titan V2 ML connector +- An Amazon Bedrock Titan V2 ML connector - A registered and deployed ML model - An ingest pipeline with text_embedding processor - A hybrid search pipeline with min-max normalization @@ -81,7 +81,7 @@ def _setup_ml_and_pipelines(event): # Step 1: Create the Bedrock connector (with retry for policy propagation) connector_body = { - "name": "Bedrock Titan Embeddings V2", + "name": "Amazon Bedrock Titan Embeddings V2", "description": "Connector for Amazon Bedrock Titan Text Embeddings V2", "version": "1.0", "protocol": "aws_sigv4", From 36237cdc248798ba354775d507f42a5daba7f265 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Mon, 8 Jun 2026 12:17:16 +0100 Subject: [PATCH 04/12] docs(opensearch-nextgen): add region availability note for AI connectors --- apigw-lambda-opensearch-serverless-nextgen/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apigw-lambda-opensearch-serverless-nextgen/README.md b/apigw-lambda-opensearch-serverless-nextgen/README.md index 21a426029..62c4b67b6 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/README.md +++ b/apigw-lambda-opensearch-serverless-nextgen/README.md @@ -14,6 +14,8 @@ Important: this application uses various AWS services and there are costs associ * [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) installed * [Python 3.14](https://www.python.org/downloads/) +**Region availability:** This pattern uses OpenSearch Serverless AI connectors and hybrid search, which are available in the following regions: US East (N. Virginia), US East (Ohio), US West (Oregon), Asia Pacific (Mumbai), Asia Pacific (Singapore), Asia Pacific (Sydney), Asia Pacific (Tokyo), Europe (Frankfurt), Europe (Ireland), Europe (Spain), and Europe (Stockholm). See the [launch announcement](https://aws.amazon.com/about-aws/whats-new/2025/08/amazon-opensearch-serverless-ai-connectors-hybrid-search/) for details. + ## Deployment Instructions From 322aa182b73cc9947100bfee9ddb449770fef937 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Mon, 8 Jun 2026 12:17:32 +0100 Subject: [PATCH 05/12] fix(opensearch-nextgen): add missing deps to test requirements --- .../tests/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/requirements.txt b/apigw-lambda-opensearch-serverless-nextgen/tests/requirements.txt index 8226d697b..7bd7ca3c9 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/tests/requirements.txt +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/requirements.txt @@ -3,3 +3,5 @@ requests>=2.34.2 requests-aws4auth>=1.3.2 boto3>=1.43 aws-lambda-powertools[tracer]>=3.0 +crhelper +opensearch-py>=2.8.0 From f8a2de0a99cf317111c38a436a7ebc798c925e39 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Mon, 8 Jun 2026 12:21:31 +0100 Subject: [PATCH 06/12] fix(opensearch-nextgen): require STACK_NAME and AWS_REGION for integration tests --- apigw-lambda-opensearch-serverless-nextgen/README.md | 4 ++++ .../tests/integration/test_search.py | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apigw-lambda-opensearch-serverless-nextgen/README.md b/apigw-lambda-opensearch-serverless-nextgen/README.md index 62c4b67b6..630c7c0e0 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/README.md +++ b/apigw-lambda-opensearch-serverless-nextgen/README.md @@ -83,6 +83,10 @@ pytest tests/unit/ -v The repository includes integration tests that exercise all three search modes against a 50-product outdoor equipment catalog: ```bash +# Set your stack name and region +export STACK_NAME="your-stack-name" +export AWS_REGION="your-region" + # Run integration tests (requires a deployed stack) pytest tests/integration/ -v -s ``` diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py b/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py index fd84322c4..0a945a129 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py @@ -21,8 +21,13 @@ import requests from requests_aws4auth import AWS4Auth -STACK_NAME = os.environ.get("STACK_NAME", "lambda-opensearch-nextgen") -REGION = os.environ.get("AWS_REGION", "eu-west-1") +STACK_NAME = os.environ.get("STACK_NAME") +REGION = os.environ.get("AWS_REGION") + +if not STACK_NAME: + raise RuntimeError("STACK_NAME environment variable must be set") +if not REGION: + raise RuntimeError("AWS_REGION environment variable must be set") def _get_auth(): From 38cb1037eee0f1719000bc955f28bf8aa24a98a3 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Mon, 8 Jun 2026 12:24:42 +0100 Subject: [PATCH 07/12] fix(opensearch-nextgen): use pytest.skip for missing env vars in integration tests --- .../tests/integration/test_search.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py b/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py index 0a945a129..a37694917 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py @@ -24,10 +24,11 @@ STACK_NAME = os.environ.get("STACK_NAME") REGION = os.environ.get("AWS_REGION") -if not STACK_NAME: - raise RuntimeError("STACK_NAME environment variable must be set") -if not REGION: - raise RuntimeError("AWS_REGION environment variable must be set") +if not STACK_NAME or not REGION: + pytest.skip( + "STACK_NAME and AWS_REGION environment variables must be set", + allow_module_level=True, + ) def _get_auth(): From 8bb9af7554cf25226a3a9a28a99dcfac228bb2a4 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Mon, 8 Jun 2026 14:03:06 +0100 Subject: [PATCH 08/12] docs(opensearch-nextgen): simplify cleanup command to use samconfig defaults --- apigw-lambda-opensearch-serverless-nextgen/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apigw-lambda-opensearch-serverless-nextgen/README.md b/apigw-lambda-opensearch-serverless-nextgen/README.md index 630c7c0e0..ce544e210 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/README.md +++ b/apigw-lambda-opensearch-serverless-nextgen/README.md @@ -149,7 +149,7 @@ awscurl --service execute-api --region $AWS_REGION -X DELETE \ 1. Delete the stack: ```bash - sam delete --stack-name STACK_NAME + sam delete ``` This removes all resources including the API Gateway, Lambda functions, OpenSearch collection, collection group, security policies, IAM roles, and CloudWatch log groups. From 224eb3fe98aae91b64d86924c54d0f729907c6cb Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Mon, 8 Jun 2026 14:03:22 +0100 Subject: [PATCH 09/12] fix(opensearch-nextgen): use pytest.exit for clear env var error message --- .../tests/integration/test_search.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py b/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py index a37694917..b961e138d 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py +++ b/apigw-lambda-opensearch-serverless-nextgen/tests/integration/test_search.py @@ -25,9 +25,9 @@ REGION = os.environ.get("AWS_REGION") if not STACK_NAME or not REGION: - pytest.skip( - "STACK_NAME and AWS_REGION environment variables must be set", - allow_module_level=True, + pytest.exit( + "STACK_NAME and AWS_REGION environment variables must be set to run integration tests", + returncode=1, ) From 00ce20520eaae16f7b62129bb4176746eb426c03 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Mon, 8 Jun 2026 14:43:55 +0100 Subject: [PATCH 10/12] docs(opensearch-nextgen): add venv setup before installing test deps --- apigw-lambda-opensearch-serverless-nextgen/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apigw-lambda-opensearch-serverless-nextgen/README.md b/apigw-lambda-opensearch-serverless-nextgen/README.md index ce544e210..e490a3647 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/README.md +++ b/apigw-lambda-opensearch-serverless-nextgen/README.md @@ -67,6 +67,8 @@ The NextGen collection group is created using a Lambda-backed custom resources s Install the test dependencies: ```bash +python -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate pip install -r tests/requirements.txt ``` @@ -101,7 +103,7 @@ Install the project dependencies (includes `awscurl`): pip install -r requirements.txt ``` -Set your stack name and region: +Set your stack name and region (if not already set): ```bash STACK_NAME="your-stack-name" From 990de22aab569abaed459b97f92f6d9a75bee7a4 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Mon, 8 Jun 2026 14:44:10 +0100 Subject: [PATCH 11/12] docs(opensearch-nextgen): add NextGen launch post to pattern resources --- .../example-pattern.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apigw-lambda-opensearch-serverless-nextgen/example-pattern.json b/apigw-lambda-opensearch-serverless-nextgen/example-pattern.json index 4cb89cec1..1c3f1a9f5 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/example-pattern.json +++ b/apigw-lambda-opensearch-serverless-nextgen/example-pattern.json @@ -23,6 +23,10 @@ }, "resources": { "bullets": [ + { + "text": "Introducing the next generation of Amazon OpenSearch Serverless", + "link": "https://aws.amazon.com/blogs/aws/introducing-the-next-generation-of-amazon-opensearch-serverless-for-building-your-agentic-ai-applications/" + }, { "text": "Amazon OpenSearch Serverless", "link": "https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless.html" From f9cdb9bff0ee751c33a8c0a21ecbc9b83cd0b9d3 Mon Sep 17 00:00:00 2001 From: Pete Davis Date: Mon, 8 Jun 2026 14:56:34 +0100 Subject: [PATCH 12/12] docs(opensearch-nextgen): add scale-to-zero CloudWatch metrics to README --- .../README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apigw-lambda-opensearch-serverless-nextgen/README.md b/apigw-lambda-opensearch-serverless-nextgen/README.md index e490a3647..ad106b78d 100644 --- a/apigw-lambda-opensearch-serverless-nextgen/README.md +++ b/apigw-lambda-opensearch-serverless-nextgen/README.md @@ -54,13 +54,27 @@ This pattern creates a REST API backed by three AWS Lambda functions that intera 1. The client sends an HTTPS request (SigV4-signed) to Amazon API Gateway with IAM authorization. 2. API Gateway routes the request to the appropriate Lambda function based on path: Search (`POST /search`), Index (`POST /index`), or Delete (`DELETE /documents`). -3. The Lambda function calls the OpenSearch Serverless collection — performing a neural/lexical/hybrid query, bulk indexing via the ingest pipeline, or a bulk delete. Lambda functions send and receive plain text only; no client-side embedding generation is needed. +3. The Lambda function calls the OpenSearch Serverless collection — performing a neural/lexical/hybrid query, bulk indexing via the ingest pipeline, or a bulk delete. 4. For semantic and hybrid search, and during document indexing, the OpenSearch ML model calls Amazon Bedrock (Amazon Titan Text Embeddings V2) to generate 1024-dimensional embeddings server-side. 5. For hybrid search, the search pipeline applies min-max score normalization to combine BM25 (lexical) and k-NN (semantic) results with configurable weights (0.3 lexical / 0.7 semantic). The OpenSearch collection lives inside a NextGen collection group, which enables scale-to-zero behavior. When idle, both indexing and search OCUs (OpenSearch Compute Units) drop to zero. When a request arrives, capacity provisions in approximately 10 seconds. Requests are queued (not dropped) during this window. -The NextGen collection group is created using a Lambda-backed custom resources since CloudFormation doesn't yet natively support the `Generation` parameter. +The NextGen collection group is created using a Lambda-backed custom resource since CloudFormation doesn't yet natively support the `Generation` parameter. + +### Scale-to-zero in action + +The chart below shows the OCU (OpenSearch Compute Unit) metrics from CloudWatch during a test run: + +![CloudWatch metrics showing Search and Indexing OCUs scaling from zero, handling traffic, then returning to zero](images/search-acu-scaling.png) + +*Figure 2 — Two test runs separated by a period of no activity. Search OCUs scale 0 → 2 during queries, Indexing OCUs scale 0 → 1 during document ingestion. Both return to 0 after the idle timeout.* + +The pattern: +1. **Idle** — Both indexing and search OCUs sit at 0. No compute cost. +2. **Traffic arrives** — First request triggers provisioning (~10 seconds). Requests are queued during this window. +3. **Active** — OCUs scale up to match demand, up to the configured maximum. +4. **Traffic subsides** — After 10 minutes of no requests, OCUs scale back to 0. ## Testing