VernLLMVernLLM
GuidesCaching Methods

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

normalized-cache.ts
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:

custom-normalized-adapter.ts
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.

On this page