What we are building
By the end you will have two vector stores running the same embeddings and a script that tells you which one to send a query to. The cold tier is an S3 Vectors index: object storage that answers similarity queries in sub-second time and costs almost nothing to keep full. The hot tier is an OpenSearch Serverless collection, created by a one-click export from the S3 index, that answers in roughly 10 milliseconds but bills you for compute whether or not anyone queries it.
The non-obvious part is that this is not a "which is better" decision. S3 Vectors and OpenSearch are priced and tuned for opposite ends of the same workload, and the right architecture keeps almost everything in S3 and promotes only the small slice that needs low latency. Most teams get this wrong by defaulting to OpenSearch for the whole corpus and paying a fixed 350 dollars a month to serve a few thousand hot vectors.
What you ship: a boto3 script that creates and loads an S3 vector index, a latency benchmark that measures cold and warm query times, a cost model that computes the crossover point for your own numbers, and the exported OpenSearch collection serving the hot tier.
Prerequisites
You need an AWS account with permissions to create S3 vector buckets, invoke Bedrock, and create OpenSearch Serverless collections. You need Python 3.11 or newer, boto3 1.40 or newer, and the AWS CLI configured with a default region where S3 Vectors is available (this tutorial uses us-east-1). You should be comfortable reading Python and know what an embedding is. If you followed episode 2 or episode 25 of this series you already have the S3 Vectors mental model, but this tutorial is self-contained and rebuilds the index from scratch.
Enable model access to Amazon Titan Text Embeddings V2 in the Bedrock console (Model access, one click) before you start, or the embedding calls will return AccessDeniedException. Estimated cost to follow along end to end: about 1 to 3 dollars if you delete the OpenSearch collection the same day, because the Serverless collection is the only thing here that bills by the hour.
Setup
Install the dependencies and confirm your identity.
python -m venv .venv && source .venv/bin/activate
pip install "boto3>=1.40" opensearch-py requests-aws4auth
aws sts get-caller-identityThe last command should print your account ID and a user or role ARN. If it errors, fix your credentials before going further. Now set two environment variables the scripts will read.
export AWS_REGION=us-east-1
export VECTOR_BUCKET=sl92-tiered-$(aws sts get-caller-identity --query Account --output text)
echo "bucket will be: $VECTOR_BUCKET"The bucket name is suffixed with your account ID because S3 vector bucket names are globally unique, same as regular buckets. That is the whole setup. There is no cluster to provision and no domain to wait on, which is exactly the point of the cold tier.
Step 1: Create the S3 vector bucket and index
We start with the cheap tier because everything lives here first. The s3vectors client creates a vector bucket, then an index inside it with a fixed dimension and distance metric.
# setup_index.py
import os, boto3
REGION = os.environ["AWS_REGION"]
BUCKET = os.environ["VECTOR_BUCKET"]
INDEX = "docs"
s3v = boto3.client("s3vectors", region_name=REGION)
s3v.create_vector_bucket(vectorBucketName=BUCKET)
s3v.create_index(
vectorBucketName=BUCKET,
indexName=INDEX,
dataType="float32",
dimension=1024,
distanceMetric="cosine",
metadataConfiguration={"nonFilterableMetadataKeys": ["source_text"]},
)
print(f"created s3://{BUCKET} index '{INDEX}'")The dimension must match your embedding model. Titan Text Embeddings V2 defaults to 1024, so that is what we pin here. distanceMetric="cosine" is the right choice for normalized text embeddings. The nonFilterableMetadataKeys list matters for cost and correctness: any metadata key you name there is stored and returned but not indexed for filtering, which keeps the source text out of the filterable metadata budget. Keep the raw chunk text non-filterable and reserve filterable metadata for things you actually filter on, like category or tenant_id.
Run it once. If the bucket already exists from a previous attempt the call raises ConflictException, which is safe to ignore.
Step 2: Embed and load a corpus
Now we put real vectors in. This script embeds a small set of documents with Titan and writes them with put_vectors. In production you would page through thousands of chunks; the shape is identical.
# load.py
import os, json, boto3
REGION, BUCKET, INDEX = os.environ["AWS_REGION"], os.environ["VECTOR_BUCKET"], "docs"
brt = boto3.client("bedrock-runtime", region_name=REGION)
s3v = boto3.client("s3vectors", region_name=REGION)
DOCS = {
"d1": "S3 Vectors stores embeddings in object storage with sub-second query latency.",
"d2": "OpenSearch Serverless answers vector queries in about 10 milliseconds.",
"d3": "A tiered strategy keeps cold vectors in S3 and promotes hot vectors to OpenSearch.",
"d4": "Titan Text Embeddings V2 returns 1024-dimensional vectors by default.",
"d5": "OpenSearch Serverless bills a two-OCU minimum even when idle.",
}
def embed(text):
body = json.dumps({"inputText": text, "dimensions": 1024, "normalize": True})
r = brt.invoke_model(modelId="amazon.titan-embed-text-v2:0", body=body)
return json.loads(r["body"].read())["embedding"]
vectors = [
{"key": k, "data": {"float32": embed(v)}, "metadata": {"source_text": v}}
for k, v in DOCS.items()
]
s3v.put_vectors(vectorBucketName=BUCKET, indexName=INDEX, vectors=vectors)
print(f"loaded {len(vectors)} vectors")put_vectors is an upsert: writing a vector with a key that already exists overwrites it, so re-running the script is idempotent. S3 Vectors gives you strong consistency on writes, which means a vector is queryable the moment put_vectors returns. There is no index-refresh delay to wait on, unlike OpenSearch, where you tune a refresh interval.
The normalize: True in the embedding body is not optional here. Cosine distance assumes unit-length vectors, and asking Titan to normalize means you do not have to divide by the norm yourself before writing.
Step 3: Query the cold tier
A query embeds the question the same way, then calls query_vectors with a topK.
# query.py
import os, json, boto3
REGION, BUCKET, INDEX = os.environ["AWS_REGION"], os.environ["VECTOR_BUCKET"], "docs"
brt = boto3.client("bedrock-runtime", region_name=REGION)
s3v = boto3.client("s3vectors", region_name=REGION)
def embed(text):
body = json.dumps({"inputText": text, "dimensions": 1024, "normalize": True})
r = brt.invoke_model(modelId="amazon.titan-embed-text-v2:0", body=body)
return json.loads(r["body"].read())["embedding"]
q = embed("How fast is OpenSearch for vector search?")
res = s3v.query_vectors(
vectorBucketName=BUCKET, indexName=INDEX,
queryVector={"float32": q}, topK=3,
returnMetadata=True, returnDistance=True,
)
for m in res["vectors"]:
print(round(m["distance"], 4), m["metadata"]["source_text"])Run it. The top hit should be the OpenSearch 10-millisecond sentence, with the smallest distance. You now have a working retrieval store that cost you a few cents to build and will cost cents per month to keep, no matter how many vectors you add up to two billion per index (the GA limit, a fortyfold increase over the preview cap announced at re:Invent 2025).
Step 4: Benchmark the latency you actually get
Here is where the decision gets concrete. S3 Vectors advertises sub-second latency, which is true and also vague. The real number depends on whether the index is warm. This script measures both.
# bench.py
import os, json, time, statistics, boto3
REGION, BUCKET, INDEX = os.environ["AWS_REGION"], os.environ["VECTOR_BUCKET"], "docs"
brt = boto3.client("bedrock-runtime", region_name=REGION)
s3v = boto3.client("s3vectors", region_name=REGION)
def embed(text):
body = json.dumps({"inputText": text, "dimensions": 1024, "normalize": True})
r = brt.invoke_model(modelId="amazon.titan-embed-text-v2:0", body=body)
return json.loads(r["body"].read())["embedding"]
qv = {"float32": embed("vector search latency")}
def one():
t = time.perf_counter()
s3v.query_vectors(vectorBucketName=BUCKET, indexName=INDEX,
queryVector=qv, topK=5)
return (time.perf_counter() - t) * 1000
cold = one()
warm = [one() for _ in range(20)]
print(f"cold query: {cold:7.1f} ms")
print(f"warm p50: {statistics.median(warm):7.1f} ms")
print(f"warm p95: {sorted(warm)[int(len(warm)*0.95)]:7.1f} ms")On a small index you will typically see a cold query in the few-hundred-millisecond range and warm queries settling toward roughly 100 milliseconds. That is the honest performance envelope: fast enough for a nightly batch job, a research tool, or an internal assistant where a human waits a beat anyway. It is not fast enough for a typeahead box or an in-request fraud check, where OpenSearch's 10-millisecond floor is the difference between shipping and not. Write your two numbers down. You need them for the next step.
Step 5: Do the cost math and find your crossover
Latency alone does not decide this. Cost does, and the two services are priced on different axes. S3 Vectors charges for storage and per query, with no idle floor. OpenSearch Serverless charges by the OCU-hour with a two-OCU minimum, so the first collection costs roughly 350 dollars a month before it serves a single query. This script encodes both and prints the monthly bill for each.
# costmodel.py
# Prices are us-east-1 list, verify against the pricing pages before quoting.
S3V_STORAGE_GB_MONTH = 0.06 # $/GB-month
S3V_QUERY_PER_M = 2.50 # $/million queries
OSS_OCU_HOUR = 0.248 # $/OCU-hour
OSS_MIN_OCU = 2 # indexing + search floor for first collection
HOURS = 730
def gb_for(n_vectors, dim=1024, bytes_per=4, overhead=1.5):
return n_vectors * dim * bytes_per * overhead / 1e9
def s3_vectors_month(n_vectors, queries_month):
return (gb_for(n_vectors) * S3V_STORAGE_GB_MONTH
+ queries_month / 1e6 * S3V_QUERY_PER_M)
def opensearch_month(_n_vectors, _queries_month):
return OSS_MIN_OCU * OSS_OCU_HOUR * HOURS # floor dominates at small scale
for n, q in [(50_000, 100_000), (2_000_000, 5_000_000), (50_000, 50_000_000)]:
s3c, osc = s3_vectors_month(n, q), opensearch_month(n, q)
winner = "S3 Vectors" if s3c < osc else "OpenSearch"
print(f"{n:>10,} vec {q:>12,} q/mo -> "
f"S3 ${s3c:8.2f} OSS ${osc:8.2f} keep in {winner}")The output makes the shape obvious. At 50,000 vectors and 100,000 queries a month, S3 Vectors costs cents and OpenSearch costs about 362 dollars, a difference of three orders of magnitude for a workload where nobody would notice the latency. The crossover only arrives when query volume gets high enough that S3's per-query fee overtakes OpenSearch's fixed compute, which for a small hot index means tens of millions of queries a month. That is the whole argument for tiering: keep the corpus in S3, and only promote the vectors whose queries are both frequent and latency-sensitive. Note that OpenSearch has since shipped a next-generation Serverless with scale-to-zero that removes the fixed floor for spiky workloads; if you are on it, replace the opensearch_month floor with a usage-based estimate, but the tiering logic does not change.
Step 6: Export the hot tier to OpenSearch Serverless
Now promote the subset that needs 10 milliseconds. AWS ships a one-click export that copies an S3 vector index into a new OpenSearch Serverless collection while leaving the S3 data in place. In the S3 console, choose Vector buckets, select your bucket, select the docs index radio button, choose Advanced search export, then Export to OpenSearch. Pick or create a service role with the required permissions and start the job. You can watch it from the OpenSearch Service console under Integrations, on the Import history page, until the status flips from In Progress to Complete.
Two properties of this export matter and are easy to miss. It is point-in-time: the copy captures the index as of the moment the job starts, and any put_vectors you do afterward will not appear in OpenSearch. And it is one-time: the export does not stay in sync, so keeping the hot tier current means re-exporting or dual-writing from your ingestion code. For a hot tier you refresh on a schedule, the point-in-time copy is fine. For one that must reflect live writes, dual-write to both stores at ingest.
Once the collection is Complete, query it with opensearch-py. The hot tier speaks the standard k-NN query API.
# query_hot.py
import os, boto3
from opensearchpy import OpenSearch, RequestsHttpConnection
from requests_aws4auth import AWS4Auth
REGION = os.environ["AWS_REGION"]
HOST = os.environ["OSS_ENDPOINT"] # collection endpoint, no https://
c = boto3.Session().get_credentials()
auth = AWS4Auth(c.access_key, c.secret_key, REGION, "aoss", session_token=c.token)
client = OpenSearch(hosts=[{"host": HOST, "port": 443}], http_auth=auth,
use_ssl=True, connection_class=RequestsHttpConnection)
body = {"size": 3, "query": {"knn": {"vector": {"vector": [0.0]*1024, "k": 3}}}}
print(client.search(index="docs", body=body))The service name in the SigV4 signature is aoss for Serverless, not es. Get that wrong and every request returns 403. Swap the zero vector for a real Titan embedding of your query to see live results.
Verify it works
You have four green lights to check. First, python query.py against the cold tier prints three results with the OpenSearch-latency sentence ranked first and a distance below roughly 0.5. Second, python bench.py prints a cold time and a warm p50, and the warm p50 is meaningfully lower than the cold time, proving the warm-cache behavior is real. Third, python costmodel.py prints S3 Vectors winning by three orders of magnitude at the 50,000-vector row. Fourth, the OpenSearch import job reaches status Complete and python query_hot.py returns a hits block with documents rather than a 403 or a connection error. If all four hold, you have a working two-tier vector architecture and the numbers that justify it.
When it breaks
If embedding calls fail with AccessDeniedException, you did not enable Titan Text Embeddings V2 in Bedrock model access. Enable it and wait a minute. If create_index raises a dimension error at query time later, your index dimension does not match the model output; delete the index and recreate it with dimension=1024. If query_hot.py returns 403, the usual cause is signing with service name es instead of aoss, or a data-access policy on the collection that does not grant your principal; both are fixed in the OpenSearch Serverless collection's data access policy, not IAM alone. If the export job sits In Progress for a long time, that is normal for the first run because it provisions a collection and an ingestion pipeline behind the scenes; check Import history rather than assuming failure. And if your OpenSearch bill surprises you, remember the two-OCU floor bills by the hour from the moment the collection exists, so an export you forget to delete is the single most expensive mistake in this tutorial.
Where to take it next
First, wire the router: a five-line function that checks whether a query's target vectors are in the hot set and dispatches to OpenSearch, else falls back to query_vectors on S3. That is the piece that turns two stores into one system. Second, automate the promotion policy by logging query keys, and re-export the top-queried slice on a schedule so the hot tier tracks demand instead of a guess. Third, if you are already an OpenSearch shop, try the other integration instead of export: create a managed-cluster k-NN index with "engine": "s3vector" on an OR1 domain, which keeps OpenSearch's hybrid search and filtering while pushing storage down to S3, trading latency for cost inside a single API. The question to keep asking is not "S3 or OpenSearch," it is "what fraction of my corpus actually needs 10 milliseconds," and the honest answer is almost always smaller than the default architecture assumes.

