Skip to content
← Writing
Technical guideProduction GenAI9 min

RAG for pennies with Amazon S3 Vectors

Markdown in S3 turned into answers with sources using S3 Vectors, Titan embeddings and Nova Micro, plus monthly costs at 100,000 and 10 million chunks.

By Rahul Ladumor
On this page

Most vector databases for retrieval-augmented generation (RAG) run on a cluster that bills by the hour, busy or idle. Amazon S3 Vectors charges for storage and for each query, with nothing running in between. We built an S3 Vectors RAG pipeline with the AWS Cloud Development Kit (CDK). You drop a Markdown file into a bucket, and an AWS Lambda function chunks it, embeds it and writes the vectors. A second function answers questions with sources.

It's for developers who know Lambda and want RAG without a cluster. You get the S3 Vectors limits that shaped the code, the cost math for 100,000 and 10 million chunks, and the bug our tests agreed with. We haven't deployed the stack. Every result below comes from 148 local tests and CI.

Key takeaways

  • At 100,000 chunks and 100,000 questions a month, S3 Vectors costs about $0.44. The answer model is $8.75 of the $9.23 total.
  • A query pays for the whole index it searches, so a smaller topK doesn't lower the processing charge. At 10 million chunks in one index and 1 million questions, query processing is $77.82 a month. Split into 100 tenant indexes, it's $1.54.
  • Store chunk text as non-filterable metadata. It skips the 2 KB filter allowance and the per-query processing charge.
  • GetVectors takes 100 keys per call, and PutVectors takes 500. Our test fake shared our wrong number, so a test now pins every limit to the docs.
  • The $350.40 monthly floor for OpenSearch Serverless applies to classic collections. NextGen collections scale to zero.
At a glance
Level Intermediate
Time to read 9 minutes
Cost About $9.23 a month for 100,000 chunks and 100,000 questions in us-east-1, $8.75 of it the answer model. S3 Vectors has no free tier for storage or queries
Code InfraTales/s3-vectors-rag
Tested with Node.js 24, CDK 2.273.0, 148 automated tests against an in-memory S3 Vectors fake, cdk-nag
Deployed Not yet. The smoke test hasn't run against AWS

Learn what S3 Vectors stores

S3 Vectors reached general availability on 2 December 2025. It adds a new bucket type, the vector bucket, and inside it you create vector indexes. An index holds up to 2 billion vectors, and you run similarity searches against one index at a time. Reads see the latest writes, so a chunk you write shows up in the next query.

Each vector has a key, float32 data and optional metadata. You set the dimension (1 to 4,096) and the distance metric (cosine or euclidean) when you create the index. You can't change either later, or the list of non-filterable metadata keys. Changing one means a new index and a full re-ingest.

AWS quotes sub-second latency for infrequent queries and as low as 100 ms for frequent ones. It also says S3 Vectors "is ideal for workloads where queries are less frequent." That sentence decides when you should pick something else.

Follow one document through the pipeline

Files land under docs/<tenant>/<category>/<doc>.md, and the path becomes the metadata. One document takes this path:

Upload triggers ingest Lambda, Titan embeds, S3 Vectors stores; query Lambda asks Nova Micro

The top row writes. An S3 event starts the ingest function, which embeds each chunk and calls PutVectors, and a failed event lands in a dead-letter queue after two retries. The bottom row reads: a signed request reaches the function URL, the function embeds the question and searches the index, and Amazon Nova Micro writes the answer. Titan appears twice to keep the arrows straight, but it's one model.

Create the vector index with CDK

The aws-cdk-lib package has low-level constructs for both resources, CfnVectorBucket and CfnIndex. The index uses 1,024 dimensions, the default for Amazon Titan Text Embeddings V2 (model ID amazon.titan-embed-text-v2:0). Trimmed:

this.index = new s3vectors.CfnIndex(this, 'ChunksIndex', {
  // Ref on a vector bucket returns its ARN, not its name. Wire by ARN everywhere.
  vectorBucketArn: this.vectorBucket.attrVectorBucketArn,
  indexName: INDEX_NAME,
  dataType: 'float32',
  dimension: DIMENSIONS,
  distanceMetric: 'cosine',
  metadataConfiguration: { nonFilterableMetadataKeys: NON_FILTERABLE_KEYS },
});

Our first draft passed vectorBucket.ref as the bucket name, the way you would for an S3 bucket. That's wrong for vector buckets: the CloudFormation reference says Ref returns the Amazon Resource Name (ARN). Synth didn't notice, and only a deploy would have failed, so a template test now pins the ARN wiring.

Chunk and embed each document

The ingest function splits a document into chunks of up to 1,200 characters. It keeps paragraphs whole when they fit, repeats a sentence or two at the start of the next chunk, and carries the nearest heading along. Titan V2 averages 4.7 characters per token in English, so a chunk is about 255 tokens. Titan costs $0.02 per million input tokens.

