Go vs Python: Serverless RAG on Lambda

Deploy a RAG service in Go and Python, then prove where Go wins with load tests.

Introduction

30 Second Summary

Every engineering team eventually hits the question: does Go actually outperform Python when both run on managed serverless infrastructure? The cloud provider handles scaling, cold starts happen either way, and the actual work is waiting on an API call. It is not obvious where raw language speed still matters.

In this project, you will build the same RAG question-answering service twice and deploy both to AWS Lambda. You will then blast both with burst traffic and collect hard numbers that prove exactly where Go wins and where the cloud neutralizes its advantage.

What You'll Build

You will run a load test against two live Lambda endpoints, watch the latency distributions diverge in real time, and pull CloudWatch metrics that settle the Go vs Python debate with data instead of opinions.

By the end of this project, you'll have:

  • A live Go RAG Lambda that retrieves context from a Bedrock Knowledge Base and generates answers via Claude Haiku 4.5, alongside an identical Python implementation as the controlled baseline.
  • Empirical load test results showing Go's P99 latency advantage under 50 concurrent burst requests, with CloudWatch metrics confirming cold start and billed duration differences.
  • A concrete cost-per-query analysis calculated from real Lambda pricing that quantifies exactly how much you save per thousand queries with Go.
  • Secret Mission: Add a concurrent batch query endpoint using Go's goroutine worker pool pattern that processes 10 questions in the wall-time of one.

Are there any prerequisites?

You need Go, Python, Docker, and the AWS CLI installed and configured with access to us-east-1. You also need Amazon Bedrock model access enabled for Claude Haiku 4.5 in your AWS account.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to test Go's performance advantage specifically under serverless burst conditions.

Set Up Infrastructure

Your RAG service needs two things to function: documents to retrieve from, and a way to prove its performance under pressure.

In this step, you will create an S3 bucket filled with sample documentation, wire it up to a Bedrock Knowledge Base for vector retrieval, and confirm your load testing tool is ready to generate burst traffic.

In this step, get ready to:
  • Create an S3 bucket with sample documentation for RAG retrieval.
  • Set up a Bedrock Knowledge Base pointing to your documents.
  • Verify the hey load testing tool is ready for burst tests.
Create sample documentation and upload to S3

Your Knowledge Base needs real text to index and retrieve from. You will create a set of markdown files about a fictional product called NexaFlow, then upload them to an S3 bucket.

  • Create a docs folder and the first two documentation files by running these commands:
mkdir docs

cat << 'EOF' > docs/faq.md
# NexaFlow FAQ

## What is NexaFlow?
NexaFlow is a high-performance API gateway that routes traffic between microservices with sub-millisecond overhead.

## What protocols does NexaFlow support?
NexaFlow supports HTTP/1.1, HTTP/2, gRPC, and WebSocket connections out of the box.

## What is the maximum throughput?
NexaFlow handles up to 100,000 requests per second per node in standard configuration.

## How does NexaFlow handle failures?
NexaFlow uses circuit breakers and automatic retries with exponential backoff to handle downstream service failures gracefully.
EOF

cat << 'EOF' > docs/architecture.md
# NexaFlow Architecture

## Core Components
NexaFlow consists of three components: the Router, the Service Registry, and the Health Monitor.

## The Router
The Router receives incoming requests and forwards them to healthy backend services using consistent hashing.

## The Service Registry
Services register themselves on startup and send heartbeats every 10 seconds. The registry removes services after 3 missed heartbeats.

## The Health Monitor
The Health Monitor runs active health checks against all registered services every 5 seconds and updates routing tables automatically.
EOF

What does this code do?

You are creating markdown files that simulate real product documentation. The Knowledge Base will chunk and index these files so your RAG service can retrieve relevant passages when answering questions.

  • Confirm the first two files were created by running:
ls docs/

You should see faq.md and architecture.md listed.

  • Create the remaining three documentation files by running these commands:
cat << 'EOF' > docs/troubleshooting.md
# NexaFlow Troubleshooting

## Connection Timeouts
If requests timeout, check that the Health Monitor has not removed the target service. Run nexaflow status to see active services.

## High Latency
High latency usually indicates the Router's consistent hash ring is rebalancing. This resolves within 30 seconds after a service joins or leaves.

## Memory Usage
Each NexaFlow node caches the last 1000 routing decisions. If memory exceeds 512MB per node, reduce the cache size in nexaflow.yaml.
EOF

cat << 'EOF' > docs/getting-started.md
# Getting Started with NexaFlow

## Installation
Install NexaFlow using the official binary: curl -sSL https://nexaflow.io/install | sh

## Configuration
Create a nexaflow.yaml file with your upstream services listed under the routes section.

## Running
Start NexaFlow with: nexaflow serve --config nexaflow.yaml
The gateway listens on port 8080 by default.
EOF

cat << 'EOF' > docs/deployment.md
# NexaFlow Deployment Guide

## Container Deployment
NexaFlow ships as a 12MB container image. Deploy with: docker run -p 8080:8080 nexaflow/gateway:latest

## Scaling
Run multiple NexaFlow nodes behind a network load balancer. Each node discovers peers via the Service Registry and shares routing state.

## Monitoring
NexaFlow exposes Prometheus metrics on port 9090. Key metrics include nexaflow_requests_total, nexaflow_latency_p99, and nexaflow_circuit_breaker_trips.
EOF
  • Confirm all five files exist by running:
ls docs/

You should see architecture.md, deployment.md, faq.md, getting-started.md, and troubleshooting.md listed.

  • Create your S3 bucket by running this command:
aws s3 mb s3://[[BUCKET_NAME="your-unique-rag-bucket-name"]] --region us-east-1

Why does my bucket name need to be unique?

S3 bucket names are globally unique across all AWS accounts. Include your name or a random string to avoid collisions.

  • Upload all documentation files to your bucket by running:
