Module ai.aws.s3
ballerinax/ai.aws.s3 Ballerina library
Overview
This package provides two building blocks for RAG pipelines on AWS: a data loader for AWS S3 and a vector store for Amazon S3 Vectors — AWS's own vector storage and similarity-search service. Together they let a whole pipeline (load, chunk, embed, store, query) come from this one import.
s3:TextDataLoader reads objects from S3 buckets and returns them as ai:TextDocument values,
ready to be chunked, embedded, and indexed. It implements the ai:DataLoader abstraction, so it
can be used anywhere an ai:DataLoader is expected and its output feeds directly into
ai:KnowledgeBase.ingest. Natively-textual objects are decoded directly; PDF, Word (.docx),
PowerPoint (.pptx) and Excel (.xlsx) documents have their text extracted in memory —
object content is never written to disk.
s3:VectorStore implements ai:VectorStore, storing and querying vector embeddings in an
Amazon S3 Vectors index. See Vector store below.
Prerequisites
1. Create an S3 bucket
Create a bucket and upload the documents you want to index, following the AWS S3 getting-started guide.
2. Obtain credentials
The loader authenticates in one of two ways:
- Static credentials — an access key pair for an IAM user or role, optionally with a session token for temporary (STS) credentials. See Managing access keys.
- EC2/ECS IAM role — no keys at all; credentials are resolved from the AWS infrastructure the code runs on. An ECS task role is resolved through the ECS container credential endpoint, while an EC2 instance profile is resolved through the EC2 Instance Metadata Service (IMDS). This works only when running on AWS infrastructure with an attached instance or task role.
3. Grant the required IAM permissions
The loader only ever reads. It needs exactly two actions:
| Action | Applied to | Why |
|---|---|---|
s3:ListBucket | the bucket ARN | to enumerate keys under a prefix |
s3:GetObject | the object ARN (/*) | to download object content |
A minimal policy:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "ListBucketContents", "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::my-corpus-bucket" }, { "Sid": "ReadObjects", "Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::my-corpus-bucket/*" } ] }
To restrict access to a single prefix, narrow the GetObject resource to
arn:aws:s3:::my-corpus-bucket/reports/* and add an s3:prefix condition to the ListBucket
statement.
Quickstart
Step 1: Import the module
import ballerinax/ai.aws.s3;
Step 2: Create a loader
s3:TextDataLoader loader = check new ( { auth: { accessKeyId: "<ACCESS_KEY_ID>", secretAccessKey: "<SECRET_ACCESS_KEY>" }, region: "us-east-1" }, [ { bucket: "my-corpus-bucket", paths: ["reports/"], recursive: true } ] );
Step 3: Load the documents
import ballerina/ai; ai:Document[]|ai:Document documents = check loader.load();
load() returns a bare ai:Document when exactly one object is resolved and an
ai:Document[] otherwise — the same contract as ai's built-in TextDataLoader. Both shapes are
accepted directly by ingest:
check knowledgeBase.ingest(documents);
Configuration
Connection
The loader's first argument is either an
s3:ConnectionConfig
— the ballerinax/aws.s3 connector's own configuration, from which the loader builds a client —
or an already-configured s3:Client you want it to reuse.
| Field | Type | Default | Description |
|---|---|---|---|
auth | auth:AuthConfig — e.g. auth:StaticAuthConfig | auth:ProfileAuthConfig | auth:DEFAULT_CREDENTIALS (from ballerinax/aws.auth) | — | How to authenticate (see below) |
region | aws:Region | string | US_EAST_1 ("us-east-1") | The region the buckets are in |
endpoint | aws:EndpointConfig? | resolved from region | FIPS and dual-stack options, or a customEndpoint for LocalStack or another S3-compatible service |
import ballerinax/aws.auth as awsAuth; import ballerinax/aws.s3 as awsS3; // Static credentials (an access key pair) awsS3:ConnectionConfig config = { auth: {accessKeyId: "AKIA...", secretAccessKey: "..."}, region: "us-east-1" }; // Temporary (STS) credentials add a session token awsS3:ConnectionConfig config = { auth: {accessKeyId: "ASIA...", secretAccessKey: "...", sessionToken: "..."}, region: "us-east-1" }; // AWS default credential chain — environment variables, ECS container credentials, // and EC2/ECS instance-profile (IAM role) credentials, resolved automatically awsS3:ConnectionConfig config = {auth: awsAuth:DEFAULT_CREDENTIALS, region: "us-east-1"}; // A named profile from the shared AWS credentials file awsS3:ConnectionConfig config = {auth: {profileName: "prod"}, region: "us-east-1"};
Prefer DEFAULT_CREDENTIALS in production when running on AWS: it resolves the instance or
task role automatically and needs no long-lived keys in configuration. The examples use static
credentials only because they must run anywhere.
Reusing an existing client
Passing a ready s3:Client lets you share one client across several loaders, or configure the
connector in ways the loader's config does not surface:
awsS3:Client s3Client = check new ({ auth: {accessKeyId: "AKIA...", secretAccessKey: "..."}, region: "us-east-1" }); s3:TextDataLoader loader = check new (s3Client, [{bucket: "my-corpus-bucket"}]);
Sources and paths
A Source names a bucket and the paths to read from it. Several sources may be configured in
one loader; their documents are aggregated in the order given.
Source field | Type | Default | Description |
|---|---|---|---|
bucket | string | — | The bucket name |
paths | string[]? | () (omit → whole bucket) | One or more object keys or key prefixes. Omit it to load the whole bucket. See "How paths are resolved" below |
recursive | boolean | false | Whether to descend into nested prefixes. Applies to every prefix in paths |
includeExtensions | string[]? | () (all types) | Case-insensitive extension allowlist; a leading dot is optional. Applies to every prefix in paths |
{ bucket: "my-corpus-bucket", paths: ["reports/2026/", "policies/handbook.md"], recursive: true, includeExtensions: [".pdf", "docx"] }
recursive and includeExtensions are set once per source and apply to all of its paths. If
different prefixes in the same bucket need different recursion or extension filters, configure them
as separate sources.
How paths are resolved
S3 has no folders — only keys that happen to contain /. So:
- Omitting
paths(or an empty-string element"") loads the whole bucket — the listing runs with the S3 prefix left off. - A value ending in
/is treated as a prefix. - Anything else is tried as an exact key first and, if no such key exists, treated as a prefix.
- Keys ending in
/(the zero-byte "folder" objects the S3 console creates) are always skipped. - With
recursive: false, only keys directly under the prefix are loaded — a key whose remainder after the prefix contains another/is skipped.
An object that can't be loaded is handled differently depending on how it was reached:
- Found while walking a prefix: unsupported types, archived objects (
GLACIER,DEEP_ARCHIVE), objects overmaxObjectSize, text that isn't valid UTF-8, PDF or Office files that can't be parsed, and objects deleted after being listed are skipped with a logged warning, so one bad file can't fail a whole corpus load. - Named as an exact key: the same cases are an error, since the caller asked for that object.
- Either way: a failure that isn't specific to one object, such as missing permissions or a
connection error, makes
load()return an error.
A path that matches no loadable object returns no documents and logs a warning.
Collision to be aware of: if a bucket holds both an object at key
reportsand objects underreports/, then the path"reports"resolves the single object and ignores the folder entirely — the exact-key match wins and there is no error. Use"reports/"when you mean the prefix.
Loader options (LoaderOptions)
| Field | Type | Default | Description |
|---|---|---|---|
maxObjectSize | int | 104857600 (100 MiB) | Largest single object read into memory. A bigger object is skipped during a prefix walk and is an error when named as a key |
s3:TextDataLoader loader = check new (connectionConfig, sources, {maxObjectSize: 20 * 1024 * 1024});
Like the SharePoint data loader, there is no document-count cap — load() returns every
matching object. See Limitations for the memory implication.
Supported file types
The supported set matches ballerina/ai's built-in TextDataLoader exactly, plus the
natively-textual formats S3 buckets commonly hold.
| Type | Extensions | How the text is obtained |
|---|---|---|
| Markup | md, markdown, html, htm | Decoded verbatim (tags and all); stripping is the chunker's job |
| Plain text | txt, text, csv, tsv, json, xml, yaml, yml, log, ini, conf, properties, css, js | Decoded directly as UTF-8 |
pdf | Apache Tika PDFParser + PDFBox, in memory | |
| Word | docx | Apache POI XWPFWordExtractor, in memory |
| PowerPoint | pptx | Apache POI SlideShowExtractor, in memory |
| Excel | xlsx | Apache POI XSSFExcelExtractor, in memory — tab-separated cells, one row per line, each sheet prefixed with its name |
| Legacy binary Office | doc, ppt, xls | Not supported — convert to the OOXML .docx / .pptx / .xlsx or PDF |
| Anything else | images, audio, unknown binary | Skipped (an error if named explicitly) |
The type is decided by the key's extension. A key named directly in paths whose extension isn't
recognised (an extensionless README, say) falls back to its S3 Content-Type.
Scanned or image-only documents are not supported. There is no OCR, so a PDF or Office file whose pages are only images yields no text (it loads as a document with empty content).
Object metadata is attached to every document: fileName (the last segment of the key),
mimeType, fileSize, modifiedAt, plus the open fields bucket, key (the full key), and
eTag. Properties embedded in the file itself (a PDF's title or author, for example) are not
copied. Chunks inherit document metadata, and S3 Vectors allows only 50 metadata keys and 2 KB of
filterable metadata per vector, which a PDF's full property set can exceed.
Limitations
Please read these before indexing a large or busy bucket.
- The whole matching corpus is read into memory. There is no document-count cap (matching the
SharePoint data loader); the loader paginates across every listing page and materializes every
document, because
ai:DataLoader.load()returns aDocument[]— there is no streaming or cursor in the interface. A very large prefix therefore produces a correspondingly large in-memory result. Narrow thepath, or split the work across several loads, if that is a concern. - A prefix walk is bounded at 10,000 listing pages. That ceiling is a safety net, not a document
cap: it guarantees the walk terminates whatever the listing returns, and reaching it is an error
rather than a partial corpus. Ten thousand pages covers ten million listed entries — counting both
objects and, for a non-recursive walk, the sub-folders S3 rolls into CommonPrefixes — so no
listing this loader could return in memory comes close. A walk that hits it needs a narrower
path. - Skipped objects are reported only to the log. Archived, over-sized, undecodable and
unsupported objects are skipped with a warning so that one bad object cannot fail a whole corpus,
but
load()returns a shorter array with no programmatic signal. A caller cannot distinguish "nothing matched" from "several objects were skipped" without reading the logs. load()has no overall time limit. The paging loop always terminates, butballerinax/aws.s3exposes no timeout, retry or HTTP configuration onConnectionConfig, so a stalled connection blocks the call indefinitely. Apply a deadline on the calling side if you need one.- S3 Express One Zone (directory) buckets are not usable. Every object in one reports the
EXPRESS_ONEZONEstorage class, which is absent from the connector'sStorageClassenum, so listing and metadata calls fail to deserialize. The exact-key path is designed to work on them — HEAD is order-independent, which unordered directory-bucket listings require — and needs no change here once the connector is fixed. - A listing is not a consistent snapshot. S3 read and list operations are strongly consistent, but a paginated load is not an atomic snapshot: the loader pages through a listing, and concurrent writes across page requests can cause objects to be missed or double-counted — inherent to paginated listing.
- Each object is read entirely into memory. Extraction reads from an in-memory buffer so that
no temporary file is ever written, but each object must therefore fit in the heap, roughly twice
over while it is passed to the extractor.
maxObjectSize(default 100 MiB) bounds each object; a larger one is skipped in a prefix walk and is an error when named as a key. - Non-recursive filtering. The loader lists with delimiter
/, so S3 returns only same-level keys (descendants roll intoCommonPrefixes, which the connector drops); a client-side filter stays as a backstop. - No
versionIdselection. The loader always reads the current version of each object. The underlying connector can fetch a specific version, but the loader does not expose it, so a corpus cannot be pinned to specific object versions. - Legacy binary Office formats are unsupported.
.doc,.pptand.xlsare recognised only so they can be rejected with a format-specific message or skipped. Convert them to their OOXML successors (.docx/.pptx/.xlsx) or PDF. The OOXML.xlsxis extracted via POI'sXSSFExcelExtractor: cells are rendered tab-separated, one row per line, each sheet prefixed with its name, and formula cells contribute their last cached result. - Apache POI's safety limits apply. POI rejects an Office file whose extracted text passes its
size cap or whose compression ratio looks like a zip bomb, which a very large or highly
compressible
.xlsx/.docxcan trip well belowmaxObjectSize. Such a file is skipped in a prefix walk and is an error when named as a key. The limits are JVM-wide POI settings, so the loader does not change them. - No requester-pays support. The connector's configuration exposes no requester-pays option, so cross-account requester-pays buckets cannot be read.
- All buckets in one loader share one connection, including its region. Use a separate loader for buckets that need a different connection.
Vector store
s3:VectorStore implements ai:VectorStore, backed by
Amazon S3 Vectors — AWS's own vector storage and
similarity-search service, in the same s3vectors namespace family as S3 itself but a distinct
service with its own endpoint and IAM actions. It exists alongside the data loader so a full RAG
pipeline can come from this one import: load documents from S3, chunk and embed them, and store
the vectors back in S3 Vectors.
There is no Ballerina connector for s3vectors (ballerinax/aws.s3 is object storage only), so
VectorStore talks to the service directly over ballerina/http, signing every request with AWS
Signature Version 4 via ballerinax/aws.auth.
Before you start: create a vector index
Unlike the loader, the vector store does not create its target for you — a vector bucket and index must already exist, and two of the index's settings are immutable once created:
- Dimension and distance metric (
cosineoreuclidean) are fixed for the life of the index and must match your embedding model's output. - The chunk's text content must be stored as a non-filterable metadata key. S3 Vectors caps
filterable metadata at 2 KB per vector — far below what chunk text routinely needs — while
non-filterable metadata has a much larger 40 KB budget.
VectorStorestores chunk text under the metadata key named byVectorStoreConfig.contentKey("content"by default), and that key must be declared in the index'snonFilterableMetadataKeysat creation time. Getting this wrong means deleting and recreating the index — it cannot be changed afterwards.
Create the vector bucket and index with the AWS CLI:
aws s3vectors create-vector-bucket --vector-bucket-name my-vector-bucket aws s3vectors create-index \ --vector-bucket-name my-vector-bucket \ --index-name my-index \ --data-type float32 \ --dimension 1024 \ --distance-metric cosine \ --metadata-configuration '{"nonFilterableMetadataKeys": ["content"]}'
By default, VectorStore.init calls GetIndex once at startup and fails with an actionable
message if the content key was left filterable — see VectorStoreConfig.validateIndexOnInit below.
Grant the required IAM permissions
| Action | Needed for |
|---|---|
s3vectors:PutVectors | add |
s3vectors:QueryVectors | query with an embedding |
s3vectors:GetVectors | Also required whenever a query requests metadata or applies a filter — which this store always does, since the chunk lives in metadata. Its absence is the most common cause of a 403 here |
s3vectors:DeleteVectors | delete |
s3vectors:ListVectors | query with no embedding (the filter-only / deleteByFilter path) |
s3vectors:GetIndex | init, when validateIndexOnInit is true (the default) |
{ "Version": "2012-10-17", "Statement": [ { "Sid": "S3VectorsAccess", "Effect": "Allow", "Action": [ "s3vectors:PutVectors", "s3vectors:QueryVectors", "s3vectors:GetVectors", "s3vectors:DeleteVectors", "s3vectors:ListVectors", "s3vectors:GetIndex" ], "Resource": "arn:aws:s3vectors:us-east-1:123456789012:bucket/my-vector-bucket/index/my-index" } ] }
Quickstart
Step 1: Import the module
import ballerinax/ai.aws.s3;
Step 2: Create the store
s3:VectorStore vectorStore = check new ( { auth: { accessKeyId: "<ACCESS_KEY_ID>", secretAccessKey: "<SECRET_ACCESS_KEY>" }, region: "us-east-1" }, {vectorBucketName: "my-vector-bucket", indexName: "my-index"} );
Step 3: Use it as an ai:VectorStore
import ballerina/ai; // From your embedding model; the length must match the index's dimension. ai:Vector embedding = [0.12, 0.98, 0.33]; check vectorStore.add([ {embedding, chunk: {'type: "text-chunk", content: "..."}} ]); ai:VectorMatch[] matches = check vectorStore.query({embedding: queryEmbedding, topK: 5}); check vectorStore.delete(["vector-id-1"]);
Or hand it to ai:VectorKnowledgeBase and let ballerina/ai drive chunking, embedding, and
retrieval:
ai:VectorKnowledgeBase knowledgeBase = new (vectorStore, embeddingModel); check knowledgeBase.ingest(documents);
Configuration
Connection (VectorStoreConnectionConfig)
| Field | Type | Default | Description |
|---|---|---|---|
auth | auth:AuthConfig | — | Same credential shapes as the loader's ConnectionConfig; pass auth:DEFAULT_CREDENTIALS for the standard AWS provider chain |
region | aws:Region | string | — | The region the vector bucket is in. S3 Vectors is not available in every AWS region |
endpoint | aws:EndpointConfig? | resolved from region | customEndpoint overrides the endpoint, e.g. for testing. fips must stay false: AWS publishes no FIPS endpoint for S3 Vectors. dualstack is ignored, since S3 Vectors has only dual-stack endpoints |
httpConfig | http:ClientConfiguration | {} | HTTP client settings, such as timeout, proxy and TLS; see below |
The store always sends HTTP/1.1 requests without chunked bodies, because S3 Vectors rejects both
HTTP/2 and chunked bodies, and it runs its own retries, re-signing each attempt. So httpVersion,
http1Settings.chunking and retryConfig in httpConfig are overridden; every other setting is
used as given.
Index (VectorIndex)
VectorIndex is either of:
| Form | Type | Example |
|---|---|---|
| Bucket and index name | VectorBucketIndex | {vectorBucketName: "my-vector-bucket", indexName: "my-index"} |
| Index ARN | IndexArn | {indexArn: "arn:aws:s3vectors:us-east-1:111122223333:bucket/my-vector-bucket/index/my-index"} |
Use the ARN for an index owned by another AWS account, since names resolve in the caller's own account.
Store behaviour (VectorStoreConfig)
| Field | Type | Default | Description |
|---|---|---|---|
contentKey | string | "content" | The metadata key chunk text is stored under. Must be declared non-filterable on the index — see above |
filters | ai:MetadataFilters? | () | Applied to every query, combined with any per-query filters under AND |
queryMode | QueryMode? | the index's mode | CLASSIC filters during the search; ENHANCED filters before it, so a filtered query isn't cut short. AWS rejects CLASSIC on an ENHANCED index |
returnVectorData | boolean | false | Whether query returns embeddings. QueryVectors never returns vector data, so this costs a follow-up GetVectors call per query |
maxListScan | int | 100000 | Cap on how many vectors a query without an embedding (the shape deleteByFilter issues) scans with ListVectors before failing |
validateIndexOnInit | boolean | true | Whether init reads the index with GetIndex and checks its dimension, metric and content key up front |
Errors
Every failure is an ai:Error. Those involving the S3 Vectors service itself — endpoint
resolution, credentials, signing, transport, and every non-2xx response — additionally carry the
response's particulars as error detail fields, named to match aws:ErrorDetails:
ai:Error? result = vectorStore.add(entries); if result is ai:Error { map<anydata|readonly> & readonly detail = result.detail(); io:println("S3 Vectors call failed: status ", detail["httpStatusCode"], ", error code ", detail["errorCode"], ", request id ", detail["requestId"]); }
The fields are httpStatusCode, httpStatusText, errorCode, errorMessage and requestId —
the last being the x-amzn-requestid value AWS support asks for first. A field the response did
not carry reads back as (), as do all of them on a failure raised before a response was
received or by the store's own argument validation (an invalid index identifier, a sparse
embedding, an untranslatable filter).
Limitations
- Dense vectors only. S3 Vectors has no sparse or hybrid index type.
addandqueryreject anything other than a plainai:Vector(float[]) embedding. - Text chunks only. A vector's content is stored as metadata, which must be JSON — so
chunk.contentmust be astring. Image, audio, file and binary chunks are rejected, including ones whose content is a URL string. - Two metadata keys are reserved. The store writes the chunk text under
contentKeyand the chunk type under_chunkType.addrejects a chunk whose own metadata uses either key. - A query with no embedding scans the index.
QueryVectorsrequires a query vector and cannot filter without one, so a query carrying only metadata filters — or neither, the shapeai:VectorKnowledgeBase.deleteByFilterissues — pages through every vector withListVectorsand evaluates filters in Ballerina, bounded byVectorStoreConfig.maxListScan. This is the only waydeleteByFiltercan work against this store at all, but it is an O(index size) operation on a large index. - On a
CLASSICindex, a filtered query with an embedding can return fewer thantopKresults, because filters are applied during the approximate search.ENHANCEDindexes (the default for vector buckets created on or after 30 September 2026) filter first. SetVectorStoreConfig.queryModetoENHANCEDto get that behaviour on an older index. - An
ENHANCEDquery allows at most 100 filter constraints. Each compared value counts once, so anINwith five values counts five. S3 Vectors rejects a larger filter with aValidationExceptionnaming the field. - Array-valued metadata is only fully supported with
EQUAL. Like S3 Vectors'$eq,EQUALmatches an array field if any element is equal, on both filter paths. AWS documents no array behaviour for the other operators, so the no-embedding path compares the whole array for them, which may not match whatQueryVectorsdoes. similarityScoreis a converted value, not the raw S3 Vectors distance. S3 Vectors returns a distance (lower is more similar);ai:VectorMatch.similarityScoremust be higher-is-better. Forcosine,1.0 - distancerecovers the true cosine similarity. Foreuclidean,1.0 / (1.0 + distance)preserves rank order but is not numerically the same conventionai:InMemoryVectorStoreuses for that metric (it returns the raw Euclidean distance, which inverts the ordering) — rank-order correctness was chosen over bit-for-bit consistency with that inversion.- Timestamps are stored as epoch-seconds numbers, not ISO-8601 strings.
ai:Metadata'screatedAt/modifiedAtaretime:Utcvalues; S3 Vectors'$gt/$gte/$lt/$lterange operators only accept numbers, so encoding them as date strings (as the Pinecone vector store does) would leave range filters on dates silently non-functional. A filter on either key can use atime:Utcvalue (converted automatically) or an epoch-seconds number. embeddingis empty unlessreturnVectorDatais enabled.QueryVectorsnever returns vector data; see Configuration above.deleteis idempotent. Deleting a key that does not exist in the index is not an error, unlikeai:InMemoryVectorStore, which raises one for a missing id.- No index management. Creating, deleting or listing vector buckets and indexes is out of scope for this store — see "Before you start" above for creating one with the AWS CLI.
- Metadata limits are AWS's, enforced client-side where practical. Up to 40 KB total metadata
per vector, 2 KB filterable, 50 keys, vector keys up to 1,024 characters, and a
contentKeyof up to 63 characters (the limit on non-filterable key names).addvalidates these before sending and names the offending vector's key in the error, rather than surfacing an opaqueValidationException. - No S3 Vectors emulator exists, so this store's transport layer is tested against an in-process mock HTTP service rather than a live endpoint or LocalStack. Verify against a real vector bucket before relying on it in production.
Examples
The ai.aws.s3 connector provides practical examples illustrating usage in various scenarios.
- Minimal load — load documents from a bucket and inspect what came back.
- RAG pipeline — load a corpus, ingest it into a knowledge base, and answer questions over it.
- Vector store RAG
— load a corpus with
s3:TextDataLoaderand store its embeddings ins3:VectorStore, loader and store from a single import.
Import
import ballerinax/ai.aws.s3;Other versions
1.0.0