Before you start: This picks up where Part 1 left off. From part 1, you would've learned how to setup a Bedrock Knowledge Base in the console. In addition to that, you should have a general understanding of how the ingestion and query pipeline works.
Introduction & Motivation
I started this project with a singular goal: to build a comprehensive Terraform module that allows developers to deploy the entire infrastructure for a "Chat with PDF" application faster.
When Amazon Bedrock was first unveiled in . If you run into issues or want to extend the module, feel free to open an issue.
Architecture
sequenceDiagram
participant User
participant S3
participant IngestionLambda as Ingestion Lambda
participant Bedrock
participant Titan as Titan (Embeddings)
participant OSS as OpenSearch Serverless
participant QueryLambda as Query Lambda
participant Claude
Note over S3,OSS: Ingestion Phase
User->>S3: Upload document
S3->>IngestionLambda: S3 ObjectCreated event
IngestionLambda->>Bedrock: StartIngestionJob
Bedrock->>S3: Fetch document
Bedrock->>Titan: Chunk + embed text
Titan-->>Bedrock: Vectors
Bedrock->>OSS: Store vectors + metadata
Note over QueryLambda,Claude: Query Phase
User->>QueryLambda: Invoke with question
QueryLambda->>Bedrock: RetrieveAndGenerate
Bedrock->>Titan: Embed query
Titan-->>Bedrock: Query vector
Bedrock->>OSS: Search for similar vectors
OSS-->>Bedrock: Top matching chunks
Bedrock->>Claude: Query + chunks
Claude-->>QueryLambda: Answer + citations
QueryLambda-->>User: Answer + source citations
In Part 3 we'll put an API Gateway in front of the query Lambda. For now we're invoking it directly from the CLI.
Project Structure
rag-bedrock-project/
├── main.tf
├── variables.tf
├── outputs.tf
├── backend.tf
├── terraform.tfvars.example
├── bootstrap/
└── modules/
├── storage/
├── opensearch/
├── bedrock/
└── lambda/
Each module owns one piece of the infrastructure and exposes what other modules need through outputs. The root main.tf wires them together by passing outputs from one module as inputs to another.
To be honest, I went back and forth on this architecture, and granted having multiple modules might be overkill. But designing this took me back to my Node.js applications days where I would put everything in a single server.js which made it difficult to debug errors. I learned about MVC which changed the way I build software. Terraform modules clicked the same way for me. One module per function. The Lambda module does not need how OpenSearch is set up. It just gets the IDs it needs through variables. As the architect, you know how each module communicates with the others.
Implementation
Step 1: Bootstrap Remote State First
Before running terraform apply on anything, you need somewhere to store your Terraform state.
Hold up? State what? Terraform state essentially tells Terraform what infrastructure already exists. Every resource it creates gets recorded in a terraform.tfstate file. Without it, Terraform can't tell what's already deployed.
If your local state file is ever lost or corrupted, Terraform loses track of everything it deployed. Storing it in S3 keeps it versioned and safe. Terraform 1.10 introduced native S3 state locking so you don't need a seperate DynamoDB table. You can read more , which stores AWS credentials in your OS keychain and injects temporary credentials at runtime. The --no-session flag skips STS session tokens, which some IAM operations reject. If you're not using aws-vault, replace aws-vault exec YOUR_PROFILE --no-session -- with your usual credential method.
aws-vault exec YOUR_PROFILE --no-session -- \
terraform -chdir=bootstrap init
aws-vault exec YOUR_PROFILE --no-session -- \
terraform -chdir=bootstrap apply \
-var="project_name=my-rag" -var="environment=dev"
Bootstrap creates two things: the S3 state bucket, and a scoped deployer IAM policy. You need AdministratorAccess to run bootstrap itself, because you can't use a scoped policy to create the scoped policy. Once it's done, you attach the scoped policy to your IAM user, detach AdministratorAccess, and every deploy from here runs least-privilege.
After it finishes, two outputs matter:
backend_config = <<EOT
terraform {
backend "s3" {
bucket = "my-rag-dev-terraform-state-123456789012"
key = "dev/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
encrypt = true
}
}
EOT
deployer_policy_arn = "arn:aws:iam::123456789012:policy/my-rag-dev-terraform-deployer"
Copy the backend_config block into backend.tf in the root directory, then run terraform init again. It'll migrate your local state to S3. Do this once and forget about it.
aws-vault exec YOUR_PROFILE --no-session -- terraform init
Then swap to the scoped policy:
# Attach the deployer policy
aws-vault exec YOUR_PROFILE --no-session -- aws iam attach-user-policy \
--user-name YOUR_IAM_USER \
--policy-arn YOUR_DEPLOYER_POLICY_ARN
# Drop AdministratorAccess
aws-vault exec YOUR_PROFILE --no-session -- aws iam detach-user-policy \
--user-name YOUR_IAM_USER \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccess
Step 2: Configure Your Variables
cp terraform.tfvars.example terraform.tfvars
Four variables, that's it:
# terraform.tfvars
project_name = "my-rag"
environment = "dev"
aws_region = "us-east-1"
embedding_dimensions = 512 # 256 or 512, half the storage cost vs 1024
Note: One thing about Bedrock model access: AWS now enables foundation models automatically on first invocation. The one exception is Anthropic models (including Claude 3 Haiku), which may prompt first-time users to submit brief use case details before the first request goes through. If your first query returns an access error, check the AWS Console under Bedrock → Model access and complete the form. This is a one-time step per AWS account.
Step 3: Walking Through the Modules
This section walks through the key pieces of each module and why they're built that way.
IAM: Inline Where It Belongs
So, IAM matters here because three different principals need to talk to each other: Bedrock during ingestion, the ingestion Lambda when it starts a job, and the query Lambda at query time. You need separate roles for each.
Please don't be that person who slaps AdministratorAccess on everything just to stop the 403s. Each role lives in the module that owns it. The Bedrock KB role is in modules/bedrock/ because that module creates the Knowledge Base. The Lambda execution roles live in modules/lambda/ for the same reason.
# modules/bedrock/main.tf
resource "aws_iam_role" "kb" {
name = "${var.config.environment}-${var.config.project_name}-bedrock-kb"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = { Service = "bedrock.amazonaws.com" }
Action = "sts:AssumeRole"
Condition = {
StringEquals = {
"aws:SourceAccount" = local.account_id
}
}
}
]
})
}
The Condition block scopes the trust to your account. Bedrock can assume this role, but only for a Knowledge Base in your account, not for any other service or account.
The role gets three inline policies: s3:GetObject and s3:ListBucket on the document bucket, aoss:APIAccessAll on the AOSS collection (required by Bedrock to write embeddings), and bedrock:InvokeModel scoped to the Titan embedding model ARN. Nothing else. The query Lambda can only call bedrock:RetrieveAndGenerate. It cannot read S3, touch AOSS, or invoke models directly. Bedrock handles all of that through the KB role.
Vector Store: OpenSearch Serverless
The KB ties everything together. It knows where to find documents (S3), where to store vectors (AOSS), which embedding model to use, and how to name the index fields:
# modules/bedrock/main.tf
resource "aws_bedrockagent_knowledge_base" "main" {
name = local.kb_name
role_arn = aws_iam_role.kb.arn
knowledge_base_configuration {
type = "VECTOR"
vector_knowledge_base_configuration {
embedding_model_arn = local.embedding_model_arn
embedding_model_configuration {
bedrock_embedding_model_configuration {
dimensions = var.config.embedding_dimensions
}
}
}
}
storage_configuration {
type = "OPENSEARCH_SERVERLESS"
opensearch_serverless_configuration {
collection_arn = var.collection_arn
vector_index_name = "bedrock-knowledge-base-default-index"
field_mapping {
vector_field = "bedrock-knowledge-base-default-vector"
text_field = "AMAZON_BEDROCK_TEXT_CHUNK"
metadata_field = "AMAZON_BEDROCK_METADATA"
}
}
}
}
Why 512 dimensions instead of the full 1024? At dev volume, the accuracy difference is negligible, but storage costs drop by half. You can always re-ingest at higher dimensionality later. The embedding_dimensions variable enforces this because it only accepts 256 or 512, so you can't accidentally provision at full dimensions and then wonder why your bill is larger than expected.
Lambda: The Query Handler and the Trigger
has the complete deployment reference. The short version:
# Step 1: create the OSS collection first
aws-vault exec YOUR_PROFILE --no-session -- terraform apply -target=module.opensearch
# Step 2: deploy everything else
aws-vault exec YOUR_PROFILE --no-session -- terraform apply
Go make coffee. AOSS takes about 10 minutes to spin up.
You only need to do this once. After that first run, the endpoint is stored in state and every subsequent deploy is just terraform apply.
One thing worth knowing if you're using aws-vault: some IAM operations reject session tokens that aws-vault generates by default. If you hit an InvalidClientTokenId error mid-apply, make sure you're using --no-session.
Step 5: Test It
Upload, check status, and invoke
Upload a document. The ingestion Lambda fires automatically on upload. No manual trigger needed:
aws-vault exec YOUR_PROFILE --no-session -- \
aws s3 cp ./my-document.pdf \
s3://$(terraform output -raw document_bucket_name)/
Check ingestion status and wait for COMPLETE before querying:
aws-vault exec YOUR_PROFILE --no-session -- \
aws bedrock-agent list-ingestion-jobs \
--knowledge-base-id $(terraform output -raw knowledge_base_id) \
--region us-east-1
Then invoke the query Lambda:
aws-vault exec YOUR_PROFILE --no-session -- \
aws lambda invoke \
--function-name $(terraform output -raw query_function_name) \
--payload '{"body":"{\"query\":\"What is the document about?\"}","requestContext":{"requestId":"test-1"},"headers":{}}' \
--cli-binary-format raw-in-base64-out \
/tmp/response.json && cat /tmp/response.json
The payload wraps the query in a body field because the Lambda parses event.body.query.
A successful response looks like:
{
"statusCode": 200,
"headers": { "Content-Type": "application/json" },
"body": "{\"answer\":\"The document covers...\",\"citations\":[{\"text\":\"...\",\"sources\":[{\"uri\":\"s3://my-rag-dev-documents/my-document.pdf\"}]}]}"
}
If you're getting empty answers, ingestion likely isn't done yet. Wait for the status to show COMPLETE and try again.
Step 6: Cleanup
Before you run terraform destroy, there's one thing to sort out. The data source has a data_deletion_policy that defaults to DELETE. When Terraform tears down the stack, it tries to clean up vectors from OpenSearch as part of deleting the data source. If the collection is also being destroyed in the same apply, Bedrock can't reach it and the deletion gets stuck.
Set it to RETAIN first, apply, then destroy:
# modules/bedrock/main.tf
resource "aws_bedrockagent_data_source" "s3" {
name = "${var.config.environment}-${var.config.project_name}-s3-source"
knowledge_base_id = aws_bedrockagent_knowledge_base.main.id
data_deletion_policy = "RETAIN" # set this before destroying
# ... rest of config
}
You could also use the console to do this
aws-vault exec YOUR_PROFILE --no-session -- terraform apply
Full cleanup: tear down the stack
aws-vault exec YOUR_PROFILE --no-session -- terraform destroy
To also clean up the bootstrap resources, first empty the versioned state bucket (versioning means objects have to be deleted explicitly), then destroy:
# Remove all object versions from the state bucket
aws-vault exec YOUR_PROFILE --no-session -- \
aws s3api delete-objects \
--bucket YOUR_STATE_BUCKET_NAME \
--delete "$(aws-vault exec YOUR_PROFILE --no-session -- \
aws s3api list-object-versions \
--bucket YOUR_STATE_BUCKET_NAME \
--query '{Objects: Versions[].{Key:Key,VersionId:VersionId}}' \
--output json)"
# Destroy bootstrap
aws-vault exec YOUR_PROFILE --no-session -- \
terraform -chdir=bootstrap destroy
Gotchas and Cost
AOSS costs ~$350/month minimum just for the collection to exist. Whether you are querying it or not. S3, Lambda and Bedrock tokens are negligible by comparison. If you're just experimenting, destroy when you're done
On the technical side:
AOSS collection won't create: You probably hit the policies-first timing issue. Make sure encryption, network, and access policies are all in depends_on.
KB ingestion fails: Check that the KB role has s3:GetObject on the bucket and aoss:APIAccessAll on the collection. Also verify the AOSS collection is in ACTIVE state before running step 2 of the deploy.
Lambda returns 400 ("query is required"): The query Lambda parses event.body.query. Make sure your payload wraps the query in a body field as shown in Step 5.
Lambda returns 503: Bedrock is throttling. The function retries once automatically, but if you're hammering it, back off.
Empty citations array: Your documents might be in a format Bedrock can't chunk. Stick to plain text, PDF, Markdown, or HTML for best results.
terraform destroy gets stuck: The default data_deletion_policy = "DELETE" causes the teardown to deadlock when the collection is also being destroyed. Set it to RETAIN and apply before you destroy. Full steps in Step 6.
What's Next?
The Knowledge Base is live and queryable from the CLI. In Part 3, we're putting an API Gateway in front of the query Lambda and wiring up a React frontend. The query Lambda's response shape already works with API Gateway's proxy integration, and the only thing left is CORS headers and the API Gateway resource itself.
Disclaimer
This is strictly for educational purposes. You will be charged for the resources created when you follow along. Remember to clean up after use.
SOCIAL SHARE CARD GENERATOR