aws s3 cp docs/ s3://[[BUCKET_NAME="your-unique-rag-bucket-name"]]/docs/ --recursive
  • Verify the upload succeeded by listing your bucket contents:
aws s3 ls s3://[[BUCKET_NAME="your-unique-rag-bucket-name"]]/docs/

You should see all five markdown files listed with their sizes and timestamps.

Seeing an access denied error?

Make sure your AWS CLI is configured with credentials that have S3 permissions. Run aws sts get-caller-identity to confirm you are authenticated.

If the bucket name is taken, pick a different unique name and re-run the aws s3 mb command.

Help me troubleshoot my S3 bucket creation

Create a Bedrock Knowledge Base

Now you will create a Knowledge Base in the Bedrock console that indexes your S3 documents into a vector store for retrieval.

  • Open the Amazon Bedrock console at https://console.aws.amazon.com/bedrock.
  • Confirm you are in the us-east-1 region (check the top-right corner of the console).
  • In the left navigation pane, click Knowledge bases.
  • Click the Create button and select Knowledge Base with vector store.
  • Enter a name for your Knowledge Base (for example, go-rag-demo-kb).
  • Under IAM permissions, select Create and use a new service role.
  • Choose Amazon S3 as your data source and click Next.
  • For the S3 URI, enter s3:// followed by your bucket name (for example, s3://your-unique-rag-bucket-name/docs/).
  • In the Embeddings model section, select Titan Embeddings G1 - Text.
  • In the Vector database section, select Quick create a new vector store.
  • Click Next, review your settings, then click Create knowledge base.

What is happening behind the scenes?

Bedrock provisions an OpenSearch Serverless collection to store vector embeddings. It also creates an IAM role granting itself permission to read from your S3 bucket. This process takes 1-2 minutes.

Once the Knowledge Base status shows Available, Bedrock is ready to index your documents.

  • Once the Knowledge Base shows Available, select your Knowledge Base from the list.
  • In the Data source section, click Sync to start indexing your documents.
  • Wait for the sync status to show Completed. This takes 1-3 minutes.
  • Copy the Knowledge Base ID shown at the top of the Knowledge Base detail page. Save this value. You will use it as an environment variable in your Lambda functions.

Save your Knowledge Base ID now

The Knowledge Base ID looks something like ABCDE12345. You will need this exact value when configuring both the Go and Python Lambda functions in the next steps.

Verify your infrastructure is ready

Before moving on, confirm both your Knowledge Base and your load testing tool are operational.

  • Verify your Knowledge Base is ACTIVE by running this command:
aws bedrock-agent get-knowledge-base --knowledge-base-id [[KB_ID="your-knowledge-base-id"]]

Before you run this, what status do you expect to see in the output?

You should see a JSON response with "status": "ACTIVE" in the knowledgeBase object. This confirms your documents have been indexed and the Knowledge Base is ready for retrieval queries.

  • Verify hey is installed and ready by running:
hey https://example.com -n 1 -c 1

You should see a summary output showing request timing statistics. This confirms hey is installed correctly and can make HTTP requests.

Knowledge Base not showing ACTIVE?

If the status shows CREATING, wait another minute and run the command again. The vector store provisioning can take a few minutes.

If the sync failed, check that your S3 bucket URI exactly matches the path you uploaded to (including the trailing /docs/ prefix).

Help me troubleshoot my Bedrock Knowledge Base status

Your infrastructure is locked in. You have documents indexed and ready for retrieval, and a load testing tool standing by. Next up, you will build the Go RAG service that turns these documents into AI-generated answers.

Build the Go RAG Service

Your infrastructure is ready. You have a Bedrock Knowledge Base loaded with documents and a load testing tool standing by.

Now you need the service that actually answers questions. It must retrieve relevant context from your Knowledge Base, feed that context to Claude Haiku 4.5 via the Converse API, and return a generated answer. Building and testing it locally first means you can validate the entire RAG pipeline before deploying to Lambda.

In this step, get ready to:
  • Write the Go Lambda handler with Retrieve and Converse API calls.
  • Build a multi-stage Docker container image targeting Lambda.
  • Test locally using the Lambda Runtime Interface Emulator.
Write the Go Lambda handler

The handler accepts a JSON question, calls the Bedrock Agent Runtime Retrieve API to fetch relevant document chunks, assembles those chunks into a system prompt, then calls the Bedrock Runtime Converse API to generate an answer.

  • Create a new project directory and move into it by running these commands:
mkdir go-rag-lambda
cd go-rag-lambda
  • Create the go.mod file that declares your module by running:
cat > go.mod << 'EOF'
module go-rag-lambda

go 1.22
EOF
  • Confirm the file was created by running:
cat go.mod

You should see your module name and Go version printed.

  • Create main.go and add the package declaration and imports by pasting the following code into the file:
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"os"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime"
	brt "github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
	"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types"
)

What do these imports do?

  • aws-lambda-go/events and aws-lambda-go/lambda provide the Lambda handler interface and Function URL event types.
  • bedrockagentruntime is the SDK client for the Knowledge Base Retrieve API.
  • bedrockruntime (aliased as brt) is the SDK client for the Converse API that generates answers.
  • Below the imports, add the global variables, init() function, and request/response types:
var (
	bedrockClient      *brt.Client
	agentRuntimeClient *bedrockagentruntime.Client
	knowledgeBaseID    string
	modelID            string
)

func init() {
	knowledgeBaseID = os.Getenv("KNOWLEDGE_BASE_ID")
	modelID = os.Getenv("MODEL_ID")

	cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion("us-east-1"))
	if err != nil {
		log.Fatalf("unable to load AWS config: %v", err)
	}

	bedrockClient = brt.NewFromConfig(cfg)
	agentRuntimeClient = bedrockagentruntime.NewFromConfig(cfg)
}

