SemanticCache

class SemanticCache(embedder: Embedder, store: CacheStore = InMemoryStore(), threshold: Double = DEFAULT_THRESHOLD, guards: List<MatchGuard> = MatchGuards.standard(), verifier: Verifier? = null, verifierTimeout: Duration? = null, candidates: Int = DEFAULT_CANDIDATES, coalesceConcurrentMisses: Boolean = true, embedFailurePolicy: EmbedFailurePolicy = EmbedFailurePolicy.PROPAGATE, negativeCacheSize: Int = 0, negativeCacheTtl: Duration? = null, listeners: List<CacheListener> = emptyList(), writeBehindScope: CoroutineScope? = null, writeBehindCapacity: Int = DEFAULT_WRITE_BEHIND_CAPACITY, clock: Clock = Clock.System, cachePolicy: CachePolicy? = null, exactCacheSize: Int = 0, exactCacheTtl: Duration? = null, thresholds: Map<String, Double> = emptyMap(), shadowThresholds: List<Double> = emptyList(), reranker: CandidateReranker? = null, deduplicateWrites: Double? = null, adaptiveThresholds: AdaptiveThresholds? = null, prices: Map<String, TokenPrices> = emptyMap(), admissionPolicy: AdmissionPolicy? = null, requireTenant: Boolean = false)

A cache keyed by what a prompt means rather than by its exact bytes.

Every prompt is embedded, compared against the prompts already cached, and — if something is close enough and survives the guards — answered from the cache instead of from the model. Two prompts worded differently but asking the same thing hit the same entry, which an exact-match cache can never do.

val cache = SemanticCache(
embedder = myEmbedder,
store = InMemoryStore(maxEntries = 10_000, ttl = 1.hours),
)

val answer = cache.getOrPut(prompt) { llm.complete(it) }

Why this is not just a threshold

The failure mode of a semantic cache is not a miss, it is a false hit: returning a cached answer to a question it does not answer. Convert 100 USD to EUR and Convert 250 USD to EUR embed at around 0.99 with every mainstream model. There is no threshold that accepts real paraphrases and rejects that pair, because on the similarity axis the pair is closer than most paraphrases are.

So similarity is only the first filter here. Candidates that clear threshold are then read as text by a chain of MatchGuards looking for concrete evidence that the answers must differ — a different number, unit, entity, time reference, or a flipped comparison. Anything that survives can be sent to an optional Verifier for a final check. The costs are asymmetric and the defaults follow that: a wrong rejection costs one API call, a wrong acceptance costs a wrong answer.

Scopes

Every entry belongs to a scope, and lookups only see their own. Anything that changes what a correct answer looks like — model, temperature, system prompt, tenant, user's language — belongs in the scope string, otherwise the cache will happily serve one model's answer to another's caller:

cache.getOrPut(prompt, scope = "gpt-4o|t=0.0|v3") { llm.complete(it) }

Instances are safe to share across coroutines, as long as the Embedder and CacheStore are.

Parameters

embedder

turns prompts into vectors; you supply it.

store

where entries live and how they expire. Defaults to a bounded in-memory store.

threshold

minimum cosine similarity for a candidate to be considered at all. The default is deliberately tight; calibrate it against your own model with dev.kmemo.calibration.ThresholdCalibrator rather than guessing.

guards

vetoes applied to candidates that clear threshold. MatchGuards.standard by default; MatchGuards.none reproduces the naive similarity-only behaviour.

verifier

optional final check, typically a cheap model call, run only on candidates that already passed everything else.

verifierTimeout

optional cap on a single Verifier.verify call. On timeout — or if the verifier throws — the candidate is rejected, not served: a check that could not complete must fail closed, since the verifier exists precisely to keep an unconfirmed answer out. null (the default) applies no timeout.

candidates

how many nearest entries to consider. Looking past the closest one matters: when a guard rejects the top candidate, the second may still be a correct answer.

coalesceConcurrentMisses

whether concurrent getOrPut calls for the same prompt in the same scope wait for the first one instead of each calling the model. On by default: a cold cache under load is the case where duplicate calls are most expensive and most likely. It governs getOrPutStreaming too, where callers attach to the one provider stream rather than waiting for it, since a streaming caller who waited for the end would be paying the latency they streamed to avoid. getOrPutAll is the exception and says why.

embedFailurePolicy

what getOrPut does when the Embedder throws — propagate (the default) or fall back to compute so a lookup is never worse than no cache. See EmbedFailurePolicy. lookup, get and put have no fallback and always propagate. CancellationException always propagates. Every fall-back is counted in CacheStats.degradedLookups and reported as CacheEvent.Degraded, so stepping aside is never silent.

exactCacheSize

