Tenant-scoped RAG lives or dies on one thing: when you filter to a single customer, does the vector search return that customer’s best matches, or just whatever it stumbled into? Amazon S3 Vectors now has an ENHANCED index mode that applies metadata filters before similarity search. Below you’ll build a small multi-tenant index with Amazon Bedrock embeddings, query it per tenant, and run a head-to-head recall test against the older CLASSIC mode so you can see the difference with your own eyes.
Prerequisites
- An AWS account with access to Amazon S3 Vectors and Amazon Bedrock in
us-east-1(any S3 Vectors Region works; adjust commands). - Bedrock model access enabled for Amazon Titan Text Embeddings V2.
- A recent AWS CLI v2 that includes the
s3vectorscommands (install or update). - Python 3.10+ with
boto3andnumpy. - IAM permissions for
s3vectors:*on your test buckets andbedrock:InvokeModel.
Build a tenant-scoped RAG index step by step
-
Create a vector bucket. Buckets created on or after September 30, 2026 default new indexes to
ENHANCED, so a fresh bucket gets the good behavior without extra flags.export AWS_REGION=us-east-1 aws s3vectors create-vector-bucket --vector-bucket-name rag-tenants-enh -
Create the index. Titan Text Embeddings V2 returns 1,024 dimensions by default, and the index dimension must match. Mark the raw chunk text as non-filterable: it is returned with results but doesn’t eat into the 2 KB filterable metadata budget documented in the S3 Vectors limits.
aws s3vectors create-index \ --vector-bucket-name rag-tenants-enh \ --index-name tickets \ --data-type float32 \ --dimension 1024 \ --distance-metric cosine \ --metadata-configuration '{"nonFilterableMetadataKeys":["source_text"]}' aws s3vectors get-index --vector-bucket-name rag-tenants-enh --index-name ticketsConfirm the
get-indexoutput shows the index mode asENHANCED. -
Generate a skewed multi-tenant corpus. Real tenants are lopsided, so fake it that way: one whale with thousands of tickets and one tiny tenant with 40. The tiny one is where filtered recall usually breaks.
# corpus.py import random, json random.seed(7) topics = ["billing error", "login failure", "API timeout", "data export", "SSO setup", "webhook retries", "invoice PDF", "rate limiting"] tenants = {"t-whale": 3000, "t-mid-a": 800, "t-mid-b": 800, "t-tiny": 40} docs = [] for tenant, n in tenants.items(): for i in range(n): t = random.choice(topics) docs.append({"key": f"{tenant}-{i}", "tenant_id": tenant, "category": t.split()[0].lower(), "created_at": 1767225600 + random.randint(0, 25_000_000), "text": f"Customer reports {t} after deploy {i}; " f"steps tried: restart, cache clear, ticket {i}."}) json.dump(docs, open("docs.json", "w")) print(len(docs), "docs") -
Embed with Bedrock. Call Titan V2 through
bedrock-runtimeand cache the vectors locally so you can load the same data into two indexes and compute ground truth later. This mirrors the S3 Vectors getting started guide.# embed.py import boto3, json, numpy as np from concurrent.futures import ThreadPoolExecutor br = boto3.client("bedrock-runtime") docs = json.load(open("docs.json")) def embed(text): r = br.invoke_model(modelId="amazon.titan-embed-text-v2:0", body=json.dumps({"inputText": text})) return json.loads(r["body"].read())["embedding"] with ThreadPoolExecutor(8) as ex: vecs = list(ex.map(embed, [d["text"] for d in docs])) np.save("vecs.npy", np.array(vecs, dtype="float32")) -
Load vectors with tenant metadata.
PutVectorstakes up to 500 vectors per call. Storecreated_atas a number so range filters work.# load.py (usage: python load.py rag-tenants-enh) import boto3, json, sys, numpy as np s3v = boto3.client("s3vectors") bucket = sys.argv[1] docs, vecs = json.load(open("docs.json")), np.load("vecs.npy") batch = [] for d, v in zip(docs, vecs): batch.append({"key": d["key"], "data": {"float32": v.tolist()}, "metadata": {"tenant_id": d["tenant_id"], "category": d["category"], "created_at": d["created_at"], "source_text": d["text"]}}) if len(batch) == 500: s3v.put_vectors(vectorBucketName=bucket, indexName="tickets", vectors=batch) batch = [] if batch: s3v.put_vectors(vectorBucketName=bucket, indexName="tickets", vectors=batch) -
Run a tenant-scoped query. The tenant filter should come from the authenticated session in your app, never from the prompt or the client. Here it is hardcoded, and the query vector is just the first cached embedding. The filter combines an equality match with an
$inset, two of the operators listed in the metadata filtering docs. Every hit should belong tot-tiny.aws s3vectors query-vectors \ --vector-bucket-name rag-tenants-enh --index-name tickets \ --query-vector "{\"float32\": $(python -c 'import numpy as n;print(n.load("vecs.npy")[0].tolist())')}" \ --top-k 10 --return-metadata --return-distance \ --filter '{"$and":[{"tenant_id":"t-tiny"},{"category":{"$in":["login","sso"]}}]}' -