type RequestBody struct {
	Question string `json:"question"`
}

type ResponseBody struct {
	Answer  string   `json:"answer"`
	Sources []string `json:"sources"`
}

What does this code do?

  • init() runs once at cold start. It reads KNOWLEDGE_BASE_ID and MODEL_ID from environment variables, loads AWS config, and creates both SDK clients.
  • RequestBody defines the shape of incoming JSON. ResponseBody defines the shape the function returns.
  • Download all the dependencies by running:
go mod tidy

You should see several go: downloading ... lines as Go fetches the AWS SDK packages. A go.sum file appears in your directory.

Seeing module download errors?

  • Make sure you saved main.go with the exact import paths shown above. A typo in any import path causes go mod tidy to fail.
  • Confirm you have internet access. The Go module proxy needs to reach proxy.golang.org.

Help me debug go mod tidy download failures.

  • Now add the handler function below the types in main.go:
func handler(ctx context.Context, request events.LambdaFunctionURLRequest) (events.LambdaFunctionURLResponse, error) {
	var reqBody RequestBody
	if err := json.Unmarshal([]byte(request.Body), &reqBody); err != nil {
		return events.LambdaFunctionURLResponse{StatusCode: 400, Body: `{"error":"invalid request body"}`}, nil
	}

	if reqBody.Question == "" {
		return events.LambdaFunctionURLResponse{StatusCode: 400, Body: `{"error":"question is required"}`}, nil
	}

	chunks, sources, err := retrieveContext(ctx, reqBody.Question)
	if err != nil {
		return events.LambdaFunctionURLResponse{StatusCode: 500, Body: fmt.Sprintf(`{"error":"retrieve failed: %v"}`, err)}, nil
	}

	answer, err := generateAnswer(ctx, reqBody.Question, chunks)
	if err != nil {
		return events.LambdaFunctionURLResponse{StatusCode: 500, Body: fmt.Sprintf(`{"error":"generate failed: %v"}`, err)}, nil
	}

	respBody := ResponseBody{Answer: answer, Sources: sources}
	respJSON, _ := json.Marshal(respBody)

	return events.LambdaFunctionURLResponse{
		StatusCode: 200,
		Headers:    map[string]string{"Content-Type": "application/json"},
		Body:       string(respJSON),
	}, nil
}

What does the handler do?

This is the entry point for every request. It parses the incoming JSON, validates that a question exists, calls retrieveContext to get relevant chunks from the Knowledge Base, then calls generateAnswer to produce a response using Claude.

  • Add the retrieveContext function below the handler:
func retrieveContext(ctx context.Context, query string) (string, []string, error) {
	input := &bedrockagentruntime.RetrieveInput{
		KnowledgeBaseId: aws.String(knowledgeBaseID),
		RetrievalQuery: &bedrockagentruntime.RetrievalQuery{
			Text: aws.String(query),
		},
		RetrievalConfiguration: &bedrockagentruntime.RetrievalConfiguration{
			VectorSearchConfiguration: &bedrockagentruntime.VectorSearchConfiguration{
				NumberOfResults: aws.Int32(3),
			},
		},
	}

	result, err := agentRuntimeClient.Retrieve(ctx, input)
	if err != nil {
		return "", nil, err
	}

	var contextText string
	var sources []string
	for _, chunk := range result.RetrievalResults {
		if chunk.Content != nil && chunk.Content.Text != nil {
			contextText += *chunk.Content.Text + "\n\n"
		}
		if chunk.Location != nil && chunk.Location.S3Location != nil && chunk.Location.S3Location.Uri != nil {
			sources = append(sources, *chunk.Location.S3Location.Uri)
		}
	}

	return contextText, sources, nil
}

What does retrieveContext do?

It calls the Bedrock Agent Runtime Retrieve API with the user's question. The API performs a vector similarity search against your Knowledge Base and returns the top 3 most relevant document chunks, plus the S3 URIs they came from.

  • Add the generateAnswer function and the main function below retrieveContext:
func generateAnswer(ctx context.Context, question string, contextText string) (string, error) {
	systemPrompt := fmt.Sprintf("You are a helpful assistant. Answer the user's question based only on the following context. If the context does not contain enough information, say so.\n\nContext:\n%s", contextText)

	userMessage := types.Message{
		Role: types.ConversationRoleUser,
		Content: []types.ContentBlock{
			&types.ContentBlockMemberText{Value: question},
		},
	}

	result, err := bedrockClient.Converse(ctx, &brt.ConverseInput{
		ModelId:  aws.String(modelID),
		Messages: []types.Message{userMessage},
		System: []types.SystemContentBlock{
			&types.SystemContentBlockMemberText{Value: systemPrompt},
		},
	})
	if err != nil {
		return "", err
	}

	responseMessage, ok := result.Output.(*types.ConverseOutputMemberMessage)
	if !ok {
		return "", fmt.Errorf("unexpected output type from Converse API")
	}

	text, ok := responseMessage.Value.Content[0].(*types.ContentBlockMemberText)
	if !ok {
		return "", fmt.Errorf("unexpected content block type from Converse API")
	}

	return text.Value, nil
}

func main() {
	lambda.Start(handler)
}

What does generateAnswer do?

It builds a system prompt containing the retrieved context, then calls the Bedrock Runtime Converse API with Claude Haiku 4.5. The Converse API is model-agnostic. You could swap the model ID to use any Bedrock model without changing the code structure.

The main() function simply registers the handler with the Lambda runtime.

  • Save main.go and verify the code compiles by running:
go build -tags lambda.norpc -o main main.go

If the build succeeds, you see no output and a main binary appears in your directory.

  • Confirm the binary exists by running:
ls -lh main

You should see the main file listed at roughly 8-12MB. That small binary size is one of Go's key advantages for Lambda.