when positive, turns on the exact-match layer: a bounded map from an exact (scope, prompt) to the entry it resolved to, so a byte-for-byte repeat is answered without an Embedder call or a store search. An identical prompt in the same scope is the same question, so the fast path runs no guards and adds no false-hit risk by construction. 0 (the default) keeps it off.

exactCacheTtl

how long a remembered prompt may be served from the exact layer. Set it no longer than your store's TTL: the layer answers without consulting the store, which is what owns expiry and eviction, so a longer window is a window in which it can serve something the store has already dropped. Past the TTL nothing stale is served — the remembered embedding is still reused, so the lookup goes through the ordinary path with the network call already paid. null means the layer never expires, which is only correct when the store has no TTL either.

thresholds

per-scope overrides of threshold, consulted by scope name with the global value as the fallback. One threshold is necessarily wrong for a service answering both regulated and casual questions, and tuning the single value to the strictest caller makes every other caller pay for it.

shadowThresholds

when non-empty, puts the cache in shadow mode: every getOrPut runs the full lookup, reports what it would have decided at each of these thresholds through CacheEvent.Shadow, and then always computes. Nothing is ever served from the cache, so a false hit cannot reach a user while you are still choosing a threshold; writes still happen, because a shadow cache that never fills would report a miss for everything and measure nothing.

reranker

reorders the candidates that cleared the threshold before the guards see them, or null (the default) to try them nearest-first. It reorders and never rescores, and it runs after the threshold filter, so it can change the order the cache tries candidates in but never which candidates are eligible. MmrReranker is the one that ships; what it buys is fewer Verifier calls on a cache whose nearest entries are near-duplicates of each other.

deduplicateWrites

when non-null, a similarity at or above which a new entry replaces the existing entry it duplicates instead of joining it. A cache that has answered the same question in six phrasings stores six copies of one answer, and every later lookup pays to score all six. Only an entry that clears this similarity and passes every guard is replaced, so deduplication can never merge two entries the cache would have refused to serve for each other. null (the default) keeps every write. Costs one extra store search per write, on the write path rather than the read path.

adaptiveThresholds

when non-null, lets each scope's threshold follow its own traffic instead of staying where it was configured. Requires a verifier, and the constructor throws without one: adaptation lowers the threshold as well as raising it, and the only thing that makes lowering safe is something above the threshold that can tell a right answer from a wrong one. Pass the same object in listeners so it can see the outcomes it adapts on. See AdaptiveThresholds.

requireTenant

refuses any read or write that did not come through forTenant. Off by default, because a single-tenant cache has nothing to isolate. On, it turns "somebody forgot the tenant" from a hit belonging to another customer into a failure at the call site, which is the only form in which an isolation property is worth having. See TenantedCache.

admissionPolicy

when non-null, makes a prompt earn its place before its answer is stored: entries are written on the second sighting of the exact prompt rather than the first, so a store in front of real traffic stops filling with questions asked once at three in the morning. Off by default. It can only ever suppress a write, never a lookup, so a wrong decision costs a future miss and nothing else. Applies to the write that follows a miss, not to put or warm. See AdmissionPolicy for what it costs in hit rate.

prices

what a model call costs, per scope, so the cache can report what its hits saved rather than leaving a hit count to be multiplied by an average nobody has. Empty by default and empty is honest: this library ships no table of provider prices, because one would be wrong the month after it shipped. The token counts are read from the served entry's CacheEntry.metadata by the keys TokenPrices names, so a saving is the cost of the call that was actually avoided. Reported in CacheStats.savings and on CacheEvent.Hit.saved. See TokenPrices.

cachePolicy

vetoes writes of data that must never be persisted, consulted once per write on every write path including warm. null (the default) caches everything the cache decides to cache. A vetoed write is a policy decision, not a failure: the call still returns its response. See CachePolicy.

negativeCacheSize

when positive, turns on a bounded negative cache: the embedding of a prompt that just missed is remembered, so an immediate repeat of the same brand-new prompt is embedded once rather than once per caller. Extends the concurrent-miss coalescing to the near-in-time sequential case. It only ever reuses an embedding — it never suppresses the store search — so it cannot cause a false hit. 0 (the default) keeps it off.

negativeCacheTtl

how long a remembered miss stays usable when negativeCacheSize is positive, or null to keep it until evicted. A short TTL is the point: it should cover a burst, not pin a stale embedding for a prompt that has since been answered elsewhere.

listeners

observers notified of every hit, miss and write as it happens (see CacheEvent). Empty by default, and an empty list is free: with no listeners the cache builds no events and measures no latencies, so the hot path is exactly as it was. Each listener runs inline and must be fast and non-throwing — see CacheListener.

writeBehindScope

when non-null, turns on write-behind: on a getOrPut miss the cache returns as soon as compute does and the store write is applied off the caller's critical path, by a single worker running on this scope. Writes are applied in submission order while buffered; if the buffer is full the write falls through synchronously rather than being dropped, so a write is never lost (only, rarely, reordered under saturation). The window between compute and write is a small chance of a duplicate compute for the same brand-new prompt. Cancel this scope to stop the worker. null (the default) writes through synchronously, and put/warm always do.

