File size: 19,923 Bytes
46985e0 48cfde6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>EchoCache — semantic cache for LLM APIs</title>
<meta name="description" content="A semantic cache with a MismatchGuard that blocks similar-looking prompts whose meaning differs. 0 wrong reuses out of 90 adversarial pairs at the default threshold.">
<style>
:root {
--bg:#ffffff; --fg:#1b1f23; --muted:#5b6672; --line:#e3e7eb; --card:#f7f9fb;
--accent:#1f7a4d; --accent-soft:#e6f4ec; --warn:#8a5a00; --warn-bg:#fff6e0; --warn-line:#f0d089;
--yes:#15803d; --yes-bg:#e8f6ee; --no:#b42318; --no-bg:#fdeceb; --code:#f2f4f7;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg:#0f1418; --fg:#e7edf2; --muted:#9aa7b2; --line:#232c34; --card:#161d23;
--accent:#4ade80; --accent-soft:#12291c; --warn:#f2c14e; --warn-bg:#2a2313; --warn-line:#5a4a1c;
--yes:#4ade80; --yes-bg:#12291c; --no:#f98b82; --no-bg:#2b1715; --code:#1a2228;
}
}
* { box-sizing:border-box; }
body {
margin:0; background:var(--bg); color:var(--fg);
font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue","Hiragino Sans","Noto Sans JP",sans-serif;
-webkit-font-smoothing:antialiased;
}
.wrap { max-width:920px; margin:0 auto; padding:0 16px 72px; }
header { padding:56px 0 8px; }
h1 { font-size:2.4rem; line-height:1.15; margin:0 0 .3em; letter-spacing:-.02em; }
h1 .emoji { font-size:1em; }
h2 { font-size:1.35rem; margin:2.6em 0 .6em; letter-spacing:-.01em; }
h3 { font-size:1.05rem; margin:1.8em 0 .4em; }
p { margin:.7em 0; }
.lead { font-size:1.12rem; color:var(--fg); }
.sub { color:var(--muted); }
a { color:var(--accent); text-decoration:none; border-bottom:1px solid transparent; }
a:hover { border-bottom-color:currentColor; }
code { background:var(--code); padding:.12em .38em; border-radius:4px; font-size:.87em;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; word-break:break-word; }
pre { background:var(--code); padding:14px 16px; border-radius:10px; overflow-x:auto; font-size:.84rem; line-height:1.55;
border:1px solid var(--line); }
pre code { background:none; padding:0; font-size:1em; }
.notice { background:var(--warn-bg); border:1px solid var(--warn-line); color:var(--fg);
border-radius:12px; padding:16px 18px; margin:26px 0; }
.notice b { color:var(--warn); }
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; margin:22px 0; }
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:16px 18px; }
.card .k { font-size:.72rem; text-transform:uppercase; letter-spacing:.09em; color:var(--muted); }
.card .v { font-size:1.02rem; font-weight:600; margin:.25em 0 .2em; }
.card p { margin:.2em 0 0; font-size:.88rem; color:var(--muted); }
.scroll { overflow-x:auto; margin:16px 0; border:1px solid var(--line); border-radius:12px; }
table { border-collapse:collapse; width:100%; font-size:.9rem; }
th, td { text-align:left; padding:10px 12px; border-bottom:1px solid var(--line); vertical-align:top; }
th { background:var(--card); font-weight:600; font-size:.78rem; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); white-space:nowrap; }
tr:last-child td { border-bottom:none; }
td.num { font-variant-numeric:tabular-nums; white-space:nowrap; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.88em; }
td.p code { display:inline-block; margin:1px 0; }
td.p .vs { color:var(--muted); font-size:.78rem; }
td.reason { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.78rem; color:var(--muted); word-break:break-word; }
.note { color:var(--muted); font-size:.78rem; margin-top:4px; }
.yes { color:var(--yes); background:var(--yes-bg); padding:.12em .5em; border-radius:20px; font-size:.74rem; font-weight:700; letter-spacing:.04em; white-space:nowrap; }
.no { color:var(--no); background:var(--no-bg); padding:.12em .5em; border-radius:20px; font-size:.74rem; font-weight:700; letter-spacing:.04em; white-space:nowrap; }
.hl { background:var(--accent-soft); }
ul { padding-left:1.2em; } li { margin:.35em 0; }
hr { border:none; border-top:1px solid var(--line); margin:3em 0 1.6em; }
footer { color:var(--muted); font-size:.85rem; }
.steps { counter-reset:s; padding-left:0; list-style:none; }
.steps li { counter-increment:s; position:relative; padding-left:34px; margin:.9em 0; }
.steps li::before { content:counter(s); position:absolute; left:0; top:1px; width:23px; height:23px;
border-radius:50%; background:var(--accent-soft); color:var(--accent); font-size:.78rem; font-weight:700;
display:flex; align-items:center; justify-content:center; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1><span class="emoji">♻️</span> EchoCache</h1>
<p class="lead">A semantic cache in front of your LLM API: it answers repeat questions from memory instead of paying for them again — and refuses to answer a <em>different</em> question from cache.</p>
<p class="sub">Matching is 100% local (hashed character n-grams + SimHash, no model download, no network). Apache-2.0.</p>
</header>
<div class="notice">
<p style="margin-top:0"><b>This page is documentation, not the running app.</b></p>
<p>Hugging Face now requires a PRO subscription to host Gradio Spaces on free CPU hardware, so this Space is published as a <em>static</em> page. Everything below is real — the code, the measurements and the deploy steps — but there is no live demo to click here.</p>
<p style="margin-bottom:0">To run it: <a href="https://huggingface.co/spaces/NagaYu/EchoCache/blob/main/app.py">take <code>app.py</code></a> and <code>requirements.txt</code> from this repository and either push them to a Gradio Space on paid hardware, or run <code>python3 app.py</code> locally. It is one file with no model download and it starts in seconds.</p>
</div>
<div class="cards">
<div class="card">
<div class="k">Space — this repo</div>
<div class="v"><a href="https://huggingface.co/spaces/NagaYu/EchoCache">NagaYu/EchoCache</a></div>
<p>The full application: cache, dashboard, threshold sweep, audit, JSON API. Static here; runnable anywhere.</p>
</div>
<div class="card">
<div class="k">Model</div>
<div class="v"><a href="https://huggingface.co/NagaYu/echocache-matcher">echocache-matcher</a></div>
<p>The matching core as one numpy-only file. Deterministic and rule-based — <strong>no trained weights</strong>.</p>
</div>
<div class="card">
<div class="k">Dataset</div>
<div class="v"><a href="https://huggingface.co/datasets/NagaYu/echocache-guard-benchmark">echocache-guard-benchmark</a></div>
<p>131 labelled pairs that measure wrong reuse. Scores any cache, not just this one.</p>
</div>
</div>
<h2>The problem a similarity score cannot solve</h2>
<p>A cosine of 0.95 means <em>“these two strings look alike”</em>. It does not mean <em>“these two questions have the same answer”</em>. <code>Can I cancel my subscription?</code> and <code>Can I <b>not</b> cancel my subscription?</code> are near-identical to any similarity function and have opposite answers. Serving the cached answer there is not a cache miss — it is a wrong answer, and it costs far more than the tokens it saved.</p>
<p>EchoCache therefore splits the decision in two. Similarity picks <em>candidates</em>; a <strong>MismatchGuard</strong> decides whether a candidate may actually be served, vetoing any reuse where the meaning has shifted.</p>
<h2>Measured correctness</h2>
<p>Scored on the companion benchmark: 131 pairs — 41 a cache <em>may</em> reuse, 90 high-similarity pairs it <em>must not</em>. <strong>Wrong reuse</strong> counts cached answers served for a question whose meaning differs.</p>
<div class="scroll">
<table>
<thead><tr><th>Threshold</th><th>MismatchGuard</th><th>Accuracy</th><th>Reuse recall</th><th>Wrong reuse</th></tr></thead>
<tbody>
<tr class="hl"><td class="num">0.92 <span class="sub">(default)</span></td><td><b>on</b></td><td class="num">97.7%</td><td class="num">92.7%</td><td class="num"><b>0 / 90</b></td></tr>
<tr><td class="num">0.92</td><td>off</td><td class="num">93.1%</td><td class="num">92.7%</td><td class="num">6 / 90</td></tr>
<tr class="hl"><td class="num">0.80</td><td><b>on</b></td><td class="num">99.2%</td><td class="num">100%</td><td class="num"><b>1 / 90</b></td></tr>
<tr><td class="num">0.80</td><td>off</td><td class="num">77.9%</td><td class="num">100%</td><td class="num">29 / 90</td></tr>
<tr><td class="num">0.70</td><td>off</td><td class="num">69.5%</td><td class="num">100%</td><td class="num">40 / 90</td></tr>
</tbody>
</table>
</div>
<p>The guard is what makes an aggressive threshold survivable: at 0.80 it turns a 32% wrong-answer rate into 1%. Reproduce it with <code>python3 evaluate.py</code> in the dataset repo.</p>
<h2>Real decisions, recorded from the implementation</h2>
<p class="sub">Every similarity below was produced by running the published matcher — not estimated.</p>
<div class="scroll">
<table>
<thead><tr><th>Prompt pair</th><th>Similarity</th><th>Threshold</th><th>Verdict</th><th>Reason</th></tr></thead>
<tbody>
<tr>
<td class="p"><code>Hi team, how do I reset my password? Thanks in advance!</code><br><span class="vs">vs</span> <code>How do I reset my password?</code><div class="note">greeting and sign-off normalized away</div></td>
<td class="num">1.0000</td><td class="num">0.92</td><td><span class="yes">REUSE</span></td><td class="reason">exact key match</td>
</tr>
<tr>
<td class="p"><code>Could you please tell me how do I reset my password?</code><br><span class="vs">vs</span> <code>How do I reset my password?</code><div class="note">polite request frame is boilerplate</div></td>
<td class="num">1.0000</td><td class="num">0.92</td><td><span class="yes">REUSE</span></td><td class="reason">exact key match</td>
</tr>
<tr>
<td class="p"><code>お世話になっております。返品の方法を教えてください。よろしくお願いいたします。</code><br><span class="vs">vs</span> <code>返品の方法を教えてください</code><div class="note">Japanese honorific opening and closing removed</div></td>
<td class="num">1.0000</td><td class="num">0.92</td><td><span class="yes">REUSE</span></td><td class="reason">exact key match</td>
</tr>
<tr>
<td class="p"><code>How can I reset my password?</code><br><span class="vs">vs</span> <code>How do I reset my password?</code><div class="note">true paraphrase, reached at a lower threshold</div></td>
<td class="num">0.8476</td><td class="num">0.80</td><td><span class="yes">REUSE</span></td><td class="reason">—</td>
</tr>
<tr>
<td class="p"><code>Can I not cancel my subscription?</code><br><span class="vs">vs</span> <code>Can I cancel my subscription?</code><div class="note">negation flip</div></td>
<td class="num">0.9101</td><td class="num">0.70</td><td><span class="no">REFUSE</span></td><td class="reason">negation_mismatch:q=True,c=False</td>
</tr>
<tr>
<td class="p"><code>retry after 30 seconds</code><br><span class="vs">vs</span> <code>retry after 3 seconds</code><div class="note">quantity</div></td>
<td class="num">0.8840</td><td class="num">0.70</td><td><span class="no">REFUSE</span></td><td class="reason">numeric_mismatch:q={30},c={3}</td>
</tr>
<tr>
<td class="p"><code>Does the plan include 100MB of storage?</code><br><span class="vs">vs</span> <code>Does the plan include 100GB of storage?</code><div class="note">unit</div></td>
<td class="num">0.9231</td><td class="num">0.70</td><td><span class="no">REFUSE</span></td><td class="reason">unit_mismatch:q={100mb},c={100gb}</td>
</tr>
<tr>
<td class="p"><code>Does the X1 Yoga support 32GB?</code><br><span class="vs">vs</span> <code>Does the X1 Carbon support 32GB?</code><div class="note">product name</div></td>
<td class="num">0.7746</td><td class="num">0.70</td><td><span class="no">REFUSE</span></td><td class="reason">proper_noun_mismatch:q={x1,yoga},c={carbon,x1}</td>
</tr>
<tr>
<td class="p"><code>What is the 2025 tax rate?</code><br><span class="vs">vs</span> <code>What is the 2024 tax rate?</code><div class="note">time anchor</div></td>
<td class="num">0.8846</td><td class="num">0.70</td><td><span class="no">REFUSE</span></td><td class="reason">numeric_mismatch:q={2025},c={2024}</td>
</tr>
<tr>
<td class="p"><code>Why do I need to reset my password?</code><br><span class="vs">vs</span> <code>How do I reset my password?</code><div class="note">question type</div></td>
<td class="num">0.7327</td><td class="num">0.70</td><td><span class="no">REFUSE</span></td><td class="reason">question_type_mismatch:q={why},c={how}</td>
</tr>
<tr>
<td class="p"><code>Translate cheers into German</code><br><span class="vs">vs</span> <code>Translate cheers into French</code><div class="note">a sign-off word used as content</div></td>
<td class="num">0.7500</td><td class="num">0.70</td><td><span class="no">REFUSE</span></td><td class="reason">proper_noun_mismatch:q={german},c={french}</td>
</tr>
<tr>
<td class="p"><code>I forgot my login credentials, what now?</code><br><span class="vs">vs</span> <code>How do I reset my password?</code><div class="note">same intent, no shared surface - a known miss</div></td>
<td class="num">0.1174</td><td class="num">0.92</td><td><span class="no">REFUSE</span></td><td class="reason">temporal_mismatch:q={now},c={}</td>
</tr>
</tbody>
</table>
</div>
<h2>How a lookup works</h2>
<ol class="steps">
<li><b>Exact.</b> sha256 of the normalized prompt. Normalization folds NFKC, case, whitespace and punctuation, and removes greetings, sign-offs, disclaimers and politeness wrappers in English and Japanese. Three very different-looking prompts collapse onto one key.</li>
<li><b>SimHash band.</b> A 64-bit fingerprint of the same character n-grams narrows thousands of entries to a handful, with a vectorized popcount. No candidate, no cosine.</li>
<li><b>Cosine + MismatchGuard.</b> Only survivors are scored against a hashed character-n-gram vector. Anything above the threshold must still pass five checks: negation flip, quantity/unit, proper noun or model number, time anchor, question type.</li>
</ol>
<p>Character n-grams mean <strong>no tokenizer and no word boundaries</strong>: Japanese, Chinese and Thai behave exactly like English. Measured on a laptop with 5,000 entries in one partition: ~0.2 ms per store, ~0.11 ms for an exact hit, ~0.3 ms for a full miss.</p>
<h2>What else is in the app</h2>
<ul>
<li><b>Per-tenant isolation</b> — each tenant gets its own exact-key map, entry list, vector matrix, fingerprint array and LRU. No method takes two partitions, so there is no path that can surface one tenant's entry to another.</li>
<li><b>A safety screen before storage</b> — Luhn-validated card numbers, credential prefixes, JWTs, PEM keys, high-entropy secrets and excess contact PII are refused with a machine-readable reason code.</li>
<li><b>Threshold sweep</b> — upload a CSV of real traffic and see, per threshold, how much you would have reused and saved, with the guard on and off. That is the number you take to a review.</li>
<li><b>Observability</b> — hit-rate over time, cumulative savings per tenant and route, latency split by hit/miss, most-reused entries, borderline decisions, index health, CSV export. Prompt text is not logged by default.</li>
<li><b>Audit</b> — every candidate the guard rejected, with its reason, so a wrong hit can be traced and the entry invalidated.</li>
</ul>
<h2>The JSON API</h2>
<p>Nine endpoints, all returning an envelope. <code>ok: true</code> means the call completed — a miss is a successful call.</p>
<pre><code>POST /gradio_api/call/lookup {"data": ["acme", "How do I reset my password?", 0.92, "support"]}
{"ok": true, "hit": true, "stage": "exact", "similarity": 1.0,
"entry_id": "b67ac733...", "age_sec": 0.022, "response": "Open Settings > Security > Reset password.",
"savings": {"saved_tokens": 18, "saved_cost_usd": 0.0},
"summary": "HIT via exact (similarity 1.0000, age 0.022s, reused 1x)"}</code></pre>
<pre><code>{"ok": true, "hit": false, "stage": "miss", "reason": "guard_rejected",
"best_similarity": 0.9101, "threshold": 0.7,
"guard_reasons": ["negation_mismatch:q=True,c=False"],
"summary": "MISS (candidate was above threshold but MismatchGuard rejected it)"}</code></pre>
<p><code>/store</code> · <code>/invalidate</code> · <code>/stats</code> · <code>/sweep</code> · <code>/audit</code> · <code>/export_index</code> · <code>/import_index</code> · <code>/health</code> complete the surface. The running app builds the exact paths for your deployment in its <em>API Docs</em> tab, because Gradio changes them between major versions.</p>
<h2>Run it yourself</h2>
<pre><code># locally - identical behaviour to a Space
git clone https://huggingface.co/spaces/NagaYu/EchoCache
cd EchoCache
pip install -r requirements.txt
python3 app.py # http://localhost:7860</code></pre>
<p>To host it: create a <b>Gradio</b> Space (paid hardware or PRO), push <code>app.py</code>, <code>requirements.txt</code> and <code>README.md</code>, and it builds in a minute or two. No secrets are required — <code>HF_TOKEN</code> is optional and only enables an embedding re-rank that the cache never depends on. Full walkthrough: <a href="https://huggingface.co/spaces/NagaYu/EchoCache/blob/main/deploy.md">deploy.md</a> · <a href="https://huggingface.co/spaces/NagaYu/EchoCache/blob/main/deploy.ja.md">日本語版</a>.</p>
<h2>Use just the matcher</h2>
<pre><code>from huggingface_hub import hf_hub_download
import importlib.util, sys
path = hf_hub_download("NagaYu/echocache-matcher", "echocache_matcher.py")
spec = importlib.util.spec_from_file_location("echocache_matcher", path)
m = importlib.util.module_from_spec(spec); sys.modules["echocache_matcher"] = m
spec.loader.exec_module(m)
m.match("Can I cancel my subscription?", "Can I not cancel my subscription?", 0.5)
# {'reuse': False, 'similarity': 0.9101, 'guard_reasons': ['negation_mismatch:q=True,c=False'], ...}</code></pre>
<h2>Limitations</h2>
<ul>
<li><b>Surface similarity only.</b> Paraphrases that share meaning but not characters are not matched, by design: <code>How do I reset my password?</code> vs <code>I forgot my login credentials, what now?</code> scores <b>0.12</b>. If your traffic is full of synonym-only rewordings, pair it with embeddings or accept the misses.</li>
<li><b>The guard refuses valid reuse on purpose.</b> Any difference in the numeric-token set vetoes a hit, so <code>a 3 day trial</code> and <code>a three day trial</code> do not match. A miss costs one API call; a wrong hit costs trust.</li>
<li><b>Free-tier reality.</b> Spaces have no persistent disk and sleep after 48h. The index lives in RAM: export it before you go idle and import it after wake-up. A cache is not a database.</li>
<li><b>Savings are estimates.</b> Tokens are approximated locally and multiplied by the prices you configure. Useful for comparing thresholds, not for reconciling an invoice.</li>
<li><b>The benchmark is not independent.</b> It was written alongside the matcher by the same author, and two matcher fixes came out of it. It is a regression baseline, not a third-party leaderboard.</li>
</ul>
<hr>
<footer>
<p>Apache-2.0 · <a href="https://huggingface.co/spaces/NagaYu/EchoCache/tree/main">browse the source</a> · <a href="https://huggingface.co/NagaYu/echocache-matcher">model</a> · <a href="https://huggingface.co/datasets/NagaYu/echocache-guard-benchmark">dataset</a></p>
<p>Semantic caching returns an answer written for a different prompt. Treat the threshold as a product decision, not a tuning knob.</p>
</footer>
</div>
</body>
</html>
|