Build failed with undefined errors?

  • Check that all four functions (handler, retrieveContext, generateAnswer, main) are present in main.go. Go requires all referenced symbols to exist before compilation.
  • Ensure you ran go mod tidy after pasting the imports. Missing dependencies cause unresolved import errors.

Help me fix Go build errors in my Lambda handler.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and that go.mod, go.sum, and the main binary all exist in your go-rag-lambda directory.

ⓧ I'd like to double check the full code

Scroll up and confirm your main.go contains these sections in order:

  • Package declaration and imports (the import (...) block with 8 standard library and AWS SDK packages).
  • Global variables (bedrockClient, agentRuntimeClient, knowledgeBaseID, modelID) and the init() function.
  • RequestBody and ResponseBody struct definitions.
  • handler function accepting LambdaFunctionURLRequest.
  • retrieveContext function calling the Retrieve API.
  • generateAnswer function calling the Converse API.
  • main() function calling lambda.Start(handler).
Build the Docker container image

Lambda runs containers from ECR. You need a multi-stage Dockerfile that compiles your Go binary in one stage and copies it into the minimal provided:al2023 base image. This base image includes the Lambda Runtime Interface Emulator for local testing.

  • Create a file called Dockerfile in your go-rag-lambda directory with the following content:
FROM golang:1.22 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY main.go .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags lambda.norpc -o main main.go

FROM public.ecr.aws/lambda/provided:al2023
COPY --from=build /app/main ./main
ENTRYPOINT ["./main"]

What does this Dockerfile do?

  • The first stage (golang:1.22) compiles the binary with CGO disabled for a static Linux executable.
  • The second stage (public.ecr.aws/lambda/provided:al2023) is a minimal Amazon Linux image that includes only the Lambda runtime emulator. The final image is roughly 8MB.
  • -tags lambda.norpc excludes the deprecated RPC component, reducing the binary size.
  • Build the Docker image by running:
docker buildx build --platform linux/amd64 --provenance=false -t go-rag-lambda:latest .

The build takes 30-60 seconds as it downloads the Go image and compiles. You should see a success message at the end.

  • Confirm the image was created by running:
docker images go-rag-lambda

You should see one row with the tag latest and a size of approximately 30-40MB (the base OS layer plus your 8MB binary).

Docker build failed?

  • Confirm Docker Desktop is running. If docker commands fail with a connection error, start Docker Desktop first.
  • Check that go.sum exists in your project directory. The Dockerfile copies both go.mod and go.sum before running go mod download.

Help me fix my Docker build for the Go Lambda image.

Test locally with the Lambda Runtime Interface Emulator

The provided:al2023 base image includes the Lambda Runtime Interface Emulator. You can run the container locally and send it requests just like Lambda would.

  • Start the container by running:
docker run --platform linux/amd64 -d -p 9000:8080 \
  -e KNOWLEDGE_BASE_ID=[[KB_ID="your Knowledge Base ID from Step 1"]] \
  -e MODEL_ID=anthropic.claude-haiku-4-5-20251001-v1:0 \
  --name go-rag-test \
  go-rag-lambda:latest

What does this command do?

  • -d runs the container in the background.
  • -p 9000:8080 maps your local port 9000 to the container's internal port 8080 where the emulator listens.
  • -e passes the environment variables your handler reads in init().

Before you send a request, what do you think will happen? The handler calls real AWS Bedrock APIs from your local machine. It needs valid AWS credentials and network access to us-east-1.

  • Send a test request to the local endpoint by running:
curl -s -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" \
  -d '{"body":"{\"question\":\"What is the product architecture?\"}"}'

You should receive a JSON response containing an answer field with a generated response about your product's architecture, and a sources array listing the S3 URIs of the documents it retrieved from.

Getting a connection refused or empty response?

  • Run docker logs go-rag-test to see if the container started successfully. Look for AWS credential or config errors.
  • If you see a credentials error, mount your AWS config into the container by adding -v ~/.aws:/root/.aws:ro to the docker run command. Stop the existing container first with docker rm -f go-rag-test.
  • If you see a Bedrock access error, confirm you have model access enabled for Claude Haiku 4.5 in the us-east-1 Bedrock console.

Help me debug my local Lambda container test.

  • Once you have confirmed a successful response, stop and remove the test container by running:
docker rm -f go-rag-test

Your Go RAG service works end to end. Next up, you will deploy both a Go and a Python version to Lambda and start collecting real performance data.

Deploy Both Services to Lambda

Your Go RAG service works locally. Now you need it running on real infrastructure where it can be load tested against a Python equivalent.

Deploying both implementations to identical Lambda functions with the same memory, region, and Function URLs gives you a fair, controlled environment. The container vs zip deployment also highlights a real structural difference: your Go binary produces an ~8MB image while the Python package with boto3 weighs in at ~50MB.

In this step, get ready to:
  • Push the Go container to ECR and deploy it as a Lambda function with a Function URL.
  • Write and deploy an identical Python RAG handler as a zip-based Lambda.
  • Test both Function URLs and compare single-request responses.
Push the Go container to ECR

Amazon ECR is a container registry that stores your Docker images so Lambda can pull from them. You need a repository, then you authenticate Docker to push to it.

  • Create an ECR repository by running this command:
aws ecr create-repository --repository-name go-rag-lambda --region us-east-1
  • Copy the repositoryUri from the output. It looks like 123456789012.dkr.ecr.us-east-1.amazonaws.com/go-rag-lambda.
  • Authenticate Docker with ECR by running:
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com
  • Tag your local Go image for ECR by running:
