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({})Source:https://dev.to/sascha-ms/elasticache-vector-search-hybrid-search-from-lambda-24b8 Related posts

Vulnerable AI services: find out which ones Dynatrace really scans

Incident Response on AWS: From Preparation to Post-Incident Review.
Look up a trucking carrier by USDOT or MC number with FMCSA's free open data (Python, no API key)

Sentinel-IR MCP 0.5: a bouncer for your AI assistant
Recalibrating a room-level sensor when the furniture moves

