AWS Builder Center
From Zero to RAG Hero: Building a Serverless Doc-QA App with Amazon Bedrock and OpenSearch Serverless

From Zero to RAG Hero: Building a Serverless Doc-QA App with Amazon Bedrock and OpenSearch Serverless

A hands-on, CLI-driven walkthrough for building a document Q&A assistant that indexes files the moment they land in S3, embeds them with Titan, stores vectors in OpenSearch Serverless, and answers questions through Lambda, API Gateway, and a tiny browser UI. Spin it up in under an hour.

Abstract
Ever wished your PDFs could just talk back? I did too. One moment I was staring at a wall of text in a document… and the next, I found myself wondering: "Why can't I just ask this thing a question and get a straight answer?"
So I decided to build exactly that — a tiny AI that wakes up the moment you drop a file into S3, reads it, understands it, and starts answering your questions like a friendly, well-trained research assistant. No massive infrastructure. No complex orchestration. Just pure serverless magic powered by Amazon Bedrock, OpenSearch Serverless, Lambda, and API Gateway.
In this hands-on guide, I'll walk you through how I built a lightweight, fully serverless RAG (Retrieval-Augmented Generation) app — the kind you can spin up in under an hour and use to turn boring PDFs into curious little geniuses.

Prerequisites (10 minutes)

  1. AWS account with billing enabled.
  2. Region: we'll use us-east-1.
  3. AWS CLI v2 installed and configured:```bash
    aws configure # set region: us-east-1```bash
    aws configure # set region: us-east-1
  4. Python 3.11 on your machine (for zipping deps locally).
  5. jq (optional, for pretty-printing JSON responses).
Note on Bedrock access (Oct 8, 2025 and later): Most serverless foundation models are automatically available in your account. You control access via IAM policies/SCPs. No separate "Model access" console step is needed anymore.
Tip: Run every step in the same terminal session. The commands build on shell variables ($REG, $BUCKET, $COLL, $ROLE_ARN, $AOSS_ENDPOINT) set in earlier steps.

Step 0 — Create a clean working folder

1
2
mkdir -p ~/aws-rag-beginner/{ingest,query}
cd ~/aws-rag-beginner

Step 1 — Set variables, create your S3 bucket & upload a test file

1
2
3
4
5
6
7
8
9
10
REG=us-east-1
COLL=raglab-beginner
BUCKET=your-unique-rag-bucket-$RANDOM
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

aws s3 mb s3://$BUCKET --region $REG

# Make a tiny test file
echo "Amazon Bedrock lets you call foundation models via a unified API." > sample.txt
aws s3 cp sample.txt s3://$BUCKET/
S3 Buket
S3 Buket
Step 2 — Create the encryption policy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cat > enc-policy.json <<EOF
{
"Rules": [
{
"ResourceType": "collection",
"Resource": ["collection/$COLL"]
}
],
"AWSOwnedKey": true
}
EOF


aws opensearchserverless create-security-policy \
--type encryption \
--name ${COLL}-enc \
--policy file://enc-policy.json \
--region $REG
Encryption policy
Encryption policy
Step 3 — Create the network policy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cat > net-policy.json <<EOF
[
{
"Rules": [
{
"ResourceType": "collection",
"Resource": ["collection/$COLL"]
}
],
"AllowFromPublic": true
}
]
EOF


aws opensearchserverless create-security-policy \
--type network \
--name ${COLL}-net \
--policy file://net-policy.json \
--region $REG
Network policy
Network policy
Step 4 — Create the collection
1
2
3
4
aws opensearchserverless create-collection \
--name $COLL \
--type VECTORSEARCH \
--region $REG

Step 4A — Wait until it's ACTIVE and grab the endpoint

Collection creation takes a few minutes. This loop waits for it, then saves the endpoint the Lambdas need:
1
2
3
4
5
6
7
8
9
10
11
12
13
until [ "$(aws opensearchserverless batch-get-collection --names $COLL --region $REG \
--query 'collectionDetails[0].status' --output text)
"
= "ACTIVE" ]; do
echo "Waiting for collection..."; sleep 20
done