docker tag go-rag-lambda:latest [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/go-rag-lambda:latest
  • Push the image to ECR by running:
docker push [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/go-rag-lambda:latest

You should see layer upload progress followed by a final digest and size output. The total pushed size will be around 8MB.

Seeing "no basic auth credentials"?

The ECR login token expires after 12 hours. Re-run the aws ecr get-login-password command above to refresh it.

Also confirm your AWS account ID is correct in the command. You can find it by running aws sts get-caller-identity.

help me fix my ECR Docker login

Create the IAM role and deploy the Go Lambda

Both Lambda functions need an IAM role that allows them to call Bedrock APIs. You will create one role and reuse it for both deployments.

  • Create a trust policy file by running:
cat > trust-policy.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"Service": "lambda.amazonaws.com"},
    "Action": "sts:AssumeRole"
  }]
}
EOF
  • Create the IAM role by running:
aws iam create-role \
  --role-name rag-lambda-role \
  --assume-role-policy-document file://trust-policy.json
  • Copy the Arn from the output. You will use it in the next commands.
  • Create a permissions policy file that grants Bedrock access by running:
cat > bedrock-policy.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "bedrock:Retrieve",
        "bedrock:InvokeModel"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "logs:*",
      "Resource": "*"
    }
  ]
}
EOF
  • Attach the inline policy to the role by running:
aws iam put-role-policy \
  --role-name rag-lambda-role \
  --policy-name bedrock-access \
  --policy-document file://bedrock-policy.json

What do these permissions allow?

  • bedrock:Retrieve lets the function query the Knowledge Base for relevant document chunks.
  • bedrock:InvokeModel lets the function call the Converse API with Claude Haiku 4.5.
  • logs:* lets Lambda write execution logs to CloudWatch, which you will need for performance analysis.

IAM role propagation takes a few seconds. Wait about 10 seconds before creating the Lambda function.

  • Deploy the Go Lambda function from the ECR image by running:
aws lambda create-function \
  --function-name go-rag-lambda \
  --package-type Image \
  --code ImageUri=[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/go-rag-lambda:latest \
  --role [[ROLE_ARN="arn:aws:iam::123456789012:role/rag-lambda-role"]] \
  --memory-size 128 \
  --timeout 30 \
  --environment "Variables={KNOWLEDGE_BASE_ID=[[KB_ID="your Knowledge Base ID"]],MODEL_ID=anthropic.claude-haiku-4-5-20251001-v1:0}"

What does this command do?

  • --package-type Image tells Lambda to pull from a container registry instead of a zip file.
  • --memory-size 128 allocates the minimum memory. Go runs comfortably here due to its low overhead.
  • MODEL_ID is set to Claude Haiku 4.5, the fastest and cheapest Claude model on Bedrock.
  • Create a Function URL so you can invoke the Lambda over HTTPS by running:
aws lambda create-function-url-config \
  --function-name go-rag-lambda \
  --auth-type NONE
  • Copy the FunctionUrl from the output. Save it somewhere. You will need it for testing.
  • Grant public access to the Function URL by running:
aws lambda add-permission \
  --function-name go-rag-lambda \
  --statement-id FunctionURLAllowPublicAccess \
  --action lambda:InvokeFunctionUrl \
  --principal "*" \
  --function-url-auth-type NONE
  • Test the Go Lambda by sending a request to the Function URL:
curl -s -X POST [[GO_FUNCTION_URL="your Go Function URL"]] \
  -H "Content-Type: application/json" \
  -d '{"question": "What is the product architecture?"}'

You should see a JSON response with an answer field containing a generated explanation and a sources array listing the S3 URIs it retrieved from.

Getting a timeout or 500 error?

The first invocation triggers a cold start. Wait 5-10 seconds and try again. If it persists, check that your KNOWLEDGE_BASE_ID environment variable matches the ID of your active Knowledge Base.

Run aws lambda get-function-configuration --function-name go-rag-lambda to confirm the environment variables are set correctly.

help me debug my Go Lambda timeout

Write and deploy the Python Lambda

Now you will build the identical service in Python as your controlled comparison. The logic mirrors your Go handler exactly: parse the question, retrieve context, generate an answer.

  • Create the Python Lambda directory by running:
mkdir python_lambda
cd python_lambda
  • Create a file called lambda_function.py and add the module setup and handler function:
import json
import os
import boto3

# Initialize Bedrock clients at module level (reused across invocations)
bedrock_runtime = boto3.client("bedrock-runtime", region_name="us-east-1")
bedrock_agent_runtime = boto3.client("bedrock-agent-runtime", region_name="us-east-1")

# Read environment variables set on the Lambda function
KNOWLEDGE_BASE_ID = os.environ["KNOWLEDGE_BASE_ID"]
MODEL_ID = os.environ["MODEL_ID"]


def lambda_handler(event, context):
    body = json.loads(event.get("body", "{}"))
    question = body.get("question", "")

    if not question:
        return {"statusCode": 400, "body": json.dumps({"error": "question is required"})}

    try:
        chunks, sources = retrieve_context(question)
        answer = generate_answer(question, chunks)
    except Exception as e:
        return {"statusCode": 500, "body": json.dumps({"error": str(e)})}

    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps({"answer": answer, "sources": sources}),
    }

What does this code do?

  • Module-level client initialization mirrors Go's init() function. Clients are created once and reused across warm invocations.
  • lambda_handler is the entry point Lambda calls. It parses the JSON body, orchestrates retrieval and generation, and returns the response.
  • Add the retrieve_context function below lambda_handler:
def retrieve_context(query):
    # Call Bedrock Knowledge Base to get relevant document chunks
    response = bedrock_agent_runtime.retrieve(
        knowledgeBaseId=KNOWLEDGE_BASE_ID,
        retrievalQuery={"text": query},
        retrievalConfiguration={
            "vectorSearchConfiguration": {"numberOfResults": 3}
        },
    )

    # Extract text content and source URIs from results
    context_text = ""
    sources = []
    for result in response.get("retrievalResults", []):
        content = result.get("content", {})
        if content.get("text"):
            context_text += content["text"] + "\n\n"
        location = result.get("location", {})
        s3_location = location.get("s3Location", {})
        if s3_location.get("uri"):
            sources.append(s3_location["uri"])

    return context_text, sources

