ElastiCache vector search is now available in ElastiCache Serverless for Valkey too, since November 10, 2026. Until now you needed a node-based cluster for it, picked instance types and planned memory yourself. Now a serverless cache is enough: it is ready in seconds and billed by usage. Combined with the public endpoints AWS introduced in September, this is a combination that made me curious straight away as a Serverless fan: semantic search from a Lambda function, with no VPC, no NAT gateway and no cluster planning.
I went through the announcement, the documentation and the Node.js client in detail and built a complete example from it. You get a small book search for a fictional city library: embeddings from Amazon Bedrock, a search index in Valkey, a Lambda function behind an HTTP API and a deployment with the Serverless Framework. We also take an honest look at costs, security and the limits of the new feature.
In short
ElastiCache Serverless for Valkey 9 can now run vector, full-text and combined searches in a single query, and through a public endpoint a Lambda function reaches the cache without any VPC. The key insight: “hybrid” here means a pre-filter plus a KNN search, not the separate FT.HYBRID command. The biggest pitfall is memory, because every vector is held twice in RAM and that drives the bill.
What you will learn in this article
- What vector, full-text and hybrid search in ElastiCache Serverless actually do
- How to create a Valkey cache with a public endpoint and IAM authentication
- How to implement the index, embeddings and hybrid query in TypeScript
- How to deploy the Lambda function without a VPC using the Serverless Framework
- How to estimate the costs realistically from vector size and memory
What AWS announced on November 10
According to the announcement, ElastiCache Serverless for Valkey 9 and later supports four things: vector search, full-text search, a combination of both and server-side aggregations. AWS quotes latencies down to the microsecond range and up to 99 percent recall for vector search. Recall describes how many of the true nearest neighbours an approximate search actually finds. New or changed entries become searchable as soon as the write has completed. So there is no indexing run you have to wait for.
The feature is available in all commercial Regions, as well as in GovCloud and the AWS European Sovereign Cloud. Existing serverless caches can be upgraded to Valkey 9. Node-based clusters already had search before; what is new is that you no longer have to think about instance sizes.
I find another sentence in the announcement more telling: every agent, session or task can get its own datastore, reachable through a public endpoint and without any network setup. That is clearly aimed at AI agents that need a memory. The public endpoint itself is not new this week, though. AWS introduced it in September for ElastiCache Serverless with Valkey 9. Only now do the two pieces fit together, because only now can the serverless cache search as well.
What is ElastiCache vector search and what does “hybrid” mean?
For a vector search you first turn texts into lists of numbers with an embedding model. These are called vectors or embeddings. Texts with a similar meaning end up close to each other, even if they share no words. A search for “murder on the night train” therefore also finds a book whose description talks about a “crime during a rail journey”. The search looks for the k nearest neighbours, KNN for short.
To keep this fast with millions of entries, Valkey uses the HNSW algorithm (Hierarchical Navigable Small World). HNSW builds a graph in which the search jumps from coarse to fine layers. The result is an approximation, which is why AWS talks about recall. Alternatively there is FLAT, an exact but slower search across all vectors.
Full text, tags and numbers as a pre-filter
Besides vectors, an index can contain three other field types: TEXT for full-text search with words, prefixes and phrases, TAG for exact values such as a genre, and NUMERIC for number ranges such as a publication year. The real strength lies in combining them. In the Valkey Search query language you write a filter, then the arrow =>, and after it the KNN search. Valkey then only looks for nearest neighbours among the entries that match the filter.
That is exactly what AWS means by hybrid search, and it is worth a closer look. Valkey Search also has a separate FT.HYBRID command that merges text and vector rankings. According to the ElastiCache feature overview, however, Valkey 9.0 does not support this command. So you get “filter plus vector” in one query, not a weighted fusion of two ranked lists. For most everyday applications that is completely fine. If you come from the Elasticsearch world and expect Reciprocal Rank Fusion, though, you should know this up front.
The Lambda function reaches Bedrock and the public ElastiCache endpoint without a VPC.
The example: a book search for the city library
The example is a fictional city library. Readers describe in their own words what they feel like reading, for instance “an exciting case on a train”. Optionally they narrow the result down to a genre, a minimum publication year or a word that must appear in the description. The application has three parts:
- a script that creates the search index and stores a few books with their embeddings,
- a Lambda function behind an HTTP API that turns the question into an embedding and runs the hybrid query,
- an ElastiCache serverless cache with a public endpoint that the function signs in to via IAM.
The Lambda function deliberately runs without a VPC. It reaches Bedrock and the cache over the internet, connections are encrypted with TLS and authenticated with IAM. How expensive a NAT gateway can get otherwise is something I described in the article Cut NAT gateway costs.
Prerequisites
- An AWS account with permissions for ElastiCache, Lambda, API Gateway, IAM and Bedrock
- AWS CLI version 2, signed in for the Region you work in (
eu-central-1in the example) - Node.js 24 and npm 10 or later
- The Serverless Framework v4 (
npm i -g serverless) - Access to the Amazon Titan Text Embeddings V2 model in Bedrock
Set up ElastiCache vector search step by step
The following eight steps take you from an empty account to your first search request. You will also find all files in the example repository at the end.
Step 1: Create a serverless cache with a public endpoint
You create the cache once with the AWS CLI. What matters are engine version 9 and the connection type public. As the user group you use the system-managed group default.iam-user-group, in which all users sign in via IAM.
aws elasticache create-serverless-cache \
--serverless-cache-name book-search \
--engine valkey \
--major-engine-version 9 \
--connection-type public \
--user-group-id default.iam-user-group \
--region eu-central-1
# Check status and address (repeat until "available" appears)
aws elasticache describe-serverless-caches \
--serverless-cache-name book-search \
--query "ServerlessCaches[0].[Status,Endpoint.Address]" \
--output text --region eu-central-1
As soon as the status changes to available, you will see an address in the form book-search-abc123.public.serverless.euc1.cache.amazonaws.com. Note it down as CACHE_HOST, because we will need it several times.
Step 2: Set up the project
As the client I use Valkey GLIDE, the official client of the Valkey project. It brings two things that matter here: built-in IAM authentication with automatic token refresh and a dedicated GlideFt module for the search commands. The core of GLIDE is written in Rust and ships as a native library. We will come back to that during deployment.
{
"name": "book-search",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc",
"test": "vitest run",
"setup-index": "tsx scripts/setup-index.ts",
"deploy": "npm run build && serverless deploy --param=\"cacheHost=$CACHE_HOST\""
},
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1166.0",
"@valkey/valkey-glide": "^2.6.0"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.164",
"@types/node": "^24.10.0",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vitest": "^5.1.0"
}
}
{
"compilerOptions": {
"target": "ES2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["src", "scripts", "test"]
}
Step 3: Connect with IAM and TLS
Public endpoints strictly require TLS 1.3 and IAM authentication as the first command on the connection. GLIDE takes care of both when you pass an iamConfig instead of a password. According to the documentation the token is valid for 15 minutes; by default GLIDE refreshes it every five minutes. The client is created once per Lambda instance outside the handler and reused afterwards.
// src/valkey.ts
import { GlideClusterClient, ServiceType } from "@valkey/valkey-glide";
let pending: Promise<GlideClusterClient> | undefined;
/** One client per Lambda instance; GLIDE refreshes the IAM token itself. */
export function getClient(): Promise<GlideClusterClient> {
pending ??= GlideClusterClient.createClient({
addresses: [{ host: required("CACHE_HOST"), port: 6379 }],
useTLS: true,
requestTimeout: 2000,
credentials: {
username: "default.iam-user",
iamConfig: {
clusterName: required("CACHE_NAME").toLowerCase(),
service: ServiceType.Elasticache,
region: required("AWS_REGION"),
},
},
}).catch((error: unknown) => {
pending = undefined; // reconnect on the next call
throw error;
});
return pending;
}
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Environment variable ${name} is missing`);
return value;
}
Pay attention to the toLowerCase(): the cache name must be lowercase when signing, otherwise ElastiCache rejects the sign-in with WRONGPASS. In addition, the function resets the stored promise on an error, so that a failed connection does not stick around for the whole lifetime of the Lambda instance.
Step 4: Create embeddings with Amazon Bedrock
For the embeddings I use Amazon Titan Text Embeddings V2. The model returns 256, 512 or 1,024 dimensions. I choose 512 because it halves the memory requirement and is enough for short book descriptions. With normalize: true all vectors have a length of 1, which fits the COSINE distance metric well.
// src/embed.ts
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
export const EMBEDDING_DIM = 512;
const bedrock = new BedrockRuntimeClient({});
/** Creates a normalized embedding with Titan Text Embeddings V2. */
export async function embed(text: string): Promise<number[]> {
const response = await bedrock.send(
new InvokeModelCommand({
modelId: "amazon.titan-embed-text-v2:0",
contentType: "application/json",
accept: "application/json",
body: JSON.stringify({ inputText: text, dimensions: EMBEDDING_DIM, normalize: true }),
}),
);
const parsed = JSON.parse(new TextDecoder().decode(response.body)) as { embedding: number[] };
return parsed.embedding;
}
Step 5: Create the index and store books
The index is created with FT.CREATE and watches all hashes whose key starts with book:. Each book is a hash with title, author, genre, year, description and the embedding as a binary value. New entries end up in the index automatically, so you do not have to index anything separately.
// scripts/setup-index.ts
import { GlideFt } from "@valkey/valkey-glide";
import { getClient } from "../src/valkey";
import { embed, EMBEDDING_DIM } from "../src/embed";
import { toVectorBuffer } from "../src/query";
import { INDEX_NAME } from "../src/handler";
const books = [
{ id: "b1", title: "Fog over the Harbour", author: "Lena Brandt", genre: "crime", year: 2019,
description: "A detective investigates in a port city where a container full of stolen paintings disappears." },
{ id: "b2", title: "The Last Train North", author: "Tom Weller", genre: "crime", year: 2011,
description: "A murder on the night train, seven suspects and a conductor who knows more than he admits." },
{ id: "b3", title: "Gardening in Small Spaces", author: "Mira Holm", genre: "nonfiction", year: 2022,
description: "A practical guide to vegetables, herbs and berries on balconies and windowsills." },
{ id: "b4", title: "The Map of Winds", author: "Jonas Reiter", genre: "fantasy", year: 2020,
description: "A young cartographer discovers that her maps control the weather of an entire kingdom." },
{ id: "b5", title: "Summer in the Lighthouse", author: "Clara Wendt", genre: "novel", year: 2018,
description: "Two sisters inherit a lighthouse on the coast and have to decide whether to stay." },
{ id: "b6", title: "Fritz and the Lost Bicycle", author: "Ida Kern", genre: "children", year: 2023,
description: "A boy and his dog search for a stolen bicycle and get to know the whole neighbourhood." },
];
async function main(): Promise<void> {
const client = await getClient();
const existing = (await GlideFt.list(client)).map(String);
if (!existing.includes(INDEX_NAME)) {
await GlideFt.create(
client,
INDEX_NAME,
[
{ type: "TEXT", name: "title" },
{ type: "TEXT", name: "description", nostem: true },
{ type: "TAG", name: "genre" },
{ type: "NUMERIC", name: "year" },
{
type: "VECTOR",
name: "embedding",
attributes: { algorithm: "HNSW", dimensions: EMBEDDING_DIM, distanceMetric: "COSINE", type: "FLOAT32" },
},
],
{ dataType: "HASH", prefixes: ["book:"] },
);
console.log(`Index ${INDEX_NAME} created`);
}
for (const book of books) {
const vector = await embed(`${book.title}. ${book.description}`);
await client.hset(`book:${book.id}`, [
{ field: "title", value: book.title },
{ field: "author", value: book.author },
{ field: "genre", value: book.genre },
{ field: "year", value: String(book.year) },
{ field: "description", value: book.description },
{ field: "embedding", value: toVectorBuffer(vector) },
]);
console.log(`stored: ${book.title}`);
}
client.close();
}
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
Two details matter to me here. First, the description field has the nostem option. The default language for word stems is English, which suits this English sample data, but texts in other languages would otherwise be cut down by English rules. According to the Valkey documentation, other languages only arrive with Valkey Search 1.3, while Valkey 9.0 on ElastiCache uses version 1.2. Second, DIM is fixed in the index. If you later change the embedding model or the dimension, you need a new index.
You can run the script locally as long as your AWS credentials allow elasticache:Connect and bedrock:InvokeModel:
npm install
export AWS_REGION=eu-central-1
export CACHE_NAME=book-search
export CACHE_HOST=book-search-abc123.public.serverless.euc1.cache.amazonaws.com
npm run setup-index
You should see a line “Index idx:books created” followed by six lines “stored: …”.
Step 6: Build and test the hybrid query
The query itself is a string. That is why it belongs in a separate, pure function you can test without AWS. Security matters here: user input must never get into the query language unchecked, otherwise someone can inject their own filters with curly braces or a |. I therefore check the genre against a fixed list, and only letters and digits survive from free text.
// src/query.ts
export const GENRES = ["crime", "novel", "nonfiction", "fantasy", "children"] as const;
export type Genre = (typeof GENRES)[number];
export interface SearchFilter {
genre?: Genre;
minYear?: number;
mustContain?: string;
}
export function isGenre(value: string | undefined): value is Genre {
return value !== undefined && (GENRES as readonly string[]).includes(value);
}
/** Removes everything that has a special meaning in the query syntax. */
export function cleanWords(input: string): string[] {
return input
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, " ")
.split(/\s+/)
.filter((w) => w.length >= 3)
.slice(0, 5);
}
/** Builds the query: optional pre-filter, then KNN over the embedding. */
export function buildQuery(filter: SearchFilter, k = 5): string {
const parts: string[] = [];
if (filter.genre) parts.push(`@genre:{${filter.genre}}`);
if (filter.minYear !== undefined && Number.isFinite(filter.minYear)) {
parts.push(`@year:[${Math.trunc(filter.minYear)} +inf]`);
}
for (const word of cleanWords(filter.mustContain ?? "")) {
parts.push(`@description:${word}`);
}
const prefilter = parts.length > 0 ? `(${parts.join(" ")})` : "*";
return `${prefilter}=>[KNN ${k} @embedding $vec AS distance]`;
}
/** Valkey expects FLOAT32 values as little-endian bytes. */
export function toVectorBuffer(vector: number[]): Buffer {
const floats = new Float32Array(vector);
const bytes = Buffer.alloc(floats.length * 4);
floats.forEach((value, i) => bytes.writeFloatLE(value, i * 4));
return bytes;
}
The tests run with Vitest and need no connection to AWS:
// test/query.test.ts
import { describe, expect, it } from "vitest";
import { buildQuery, cleanWords, isGenre, toVectorBuffer } from "../src/query";
describe("buildQuery", () => {
it("searches all books without a filter", () => {
expect(buildQuery({})).toBe("*=>[KNN 5 @embedding $vec AS distance]");
});
it("combines genre, year and required word as a pre-filter", () => {
expect(buildQuery({ genre: "crime", minYear: 2015, mustContain: "Train!" }, 3)).toBe(
"(@genre:{crime} @year:[2015 +inf] @description:train)=>[KNN 3 @embedding $vec AS distance]",
);
});
it("removes special characters and short words", () => {
expect(cleanWords("Harbour | -Murder* on {x}")).toEqual(["harbour", "murder"]);
});
it("ignores invalid years", () => {
expect(buildQuery({ minYear: Number("abc") })).toBe("*=>[KNN 5 @embedding $vec AS distance]");
});
});
describe("isGenre", () => {
it("only allows known genres", () => {
expect(isGenre("crime")).toBe(true);
expect(isGenre("crime} | @year:[0 +inf]")).toBe(false);
});
});
describe("toVectorBuffer", () => {
it("writes 4 bytes per dimension as little endian", () => {
const buffer = toVectorBuffer([1, -0.5]);
expect(buffer.length).toBe(8);
expect(buffer.readFloatLE(0)).toBe(1);
expect(buffer.readFloatLE(4)).toBe(-0.5);
});
});
npm test
🎬 How a hybrid query works – this animated diagram only runs on my site: watch it on sascha-ms.de
Step 7: The Lambda handler
The handler reads the parameters from the URL, creates the embedding of the question and opens the connection in parallel. The vector is passed as a binary value via PARAMS, never as text in the query string. Valkey returns the distance under the name distance. With COSINE, smaller means more similar.
// src/handler.ts
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import { GlideFt } from "@valkey/valkey-glide";
import { getClient } from "./valkey";
import { embed } from "./embed";
import { buildQuery, isGenre, toVectorBuffer } from "./query";
export const INDEX_NAME = "idx:books";
export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
const params = event.queryStringParameters ?? {};
const question = (params.q ?? "").trim();
if (question.length < 3 || question.length > 300) {
return json(400, { message: "Parameter q is missing or too long (3–300 characters)" });
}
const genre = isGenre(params.genre) ? params.genre : undefined;
const minYear = params.from ? Number(params.from) : undefined;
const query = buildQuery({ genre, minYear, mustContain: params.word });
const [client, vector] = await Promise.all([getClient(), embed(question)]);
const [total, docs] = await GlideFt.search(client, INDEX_NAME, query, {
params: [{ key: "vec", value: toVectorBuffer(vector) }],
returnFields: [
{ fieldIdentifier: "title" },
{ fieldIdentifier: "author" },
{ fieldIdentifier: "year" },
{ fieldIdentifier: "distance" },
],
dialect: 2,
});
const results = docs
.map((doc) => {
const fields = Object.fromEntries(doc.value.map((f) => [String(f.key), String(f.value)]));
return {
id: String(doc.key),
title: fields.title,
author: fields.author,
year: Number(fields.year),
distance: Number(fields.distance),
};
})
.sort((a, b) => a.distance - b.distance);
return json(200, { query, total, results });
}
function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 {
return { statusCode, headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
}
Step 8: Deploy with the Serverless Framework
In the serverless.yml, what stands out is what is missing: there is no vpc block, no subnets and no security groups. The function only needs two IAM permissions: elasticache:Connect on the cache and on the user default.iam-user, plus bedrock:InvokeModel on the embedding model.
# serverless.yml
service: book-search
frameworkVersion: "4"
build:
esbuild: false # we compile with tsc ourselves because GLIDE ships a native library
provider:
name: aws
runtime: nodejs24.x
architecture: x86_64
region: eu-central-1
memorySize: 512
timeout: 10
environment:
CACHE_NAME: ${param:cacheName, 'book-search'}
CACHE_HOST: ${param:cacheHost}
iam:
role:
statements:
- Effect: Allow
Action: elasticache:Connect
Resource:
- arn:aws:elasticache:${aws:region}:${aws:accountId}:serverlesscache:${param:cacheName, 'book-search'}
- arn:aws:elasticache:${aws:region}:${aws:accountId}:user:default.iam-user
- Effect: Allow
Action: bedrock:InvokeModel
Resource: arn:aws:bedrock:${aws:region}::foundation-model/amazon.titan-embed-text-v2:0
package:
patterns:
- "!**"
- dist/src/**
- node_modules/**
- "!node_modules/.bin/**"
- "!node_modules/typescript/**"
- "!node_modules/vitest/**"
- "!node_modules/@vitest/**"
- "!node_modules/vite/**"
- "!node_modules/esbuild/**"
- "!node_modules/@esbuild/**"
- "!node_modules/tsx/**"
- "!node_modules/rollup/**"
- "!node_modules/@rollup/**"
functions:
search:
handler: dist/src/handler.handler
events:
- httpApi:
method: GET
path: /search
I switch off the framework’s built-in esbuild bundling and compile with tsc. The reason is GLIDE’s native library: it has to sit in node_modules as a package for Linux and cannot be bundled into a single JavaScript file. If you work on a Mac, build first and then fetch the Linux variant of the packages, because the native TypeScript 7 compiler is platform-specific as well. The cleanest option is to deploy from a CI pipeline on Linux anyway.
export CACHE_HOST=book-search-abc123.public.serverless.euc1.cache.amazonaws.com
# On Linux (e.g. in CI) this is enough:
npm run deploy
# On macOS: build first, then fetch the Linux variant of the native packages and deploy
npm run build
npm install --os=linux --cpu=x64
serverless deploy --param="cacheHost=$CACHE_HOST"
npm install # afterwards restore the local packages
# Clean up when you are done
serverless remove
aws elasticache delete-serverless-cache --serverless-cache-name book-search --region eu-central-1
After the deployment the framework prints the URL of the HTTP API. A test call looks for a crime novel about a train journey:
curl "https://abc123.execute-api.eu-central-1.amazonaws.com/search?q=exciting%20case%20during%20a%20train%20journey&genre=crime"
As a response you can expect JSON with the query used, the number of hits and a list sorted by distance. With the sample data, “The Last Train North” should come first, even though neither “murder” nor “night train” appears in the question. The distance values in the sample output are for illustration only; your values depend on the model and the data.
{
"query": "(@genre:{crime})=>[KNN 5 @embedding $vec AS distance]",
"total": 2,
"results": [
{ "id": "book:b2", "title": "The Last Train North", "author": "Tom Weller", "year": 2011, "distance": 0.41 },
{ "id": "book:b1", "title": "Fog over the Harbour", "author": "Lena Brandt", "year": 2019, "distance": 0.67 }
]
}
Common errors and how to fix them
| Error message | Cause | Fix |
|---|---|---|
NOAUTH Authentication required |
The client sends another command before signing in | Pass IAM authentication when connecting, not later via AUTH
|
WRONGPASS |
Cache name signed with uppercase letters | Use a lowercase name, check the elasticache:Connect permission for cache and user |
TLS handshake failure |
Client does not speak TLS 1.3 or TLS is off | Set useTLS: true, use a current client version |
| “Cannot find module” or “invalid ELF header” in Lambda | Native GLIDE library packaged for the wrong operating system | Install for Linux, match the function’s architecture |
| No hits despite data | Wrong vector size or wrong key prefix | Match DIM to the model, let keys start with book:
|
ERR Exceeded limit of IAM Authentication requests |
Too many new connections at once | Reuse the client per instance, retry with backoff |
The last point deserves one more sentence. Every new Lambda instance opens a connection and signs in via IAM. During a sudden load spike with many cold starts, this can hit the limits for new connections. Retries with backoff help here, as I use them in the article on DynamoDB rate limiting, or a limited reserved concurrency.
What does ElastiCache vector search cost?
ElastiCache Serverless charges for two things: data stored in GB-hours and ElastiCache Processing Units, ECPUs for short. For US East (N. Virginia), the pricing page shows 0.084 US dollars per GB-hour and 0.0023 US dollars per million ECPUs for Valkey. At least 100 MB per cache are billed. Extrapolated to a month of 730 hours, that is roughly 61 US dollars per GB and a floor of about 6 US dollars for an almost empty cache. For Frankfurt, it is best to check the prices in the AWS Pricing Calculator.
Memory is where it gets interesting. According to the AWS documentation, the index keeps an additional copy of every vector. A vector with 512 dimensions in FLOAT32 takes 2 KB, so at least 4 KB in the cache, plus the HNSW graph and your other fields. A million books therefore land roughly at 4 GB or more, which means over 240 US dollars of storage per month. With 1,024 dimensions the figure doubles. The choice of vector size is therefore not a detail but your biggest cost lever.
For ECPUs: simple read and write commands cost one ECPU per kilobyte transferred. Commands that need more compute time consume proportionally more. The pricing page gives no fixed factor for search commands. So measure with realistic data and look at the CloudWatch metrics before you extrapolate. With the cache usage limits you can cap storage and ECPUs per second, which is a good safety net in test accounts in particular.
Security with a public endpoint
“Publicly reachable” sounds like a red flag to many security teams at first. The hurdles are high, though: nobody gets in without a valid IAM token, and every connection is encrypted with TLS 1.3. There is no static password lying around in a configuration file. Responsibility thus moves from the network perimeter to your IAM policies, similar to DynamoDB or S3.
I would still always do three things. Grant elasticache:Connect only on the specific cache and user, never with wildcards. Use the condition key elasticache:ConnectionType in your organization to control who may create public caches at all. And remember for an incident: if you revoke the IAM permission, existing connections stay open. You can only disconnect a user immediately by removing them from the cache’s user group. I did not find an IP allow list for public endpoints in the documentation. If you need network isolation, stick with the VPC connection type.
ElastiCache, OpenSearch Serverless or S3 Vectors?
AWS now has several places for vectors. Which one fits depends mainly on latency, data volume and query frequency. The following table is my rule of thumb, not a benchmark:
| Criterion | ElastiCache Serverless | OpenSearch Serverless | S3 Vectors |
|---|---|---|---|
| Strength | Very low latency, data searchable immediately | Extensive search features and relevance tuning | Very cheap storage for large vector sets |
| Latency according to AWS | down to the microsecond range | intended for high query rates at low latency | under a second, warm from about 100 ms |
| Cost driver | Memory per GB | Compute capacity | Storage and queries |
| Good fit for | Semantic cache, agent memory, real-time recommendations | Search applications with facets and complex ranking | Archive knowledge, rarely queried RAG data |
In practice the options do not exclude each other. One pattern I like: S3 Vectors as the cheap foundation for the whole data set and ElastiCache as a fast semantic cache for the most frequent questions in front of it.
My take
For me, the combination of search and public endpoint is the real news. Vector search in a cache existed before, but it always came with cluster planning and VPC configuration. Now it fits the way I like to build Serverless applications: create the resource, grant permissions, get going. For prototypes, for AI agents with their own short-term memory and for semantic caching in front of expensive model calls, this is real progress.
I am sceptical about the term “hybrid”. Without FT.HYBRID on Valkey 9.0 you get filtered vector search, not a true fusion of full-text and vector ranking. That is not a flaw, but it should not get lost in any architecture decision. Memory also stays expensive. Anyone who wants to store ten million documents with 1,024 dimensions should do the maths first instead of assuming “Serverless means cheap”. And finally: a cache is still a cache. The source of truth for your books still belongs in a database from which you can rebuild the index at any time.
FAQ
Does ElastiCache Serverless support vector search?
Yes, since November 10, 2026. It requires ElastiCache Serverless for Valkey 9 or later. Vector, full-text, tag and numeric searches as well as aggregations are supported.
Does Lambda need a VPC for ElastiCache Serverless?
Not anymore. With the “Public” connection type, a Lambda function without a VPC reaches the cache over the internet, secured with TLS 1.3 and IAM. For network isolation, the VPC connection type remains the right choice.
How many dimensions can a vector have in ElastiCache Serverless?
According to the limits overview, up to 4,096 dimensions. Node-based clusters allow considerably more. For KNN queries, the limit for k in Serverless is 512.
Does search in ElastiCache cost extra?
There is no separate search price. You pay for stored data and ECPUs. Depending on the compute effort, search commands use more ECPUs than a simple GET, and the vectors increase memory usage.
Your next step
Create a serverless cache with a public endpoint in a test account, set usage limits and run through the example once. After that, take a look at your own data: how many entries do you have, and are 512 dimensions enough for you? With these two numbers you will quickly know whether ElastiCache is the right vector database for you. How to stream AI answers live to the browser afterwards is something I show in the article Lambda response streaming with Bedrock.
I am curious: what would you use vector search in a cache for — as a semantic cache, as memory for agents or as the actual search? Let me know in the comments.
All code from this article, ready to run: smirowstanitzok/sascha-ms-examples
Sources
- AWS What's New: ElastiCache Serverless supports vector, full-text and hybrid searches plus aggregations (Nov 10, 2026)
- AWS documentation: Search features and limits
- AWS documentation: Choosing the appropriate configuration
- AWS documentation: Create a Valkey serverless cache with a public endpoint
- AWS documentation: Connect to a cache with a public endpoint
- Amazon ElastiCache pricing
- Amazon Bedrock: Titan Text Embeddings
- Valkey: FT.CREATE, FT.SEARCH and query syntax
- Valkey GLIDE on GitHub
- Amazon S3 Vectors
This post was created with AI assistance.
👉 Next step: Not sure which AWS service fits your use case? My AWS service finder asks a few questions and points you to the right one.
📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)