Create a CLASSIC twin for comparison. Set a second bucket’s default to
CLASSIC, create the same index, and load the same vectors.aws s3vectors create-vector-bucket --vector-bucket-name rag-tenants-cls aws s3vectors put-vector-bucket-default-index-mode \ --vector-bucket-name rag-tenants-cls --default-index-mode CLASSIC aws s3vectors create-index --vector-bucket-name rag-tenants-cls \ --index-name tickets --data-type float32 --dimension 1024 \ --distance-metric cosine \ --metadata-configuration '{"nonFilterableMetadataKeys":["source_text"]}' python load.py rag-tenants-cls -
Measure recall@10 for the tiny tenant. Because you cached every embedding, exact ground truth is a numpy dot product over the tenant’s 40 vectors. Use other tickets as queries.
# recall.py import boto3, json, numpy as np s3v = boto3.client("s3vectors") docs, vecs = json.load(open("docs.json")), np.load("vecs.npy") vecs /= np.linalg.norm(vecs, axis=1, keepdims=True) tiny = [i for i, d in enumerate(docs) if d["tenant_id"] == "t-tiny"] K = 10 def recall(bucket, qi): sims = vecs[tiny] @ vecs[qi] truth = {docs[tiny[j]]["key"] for j in np.argsort(-sims)[:K]} res = s3v.query_vectors(vectorBucketName=bucket, indexName="tickets", queryVector={"float32": vecs[qi].tolist()}, topK=K, filter={"tenant_id": "t-tiny"}) return len(truth & {v["key"] for v in res["vectors"]}) / K queries = np.random.default_rng(1).choice(len(docs), 50, replace=False) for b in ["rag-tenants-cls", "rag-tenants-enh"]: print(b, round(np.mean([recall(b, q) for q in queries]), 3))Expect the ENHANCED bucket at or near 1.0. How far CLASSIC drops depends on how selective the filter is relative to index size: the smaller the tenant and the bigger the index, the wider the gap. AWS reports up to 5x on highly selective filters at much larger scale than this toy corpus.
-
Flip CLASSIC to ENHANCED in place. This is the migration path for indexes in older buckets. No re-ingestion.
aws s3vectors update-index-mode --vector-bucket-name rag-tenants-cls \ --index-name tickets --index-mode ENHANCED python recall.py
What you built
You now have a tenant-scoped RAG retrieval layer on S3 Vectors: Titan embeddings, tenant and category metadata, numeric dates ready for range filters, and a repeatable recall test that proves filtered queries return what they should. Wire query_vectors into your generation step, or point an Amazon Bedrock Knowledge Base at the index instead of rolling your own. Keep recall.py in CI with a larger corpus and real queries pulled from logs; it is the cheapest regression test you’ll ever own, and it catches the day someone ships an index into an old bucket that still defaults to CLASSIC. If you’d rather not hand-roll embedding scripts, the S3 Vectors Embed CLI wraps the embed-and-put loop.
Cleanup takes four commands:
for b in rag-tenants-enh rag-tenants-cls; do
aws s3vectors delete-index --vector-bucket-name $b --index-name tickets
aws s3vectors delete-vector-bucket --vector-bucket-name $b
done
At Enkompass (enkompass.net), this is our home turf. We ship production Bedrock RAG and agent workloads on S3 Vectors, with tenant isolation baked into retrieval and recall tests guarding every release. If you’d rather have seasoned cloud people handle it instead of learning the hard way at 2 AM, let’s talk. Reach out through our contact page or call us directly at +1-828-436-5667 — we’ll help you turn this from a someday-project into a done-deal.
