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.systemUTC())

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.

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.

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.systemUTC())

Types

Link copied to clipboard
object Companion

Functions

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

Removes every entry in scope, or the whole cache when scope is 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
suspend fun get(prompt: String, scope: String = DEFAULT_SCOPE): 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.

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: caches the assembled text of a streamed answer and replays it on a hit, so a streaming caller never has to fall back to the blocking path.

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 lookup(prompt: String, scope: String = DEFAULT_SCOPE): 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()): 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).