What does this code do?

This calls the same Bedrock Retrieve API as your Go version. It requests 3 chunks from the Knowledge Base and collects both the text content and the S3 source locations for attribution.

  • Add the generate_answer function below retrieve_context:
def generate_answer(question, context_text):
    # Build the system prompt with retrieved context
    system_prompt = (
        "You are a helpful assistant. Answer the user's question based only on the "
        "following context. If the context does not contain enough information, say so.\n\n"
        f"Context:\n{context_text}"
    )

    # Call Bedrock Converse API with Claude Haiku 4.5
    response = bedrock_runtime.converse(
        modelId=MODEL_ID,
        messages=[{"role": "user", "content": [{"text": question}]}],
        system=[{"text": system_prompt}],
    )

    return response["output"]["message"]["content"][0]["text"]

What does this code do?

This mirrors your Go generateAnswer function. It constructs the same system prompt with the retrieved context and calls the Converse API. The Python boto3 API uses dictionaries where Go uses typed structs, but the request shape is identical.

  • Save lambda_function.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved lambda_function.py in the python_lambda directory.

ⓧ I'd like to double check the full code

Scroll up and confirm these three chunks appear in order in your lambda_function.py:

  • Module setup: imports, boto3 client initialization, and environment variable reads.
  • lambda_handler function: parses the question, calls retrieve_context and generate_answer, returns JSON.
  • retrieve_context function: calls bedrock_agent_runtime.retrieve() and extracts text and source URIs.
  • generate_answer function: builds the system prompt, calls bedrock_runtime.converse(), returns the text.
  • Create requirements.txt in the same python_lambda directory:
cat > requirements.txt << 'EOF'
boto3>=1.35.0
EOF
  • Install the dependencies into a local package directory and zip everything together by running:
pip install -r requirements.txt -t package/
cd package && zip -r ../function.zip . && cd ..
zip function.zip lambda_function.py

Why package boto3 manually?

Lambda's built-in boto3 version may lag behind the bedrock-agent-runtime API. Bundling your own ensures the Retrieve API is available regardless of Lambda's pre-installed version.

  • Confirm the zip file exists by running:
ls -lh function.zip

You should see the file listed at approximately 40-60MB. This is the Python deployment package with all boto3 dependencies bundled.

  • Deploy the Python Lambda function by running:
aws lambda create-function \
  --function-name python-rag-lambda \
  --runtime python3.12 \
  --handler lambda_function.lambda_handler \
  --role [[ROLE_ARN="arn:aws:iam::123456789012:role/rag-lambda-role"]] \
  --zip-file fileb://function.zip \
  --memory-size 128 \
  --timeout 30 \
  --environment "Variables={KNOWLEDGE_BASE_ID=[[KB_ID="your Knowledge Base ID"]],MODEL_ID=anthropic.claude-haiku-4-5-20251001-v1:0}"
  • Create the Function URL for the Python Lambda by running:
aws lambda create-function-url-config \
  --function-name python-rag-lambda \
  --auth-type NONE
  • Copy the FunctionUrl from the output. Save it alongside your Go URL.
  • Grant public access to the Python Function URL by running:
aws lambda add-permission \
  --function-name python-rag-lambda \
  --statement-id FunctionURLAllowPublicAccess \
  --action lambda:InvokeFunctionUrl \
  --principal "*" \
  --function-url-auth-type NONE
  • Test the Python Lambda by sending the same question:
curl -s -X POST [[PYTHON_FUNCTION_URL="your Python Function URL"]] \
  -H "Content-Type: application/json" \
  -d '{"question": "What is the product architecture?"}'

You should see the same JSON structure: an answer field with a generated explanation and a sources array.

Getting a module import error?

Make sure lambda_function.py is at the root of the zip file, not nested inside a subdirectory. Run zipinfo function.zip | head to verify.

Also confirm the --handler value matches your filename and function: lambda_function.lambda_handler means the file is lambda_function.py and the entry function is lambda_handler.

help me fix my Python Lambda import error

Compare single-request performance

Both services are live. Time to see how they compare on a single request. At this point, you might expect Go to be noticeably faster.

  • Before you run the comparison, predict: will the Go Lambda respond significantly faster than the Python Lambda on a single warm request?
  • Time the Go response by running:
time curl -s -X POST [[GO_FUNCTION_URL="your Go Function URL"]] \
  -H "Content-Type: application/json" \
  -d '{"question": "How do I troubleshoot connection issues?"}' > /dev/null
  • Time the Python response with the same question:
time curl -s -X POST [[PYTHON_FUNCTION_URL="your Python Function URL"]] \
  -H "Content-Type: application/json" \
  -d '{"question": "How do I troubleshoot connection issues?"}' > /dev/null

Note the real time from both commands. They should be surprisingly similar, both around 2-4 seconds total.

Why are they almost the same speed?

On a single warm request, the total latency is dominated by the Bedrock API calls (Retrieve + Converse), not by Go or Python execution speed. Both functions spend 90%+ of their time waiting on the same network calls to the same model.

This is the designed observation. Lambda masks per-invocation performance behind I/O wait. The real difference emerges under burst traffic, where cold starts multiply and per-invocation overhead compounds across dozens of concurrent instances.

Both services are deployed and returning correct answers. The single-request numbers look almost identical. In the next step, you will hit them with burst traffic and reveal where Go's advantage actually lives.

Load Test Under Burst Traffic

In the previous step, you deployed both Lambda functions and confirmed they return identical answers to the same question. The single-request latency looked similar for both.

That similarity is deceptive. A single request only measures the Bedrock API call time, which dominates both functions equally. Burst traffic forces 50 cold starts simultaneously, and that is where Go's compiled binary advantage becomes measurable in P99 latency and billed duration.