writeBehindCapacity

how many pending writes to buffer before falling through to a synchronous write. Only meaningful when writeBehindScope is set.

clock

time source for entry timestamps.

Constructors

Link copied to clipboard
constructor(embedder: Embedder, store: CacheStore = InMemoryStore(), threshold: Double = DEFAULT_THRESHOLD, guards: List<MatchGuard> = MatchGuards.standard(), verifier: Verifier? = null, verifierTimeout: Duration? = null, candidates: Int = DEFAULT_CANDIDATES, coalesceConcurrentMisses: Boolean = true, embedFailurePolicy: EmbedFailurePolicy = EmbedFailurePolicy.PROPAGATE, negativeCacheSize: Int = 0, negativeCacheTtl: Duration? = null, listeners: List<CacheListener> = emptyList(), writeBehindScope: CoroutineScope? = null, writeBehindCapacity: Int = DEFAULT_WRITE_BEHIND_CAPACITY, clock: Clock = Clock.System, cachePolicy: CachePolicy? = null, exactCacheSize: Int = 0, exactCacheTtl: Duration? = null, thresholds: Map<String, Double> = emptyMap(), shadowThresholds: List<Double> = emptyList(), reranker: CandidateReranker? = null, deduplicateWrites: Double? = null, adaptiveThresholds: AdaptiveThresholds? = null, prices: Map<String, TokenPrices> = emptyMap(), admissionPolicy: AdmissionPolicy? = null, requireTenant: Boolean = false)

Types

Link copied to clipboard
object Companion

Functions

Link copied to clipboard
suspend fun clear(scope: String? = null)
Link copied to clipboard
suspend fun explain(prompt: String, scope: String = DEFAULT_SCOPE): CacheExplanation

Explains how prompt would be decided in scope, without changing anything.

Link copied to clipboard

A view of this cache bound to tenant, through which nothing can reach another tenant's entries.

Link copied to clipboard
suspend fun get(prompt: String, scope: String = DEFAULT_SCOPE, context: List<String> = emptyList()): String?

Returns the cached response for prompt, or null. The short form of lookup.

Link copied to clipboard
suspend fun getOrPut(prompt: String, scope: String = DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), compute: suspend (String) -> String): String

Returns the cached answer to prompt, or calls compute and caches what it returns.

suspend fun <T> getOrPut(prompt: String, codec: ResponseCodec<T>, scope: String = DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), compute: suspend (String) -> T): T

A getOrPut that caches a structured response of type T, not just its text.

suspend fun getOrPut(prompt: String, context: List<String>, tags: Set<String> = emptySet(), scope: String = DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), compute: suspend (String) -> String): String

The conversation-aware getOrPut: keys the turn on context as well as prompt.

Link copied to clipboard
suspend fun getOrPutAll(prompts: List<String>, scope: String = DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), compute: suspend (String) -> String): List<String>

The batch form of getOrPut: looks up many prompts at once, embedding them in a single Embedder.embedAll call.

Link copied to clipboard
suspend fun getOrPutStreaming(prompt: String, scope: String = DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), compute: suspend (String) -> Flow<String>): Flow<String>

A getOrPut for streaming completions: forwards a streamed answer to the caller as it arrives, keeps it, and replays it chunk for chunk on a later hit.

suspend fun getOrPutStreaming(prompt: String, replay: StreamReplay, scope: String = DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), compute: suspend (String) -> Flow<String>): Flow<String>

The getOrPutStreaming that chooses how a hit is replayed.

Link copied to clipboard
suspend fun invalidate(id: String): Boolean

Removes the entry id, typically one reported by a CacheLookup.Hit that proved wrong.

Link copied to clipboard
suspend fun invalidateByTag(tag: String, scope: String? = null): Int

Drops every entry carrying tag, optionally narrowed to scope, and returns how many went.

Link copied to clipboard
suspend fun lookup(prompt: String, scope: String = DEFAULT_SCOPE, context: List<String> = emptyList()): CacheLookup

Looks up prompt and reports the full outcome, including why a miss was a miss.

Link copied to clipboard
suspend fun put(prompt: String, response: String, scope: String = DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), tags: Set<String> = emptySet()): String

Caches response as the answer to prompt and returns the new entry's id.

Link copied to clipboard
suspend fun size(scope: String? = null): Int

Number of cached entries in scope, or in the whole cache when scope is null.

Link copied to clipboard

Counters since this instance was created. See CacheStats for what they tell you.

Link copied to clipboard
suspend fun warm(entries: List<WarmEntry>): List<String>

Seeds the cache with known prompt/response pairs, embedding them in one batch call where the Embedder supports it (see Embedder.embedAll).