ElastiCache for RAG: How to Store and Query Embeddings in
ElastiCache for RAG: store and query embeddings in Redis from Node.js and TypeScript, set up the cluster, create the vector index and run similarity search.
This article is published in English.
Sources: ElastiCache for RAG: How to Store and Query Embeddings in Redis with Node.js and TypeScript
Use this as an operator-facing rebuild of the ideas in “ElastiCache for RAG: How to Store and Query Embeddings in Redis with Node.js and TypeScript”: clear stages, ordered code slots, and recovery notes that survive a handoff. Overview works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion.
Why Redis Can Be a Vector Store
For Why Redis Can Be a Vector Store, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.
Setting Up an ElastiCache Serverless Cluster
For Setting Up an ElastiCache Serverless Cluster, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.
1. Create the cluster
For 1. Create the cluster, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.
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
For 1. Create the cluster, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion.
2. Note the gotchas
When working through 2. Note the gotchas, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.
Type‑Safe Lambda Code with the satisfies Operator
When working through Type‑Safe Lambda Code with the satisfies Operator, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest.
Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph.
Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.
1. Install the required packages
When working through 1. Install the required packages, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.
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
When working through 2. The Lambda skeleton, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.
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);
}
Storing Embeddings with RedisJSON
When working through Storing Embeddings with RedisJSON, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.
1. Why JSON instead of a raw string?
When working through 1. Why JSON instead of a raw string?, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.
2. Minimal code to write a document
When working through 2. Minimal code to write a document, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.
// 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)
When working through 3. Reading back only the embedding (to save bandwidth), write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.
// 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);
When working through 3. Reading back only the embedding (to save bandwidth), write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion.
4. Gotchas with RedisJSON on ElastiCache
- Gotchas with RedisJSON on ElastiCache works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.
Querying and Scoring Vectors in Real Time
Querying and Scoring Vectors in Real Time works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.
1. The similarity metric
- The similarity metric works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.
- The similarity metric works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion.
cosine(a, b) = (a · b) / (|a| * |b|)
2. The Lua script
For 2. The Lua script, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.
-- 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
For 3. Running the script from TypeScript, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.
// 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
For 4. Why we use Lua instead of client‑side loops, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap. For 4. Why we use Lua instead of client‑side loops, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion.
The Takeaway
When working through The Takeaway, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.
Operational checklist
When working through Operational checklist, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest.
Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline.
Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.
Keep render work cheap and push expensive derivation behind memoization only after measuring. Premature memo can hide stale props bugs.
Add a smoke test that exercises the critical path in CI with fixtures, not live paid APIs, whenever budgets allow.
Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph.
Before promoting the stack, freeze versions, capture a golden transcript for the critical path, and confirm rollback steps. Shared environments need rate limits, tenancy checks, and a clear owner for secret rotation. Prefer boring reliability over clever one-off demos.