Normalized Caching
Matching keys that differ only in case, whitespace, or punctuation
A lot of "these should be the same cache entry" cases aren't semantic at all, they're just
formatting differences. "What is 2+2?", "what is 2+2", and " WHAT IS 2+2? " mean the same
thing and differ only in case, punctuation, and whitespace. NormalizedCacheAdapter handles this
class of duplicate for free, with no embedding calls and no external dependencies.
Usage
import { NormalizedCacheAdapter, VernLLM } from 'vern-llm';
const llm = new VernLLM({
client: openai,
model: 'gpt-4o',
cache: new NormalizedCacheAdapter(), // wraps InMemoryCacheAdapter by default
});
await llm.cachedCall({
cacheKey: userQuestion, // pass the raw text, same as with semantic caching
ttl: 3600,
call: { systemPrompt: 'Answer concisely.', userContent: userQuestion },
});Like SemanticCacheAdapter (see Semantic Caching), cacheKey
here should be the actual prompt text, not a hand-picked id, normalization needs real content to
operate on.
What it normalizes
The default normalization is intentionally simple:
key
.toLowerCase()
.trim()
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.trim();Lowercases, trims leading/trailing whitespace, replaces punctuation with a space, and collapses
repeated whitespace into single spaces (with a final trim to clean up any edge space left behind
by that replacement). This catches formatting noise but nothing more; "2+2" and "2 + 2" both
normalize to "2 2", since punctuation is replaced with a space rather than deleted outright, so
stripping + never collapses adjacent characters together the way outright deletion would.
Normalization is a text transform, not a parser; it doesn't understand what the text means, so
"2+2" and "four" still normalize differently even though they mean the same thing.
If your inputs vary in more than surface formatting, different word choice, different phrasing, different language, normalization won't catch it. That's the line between this and semantic caching: normalization handles "same words, different formatting"; semantic caching handles "different words, same meaning."
Wrapping a different inner adapter
NormalizedCacheAdapter doesn't do its own storage, it normalizes the key, then delegates to
whatever adapter you pass it. Swap in Redis, Upstash, or any other CacheAdapter without changing
the normalization logic:
const cache = new NormalizedCacheAdapter(new UpstashCacheAdapter());Custom normalization
The shipped normalization is deliberately generic. If your domain needs something different,
stemming, stripping filler words, normalizing numbers written as words vs digits, write your own
adapter with a custom resolveKey rather than trying to configure NormalizedCacheAdapter
further:
import type { CacheAdapter } from 'vern-llm';
class DomainNormalizedAdapter<T> implements CacheAdapter<T> {
constructor(private inner: CacheAdapter<T>) {}
private normalize(key: string): string {
return (
key
.toLowerCase()
.replace(/\b(please|could you|can you)\b/g, '') // strip filler phrases
.replace(/[^\w\s]/g, ' ') // replace punctuation with a space, not delete, so adjacent
// characters/digits don't get glued together (e.g. "2+2" vs "2 + 2")
.trim()
.replace(/\s+/g, ' ')
);
}
async resolveKey(key: string) {
return this.normalize(key);
}
get(key: string) {
return this.inner.get(this.normalize(key));
}
set(key: string, value: T, ttl: number) {
return this.inner.set(this.normalize(key), value, ttl);
}
async delete(key: string): Promise<void> {
await this.inner.delete?.(this.normalize(key));
}
}This is the same shape as NormalizedCacheAdapter itself, the built-in version is just one
reasonable default, not the only option.