Each vector gets six metadata keys. tenant, category, doc and updated are filterable. The chunk text and its heading title are non-filterable, for two reasons. S3 Vectors caps filterable metadata at 2 KB per vector, and a chunk can be bigger. And AWS doesn't count non-filterable metadata in the query processing charge.

S3 doesn't promise that events arrive in order, so the handler ignores the event type. It reads the object as it is now: if it exists, the function indexes it, and if it's gone, its vectors go. That check needs s3:ListBucket. Without it, S3 answers 403 for a missing key, not 404. Our first handler expected 404, so every quick upload-then-delete would have landed in the dead-letter queue.

Up to two ingest functions run together, so two uploads of the same file can overlap. The older one can finish last and leave stale chunks. So after each write the handler checks the object's ETag again, and if the file changed in the meantime, it indexes it again. A flow test plays out that race.

What we got wrong first: the GetVectors limit

A re-upload overwrites chunk keys in place, and a shorter version leaves old chunks behind. The cleanup probes for leftover keys with GetVectors, a window at a time. We batched every call at 500, because PutVectors and DeleteVectors take 500.

Every test passed. The in-memory index enforces the documented limits, but it read them from the same constants file as the app. A wrong number in the app meant the same wrong number in the fake. The S3 Vectors limits page says GetVectors takes up to 100 keys per call, so each cleanup call asked for five times the maximum.

We fixed the constant. This test pins each limit to the docs (trimmed):

test('limits match the documented S3 Vectors quotas', () => {
  expect(S3_VECTORS_LIMITS).toEqual({
    maxVectorsPerWrite: 500, // "Vectors per PutVectors API call: Up to 500" (DeleteVectors: same)
    maxKeysPerGet: 100, // "Vectors per GetVectors API call: Up to 100"
    maxTopK: 10_000, // "Top-K results per QueryVectors request: Up to 10,000"
    // ...five more limits, each with its quote
  });
});

A fake that reads the app's constants tests the code against your own beliefs. Pin each limit to its quote.

Filter every query by tenant

All tenants share one index, and a metadata filter keeps them apart. Callers can't send raw filter JSON. They pick named options, and the tenant comes first (trimmed):

export function buildFilter(f: QueryFilters): MetadataFilter {
  if (!ID.test(f.tenant)) throw new FilterError('tenant must be 1-63 lowercase letters, digits or hyphens');
  const conditions: MetadataFilter[] = [{ tenant: { $eq: f.tenant } }];
  // categories ($in), updatedAfter ($gte) and doc ($eq) each push one more condition
  return conditions.length === 1 ? (conditions[0] as MetadataFilter) : { $and: conditions };
}

An operator such as $or where a tenant, category or doc ID belongs gets a 400 before any AWS call. The handler ignores any other field, such as a raw filter. That stops a caller from widening the search to other tenants. In this sample the tenant itself comes from the request body, so any caller allowed to invoke the URL can name any tenant. In production, take it from the caller's identity instead, such as a claim in a Cognito token. The query role needs s3vectors:GetVectors as well as QueryVectors, because a filtered query without it fails with 403.

Index mode matters here. Since 30 September 2026, new vector buckets create ENHANCED indexes, which apply the filter before the search. Older buckets default to CLASSIC, where a selective filter "may return fewer than top K results." Our stack creates a new bucket, so it should get ENHANCED. CloudFormation has no property for the mode, so check it with aws s3vectors get-index after a deploy.

Skip the model when nothing is close

The query function drops any match with a cosine distance above 0.6. If nothing is left, it answers "not found" without calling the model:

const filter = buildFilter(req);
const q = await deps.embedder.embed(question);
const matches = await deps.store.query({ vector: q.vector, topK: req.topK ?? deps.defaultTopK, filter });
const close = matches.filter((m) => m.distance <= deps.maxDistance);
if (close.length === 0) {
  return { answer: NO_ANSWER, sources, generated: false, usage: { embedTokens: q.tokens, inputTokens: 0, outputTokens: 0 } };
}

The code builds the filter first, so a bad request fails before we pay for an embedding. We haven't tuned the 0.6. It assumes S3 Vectors reports cosine distance as 1 minus cosine similarity. The QueryVectors reference calls it "the measure of similarity" and says no more. Log the distance of each source for real questions and set the value from those.

Find the cheapest vector database on AWS

The usual alternatives charge for capacity that runs between questions. S3 Vectors has none. OpenSearch Serverless bills in OpenSearch compute units (OCU) and Aurora in Aurora capacity units (ACU). These are the us-east-1 floors from the AWS Price List API:

Option Compute floor a month Catch
S3 Vectors $0 Each query pays for the whole index it searches
OpenSearch Serverless, classic collection $350.40 (2 OCU × $0.24 × 730 hours), or $175.20 for dev/test Billed for the first collection in an account
OpenSearch Serverless, NextGen collection group $0 while idle Scales to zero after 10 minutes idle; the first request after that waits 10 to 30 seconds. Hot storage is $0.30 per GB-month
Aurora Serverless v2 with pgvector $43.80 at 0.5 ACU, or $0 while auto-paused Resume takes about 15 seconds