In this step, get ready to:
  • Run burst load tests with 50 concurrent requests against both Function URLs.
  • Pull CloudWatch metrics showing Init Duration, Billed Duration, and Max Memory Used.
  • Calculate and compare cost-per-query for Go vs Python.
Force cold starts before testing

Lambda reuses warm instances by default. To force cold starts across all 50 concurrent requests, you need to ensure no warm instances exist.

  • Update both Lambda functions to a dummy environment variable, which forces Lambda to discard existing execution environments by running this command for the Go function:
  • Wait 5 seconds, then do the same for the Python function.
aws lambda update-function-configuration \
  --function-name go-rag-lambda \
  --environment "Variables={KNOWLEDGE_BASE_ID=$(aws lambda get-function-configuration --function-name go-rag-lambda --query 'Environment.Variables.KNOWLEDGE_BASE_ID' --output text),MODEL_ID=$(aws lambda get-function-configuration --function-name go-rag-lambda --query 'Environment.Variables.MODEL_ID' --output text),COLD_START_RESET=1}" \
  --query 'FunctionName' --output text
  • Repeat for the Python function by running:
aws lambda update-function-configuration \
  --function-name python-rag-lambda \
  --environment "Variables={KNOWLEDGE_BASE_ID=$(aws lambda get-function-configuration --function-name python-rag-lambda --query 'Environment.Variables.KNOWLEDGE_BASE_ID' --output text),MODEL_ID=$(aws lambda get-function-configuration --function-name python-rag-lambda --query 'Environment.Variables.MODEL_ID' --output text),COLD_START_RESET=1}" \
  --query 'FunctionName' --output text
  • Wait about 30 seconds for both updates to finish propagating.

Why reset the environment?

Updating a Lambda function's environment variables forces AWS to discard all existing execution environments. The next invocation of each instance must cold start from scratch. This ensures the burst test hits 50 fresh cold starts simultaneously instead of reusing warm containers from your earlier single-request tests.

Run the burst test against Go

Before you run this, think about what you expect to happen. Do you think Go will be noticeably faster than Python under burst traffic, or will Lambda's auto-scaling neutralize the difference?

  • Fire 50 concurrent requests at the Go Function URL by running this command (replace the URL with your Go Function URL):
hey -n 50 -c 50 -m POST -T "application/json" \
  -d '{"question":"What is the product architecture?"}' \
  -t 60 \
  [[GO_FUNCTION_URL="your Go Lambda Function URL"]]

What do these flags do?

  • -n 50 sends 50 total requests.
  • -c 50 makes all 50 concurrent, so they hit simultaneously.
  • -m POST sets the HTTP method.
  • -T "application/json" sets the Content-Type header.
  • -t 60 raises the per-request timeout to 60 seconds because RAG queries take a few seconds.

The test takes 30-60 seconds to complete. When it finishes, you see a latency distribution report showing P50, P99, slowest, and fastest response times.

  • Note the P99 latency value from the output. You will compare it to the Python result shortly.
Run the burst test against Python
  • Fire the same 50 concurrent requests at the Python Function URL by running:
hey -n 50 -c 50 -m POST -T "application/json" \
  -d '{"question":"What is the product architecture?"}' \
  -t 60 \
  [[PYTHON_FUNCTION_URL="your Python Lambda Function URL"]]
  • Note the P99 latency value from Python's output.
  • Compare the two P99 values. Go's P99 should be measurably lower because its cold starts complete 3-4x faster.

What should I see?

The hey output prints a latency histogram and summary statistics. Under the response time distribution section, look at the 99% line. Go's P99 is typically 3-5 seconds lower than Python's under burst conditions because each of the 50 Go instances initialises in ~110ms, while each Python instance takes ~400ms or more.

Seeing timeouts or errors?

  • If many requests return errors, your Bedrock rate limit may be throttling you. Wait 60 seconds and retry with fewer concurrent requests (try -c 25).
  • If hey itself reports connection errors, confirm the Function URL is correct and includes the trailing / if required.
  • help me debug timeout errors from my hey load test against Lambda
Pull CloudWatch metrics for both functions

The hey output shows end-to-end client latency. CloudWatch Logs Insights lets you drill into the Lambda-internal numbers: Init Duration (cold start time), Billed Duration, and Max Memory Used.

  • Query the Go function's REPORT logs by running this command (replace the start and end times with a window covering your test):
aws logs start-query \
  --log-group-name /aws/lambda/go-rag-lambda \
  --start-time $(date -d '10 minutes ago' +%s) \
  --end-time $(date +%s) \
  --query-string 'filter @type = "REPORT" | stats avg(@initDuration) as avgInit, max(@initDuration) as maxInit, avg(@billedDuration) as avgBilled, max(@maxMemoryUsed/1024/1024) as maxMemMB by bin(5m)'

What does this query do?

  • filter @type = "REPORT" selects only the summary line Lambda emits after each invocation.
  • @initDuration is the cold start time in milliseconds.
  • @billedDuration is what AWS charges you for.
  • @maxMemoryUsed shows peak memory consumption per invocation.
  • The command returns a query ID. Retrieve the results by running (replace the query ID):
aws logs get-query-results --query-id [[QUERY_ID="your query ID from the previous command"]]
  • Note the avgInit, avgBilled, and maxMemMB values for Go.
  • Run the same query for the Python function by running:
aws logs start-query \
  --log-group-name /aws/lambda/python-rag-lambda \
  --start-time $(date -d '10 minutes ago' +%s) \
  --end-time $(date +%s) \
  --query-string 'filter @type = "REPORT" | stats avg(@initDuration) as avgInit, max(@initDuration) as maxInit, avg(@billedDuration) as avgBilled, max(@maxMemoryUsed/1024/1024) as maxMemMB by bin(5m)'
  • Retrieve the Python results with the same get-query-results command using the new query ID.

CloudWatch Logs take time to arrive

