Skip to content

Commit dbfbf36

Browse files
Add semantic-cache example (cache LLM responses by meaning) (#354)
## What A small, runnable example under `moss-live-labs/examples/semantic-cache` that caches LLM responses by **meaning** instead of exact text. A normal cache keys on the literal request string, so two phrasings of the same question miss and you pay the model twice: ``` "what are your hours?" -> MISS -> calls the model "when do you open?" -> MISS -> calls the model again ``` This example embeds each question, looks up the nearest one already answered, and returns the stored answer if it clears a similarity threshold — no model call: ``` "what are your hours?" -> MISS -> calls the model, stores the answer "when do you open?" -> HIT -> returns the stored answer, no model call ``` ## The whole thing ```python async def ask(self, question): hit = await self.index.query(question, QueryOptions(top_k=1)) if hit.docs and hit.docs[0].score >= THRESHOLD: # close enough in meaning? return hit.docs[0].metadata["answer"] # cache hit — no LLM call answer = await call_the_model(question) # miss — ask once await self.index.add_docs( [DocumentInfo(id=question, text=question, metadata={"answer": answer})]) return answer ``` Moss serves the nearest-match lookup on-device in **<10 ms**, far cheaper than the model call it avoids. The one knob that matters is `THRESHOLD` (cosine similarity). ## Contents - `semantic_cache.py` — `SemanticCache` class + a runnable demo (first question misses, the paraphrased second hits) - `README.md`, `.env.example`, `pyproject.toml` ## Run ```bash uv sync && cp .env.example .env # Moss + OpenAI keys python semantic_cache.py ```
1 parent bb03bca commit dbfbf36

6 files changed

Lines changed: 662 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Moss
2+
MOSS_PROJECT_ID=Your moss project id here
3+
MOSS_PROJECT_KEY=Your moss project key here
4+
5+
# OpenAI — the model whose responses we cache
6+
OPENAI_API_KEY=Your openai api key here
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.12
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Semantic cache for LLM responses
2+
3+
Cache LLM answers by **meaning**, not by exact text.
4+
5+
A normal cache keys on the literal request string, so two phrasings of the same
6+
question miss:
7+
8+
```
9+
"what are your hours?" -> MISS -> calls the model
10+
"when do you open?" -> MISS -> calls the model again (paid twice)
11+
```
12+
13+
A semantic cache embeds the question and looks up the nearest one it has already
14+
answered. If it's close enough, it returns the stored answer with no model call.
15+
Repeat questions come back in single-digit-millisecond retrieval, on-device.
16+
17+
```
18+
"what are your hours?" -> MISS -> calls the model, stores the answer
19+
"when do you open?" -> HIT -> returns the stored answer, no model call
20+
```
21+
22+
## How it works
23+
24+
Here's the idea, simplified (the runnable `ask()` in
25+
[`semantic_cache.py`](./semantic_cache.py) also returns whether it was a hit and
26+
prints timing):
27+
28+
```python
29+
async def ask(self, question):
30+
hit = await self.store.query(question, QueryOptions(top_k=1, alpha=1.0)) # pure semantic
31+
if hit.docs and hit.docs[0].score >= THRESHOLD: # close enough in meaning?
32+
return (hit.docs[0].metadata or {}).get("answer") # cache hit — no LLM call
33+
answer = await call_the_model(question) # miss — ask once
34+
await self.store.add_docs(
35+
[DocumentInfo(id=question, text=question, metadata={"answer": answer})])
36+
return answer
37+
```
38+
39+
Moss keys the cache on the question's embedding and serves the nearest match
40+
in <10 ms locally, so the lookup is far cheaper than the model call it avoids.
41+
The one knob that matters is `THRESHOLD` (the similarity score, 0-1): too low and
42+
you answer questions people didn't quite ask; too high and you miss obvious matches.
43+
44+
## What you need
45+
46+
- A [Moss](https://moss.dev) account (`MOSS_PROJECT_ID` / `MOSS_PROJECT_KEY`)
47+
- An OpenAI key (the example uses `gpt-4o-mini` as the model being cached)
48+
- Python 3.10+
49+
50+
## Run
51+
52+
```bash
53+
uv sync # or: pip install moss openai python-dotenv
54+
cp .env.example .env # fill in your keys
55+
uv run python semantic_cache.py # runs inside the project venv (plain `python` if you used pip)
56+
```
57+
58+
Expected: the first question is a `MISS` (calls the model), the paraphrased
59+
second question is a `HIT` (returns instantly, no model call).
60+
61+
## Resources
62+
63+
- [Docs](https://docs.moss.dev)
64+
- [GitHub](https://github.com/usemoss/moss)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[project]
2+
name = "moss-semantic-cache"
3+
version = "0.1.0"
4+
description = "Semantic cache for LLM responses, built on Moss"
5+
requires-python = ">=3.10"
6+
dependencies = [
7+
"moss>=1.7.1",
8+
"openai>=1.0",
9+
"python-dotenv>=1.0",
10+
]
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Semantic cache for LLM responses, built on Moss.
2+
3+
A normal cache keys on the exact text of a request, so two ways of asking the
4+
same thing ("what are your hours?" / "when do you open?") miss and you pay the
5+
model twice. A *semantic* cache keys on meaning: embed the question, look up the
6+
nearest one you've already answered, and if it's close enough, return the stored
7+
answer without calling the model.
8+
9+
The core is the SemanticCache class below (the `ask` method is the whole idea).
10+
The one knob that matters is the similarity threshold.
11+
"""
12+
13+
import asyncio
14+
import os
15+
import sys
16+
import time
17+
import uuid
18+
19+
from dotenv import load_dotenv
20+
from openai import AsyncOpenAI
21+
22+
from moss import MossClient, DocumentInfo, QueryOptions
23+
24+
# semantic similarity above which a cached answer is "close enough" to reuse.
25+
# too low -> you answer questions people didn't quite ask; too high -> you miss.
26+
THRESHOLD = 0.92
27+
28+
29+
class SemanticCache:
30+
"""A small store of past questions -> answers, looked up by meaning.
31+
32+
`store` is any Moss index/session with query/add_docs; `llm` is an AsyncOpenAI
33+
client. Both are injected so this class stays reusable and free of import-time
34+
side effects.
35+
36+
Note: entries here are keyed by question meaning only. A production cache
37+
should also scope by tenant / user / model / prompt version (e.g. a separate
38+
index per scope, or a metadata filter) so answers aren't replayed across
39+
contexts that merely phrase things similarly.
40+
"""
41+
42+
def __init__(self, store, llm, model: str = "gpt-4o-mini"):
43+
self.store = store
44+
self.llm = llm
45+
self.model = model
46+
47+
async def ask(self, question: str) -> tuple[str, bool]:
48+
# 1. look for the closest question we've already answered.
49+
# alpha=1.0 -> pure semantic (embedding) match, so the score reflects
50+
# meaning rather than keyword overlap.
51+
hit = await self.store.query(question, QueryOptions(top_k=1, alpha=1.0))
52+
if hit.docs and hit.docs[0].score >= THRESHOLD:
53+
answer = (hit.docs[0].metadata or {}).get("answer")
54+
if answer is not None:
55+
return answer, True # cache hit — no LLM call
56+
57+
# 2. miss: ask the model once
58+
resp = await self.llm.chat.completions.create(
59+
model=self.model,
60+
messages=[{"role": "user", "content": question}],
61+
)
62+
answer = resp.choices[0].message.content or ""
63+
64+
# 3. remember it so any wording of it is instant next time
65+
await self.store.add_docs(
66+
[DocumentInfo(id=question, text=question, metadata={"answer": answer})]
67+
)
68+
return answer, False
69+
70+
71+
def _require(name: str) -> str:
72+
value = os.getenv(name)
73+
if not value:
74+
sys.exit(f"Missing {name}. Copy .env.example to .env and fill in your keys.")
75+
return value
76+
77+
78+
async def main():
79+
load_dotenv()
80+
# fail fast with a clear message if credentials are missing
81+
project_id = _require("MOSS_PROJECT_ID")
82+
project_key = _require("MOSS_PROJECT_KEY")
83+
_require("OPENAI_API_KEY") # read by AsyncOpenAI from the environment
84+
85+
moss = MossClient(project_id=project_id, project_key=project_key)
86+
87+
# AsyncOpenAI owns an HTTP client; the context manager closes it on exit.
88+
async with AsyncOpenAI() as llm:
89+
# A Moss session is the cache store. We use a unique name per run so the demo
90+
# always starts empty and shows a clean MISS -> HIT (a session auto-loads an
91+
# existing cloud index of the same name, which would otherwise make the first
92+
# question a HIT). In production, use a stable name and call
93+
# `await store.push_index()` to persist the cache across runs and processes.
94+
store = await moss.session(index_name=f"qa-cache-demo-{uuid.uuid4().hex[:8]}")
95+
cache = SemanticCache(store, llm)
96+
97+
# the 2nd question means the same as the 1st, phrased differently -> cache hit
98+
questions = [
99+
"What are your opening hours?",
100+
"when do you open?",
101+
"How do I reset my password?",
102+
]
103+
for q in questions:
104+
t = time.perf_counter()
105+
answer, hit = await cache.ask(q)
106+
ms = (time.perf_counter() - t) * 1000
107+
tag = "HIT " if hit else "MISS"
108+
print(f"[{tag} {ms:7.1f} ms] {q}\n -> {answer.strip()[:90]}\n")
109+
110+
111+
if __name__ == "__main__":
112+
asyncio.run(main())

0 commit comments

Comments
 (0)