You can serve LLM‑powered answers in milliseconds by caching vector embeddings in Redis—no separate vector DB required. This post shows a real‑world Lambda that writes, retrieves, and scores embeddings using the AWS SDK v3 and TypeScript’s new satisfies operator.
Why Redis Can Be a Vector Store
In plain English: Redis isn’t just a place to keep strings; it can also hold vectors—lists of numbers that capture the meaning of a piece of text.
When you ask a language model (LLM) a question, you usually turn the question into an embedding (a fixed‑length array of floating‑point numbers). To find relevant documents you compare this array against the arrays you already stored. The comparison is cheap (just a dot‑product or cosine similarity) and can be done in memory.
Redis has two features that make it a natural fit for this pattern:
- RedisJSON – a module that lets you store and retrieve JSON documents, including binary blobs. We can keep the raw embedding array inside a JSON field.
- Lua scripting – a way to run a short program inside Redis so you can read many keys, compute similarities, sort, and return the top‑k results without round‑tripping to your Lambda.
Because Redis runs in RAM, those calculations finish in a few hundred microseconds. Compared to a dedicated vector DB that lives on disk, the latency drop is dramatic—perfect for Retrieval‑Augmented Generation (RAG) where you need to answer a user query fast.
Key takeaway: Redis provides low‑latency, in‑memory data structures plus server‑side scripting, making it a lightweight vector store for RAG workloads.
Setting Up an ElastiCache Serverless Cluster
Tip: Use cluster mode enabled when you plan to run Lua that touches multiple keys. Without it you’ll see
CROSSSLOTerrors that silently break the pipeline.
1. Create the cluster
aws elasticache create-serverless-cache \
--engine redis \
--cache-name rag-cache \
--security-group-ids sg-0123456789abcdef0 \
--subnet-group-name my-subnet-group \
--description "Redis for RAG embeddings" \
--cluster-mode-enabled # <-- critical for multi‑key Lua scripts
Why each flag matters
-
--engine redistells ElastiCache to spin up a Redis‑compatible endpoint. -
--cluster-mode-enabledmakes the cluster a sharded Redis deployment. In a sharded cluster, a Lua script can access any key because the script runs on every shard. Without this flag the script is limited to a single slot, and trying to read two different keys throwsCROSSSLOTerrors. -
--security-group-idsand--subnet-group-nameplace the cluster inside your VPC, which is required for Lambda to reach it.
2. Note the gotchas
- Serverless cost: Each operation (GET, SET, EVAL) is billed per request. At low traffic the price looks small, but at scale a provisioned cluster is cheaper per million ops.
-
Valkey compatibility: AWS’s managed offering is based on Valkey, a Redis fork. Most commands behave the same, but a few (e.g.,
JSON.ARRAPPEND) have subtle differences. Test your Lua locally with the same version. - VPC cold start: Because the cluster lives inside a VPC, a Lambda that also sits in the VPC will incur a cold‑start penalty the first time it runs after a period of inactivity. Keep this in mind when budgeting for latency.
In plain English: Turn on cluster mode, or your Lua script will fail with a “CROSSSLOT” error that looks like a random crash.
Type‑Safe Lambda Code with the satisfies Operator
Analogy: Think of
satisfiesas a contract that says “this object must have exactly these properties, no more, no less.” It gives you compile‑time safety without the runtime overhead ofinstanceof.
Our Lambda runs on Node.js 22, uses the AWS SDK v3 (modular imports), and is written in TypeScript. We’ll show a minimal handler that:
- Receives a user query from API Gateway.
- Calls OpenAI’s embedding endpoint (simulated here).
- Stores the embedding in RedisJSON.
- Runs a Lua script to fetch the nearest neighbors.
- Returns an augmented answer.
1. Install the required packages
npm install @aws-sdk/client-elasticache @aws-sdk/client-lambda @aws-sdk/client-redis @aws-sdk/lib-dynamodb
npm install redis@4 ioredis@5 # ioredis supports cluster mode and Lua
npm install openai # for embedding API (optional)
2. The Lambda skeleton
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { ElastiCacheClient, DescribeCacheClustersCommand } from "@aws-sdk/client-elasticache";
import Redis from "ioredis";
// ---- 1️⃣ Type for the shape of our Redis JSON document ----
interface DocumentRecord {
id: string; // unique identifier for the source chunk
text: string; // original text
embedding: number[]; // vector of floats from OpenAI
}
// The `satisfies` operator guarantees that the constant below conforms exactly to DocumentRecord
const sampleDoc = {
id: "doc-001",
text: "Redis can store vectors.",
embedding: [0.12, -0.34, 0.56],
} satisfies DocumentRecord;
// ---- 2️⃣ Helper to get the Redis endpoint from ElastiCache ----
async function getRedisEndpoint(): Promise<string> {
const client = new ElastiCacheClient({});
const cmd = new DescribeCacheClustersCommand({ ShowCacheNodeInfo: true });
const resp = await client.send(cmd);
// Assume only one cluster exists for simplicity
const node = resp.CacheClusters?.[0].CacheNodes?.[0];
if (!node?.Endpoint?.Address) throw new Error("Redis endpoint not found");
return `${node.Endpoint.Address}:${node.Endpoint.Port}`;
}
// ---- 3️⃣ Lambda entry point ----
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
// Parse the incoming query string
const query = JSON.parse(event.body ?? "{}").question;
if (!query) {
return { statusCode: 400, body: JSON.stringify({ error: "Missing question" }) };
}
// 3️⃣️⃣️⃣ Get an embedding (replace with real OpenAI call)
const embedding = await fakeEmbedding(query); // returns number[]
// 4️⃣ Connect to Redis (cluster mode)
const redisEndpoint = await getRedisEndpoint();
const redis = new Redis(redisEndpoint, {
// If your cluster uses TLS (recommended) add:
// tls: {}
});
// 5️⃣ Store the embedding using RedisJSON
const docId = `doc:${Date.now()}`; // simple unique key
await redis.call(
"JSON.SET",
docId,
".",
JSON.stringify({
id: docId,
text: query,
embedding,
})
);
// 6️⃣ Run the KNN Lua script (defined later) to fetch top‑3 nearest vectors
const topK = 3;
const neighbors = await redis.eval(
KNN_SCRIPT,
0, // number of KEYS we pass (none, we use SCAN inside the script)
docId,
topK
);
// 7️⃣ Build a mock answer (in reality you would feed the neighbor texts to the LLM)
const answer = `Found ${neighbors.length} similar chunks.`;
await redis.quit();
return {
statusCode: 200,
body: JSON.stringify({ answer, neighbors }),
};
};
// ---- 4️⃣ Fake embedding generator (replace with real OpenAI call) ----
async function fakeEmbedding(text: string): Promise<number[]> {
// Simple deterministic pseudo‑embedding for demo purposes
const hash = Array.from(text).reduce((a, c) => a + c.charCodeAt(0), 0);
return Array.from({ length: 1536 }, (_, i) => ((hash + i) % 100) / 100);
}
Key takeaway: Using
satisfiesgives you a compile‑time guarantee that the object you store in Redis matches the expected shape, helping avoid subtle bugs later.
Storing Embeddings with RedisJSON
Analogy: Think of RedisJSON like a shelf where each slot holds a small JSON book. The book can contain the raw text, its ID, and a list of numbers (the embedding). The shelf is still in memory, so pulling a book out is almost instantaneous.
1. Why JSON instead of a raw string?
- Self‑describing: You can retrieve the whole document (text + embedding) in one call.
- Partial updates: You can update just the embedding field without rewriting the whole entry.
-
Compatibility: RedisJSON works with both plain Redis and Valkey, and the
JSON.SETcommand is stable across versions.
2. Minimal code to write a document
// Assume `redis` is an ioredis instance connected to the cluster
const docKey = "doc:12345";
const payload: DocumentRecord = {
id: docKey,
text: "Why Redis can store vectors",
embedding: [0.01, 0.23, -0.45, /* … up to 1536 dims … */],
};
// Store the whole object as JSON under the root path "."
await redis.call(
"JSON.SET",
docKey,
".",
JSON.stringify(payload) // serialize to string before sending
);
3. Reading back only the embedding (to save bandwidth)
// Retrieve only the `.embedding` field
const rawEmbedding = await redis.call("JSON.GET", docKey, ".embedding");
// Convert the JSON string back to a number array
const embedding: number[] = JSON.parse(rawEmbedding as string);
Tip: When you only need the vector for similarity, ask Redis for the specific path. It avoids sending the full text over the network.
4. Gotchas with RedisJSON on ElastiCache
-
Binary vs. text: RedisJSON stores numbers as JSON numbers (text). If you need higher precision or want to save space, you can base‑64 encode a binary
Float32Array. The demo uses plain numbers for readability. -
Module version mismatch: ElastiCache Serverless runs Valkey 7.x with RedisJSON 2.x. Some newer commands (
JSON.ARRTRIMwith negative indices) are not available. Test locally with the same module version.
Querying and Scoring Vectors in Real Time
In plain English: The Lua script runs inside Redis, pulls every stored embedding, computes how close each one is to the query vector, and returns the best matches—all without leaving the server.
1. The similarity metric
For RAG we usually use cosine similarity because it measures the angle between vectors, ignoring their magnitude. Cosine similarity can be expressed as:
cosine(a, b) = (a · b) / (|a| * |b|)
Where · is the dot product and |a| is the Euclidean norm (length) of vector a.
2. The Lua script
-- KNN_SCRIPT: Find top‑K nearest vectors to a query embedding stored at `queryKey`.
-- Arguments:
-- KEYS[1] = (unused) – we pass 0 keys, the script scans the keyspace itself.
-- ARGV[1] = queryKey (the key that holds the fresh embedding)
-- ARGV[2] = k (how many neighbors to return)
local queryKey = ARGV[1]
local k = tonumber(ARGV[2])
-- 1️⃣ Load the query embedding from JSON
local queryEmbeddingJson = redis.call('JSON.GET', queryKey, '.embedding')
local queryEmbedding = cjson.decode(queryEmbeddingJson)
-- Helper: compute dot product
local function dot(a, b)
local sum = 0
for i = 1, #a do
sum = sum + a[i] * b[i]
end
return sum
end
-- Helper: compute vector length
local function norm(v)
return math.sqrt(dot(v, v))
end
local queryNorm = norm(queryEmbedding)
-- 2️⃣ Scan all keys that match the pattern "doc:*"
local cursor = "0"
local results = {} -- will hold {id, score, text}
repeat
local scan = redis.call('SCAN', cursor, 'MATCH', 'doc:*', 'COUNT', 1000)
cursor = scan[1]
local keys = scan[2]
for _, key in ipairs(keys) do
-- Skip the query key itself
if key ~= queryKey then
local docJson = redis.call('JSON.GET', key, '.')
local doc = cjson.decode(docJson)
local sim = dot(queryEmbedding, doc.embedding) / (queryNorm * norm(doc.embedding))
table.insert(results, { id = doc.id, score = sim, text = doc.text })
end
end
until cursor == "0"
-- 3️⃣ Sort by descending similarity and keep top‑k
table.sort(results, function(a, b) return a.score > b.score end)
local top = {}
for i = 1, math.min(k, #results) do
table.insert(top, results[i])
end
return cjson.encode(top)
3. Running the script from TypeScript
// The script is stored as a string constant in our code
const KNN_SCRIPT = `...lua code from above...`;
// Execute the script
const topK = 5;
const rawResult = await redis.eval(KNN_SCRIPT, 0, docId, topK);
const neighbors: Array<{ id: string; score: number; text: string }> = JSON.parse(
rawResult as string
);
// `neighbors` now contains the most similar chunks, ready for the LLM.
4. Why we use Lua instead of client‑side loops
- Network efficiency: Pulling every embedding to the Lambda would cost many round‑trips (hundreds of KB per request). Lua does it all in one internal call.
- Atomicity: The script sees a consistent snapshot of the data; no other client can modify a key while the script is running.
- Latency: The entire computation finishes inside Redis, usually under 2 ms for a few thousand vectors.
Key takeaway: A short Lua script can turn Redis into a full‑featured vector similarity engine, eliminating the need for a separate vector database.
The Takeaway
In plain English: You don’t need a heavyweight vector store to build a fast RAG system. With a properly configured ElastiCache cluster, RedisJSON, and a little Lua, you can store embeddings, query them in real time, and keep costs low.
- Redis works as a vector store because it holds embeddings in memory and can run server‑side code that computes similarity instantly.
-
Cluster mode must be enabled; otherwise Lua scripts that touch multiple keys hit
CROSSSLOTerrors, breaking the pipeline. -
Type‑safe Lambda code using TypeScript’s
satisfiesoperator guarantees your data shape matches what you store in Redis, catching bugs at compile time. - RedisJSON lets you keep the whole document (text + vector) together, making retrieval of context for the LLM trivial.
- A Lua script does the heavy lifting of scanning keys, computing cosine similarity, sorting, and returning the top‑k results without leaving Redis.
- Watch the hidden costs: Serverless ElastiCache charges per operation, VPC cold starts add latency, and Lambda layers with ESM can break silently in Node 22.
Armed with these pieces, you can spin up a low‑latency, cost‑effective RAG backend in minutes and focus on the interesting part—building great user experiences with LLMs. Happy coding!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-08-24 · Primary focus: ElastiCache
All code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)