If the query returns empty results, wait 2-3 minutes and try again. Lambda emits REPORT logs after invocation completion, and CloudWatch Logs Insights needs a few minutes to index them.

Calculate cost-per-query and build the comparison

AWS Lambda pricing charges per GB-second of compute. The formula is: (Billed Duration in ms / 1000) x (Memory in MB / 1024) x price per GB-second.

With 128MB memory and the us-east-1 price of $0.0000166667 per GB-second, build your comparison table:

  • For each function, calculate: (avg Billed Duration / 1000) x (128 / 1024) x 0.0000166667 = cost per query.
  • Compare these numbers alongside the Init Duration and P99 latency you already captured.

What should the numbers look like?

Go cold starts average around 110ms. Python cold starts average around 400ms or higher. Go's billed duration is typically lower because the runtime overhead is smaller. At scale (millions of queries), even a small per-query cost difference compounds into significant savings.

The max memory used for Go is typically around 30-50MB of the 128MB allocated, while Python uses 70-90MB. This means Go could run at an even lower memory allocation if needed, further reducing cost.

Where Lambda neutralises the difference

Both functions spend most of their wall-clock time waiting for Bedrock to respond (1-3 seconds). Lambda auto-scaling gives each request its own instance, so Go cannot use goroutines to process requests faster here. The real Go advantage is in cold start and memory efficiency, not request-handling throughput.

You now have empirical proof. Go's cold starts are 3-4x faster, its billed duration is lower, and its memory footprint is smaller. Under burst traffic where cold starts multiply, these advantages compound into measurably lower P99 latency and real cost savings.

Next up, you will push Go's advantage even further with a pattern Lambda cannot help Python replicate: processing multiple questions concurrently within a single invocation using goroutines.

Secret mission

Concurrent Batch Query Endpoint

Your load test proved Go wins on cold starts. But Lambda's horizontal scaling means each invocation still handles one question at a time. What if a single Go invocation could answer 10 questions concurrently using goroutines, while Python would need 10 separate Lambda invocations to match? That is the pattern where Go becomes undeniable.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The Bedrock Knowledge Base uses an OpenSearch Serverless collection that charges per hour, so leaving it running will accumulate costs.

Cost warning

The OpenSearch Serverless collection backing your Knowledge Base charges approximately $0.24/hr per OCU (with a 2 OCU minimum). If you leave it running, expect around $11/day in charges. Delete the Knowledge Base and its collection promptly if you are done with this project.

Resources you used:

  • S3 bucket with sample documentation files.
  • Bedrock Knowledge Base with OpenSearch Serverless vector store.
  • ECR repository with Go container image.
  • Go Lambda function (go-rag-lambda) with Function URL.
  • Python Lambda function (python-rag-lambda) with Function URL.
  • Local project files (go-rag-lambda folder).

Keep everything running

No action needed. Choose this if you are still actively experimenting with the RAG services or want to run more load tests.

  • Your Lambda functions cost nothing when idle (you only pay per invocation).
  • The ECR repository is covered under the free tier for up to 500MB of storage.
  • The OpenSearch Serverless collection backing your Knowledge Base charges approximately $0.24/hr per OCU. This is the only resource with meaningful ongoing cost while idle.
  • The hey binary remains installed at ~/go/bin/hey.

Pause - I'll come back to this later

Shut down the resources that cost money while idle, but keep your local code so you can redeploy later.

  • Delete both Lambda functions to remove the Function URLs.
  • Delete the ECR repository to free image storage.
  • Delete the Bedrock Knowledge Base and its OpenSearch Serverless collection to stop hourly charges.
  • Keep your local project files and S3 bucket. S3 storage costs are negligible for a few markdown files.

Delete - I don't want to use this again

Remove all project resources and start fresh. Run the following commands in your terminal to delete everything.

  • Delete both Lambda functions by running:
aws lambda delete-function --function-name go-rag-lambda
aws lambda delete-function --function-name python-rag-lambda
  • Delete the ECR repository and all its images by running:
aws ecr delete-repository --repository-name [[ECR_REPO="your ECR repository name"]] --force
  • Delete the Bedrock Knowledge Base by running (replace the ID with your own):
aws bedrock-agent delete-knowledge-base --knowledge-base-id [[KB_ID="your Knowledge Base ID"]]
  • Delete the OpenSearch Serverless collection that backed the Knowledge Base. Find the collection ID in the OpenSearch Serverless console under Collections, then run:
aws opensearchserverless delete-collection --id [[COLLECTION_ID="your OpenSearch Serverless collection ID"]]
  • Delete the S3 bucket and all its contents by running:
aws s3 rb s3://[[S3_BUCKET="your-bucket-name"]] --force
  • Remove your local project files by running:
cd ~/Desktop
rm -rf go-rag-lambda
  • Remove the hey load testing binary by running:
rm ~/go/bin/hey

What about the IAM role?

The IAM role you created for the Lambda functions does not incur charges. You can leave it in place or delete it from the IAM console under Roles if you prefer a clean account.

Nice Work!

Nice Work!

You just built a production-grade serverless RAG service in Go, deployed an identical Python version as a controlled baseline, and collected hard data proving exactly where Go wins under burst traffic.

You've learned how to:

  • Build a RAG query service in Go that retrieves context from a Bedrock Knowledge Base and generates answers via the Converse API, deployed as an 8MB container image to Lambda with sub-110ms cold starts.
  • Deploy an identical Python Lambda as a controlled comparison and run burst load tests with 50 concurrent requests to force cold starts across both implementations simultaneously.
  • Prove Go's P99 latency advantage empirically using CloudWatch metrics and quantify the cost-per-query savings from Go's lower billed duration and memory usage.
  • (Secret Mission) Implement a concurrent batch query endpoint using Go's goroutine worker pool pattern that processes N questions in the wall-time of 1.

Ready to quiz yourself?