NextGen reached general availability on 28 May 2026, so older "OpenSearch costs $350 a month" advice describes classic collections.

Work out the monthly cost

Each vector in this repo is about 5,536 bytes: 4,096 for the vector, 40 for the key, 100 of filterable metadata and 1,300 of chunk text. Queries process only 4,236 of them, because the text is non-filterable. Prices are per GB in binary units, as on the S3 pricing page.

For 100,000 chunks and 100,000 questions:

  • Storage: 100,000 × 5,536 bytes = 0.516 GB × $0.06 = $0.03.
  • Query processing: 100,000 × 4,236 bytes = 0.395 GB per query × $0.000003906 = $0.0000015, so $0.15 for the month.
  • Query requests: 100,000 × $2.50 per million = $0.25.
  • Nova Micro: 170 million input tokens × $0.035 per million plus 20 million output tokens × $0.14 per million = $8.75.

For 10 million chunks in one index, a query processes 0.395 GB at the first-tier price and 39.06 GB at $0.000001953. That's $0.0000778 per query, or $77.82 for 1 million questions. Split the same chunks into 100 tenant indexes of 100,000, and each query stays in the first tier: $1.54 for those 1 million questions. npm run cost prints the whole table:

Chunks Indexes Questions a month Storage Requests Processing Nova Micro Monthly One-time embed + write
100,000 1 100,000 $0.03 $0.25 $0.15 $8.75 $9.23 $0.84
100,000 1 1,000,000 $0.03 $2.50 $1.54 $87.50 $91.97 $0.84
10,000,000 1 1,000,000 $3.09 $2.50 $77.82 $87.50 $171.31 $84.41
10,000,000 100 1,000,000 $3.09 $2.50 $1.54 $87.50 $95.03 $84.41

"Monthly" adds question embeddings ($0.04 per 100,000 questions), and we round each line. The model assumes 300 tokens per chunk and 1,700 prompt and 200 answer tokens per question. For the one-time write, it assumes 10 chunks per document. Ingest makes one PutVectors call per document, and each call bills at least 128 KB, so a 10-chunk document pays for 128 KB, not 54 KB. Lambda, CloudWatch Logs and document storage aren't in the table.

When this isn't the right fit

  • You need hybrid keyword and vector search. S3 Vectors does similarity search only.
  • One big index takes heavy traffic. Processing grows with index size, so price it with npm run cost before you commit.
  • You'd rather not write ingestion code. Amazon Bedrock Knowledge Bases can use an S3 vector bucket as its store, with up to 1 KB of custom metadata and 35 keys per vector.
  • Tenants need hard isolation. Use one index per tenant and scope each role to its index ARN.

Try it yourself

You need Node.js 24. Only the last line needs AWS: a CDK-bootstrapped account with access to Titan and Nova Micro:

npm ci
npm run check   # type check, lint, bundle, 148 tests, synth with cdk-nag
npm run cost -- --vectors 2000000 --queries 300000 --indexes 20
npm run deploy && npm run smoke

The smoke test uploads a document and waits for search to find it. It checks that another tenant can't see it, then deletes it and waits for the vectors to go. We haven't run it, so tell us what breaks.

Clean up

Run npm run destroy. It deletes the vector bucket and index, since you can rebuild them from the documents. It keeps the documents bucket on purpose, so empty and delete that one yourself when you're sure.

Does your RAG app sit closer to 100,000 chunks or 10 million, and which line of this bill would hit you first?

This article first appeared on AWS Builder Center. The code is on GitHub at InfraTales/s3-vectors-rag.

Next step

Have the same problem on your stack?

Send the architecture, AWS bill concern, deploy pain, or GenAI reliability issue. I'll find the first real bottleneck and propose a small, reversible fix.

FAQ

How much does RAG on Amazon S3 Vectors cost a month?

For 100,000 chunks and 100,000 questions in us-east-1, about $9.23. S3 Vectors itself is about $0.44 of that, and $8.75 is the Nova Micro answer model. S3 Vectors has no free tier for storage or queries.

Why split one large S3 Vectors index into tenant indexes?

A query pays for the whole index it searches, and a smaller topK doesn't change that. At 10 million chunks in one index and 1 million questions, query processing is $77.82 a month. Split into 100 tenant indexes of 100,000 chunks, it's $1.54.

When is S3 Vectors the wrong choice?

Skip it if you need hybrid keyword and vector search, because S3 Vectors does similarity search only. Be careful if one big index takes heavy traffic, since processing grows with index size. AWS itself says S3 Vectors is ideal for workloads where queries are less frequent.

Related reading

#amazon-s3-vectors#amazon-bedrock#amazon-s3#cost-optimization#serverless
Rahul Ladumor

Rahul Ladumor

Principal Cloud & AI Platform Architect. AWS Professional certified, 5x AWS Community Builder. I work with teams that have real users, real AWS bills, and real production pressure.

About Rahul →