Chapter 16: Semantic Caching (Redis + Embeddings)
Theoretical Foundations
Imagine you are building a sophisticated customer support chatbot for an e-commerce platform. A user asks, "My package hasn't arrived, and the tracking number is invalid." A minute later, another user asks, "Where is my order? The tracking code isn't working." A traditional, exact-match caching system (like a simple Redis key-value store using the raw query string) would treat these as two entirely different requests. It would process both through the expensive LLM, generate a response, and store two separate cache entries. This is inefficient, slow, and costly.
Semantic caching solves this by acting as a conceptual memory for your backend. Instead of matching the literal text of a query, it matches the underlying intent and meaning. It understands that both queries are semantically identical: they are both about a failed delivery with an invalid tracking number. It retrieves the cached response for the first query and serves it immediately to the second user, bypassing the LLM entirely.
This is not just a performance optimization; it is a fundamental shift in how we think about API state. It moves us from a stateless, request-response paradigm to a stateful, context-aware paradigm, where the backend remembers not just what was said, but what was meant.
The "Why": The Economics of Computation and Latency
To understand the necessity of semantic caching, we must first appreciate the computational hierarchy of modern web applications.
- CPU-Bound Operations (Traditional APIs): A standard database query or a simple business logic calculation is CPU-bound. These are fast (milliseconds) and cheap.
- GPU-Bound Operations (LLM Inference): Generating a response from a Large Language Model is GPU-bound. It involves massive matrix multiplications across billions of parameters. This is slow (seconds) and expensive (tokens consumed per request).
The Problem: As we integrate LLMs into user-facing features (e.g., dynamic content generation, summarization, Q&A), we expose our backend to an explosion of unique, uncacheable queries. Each query, no matter how similar to a previous one, triggers a full-scale GPU computation.
The Analogy: The Library vs. The Researcher
Think of your LLM as a brilliant but slow Researcher living in a library. The library contains all the world's knowledge (the LLM's training data). When you ask a question, the Researcher must walk to the correct shelf, read multiple books, synthesize the information, and write a detailed report. This takes time and mental energy (cost).
A traditional cache is like a sticky note on your desk. If you ask the exact same question verbatim, you can just read the sticky note. But if you rephrase the question, the sticky note is useless.
Semantic caching is like a personal assistant who listens to your questions. This assistant doesn't just write down the words; they understand the intent. If you ask, "What is the capital of France?" and later ask, "What city is the Eurydice capital?", the assistant recognizes the shared concept and hands you the pre-written report from the Researcher. The assistant (semantic cache) acts as a high-speed, low-cost intermediary between you and the slow, expensive Researcher (LLM).
The Economic Impact:
- Latency: Retrieving a vector from a high-performance in-memory store like Redis (sub-millisecond) is orders of magnitude faster than generating a response from an LLM (seconds).
- Cost: Every cache hit is a saved API call to your LLM provider (e.g., OpenAI). For a high-traffic application, this can reduce LLM costs by 80-90%.
- Consistency: It provides deterministic responses for semantically identical queries, which is crucial for tasks like generating product descriptions or summarizing legal documents where consistency is key.
The "How": The Mechanics of Vector Similarity
The magic of semantic caching lies in its two-stage process: Indexing (writing to cache) and Retrieval (reading from cache).
1. The Embedding Model: The Universal Translator
At the heart of this system is an Embedding Model (e.g., OpenAI's text-embedding-ada-002). This model acts as a Universal Translator that converts human language into a mathematical format a computer can understand for comparison: vector embeddings.
An embedding is a list of numbers (a vector) that represents the semantic meaning of a piece of text. For example:
- "My package is late" might be represented as
[0.12, -0.45, 0.67, ...] - "Where is my delivery?" might be represented as
[0.11, -0.43, 0.65, ...]
Notice how similar the numbers are? The embedding model has learned that these two phrases occupy a similar position in a high-dimensional "meaning space." The distance between these vectors (often measured using cosine similarity) tells us how conceptually close the two queries are.
Analogy: The Web Development Hash Map
In traditional web development, we use Hash Maps (or Objects in JavaScript) to store key-value pairs. The key is a unique identifier (like a user ID 123), and the value is the associated data. Hash maps are incredibly fast for exact lookups (O(1) complexity).
Semantic caching is like a Fuzzy Hash Map. Instead of a discrete key, the "key" is a region in a continuous vector space. When a new query comes in, we:
- Convert it into a vector (the "key").
- Search the map for the nearest existing key (vector) within a predefined similarity threshold.
- If a neighbor is found, we return its value (the cached response).
This transforms the problem from a simple exact-match lookup into a nearest neighbor search problem, which requires specialized data structures like vector indexes.
2. The Storage Layer: Redis as a Vector Database
While Redis is traditionally a key-value store, modern versions (with modules like Redis Stack or RedisVL) support vector similarity search. This makes it an ideal, low-latency backend for our semantic cache.
We don't just store the query and response. We store a composite structure:
- Vector: The numerical embedding of the query.
- Response: The generated text from the LLM.
- Metadata: Timestamp, user ID, model version, etc.
When we receive a new query, we don't just look for an exact key. We perform a Vector Range Query or K-Nearest Neighbors (KNN) Query against the Redis database.
Analogy: The Librarian's Catalog System Imagine a library where books are not organized by title or author, but by the themes and ideas within them. Books about "ocean navigation" are physically placed near books about "shipbuilding," which are near "celestial mapping."
- The Librarian (tRPC Router): Receives your query.
- The Catalog (Redis Vector Index): The Librarian doesn't search for your exact question. They take your question, understand its core theme (the embedding), and then look in the catalog for the section that matches that theme.
- The Proximity Check (Similarity Threshold): The Librarian checks if your question is close enough to a theme that already has a summary. If the distance is small (e.g., 0.95 similarity), they hand you the existing summary. If it's a new theme (distance > 0.95), they go to the Researcher (LLM) to generate a new summary.
The Architecture: A Visual Flow
The following diagram illustrates the data flow for both a cache miss (new query) and a cache hit (similar query).
The Critical Role of the Threshold
The similarity threshold is the most important hyperparameter in a semantic caching system. It's the gatekeeper that determines whether a query is "close enough" to a cached entry.
- High Threshold (e.g., 0.98): Very strict. Only near-identical queries will hit the cache. This is safe but less effective at reducing costs.
- Low Threshold (e.g., 0.85): Very permissive. A wide range of related queries will hit the same cache entry. This is highly efficient but risks serving a slightly irrelevant response (e.g., caching a response about "order status" for a query about "return policy" if they are too semantically close).
Analogy: The Tuning Fork Imagine you have a set of tuning forks, each vibrating at a specific frequency (representing a cached query's meaning). When a new query comes in, you strike it and listen to its frequency. The similarity threshold is the tolerance for how "out of tune" you allow the new fork to be before you decide it's a completely different note. If the new fork's vibration is within 1% of an existing fork's frequency, you declare it a "hit" and use the existing fork's sound (response). If it's more than 1% off, you must create a new sound (call the LLM).
Connection to Previous Concepts: Chunking and Checkpointing
This chapter builds directly upon foundational concepts introduced earlier:
-
Chunking Strategy (from Book 6): In the previous module on Retrieval-Augmented Generation (RAG), we discussed chunking—breaking large documents into smaller, semantically meaningful segments. This is conceptually parallel to semantic caching. Just as we chunk documents to find the most relevant text segment for a user's query, we use semantic caching to find the most relevant pre-computed response. Both processes rely on embedding vectors and similarity search to navigate a large corpus of information. The key difference is the content: RAG chunks source documents, while semantic caching chunks the history of user queries and LLM responses.
-
Checkpointer (from LangGraph): A Checkpointer in LangGraph saves the complete state of a graph after each node execution, allowing for resumption and rollback. Semantic caching is a specialized, high-performance form of a checkpointer for a specific type of state: API response state. Instead of saving the entire graph state, we are saving the mapping between a conceptual input (query embedding) and its resulting output (LLM response). The
transaction IDin a Checkpointer is analogous to thevector keyin our semantic cache—both are unique identifiers that allow for precise state retrieval. By integrating this into a tRPC backend, we are essentially creating a distributed, intelligent checkpointer for our entire application's user interaction flow.
By mastering semantic caching, you are not just implementing a cache; you are architecting a backend that possesses a form of institutional memory, capable of understanding and recalling the conceptual intent behind user interactions, thereby creating a faster, cheaper, and more intelligent application.
Basic Code Example
This example demonstrates a lightweight semantic caching system built for a SaaS application. We will simulate a backend endpoint (using tRPC-like patterns) that queries an LLM for user support. Instead of calling the expensive LLM every time, we check a Redis cache for a semantically similar previous query. If found, we return the cached response instantly.
Prerequisites:
- Node.js environment.
- Redis instance (local or cloud).
- OpenAI API Key (for generating embeddings).
Dependencies:
The Implementation
We will build a self-contained script that:
- Connects to Redis.
- Generates an embedding for the user's query.
- Performs a vector search in Redis to find the closest matching query.
- Returns the cached result if similarity is high; otherwise, calls the LLM and caches the new result.
/**
* semantic-cache-hello-world.ts
*
* A self-contained example of Semantic Caching using Redis and OpenAI.
*
* Context: SaaS Support Chatbot
* Goal: Reduce LLM latency and costs by caching responses based on query meaning.
*/
import { createClient } from 'redis';
import OpenAI from 'openai';
import dotenv from 'dotenv';
// Load environment variables (OpenAI API Key and Redis URL)
dotenv.config();
// --- Configuration & Constants ---
const SIMILARITY_THRESHOLD = 0.91; // Cosine similarity threshold (0.0 to 1.0)
const REDIS_KEY_PREFIX = 'semantic_cache:';
const EMBEDDING_MODEL = 'text-embedding-3-small';
// --- Types ---
interface CacheEntry {
response: string;
timestamp: number;
}
// --- 1. Initialization ---
/**
* Initializes the Redis client and OpenAI instance.
* In a real app, these would be singletons or dependency-injected services.
*/
async function initializeClients() {
const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379'
});
redisClient.on('error', (err) => console.error('Redis Client Error', err));
await redisClient.connect();
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
return { redisClient, openai };
}
/**
* Generates a vector embedding for a given text string using OpenAI.
*
* @param openai - The OpenAI client instance.
* @param text - The user query to embed.
* @returns A Promise resolving to an array of numbers (the vector).
*/
async function getEmbedding(openai: OpenAI, text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: text,
});
// OpenAI returns the embedding in the data array
return response.data[0].embedding;
}
/**
* Calculates the Cosine Similarity between two vectors.
*
* Formula: (A . B) / (||A|| * ||B||)
*
* @param vecA - The query embedding.
* @param vecB - The cached embedding (retrieved from Redis).
* @returns A number between 0.0 and 1.0 representing similarity.
*/
function cosineSimilarity(vecA: number[], vecB: number[]): number {
const dotProduct = vecA.reduce((acc, val, i) => acc + val * (vecB[i] || 0), 0);
const magnitudeA = Math.sqrt(vecA.reduce((acc, val) => acc + val * val, 0));
const magnitudeB = Math.sqrt(vecB.reduce((acc, val) => acc + val * val, 0));
if (magnitudeA === 0 || magnitudeB === 0) return 0;
return dotProduct / (magnitudeA * magnitudeB);
}
/**
* The Core Semantic Search Logic.
*
* 1. Generates embedding for the input query.
* 2. Scans Redis for keys starting with the cache prefix.
* 3. Iterates through cached entries, fetching their stored embeddings.
* 4. Calculates similarity score for each.
* 5. Returns the best match if it exceeds the threshold.
*
* Note: In production, use Redis Vector Search (Redis Stack) or a dedicated
* vector DB (Pinecone, Qdrant) for efficient ANN search. This linear scan is for simplicity.
*/
async function findSemanticMatch(
redisClient: any,
openai: OpenAI,
query: string
): Promise<{ key: string, data: CacheEntry, score: number } | null> {
const queryEmbedding = await getEmbedding(openai, query);
// Scan for all cache keys
const keys = await redisClient.keys(`${REDIS_KEY_PREFIX}*`);
let bestMatch = null;
let highestScore = 0;
for (const key of keys) {
// Structure: semantic_cache:<embedding_string>:<hash>
// We extract the embedding vector stored in the key name or a separate hash field.
// For this example, we stored the vector in a separate key: `${key}:vector`
const storedVectorString = await redisClient.get(`${key}:vector`);
if (!storedVectorString) continue;
const storedVector: number[] = JSON.parse(storedVectorString);
const score = cosineSimilarity(queryEmbedding, storedVector);
if (score > highestScore && score >= SIMILARITY_THRESHOLD) {
highestScore = score;
const cachedData = await redisClient.get(key);
if (cachedData) {
bestMatch = {
key,
data: JSON.parse(cachedData) as CacheEntry,
score
};
}
}
}
return bestMatch;
}
/**
* Simulates the expensive LLM call.
* In a real app, this would be `openai.chat.completions.create(...)`.
*/
async function callExpensiveLLM(query: string): Promise<string> {
console.log(` [LLM] Calling model for: "${query}"`);
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
return `Generated response for: ${query}. This took 500ms and cost $0.002.`;
}
/**
* The Main Controller (Simulating a tRPC Router procedure).
*
* Logic Flow:
* 1. Check Semantic Cache.
* 2. If Hit: Return cached response immediately.
* 3. If Miss: Call LLM, generate new embedding, save to Redis, return response.
*/
async function semanticCacheHandler(query: string) {
const { redisClient, openai } = await initializeClients();
console.log(`\n--- Processing Query: "${query}" ---`);
try {
// 1. Attempt Semantic Retrieval
const match = await findSemanticMatch(redisClient, openai, query);
if (match) {
console.log(`✅ CACHE HIT (Score: ${match.score.toFixed(4)})`);
console.log(` Response: "${match.data.response}"`);
await redisClient.quit();
return match.data.response;
}
// 2. Cache Miss: Call LLM
console.log('❌ CACHE MISS (No similar query found)');
const llmResponse = await callExpensiveLLM(query);
// 3. Store New Entry
// Generate embedding again (or reuse from search step if optimized)
const newEmbedding = await getEmbedding(openai, query);
// Create a unique key based on the query content or a hash
const cacheKey = `${REDIS_KEY_PREFIX}${Date.now()}`;
// Store the response data
await redisClient.set(cacheKey, JSON.stringify({
response: llmResponse,
timestamp: Date.now()
}));
// Store the vector separately for retrieval (In Redis Stack, this is done via a vector field)
await redisClient.set(`${cacheKey}:vector`, JSON.stringify(newEmbedding));
console.log('💾 Saved to Semantic Cache');
await redisClient.quit();
return llmResponse;
} catch (error) {
console.error('Error in semantic cache handler:', error);
await redisClient.quit();
throw error;
}
}
// --- Execution ---
/**
* Main function to run the demo.
*/
async function main() {
// Scenario: User asks a question, then asks a slightly rephrased version.
// 1. First call (Cold start)
await semanticCacheHandler("How do I reset my password?");
// 2. Second call (Exact match - string cache would catch this, but we do semantic)
await semanticCacheHandler("How do I reset my password?");
// 3. Third call (Semantic match - string cache would miss this)
await semanticCacheHandler("I forgot my login credentials, help.");
// 4. Fourth call (Semantic match - different phrasing)
await semanticCacheHandler("Can't remember my password.");
// 5. Fifth call (No match - completely different topic)
await semanticCacheHandler("How do I upgrade my subscription plan?");
}
// Run if executed directly
if (require.main === module) {
main().catch(console.error);
}
Line-by-Line Explanation
1. Setup and Configuration
- Why: We import the necessary libraries.
redishandles storage,openaihandles vector generation, anddotenvmanages secrets (API keys). - Under the Hood: These are standard Node.js modules. In a serverless environment (like Vercel),
dotenvis often handled by platform environment variables directly.
- Why: We define constants to avoid magic strings.
- Under the Hood: The
SIMILARITY_THRESHOLDis critical. A score of 1.0 means identical meaning. 0.91 is a strict threshold to prevent "hallucinated" matches where two unrelated topics share common words (e.g., "Apple" the fruit vs. "Apple" the tech company).
2. Initialization
async function initializeClients() {
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
// ... OpenAI init
}
- Why: We need persistent connections to external services.
- Under the Hood: In a long-running Node.js server, you create the client once and reuse it. In serverless functions (AWS Lambda/Vercel), you might instantiate this per invocation, but connection pooling is handled differently. Redis
createClientsets up the TCP socket.
3. Vector Generation
async function getEmbedding(openai: OpenAI, text: string): Promise<number[]> {
const response = await openai.embeddings.create({ ... });
return response.data[0].embedding;
}
- Why: Semantic caching relies on math, not text comparison. We convert text into a high-dimensional vector (array of numbers) that represents its semantic meaning.
- Under the Hood: The model
text-embedding-3-smallconverts the input string into a vector (e.g., 1536 dimensions). Words with similar meanings (like "reset" and "forgot") will have vectors that point in similar directions in this high-dimensional space.
4. Similarity Calculation
function cosineSimilarity(vecA: number[], vecB: number[]): number {
const dotProduct = vecA.reduce((acc, val, i) => acc + val * (vecB[i] || 0), 0);
// ... magnitude calculation
return dotProduct / (magnitudeA * magnitudeB);
}
- Why: We need a mathematical way to compare two vectors.
- Under the Hood: Cosine similarity measures the angle between two vectors. If the angle is 0 degrees, the cosine is 1 (perfect match). If 90 degrees, it's 0 (unrelated). This is preferred over Euclidean distance because it handles the magnitude of the vectors (length of the text) better.
5. The Search Logic (Linear Scan)
const keys = await redisClient.keys(`${REDIS_KEY_PREFIX}*`);
for (const key of keys) {
const storedVectorString = await redisClient.get(`${key}:vector`);
// ... calculate score
}
- Why: To find a match, we must compare the current query against all stored queries.
- Under the Hood:
- Warning:
redisClient.keys()is a blocking operation and not recommended for production databases with millions of keys. It is used here for simplicity. - In a real production system, you would use Redis Stack with the
RediSearchmodule or a dedicated vector database (like Pinecone). These use Approximate Nearest Neighbor (ANN) algorithms (like HNSW) to find matches in milliseconds without scanning every item.
- Warning:
6. The Handler (tRPC Simulation)
const match = await findSemanticMatch(redisClient, openai, query);
if (match) {
return match.data.response; // Fast return
}
- Why: This is the core logic flow.
- Under the Hood:
- Hit: If the similarity score is > 0.91, we return the cached response immediately. This saves the cost of the LLM call and the network latency.
- Miss: If no match is found (or score is too low), we proceed to the expensive operation (
callExpensiveLLM).
// Storing the data
await redisClient.set(cacheKey, JSON.stringify({ response: llmResponse, ... }));
await redisClient.set(`${cacheKey}:vector`, JSON.stringify(newEmbedding));
- Why: We store two pieces of information: the actual data (the response) and the metadata (the vector) used to find it.
- Under the Hood: Redis stores strings. We must
JSON.stringifyarrays/objects. In Redis Stack, you would store these in a single Hash field with a specific vector index.
Visualization of Data Flow
Common Pitfalls
-
The "Vercel Timeout" Trap (Serverless Limits):
- Issue: Vercel Serverless Functions have a default timeout (often 10s for Hobby, 60s for Pro). Scanning a large Redis cache linearly (using
keys *) or generating embeddings can easily exceed this, causing the request to hang and fail. - Solution: Always set a timeout on your Redis operations (
client.get('key', { timeout: 2000 })). For semantic caching in serverless, use a managed vector database (like Upstash Vector) that guarantees low-latency retrieval, rather than managing your own Redis cluster which might have cold starts.
- Issue: Vercel Serverless Functions have a default timeout (often 10s for Hobby, 60s for Pro). Scanning a large Redis cache linearly (using
-
Async/Await Loop Blocking:
- Issue: Using
awaitinside aforloop (as done in thefindSemanticMatchexample) creates a sequential chain of promises. If you have 100 cached items, the second request waits for the first to finish. - Solution: Use
Promise.all()for parallel processing if the operation is read-only and safe. However, be careful not to overload the Redis connection pool. In production, offload this filtering to the database engine (e.g., Redis Vector Search) rather than handling it in Node.js.
- Issue: Using
-
Hallucinated Matches (Low Thresholds):
- Issue: Setting the similarity threshold too low (e.g., 0.7) results in "false positives." A query about "Java programming" might match a cached response about "Indonesian coffee" because they share the word "Java" and some semantic overlap.
- Solution: Tune your threshold based on your domain. For technical support, 0.92+ is usually safe. Always log the similarity score of hits to monitor the distribution.
-
Token Limit & Cost of Embeddings:
- Issue: Embedding models have a context window (e.g., 8191 tokens). Sending a massive user input (like a paste of a whole code file) will fail or cost a fortune.
- Solution: Implement Chunking (mentioned in definitions). Truncate the input text to a reasonable length (e.g., first 500 words) before generating the embedding for the cache check. This ensures the cache key is based on the intent of the query, not the noise.
The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon
Loading knowledge check...
Code License: All code examples are released under the MIT License. Github repo.
Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.
All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.