AOSS_ENDPOINT=$(aws opensearchserverless batch-get-collection --names $COLL --region $REG \
--query 'collectionDetails[0].collectionEndpoint' --output text)
echo "AOSS endpoint: $AOSS_ENDPOINT"

# Sanity checks
aws opensearchserverless list-collections --region $REG --output table
aws opensearchserverless list-security-policies --type encryption --region $REG
aws opensearchserverless list-security-policies --type network --region $REG
OpenSearch collection
OpenSearch collection
Step 5 — Create an IAM role for Lambda
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ROLE_NAME=rag-beginner-lambda-role
ROLE_ARN=arn:aws:iam::$ACCOUNT_ID:role/$ROLE_NAME

cat > trust.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole" }
]
}
JSON

aws iam create-role --role-name $ROLE_NAME --assume-role-policy-document file://trust.json

# Lets the Lambdas write logs to CloudWatch (needed for `aws logs tail` later)
aws iam attach-role-policy --role-name $ROLE_NAME \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

Step 5A — Inline policy for Bedrock + S3 + AOSS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
cat > inline-policy.json <<JSON
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BedrockInvoke",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": "*"
},
{
"Sid": "S3ReadDocs",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::$BUCKET/*"
},
{
"Sid": "AOSSDataPlane",
"Effect": "Allow",
"Action": ["aoss:APIAccessAll"],
"Resource": "*"
}
]
}
JSON


aws iam put-role-policy \
--role-name $ROLE_NAME --policy-name rag-inline --policy-document file://inline-policy.json
IAM role
IAM role

Step 5B — AOSS data access policy (bind role to collection/index)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Create the data access policy document (note: it's an ARRAY!)
cat > data-policy.json <<EOF
[
{
"Description": "Lambda read/write to collection ${COLL}",
"Rules": [
{
"ResourceType": "index",
"Resource": [ "index/${COLL}/*" ],
"Permission": [
"aoss:CreateIndex",
"aoss:UpdateIndex",
"aoss:DescribeIndex",
"aoss:ReadDocument",
"aoss:WriteDocument"
]
},
{
"ResourceType": "collection",
"Resource": [ "collection/${COLL}" ],
"Permission": [
"aoss:CreateCollectionItems",
"aoss:DescribeCollectionItems"
]
}
],
"Principal": [ "${ROLE_ARN}" ]
}
]
EOF


aws opensearchserverless create-access-policy \
--type data \
--name ${COLL}-lambda-access \
--policy file://data-policy.json \
--region "$REG"
Quick validations:
1
2
3
4
5
# See the policy you just created
aws opensearchserverless list-access-policies --type data --region "$REG"

# Confirm the Lambda role ARN you used as Principal
echo "$ROLE_ARN"
IAM roles can take a few seconds to propagate. If create-function in the next step complains that the role can't be assumed, wait 10–15 seconds and retry.

Step 6 — Lambda code (Ingest + Query)

We'll use:
  • Embeddings: amazon.titan-embed-g1-text-02
  • Text model: amazon.titan-text-lite-v1 (cheap and fine for concise answers)
Both use Bedrock InvokeModel (no chat schema needed).

Step 6A — Ingest Lambda (chunks text from S3, embeds, writes to AOSS)

ingest/lambda_ingest.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import os, json, boto3
from opensearchpy import OpenSearch, RequestsHttpConnection, AWSV4SignerAuth

region = os.environ.get("AWS_REGION", "us-east-1")
aoss_endpoint = os.environ["AOSS_ENDPOINT"]
bucket = os.environ["RAG_BUCKET"]

BASE_INDEX = "ragdocs" # we'll append _<dim>
session = boto3.Session()
credentials = session.get_credentials()
auth = AWSV4SignerAuth(credentials, region, "aoss")

client = OpenSearch(
hosts=[{"host": aoss_endpoint.replace("https://",""), "port":443}],
http_auth=auth, use_ssl=True, verify_certs=True,
connection_class=RequestsHttpConnection,
timeout=30, max_retries=2, retry_on_timeout=True
)
s3 = boto3.client("s3")
bedrock = boto3.client("bedrock-runtime", region_name=region)

def embed(text: str):
resp = bedrock.invoke_model(
modelId="amazon.titan-embed-g1-text-02", # use the model you actually want
contentType="application/json", accept="application/json",
body=json.dumps({"inputText": text})
)
out = json.loads(resp["body"].read())
return out["embedding"]

def index_name_for_dim(dim: int) -> str:
return f"{BASE_INDEX}_{dim}"

def ensure_index(dim: int):
name = index_name_for_dim(dim)
if not client.indices.exists(name):
body = {
"settings": { "index": { "knn": True } },
"mappings": {
"properties": {
"doc_id": {"type":"keyword"},
"text": {"type":"text"},
"vector": {
"type":"knn_vector","dimension":dim,
"method":{"name":"hnsw","space_type":"cosinesimil","engine":"nmslib"}
}
}
}
}
client.indices.create(name, body=body)
return name

def chunk(text, size=800, overlap=200):
i = 0
step = max(1, size - overlap)
while i < len(text):
yield text[i:i+size]
i += step

def handler(event, _ctx):
try:
# 1) compute dim once
dim = len(embed("dimension probe"))
index = ensure_index(dim)
print(f"[ingest] using index={index} with dim={dim}")

# 2) read S3 object
rec = event["Records"][0]
key = rec["s3"]["object"]["key"]
obj = s3.get_object(Bucket=bucket, Key=key)
text = obj["Body"].read().decode("utf-8", errors="replace")

# 3) build bulk actions
from opensearchpy.helpers import bulk
actions = []
for piece in chunk(text):
vec = embed(piece)
actions.append({
"_op_type": "index",
"_index": index,
"_source": {"doc_id": key, "text": piece, "vector": vec}
})

ok, errs = bulk(client, actions, raise_on_error=False) # no refresh here
if errs:
print("Bulk had errors (first 2):", errs[:2])

return {"statusCode": 200, "body": json.dumps({"index": index, "chunks_indexed": len(actions)})}
except Exception as e:
print("ERROR:", repr(e))
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}
Package the dependencies:
1
2
3
4
5
cd ~/aws-rag-beginner/ingest
python3.11 -m venv .venv && source .venv/bin/activate
pip install opensearch-py==2.4.2 requests==2.32.3 boto3==1.34.162 -t build
cp lambda_ingest.py build/
cd build && zip -r ../ingest.zip . >/dev/null && cd ..
Create the Lambda (the default 3-second timeout is too short for embedding several chunks, so we raise it):
1
2
3
4
5
6
7
8
9
10
aws lambda create-function \
--function-name RAGIngest \
--runtime python3.11 \
--handler lambda_ingest.handler \
--role "$ROLE_ARN" \
--zip-file fileb://ingest.zip \
--timeout 120 \
--memory-size 512 \
--region "$REG" \
--environment "Variables={AOSS_ENDPOINT=$AOSS_ENDPOINT,RAG_BUCKET=$BUCKET}"
Allow S3 to invoke the Lambda:
1
2
3
4
5
6
7
8
9
10
FUNC_ARN=$(aws lambda get-function --function-name RAGIngest --region "$REG" \
--query 'Configuration.FunctionArn' --output text)

aws lambda add-permission \
--function-name RAGIngest \
--action lambda:InvokeFunction \
--principal s3.amazonaws.com \
--statement-id s3invoke \
--source-arn arn:aws:s3:::$BUCKET \
--region "$REG"
Set the S3 trigger (suffix .txt to keep it simple):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cat > notif.json <<EOF
{
"LambdaFunctionConfigurations": [
{
"Id": "rag-ingest",
"LambdaFunctionArn": "$FUNC_ARN",
"Events": ["s3:ObjectCreated:*"],
"Filter": { "Key": { "FilterRules": [ { "Name": "Suffix", "Value": ".txt" } ] } }
}
]
}
EOF


aws s3api put-bucket-notification-configuration \
--bucket "$BUCKET" \
--notification-configuration file://notif.json \
--region "$REG"
Trigger an invocation:
1
2
aws s3 cp ../sample.txt s3://$BUCKET/sample-$(date +%s).txt
aws logs tail /aws/lambda/RAGIngest --since 5m --follow --region "$REG"
Ingest Lambda
Ingest Lambda
Step 6B — Query Lambda (KNN search + Titan Text answer)
query/lambda_query.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import os, json, boto3
from opensearchpy import OpenSearch, RequestsHttpConnection, AWSV4SignerAuth

region = os.environ.get("AWS_REGION","us-east-1")
aoss_endpoint = os.environ["AOSS_ENDPOINT"]
BASE_INDEX = "ragdocs"

session = boto3.Session()
credentials = session.get_credentials()
auth = AWSV4SignerAuth(credentials, region, "aoss")
client = OpenSearch(
hosts=[{"host": aoss_endpoint.replace("https://",""), "port":443}],
http_auth=auth, use_ssl=True, verify_certs=True,
connection_class=RequestsHttpConnection,
timeout=30, max_retries=2, retry_on_timeout=True
)
bedrock = boto3.client("bedrock-runtime", region_name=region)

def embed(text):
resp = bedrock.invoke_model(
modelId="amazon.titan-embed-g1-text-02", # same model family as ingest
contentType="application/json", accept="application/json",
body=json.dumps({"inputText": text})
)
vec = json.loads(resp["body"].read())["embedding"]
print(f"[query] embed dim={len(vec)}")
return vec

def index_for_dim(dim: int) -> str:
return f"{BASE_INDEX}_{dim}"

def llm_with_context(question, passages):
context = "\n\n".join(passages) or "No context."
prompt = (
"Answer ONLY from the context. If unknown, say you don't know.\n"
f"Context:\n{context}\n\nQuestion: {question}\nAnswer:"
)
body = {
"inputText": prompt,
"textGenerationConfig": {
"maxTokenCount": 300,
"temperature": 0.2,
"topP": 0.9,
"stopSequences": []
}
}
out = bedrock.invoke_model(
modelId="amazon.titan-text-lite-v1",
contentType="application/json",
accept="application/json",
body=json.dumps(body),
)
payload = json.loads(out["body"].read())
return payload["results"][0]["outputText"]

def handler(event, _ctx):
try:
# accept either raw body JSON or already-parsed
body_raw = event.get("body")
body = json.loads(body_raw) if isinstance(body_raw, str) else (body_raw or {})
q = (body.get("question") or "").strip()
if not q:
return {"statusCode":400,
"headers":{"access-control-allow-origin":"*"},
"body":json.dumps({"error":"provide 'question'"})}

qvec = embed(q)
dim = len(qvec)
index = index_for_dim(dim)
if not client.indices.exists(index):
return {"statusCode":404,
"headers":{"access-control-allow-origin":"*"},
"body": json.dumps({
"error": f"Index {index} not found. Ingest documents first for dim={dim}."
})}

res = client.search(index=index, body={
"size": 4,
"query": { "knn": { "vector": { "vector": qvec, "k": 4 } } }
})
hits = res.get("hits",{}).get("hits",[])
passages = [h["_source"]["text"] for h in hits]

answer = llm_with_context(q, passages)
return {"statusCode":200,
"headers":{"content-type":"application/json",
"access-control-allow-origin":"*"},
"body": json.dumps({"answer":answer,"sources":passages,"index":index})}
except Exception as e:
print("ERROR:", repr(e))
return {"statusCode":500,
"headers":{"access-control-allow-origin":"*"},
"body": json.dumps({"error": str(e)})}
Package & create:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cd ~/aws-rag-beginner/query
python3.11 -m venv .venv && source .venv/bin/activate
pip install opensearch-py==2.4.2 requests==2.32.3 boto3==1.34.162 -t build
cp lambda_query.py build/
cd build && zip -r ../query.zip . >/dev/null && cd ..

aws lambda create-function \
--function-name RAGQuery \
--runtime python3.11 \
--handler lambda_query.handler \
--role "$ROLE_ARN" \
--zip-file fileb://query.zip \
--timeout 30 \
--memory-size 512 \
--region "$REG" \
--environment "Variables={AOSS_ENDPOINT=$AOSS_ENDPOINT}"
Quick test:
1
2
3
4
5
6
aws lambda invoke \
--function-name RAGQuery \
--region "$REG" \
--cli-binary-format raw-in-base64-out \
--payload '{"body":"{\"question\":\"What does the sample document say?\"}"}' \
/dev/stdout | jq -r
Query Lambda
Query Lambda
Step 7 — Create an HTTPS endpoint (API Gateway)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
API_ID=$(aws apigatewayv2 create-api --name rag-beginner \
--protocol-type HTTP --target arn:aws:lambda:$REG:$ACCOUNT_ID:function:RAGQuery \
--region $REG --query ApiId --output text)

# Allow API Gateway to invoke the Query Lambda
aws lambda add-permission \
--function-name RAGQuery \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--statement-id apigw-invoke \
--source-arn "arn:aws:execute-api:$REG:$ACCOUNT_ID:$API_ID/*" \
--region $REG

# Add CORS (optional; we already set a permissive header in Lambda)
aws apigatewayv2 update-api --api-id $API_ID --cors-configuration AllowOrigins='["*"]' --region $REG

URL=$(aws apigatewayv2 get-apis --region $REG --query "Items[?ApiId=='$API_ID'].ApiEndpoint" --output text)
echo "Invoke URL: $URL"
Test it:
1
2
3
curl -s -X POST "$URL/" \
-H "content-type: application/json" \
-d '{"question":"What is Bedrock?"}' | jq

Step 8 — Tiny browser UI (copy-paste)

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
<!doctype html>
<meta charset="utf-8"/>
<title>Ask My PDF (AWS)</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='28' fill='%230f172a'/%3E%3Ctext x='32' y='40' font-size='28' text-anchor='middle' fill='%23e2e8f0' font-family='system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif'%3EAI%3C/text%3E%3C/svg%3E">
<style>
:root { color-scheme: light dark; }
body { font: 16px/1.5 system-ui, sans-serif; max-width: 720px; margin: 3rem auto; padding: 0 1rem; }
textarea, input { width: 100%; padding: .7rem .8rem; font: inherit; border: 1px solid #cbd5e1; border-radius: 8px; }
button { padding: .6rem 1rem; border-radius: 8px; border: 1px solid #0f172a; background: #0f172a; color: #fff; cursor: pointer; }
button:disabled { opacity: .6; cursor: not-allowed; }
pre { background:#0f172a; color:#e2e8f0; padding:1rem; border-radius:8px; white-space:pre-wrap; }
.row { display:flex; gap:.75rem; align-items:center; }
.row > * { flex:1; }
small.hint { color:#64748b; }
</style>

<h1>Ask My PDF (AWS)</h1>
<p>Type a question about your uploaded file.</p>

<div class="row">
<textarea id="q" rows="3" placeholder="Ask a question..."></textarea>
</div>
<br/>
<div class="row">
<input id="url" placeholder="API URL (https://xxxxx.execute-api.us-east-1.amazonaws.com/)" />
<button id="go">Ask</button>
</div>
<small class="hint">Tip: paste your Invoke URL from API Gateway into the field above.</small>

<h3>Answer</h3>
<pre id="ans">—</pre>

<script>
const urlInput = document.getElementById('url');
const btn = document.getElementById('go');
const ans = document.getElementById('ans');

function normalizeApiUrl(u) {
// ensure trailing slash
return u.endsWith('/') ? u : (u + '/');
}

async function parseApiGatewayResponse(resp) {
// Some setups return the Lambda proxy wrapper {statusCode, headers, body}
// Others return the body JSON directly. Handle both.
const text = await resp.text();
try {
const obj = JSON.parse(text);
if ('statusCode' in obj && 'body' in obj) {
try {
return JSON.parse(obj.body);
} catch {
return { raw: obj.body };
}
}
return obj;
} catch {
return { raw: text };
}
}

btn.onclick = async () => {
const url = (urlInput.value || "").trim();
const q = document.getElementById('q').value.trim();
if (!url) { ans.textContent = "Please paste your API URL first."; return; }
if (!q) { ans.textContent = "Please type a question."; return; }

ans.textContent = "Thinking…";
btn.disabled = true;

try {
const r = await fetch(normalizeApiUrl(url), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ question: q })
});

if (!r.ok) {
const txt = await r.text();
ans.textContent = `HTTP ${r.status} — ${txt}`;
return;
}

const payload = await parseApiGatewayResponse(r);
if (payload.answer) {
const sources = payload.sources ? `\n\nSources:\n- ` + payload.sources.join('\n- ') : '';
ans.textContent = payload.answer + sources;
} else {
ans.textContent = JSON.stringify(payload, null, 2);
}
} catch (e) {
ans.textContent = "Request failed: " + (e && e.message ? e.message : e);
} finally {
btn.disabled = false;
}
};

// OPTIONAL: preset your API URL here so you don't paste it each time, e.g.
// urlInput.value = "https://abc123.execute-api.us-east-1.amazonaws.com/";
</script>
Web UI
Web UI
Now upload your own content and start asking questions:
1
2
aws s3 cp your-notes.txt s3://$BUCKET/
# (or a .txt export of a PDF for this first pass)
Troubleshooting (fast answers)
  • "The provided model identifier is invalid." Double-check the model IDs exactly (embeddings: amazon.titan-embed-g1-text-02; text: amazon.titan-text-lite-v1) and confirm you're in us-east-1.
  • 403 from OpenSearch (AuthorizationException). Your Lambda role must be in the AOSS data access policy (Step 5B). Recreate data-policy.json and run create-access-policy again.
  • Lambda "Task timed out after 3.00 seconds". The function was created without --timeout. Fix with aws lambda update-function-configuration --function-name RAGIngest --timeout 120.
  • aws logs tail shows nothing. Check that AWSLambdaBasicExecutionRole is attached to the role (Step 5).
  • S3 trigger error "overlapping suffixes". Make sure you have one Lambda config for s3:ObjectCreated:* with the .txt suffix. Remove duplicates.
  • Lambda "Unable to import module 'lambda_function'". Set the handler to lambda_ingest.handler or lambda_query.handler (matching the file name), and zip from the build folder so the module file sits at the root of the zip.
  • API returns {"message":"Internal Server Error"} immediately. API Gateway probably lacks permission to invoke the Lambda; re-run the add-permission command in Step 7.

Costs & cleanup

This is low-cost at small scale, but note that OpenSearch Serverless bills for a minimum amount of compute while a collection exists, even when idle. Delete everything when you're done:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
aws apigatewayv2 delete-api --api-id $API_ID --region $REG
aws lambda delete-function --function-name RAGQuery --region $REG
aws lambda delete-function --function-name RAGIngest --region $REG
aws s3 rm s3://$BUCKET --recursive
aws s3 rb s3://$BUCKET
aws opensearchserverless delete-collection --region $REG \
--id $(aws opensearchserverless list-collections --region $REG \
--query "collectionSummaries[?name=='$COLL'].id" --output text)
aws opensearchserverless delete-access-policy --type data --name ${COLL}-lambda-access --region $REG
aws opensearchserverless delete-security-policy --type network --name ${COLL}-net --region $REG
aws opensearchserverless delete-security-policy --type encryption --name ${COLL}-enc --region $REG
aws iam delete-role-policy --role-name $ROLE_NAME --policy-name rag-inline
aws iam detach-role-policy --role-name $ROLE_NAME --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name $ROLE_NAME

Conclusion

With your "Ask My PDF" app now alive and answering questions, you've already unlocked the core magic of a serverless RAG workflow. But this foundation is just the start. You can instantly boost its intelligence by upgrading from amazon.titan-text-lite-v1 to a larger model, such as amazon.titan-tg1-large, giving your assistant more reasoning power and richer responses. And if you want to make the experience feel even more human, integrate Amazon Polly so your app doesn't just respond — it speaks.
You can also scale your app beyond a single document. Index multiple files, pull insights from across an entire folder, and return citations so users know exactly which chunk of text informed the answer. That small "sources" field in your response can evolve into a powerful research feature. What began as a simple experiment quickly becomes a flexible, voice-enabled, multi-document AI assistant. The building blocks are in place — now it's your turn to push the boundaries.

Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article