next-semantic-cache 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,303 +1,188 @@
1
- # next-semantic-cache
2
-
3
- ### Drop-in local semantic caching for the Vercel AI SDK.
4
-
5
- **Cut your OpenAI / Anthropic bill. Slash latency to ~50 ms. Compute embeddings for free — locally, on every request.**
6
-
7
- [![npm version](https://img.shields.io/npm/v/next-semantic-cache?color=6366f1&style=flat-square)](https://www.npmjs.com/package/next-semantic-cache)
8
- [![license](https://img.shields.io/npm/l/next-semantic-cache?color=22c55e&style=flat-square)](./LICENSE)
9
- [![bundle size](https://img.shields.io/bundlephobia/minzip/next-semantic-cache?color=f59e0b&style=flat-square)](https://bundlephobia.com/package/next-semantic-cache)
10
-
11
- ---
12
-
13
- ## Why next-semantic-cache?
14
-
15
- Every time a user asks *"What is the return policy?"* your app pays for a full GPT-4o call — even when you answered the same question 200 times yesterday. That is wasted money and wasted latency.
16
-
17
- `next-semantic-cache` sits **between your app and the LLM** as a Vercel AI SDK middleware. It:
18
-
19
- 1. **Embeds** the incoming prompt using `all-MiniLM-L6-v2` — a 22 MB model that runs locally inside your serverless function. **Zero embedding API cost.**
20
- 2. **Searches** your vector store for a semantically equivalent previous prompt (cosine similarity ≥ threshold).
21
- 3. **Returns** the cached response in **~50 ms** if a match is found — no LLM call, no token spend.
22
- 4. **Saves** every new LLM response asynchronously for future cache hits — without adding latency to the current request.
23
-
24
- The result: **identical answers are served instantly, and you only pay the LLM for genuinely novel questions.**
25
-
26
- ---
27
-
28
- ## How it works
29
-
30
- ```
31
- Incoming prompt
32
-
33
-
34
- ┌─────────────────────────────┐
35
- │ transformParams() │ ← embed prompt locally (all-MiniLM-L6-v2)
36
- │ vector search in Redis │ ← cosine similarity ≥ threshold?
37
- └────────────┬────────────────┘
38
-
39
- ┌──────┴──────┐
40
- │ HIT (~50ms) │ NO LLM call. Return cached string immediately.
41
- └─────────────┘
42
-
43
- ┌──────┴──────┐
44
- │ MISS │ Call real LLM → stream response to user
45
- └──────┬──────┘
46
-
47
- ┌─────────────────┐
48
- fire-and-forget │ save(vector, response) → Redis, with optional TTL
49
- └─────────────────┘
50
- ```
51
-
52
- > **Key insight:** Embeddings are computed with `@huggingface/transformers` using quantised ONNX weights loaded **once per cold start** as a singleton. Warm requests pay ~2–5 ms for the embedding step — everything else is a Redis RTT.
53
-
54
- ---
55
-
56
- ## Installation
57
-
58
- ```bash
59
- npm install next-semantic-cache @upstash/redis uuid ai
60
- npm install -D @types/uuid
61
- ```
62
-
63
- Set your Upstash credentials in `.env.local`:
64
-
65
- ```bash
66
- UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
67
- UPSTASH_REDIS_REST_TOKEN=your-token-here
68
- ```
69
-
70
- > **Upstash requirement:** Your Redis database must have **RediSearch / Redis Stack** enabled. All new Upstash databases have this by default. For self-hosted Redis, use `redis-stack` or load the `redisearch` module.
71
-
72
- ---
73
-
74
- ## Quick start
75
-
76
- ```ts
77
- import { withSemanticCache, RedisVectorAdapter } from "next-semantic-cache";
78
- import { openai } from "@ai-sdk/openai";
79
-
80
- const adapter = new RedisVectorAdapter({
81
- redisUrl: process.env.UPSTASH_REDIS_REST_URL!,
82
- redisToken: process.env.UPSTASH_REDIS_REST_TOKEN!,
83
- ttlSeconds: 86_400, // cache entries expire after 24 hours
84
- });
85
-
86
- // Wrap your model once. Use it everywhere.
87
- export const cachedModel = withSemanticCache(openai("gpt-4o"), {
88
- adapter,
89
- threshold: 0.92, // cosine similarity tune to taste
90
- });
91
- ```
92
-
93
- That's it. Pass `cachedModel` to `streamText`, `generateText`, or `generateObject` exactly as you would with the unwrapped model.
94
-
95
- ---
96
-
97
- ## Next.js Edge function example
98
-
99
- ```ts
100
- // app/api/chat/route.ts
101
- import { withSemanticCache, RedisVectorAdapter } from "next-semantic-cache";
102
- import { openai } from "@ai-sdk/openai";
103
- import { streamText } from "ai";
104
-
105
- export const runtime = "edge"; // ← works in Edge runtime
106
-
107
- // ── Build adapter & cached model outside the handler ─────────────────────
108
- // On warm lambdas this is reused across requests. The embedding model
109
- // singleton is loaded once and stays in memory between invocations.
110
- const adapter = new RedisVectorAdapter({
111
- redisUrl: process.env.UPSTASH_REDIS_REST_URL!,
112
- redisToken: process.env.UPSTASH_REDIS_REST_TOKEN!,
113
- ttlSeconds: 60 * 60 * 24 * 7, // 7-day TTL — FAQ answers stay fresh
114
- connectionTimeoutMs: 1_500, // fail fast; fall back to LLM
115
- });
116
-
117
- const cachedModel = withSemanticCache(openai("gpt-4o"), {
118
- adapter,
119
- threshold: 0.92,
120
- debug: process.env.NODE_ENV === "development",
121
- });
122
-
123
- // ── Edge handler ──────────────────────────────────────────────────────────
124
- export async function POST(req: Request) {
125
- const { messages } = await req.json();
126
-
127
- // Drop-in replacement the API is identical to the unwrapped model.
128
- const result = streamText({
129
- model: cachedModel,
130
- messages,
131
- system: "You are a helpful customer support assistant.",
132
- });
133
-
134
- return result.toDataStreamResponse();
135
- }
136
- ```
137
-
138
- A cache hit response includes `providerMetadata.semanticCache.hit = true` so you can log it or expose it in your analytics:
139
-
140
- ```ts
141
- const result = await generateText({ model: cachedModel, prompt });
142
-
143
- if (result.experimental_providerMetadata?.semanticCache?.hit) {
144
- console.log("Served from cache — 0 tokens billed.");
145
- }
146
- ```
147
-
148
- ---
149
-
150
- ## API reference
151
-
152
- ### `withSemanticCache(model, options)`
153
-
154
- The primary entry point. Returns the wrapped model — fully compatible with all Vercel AI SDK functions.
155
-
156
- ```ts
157
- function withSemanticCache<M extends LanguageModelV1>(
158
- model: M,
159
- options: SemanticCacheOptions
160
- ): LanguageModelV1
161
- ```
162
-
163
- | Option | Type | Default | Description |
164
- |---|---|---|---|
165
- | `adapter` | `VectorStoreAdapter` | **required** | Storage backend. Use the built-in `RedisVectorAdapter` or bring your own. |
166
- | `threshold` | `number` | `0.92` | Cosine similarity cutoff in `[0, 1]`. Higher = stricter matching. |
167
- | `debug` | `boolean` | `false` | Log cache hits/misses to `console.debug`. |
168
-
169
- ---
170
-
171
- ### `RedisVectorAdapter`
172
-
173
- Built-in adapter for Upstash Redis (and any Redis Stack instance).
174
-
175
- ```ts
176
- const adapter = new RedisVectorAdapter(options);
177
- ```
178
-
179
- | Option | Type | Default | Description |
180
- |---|---|---|---|
181
- | `redisUrl` | `string` | | Upstash REST endpoint URL. |
182
- | `redisToken` | `string` | | Upstash REST API token. |
183
- | `client` | `Redis` | — | Pass an existing `@upstash/redis` client instead of `url` + `token`. |
184
- | `ttlSeconds` | `number` | `0` (no expiry) | Seconds until a cache entry is automatically deleted by Redis. |
185
- | `connectionTimeoutMs` | `number` | `2000` | Max ms to wait for Redis before treating the call as failed and falling back to the LLM. |
186
-
187
- ```ts
188
- // Flush all cache entries (testing / manual invalidation)
189
- const deleted = await adapter.flush();
190
- console.log(`Removed ${deleted} entries from the semantic cache.`);
191
- ```
192
-
193
- ---
194
-
195
- ### `VectorStoreAdapter` interface
196
-
197
- Bring your own storage backend by implementing two methods:
198
-
199
- ```ts
200
- export interface VectorStoreAdapter {
201
- /**
202
- * Return the cached response string if a similar prompt exists,
203
- * or null if no match exceeds the threshold.
204
- */
205
- search(vector: number[], threshold: number): Promise<string | null>;
206
-
207
- /**
208
- * Persist the prompt embedding alongside its LLM response.
209
- */
210
- save(promptVector: number[], response: string): Promise<void>;
211
- }
212
- ```
213
-
214
- #### Example — in-memory adapter (for unit testing)
215
-
216
- ```ts
217
- import type { VectorStoreAdapter } from "next-semantic-cache";
218
-
219
- class InMemoryAdapter implements VectorStoreAdapter {
220
- private store: Array<{ vector: number[]; response: string }> = [];
221
-
222
- private cosineSimilarity(a: number[], b: number[]): number {
223
- const dot = a.reduce((sum, v, i) => sum + v * b[i], 0);
224
- const magA = Math.sqrt(a.reduce((s, v) => s + v * v, 0));
225
- const magB = Math.sqrt(b.reduce((s, v) => s + v * v, 0));
226
- return dot / (magA * magB);
227
- }
228
-
229
- async search(vector: number[], threshold: number) {
230
- for (const entry of this.store) {
231
- if (this.cosineSimilarity(vector, entry.vector) >= threshold) {
232
- return entry.response;
233
- }
234
- }
235
- return null;
236
- }
237
-
238
- async save(promptVector: number[], response: string) {
239
- this.store.push({ vector: promptVector, response });
240
- }
241
- }
242
- ```
243
-
244
- ---
245
-
246
- ## Architecture notes
247
-
248
- ### Singleton embedding model
249
-
250
- `@huggingface/transformers` loads `Xenova/all-MiniLM-L6-v2` (quantised q8 ONNX, ~23 MB) **once per process**. The pipeline instance is held in a module-level variable and reused on every subsequent request. Cold-start cost: ~400–800 ms. Warm embedding cost: ~2–5 ms.
251
-
252
- ### Middleware interception flow
253
-
254
- The middleware implements two hooks from the Vercel AI SDK's `LanguageModelV1Middleware` spec:
255
-
256
- - **`transformParams`** — runs synchronously *before* the LLM call. Embeds the prompt and attaches a `__semanticCacheHit` sentinel to `providerMetadata` on a hit.
257
- - **`wrapGenerate`** — reads the sentinel. If present, returns the cached result and **never calls `doGenerate()`**. On a miss it calls `doGenerate()` and fire-and-forgets the `save()`.
258
-
259
- ### Failure isolation
260
-
261
- If Redis is unreachable or times out:
262
- - `search()` returns `null` → the middleware calls the real LLM transparently.
263
- - `save()` logs the error and returns — the LLM response has already been streamed to the user.
264
-
265
- **Redis failures are completely invisible to your users.**
266
-
267
- ---
268
-
269
- ## Threshold tuning guide
270
-
271
- | Threshold | Behaviour | Use-case |
272
- |---|---|---|
273
- | **0.98** | Near-identical phrasing only | Deduplicate exact retries |
274
- | **0.92** *(default)* | Catches paraphrases & minor rewording | General-purpose FAQ / support bots |
275
- | **0.88** | Broad semantic matching | High-traffic apps where some imprecision is acceptable |
276
- | **0.80** | Very aggressive | Only for tightly constrained, single-domain prompts |
277
-
278
- ---
279
-
280
- ## FAQ
281
-
282
- **Does it work in the Vercel Edge Runtime?**
283
- Yes. `@huggingface/transformers` with ONNX backend runs in Edge (V8 isolate) environments. The `@upstash/redis` client uses the REST API so there are no raw TCP sockets.
284
-
285
- **Can I use a different LLM — Anthropic Claude, Google Gemini, etc.?**
286
- Yes. `withSemanticCache` accepts any `LanguageModelV1`-compatible model. Swap `openai(...)` for `anthropic(...)`, `google(...)`, or any community provider.
287
-
288
- **Does it support streaming?**
289
- Cache misses stream normally via the underlying model. Cache hits return a non-streaming synthetic result — the text is complete and the SDK delivers it as a resolved promise. To surface hits as streams, extend `buildCachedGenerateResult` to use `wrapStream` instead.
290
-
291
- **Does it work with `generateObject` / structured output?**
292
- The middleware hooks into `wrapGenerate`, which also underlies `generateObject`. Cached responses are raw text; if your object-generation prompt is highly templated and structurally identical across calls, hits will work correctly. For variable schema inputs, raise `threshold` to ≥ 0.97.
293
-
294
- **Will it slow down my first request on a cold start?**
295
- The embedding model loads in ~400–800 ms on the first invocation. Vercel pre-warms functions in production, reducing the impact. You can also explicitly warm the pipeline by calling `getEmbeddingPipeline()` from a `warmup` route.
296
-
297
- ---
298
-
299
- ## License
300
-
301
- MIT © 2026 — contributions welcome.
302
-
303
- > **Install deps:** `npm i next-semantic-cache @upstash/redis uuid ai`
1
+ # next-semantic-cache
2
+
3
+ > Semantic caching middleware for the [Vercel AI SDK](https://sdk.vercel.ai) — short-circuit LLM calls with vector similarity lookups.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/next-semantic-cache.svg)](https://www.npmjs.com/package/next-semantic-cache)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ `next-semantic-cache` is a drop-in `LanguageModelV4Middleware` that intercepts prompts, embeds them locally, and serves cached responses when a semantically similar prompt has been seen before. It ships with a pluggable `VectorStoreAdapter` interface and a production-ready Redis (RediSearch / Upstash) implementation.
9
+
10
+ ---
11
+
12
+ ## Why?
13
+
14
+ LLM calls are **slow** and **expensive**. Traditional caching only matches *exact* strings — but users phrase the same question in countless ways. `next-semantic-cache` matches on **meaning**, not exact text:
15
+
16
+ | Prompt A | Prompt B | Exact cache | Semantic cache |
17
+ | --- | --- | --- | --- |
18
+ | "How do I reset my password?" | "What's the steps to change my password?" | ❌ Miss | ✅ Hit |
19
+
20
+ ---
21
+
22
+ ## Features
23
+
24
+ - 🧠 **Semantic matching** embeds prompts with `all-MiniLM-L6-v2` and matches on cosine similarity.
25
+ - 🔌 **Pluggable storage** — implement `VectorStoreAdapter` for any vector DB (Pinecone, pgvector, Weaviate, in-memory, …).
26
+ - 🚀 **Redis adapter included** — works with **Upstash Redis** (serverless) or self-hosted **Redis Stack**.
27
+ - 🛡️ **Defensive by design** — every cache operation is wrapped in try/catch. If the store is down, you transparently fall back to the live LLM. **A cache failure never breaks your app.**
28
+ - ⏱️ **Configurable TTL** — auto-expire entries via Redis `EXPIRE`.
29
+ - 📦 **Zero-config embeddings** — the embedding model runs locally; no extra API keys required.
30
+
31
+ ---
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ npm install next-semantic-cache ai @huggingface/transformers
37
+ ```
38
+
39
+ For the Redis adapter, also install:
40
+
41
+ ```bash
42
+ npm install @upstash/redis uuid
43
+ npm install -D @types/uuid
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Quick Start
49
+
50
+ ```ts
51
+ import { openai } from "@ai-sdk/openai";
52
+ import { wrapLanguageModel, generateText } from "ai";
53
+ import { SemanticCacheMiddleware } from "next-semantic-cache";
54
+ import { RedisVectorAdapter } from "next-semantic-cache/redis-vector-adapter";
55
+
56
+ // 1. Create a vector store adapter.
57
+ const vectorStore = new RedisVectorAdapter({
58
+ redisUrl: process.env.UPSTASH_REDIS_REST_URL!,
59
+ redisToken: process.env.UPSTASH_REDIS_REST_TOKEN!,
60
+ ttlSeconds: 60 * 60 * 24, // 24-hour cache expiry
61
+ });
62
+
63
+ // 2. Wrap your model with the middleware.
64
+ const model = wrapLanguageModel({
65
+ model: openai("gpt-4o"),
66
+ middleware: SemanticCacheMiddleware({
67
+ vectorStore,
68
+ similarityThreshold: 0.92, // 0–1; higher = stricter match
69
+ }),
70
+ });
71
+
72
+ // 3. Use it exactly like any AI SDK model.
73
+ const { text } = await generateText({
74
+ model,
75
+ prompt: "Explain quantum entanglement in one sentence.",
76
+ });
77
+
78
+ console.log(text);
79
+ // First call → hits the LLM, caches the result.
80
+ // Later calls with a similar prompt → served instantly from cache.
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Configuration
86
+
87
+ ### `SemanticCacheMiddleware(options)`
88
+
89
+ | Option | Type | Default | Description |
90
+ | --- | --- | --- | --- |
91
+ | `vectorStore` | `VectorStoreAdapter` | **required** | The persistence backend for embeddings and responses. |
92
+ | `similarityThreshold` | `number` | `0.92` | Minimum cosine similarity (0–1) for a cache hit. Higher = fewer, more accurate hits. |
93
+
94
+ ### `RedisVectorAdapter(options)`
95
+
96
+ | Option | Type | Default | Description |
97
+ | --- | --- | --- | --- |
98
+ | `client` | `Redis` | — | A pre-configured `@upstash/redis` client. Mutually exclusive with `redisUrl` + `redisToken`. |
99
+ | `redisUrl` | `string` | — | Upstash REST endpoint. Required if `client` is omitted. |
100
+ | `redisToken` | `string` | — | Upstash REST token. Required if `client` is omitted. |
101
+ | `ttlSeconds` | `number` | `0` | Auto-expire entries after N seconds. `0` = keep indefinitely. |
102
+ | `connectionTimeoutMs` | `number` | `2000` | Timeout per Redis command before falling back to the LLM. |
103
+
104
+ ---
105
+
106
+ ## Writing a Custom Adapter
107
+
108
+ Implement the `VectorStoreAdapter` interface to plug in any backend:
109
+
110
+ ```ts
111
+ import type { VectorStoreAdapter } from "next-semantic-cache";
112
+
113
+ export class MyVectorAdapter implements VectorStoreAdapter {
114
+ /**
115
+ * Return the cached response if a stored vector is within `threshold`
116
+ * cosine similarity of `vector`, otherwise `null`.
117
+ */
118
+ async search(vector: number[], threshold: number): Promise<string | null> {
119
+ // ... query your vector DB ...
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Persist a prompt embedding and its LLM response.
125
+ * Failures should be swallowed caching is best-effort.
126
+ */
127
+ async save(promptVector: number[], response: string): Promise<void> {
128
+ // ... upsert into your vector DB ...
129
+ }
130
+ }
131
+ ```
132
+
133
+ ---
134
+
135
+ ## How It Works
136
+
137
+ ```
138
+ ┌──────────────────────────────┐
139
+ prompt ──────────► │ transformParams │
140
+ │ • embed prompt (MiniLM-L6) │
141
+ │ • vectorStore.search(...)
142
+ └───────────┬──────────────────┘
143
+
144
+ ┌────────────────┴─────────────────┐
145
+ ▼ ▼
146
+ Cache HIT Cache MISS
147
+ ┌──────────────────────┐ ┌──────────────────────────┐
148
+ │ return synthetic │ │ wrapGenerate: │
149
+ │ result — LLM is │ │ • call real model │
150
+ NEVER invoked │ │ • vectorStore.save(...) │
151
+ └──────────────────────┘ │ (fire-and-forget) │
152
+ └──────────────────────────┘
153
+ ```
154
+
155
+ 1. **`transformParams`** embeds the incoming prompt and queries the vector store.
156
+ 2. **Cache hit** → a synthetic `LanguageModelV4GenerateResult` is returned immediately; the real model is short-circuited.
157
+ 3. **Cache miss** → the request falls through to the real model, and the response is saved to the store asynchronously (never blocking the response path).
158
+
159
+ ---
160
+
161
+ ## Redis Storage Layout
162
+
163
+ The `RedisVectorAdapter` stores each entry as a Redis `HASH` and creates an HNSW vector index over them:
164
+
165
+ | Component | Value |
166
+ | --- | --- |
167
+ | Key prefix | `sc:<uuid>` |
168
+ | Fields | `response`, `vector` (Float32 binary), `createdAt` (ISO-8601) |
169
+ | Index | `FT.CREATE` — HNSW, `FLOAT32`, `DIM 384`, `COSINE` distance |
170
+
171
+ The index is created **lazily** on first `save()`/`search()` and is idempotent.
172
+
173
+ > ⚠️ **Note on Upstash + binary vectors:** `@upstash/redis` transports commands as JSON over HTTP. Binary vector payloads may not round-trip reliably through the REST layer. If you encounter KNN query errors, use a **Redis Stack** instance with a native `ioredis` client for full binary support.
174
+
175
+ ---
176
+
177
+ ## Requirements
178
+
179
+ - **Node.js** 18
180
+ - **Vercel AI SDK** (`ai`) v4+
181
+ - A vector store (Redis Stack / Upstash, or your own adapter)
182
+ - Embedding model: `all-MiniLM-L6-v2` (384-dim) via `@huggingface/transformers`
183
+
184
+ ---
185
+
186
+ ## License
187
+
188
+ MIT © [Prateek Chaturvedi](https://github.com/chaturvedi-prateek)
@@ -0,0 +1,135 @@
1
+ /**
2
+ * redis-vector-adapter.ts
3
+ *
4
+ * Concrete implementation of VectorStoreAdapter using Upstash Redis
5
+ * (compatible with any Redis instance that has the RediSearch / Redis Stack
6
+ * module enabled — the same FT.* command surface is used in both cases).
7
+ *
8
+ * Storage layout
9
+ * ─────────────
10
+ * Key prefix : sc:<uuid> (Redis HASH)
11
+ * Fields : response — raw LLM response text
12
+ * vector — Float32 embedding encoded as a binary string
13
+ * createdAt — ISO-8601 timestamp for observability
14
+ *
15
+ * Index : FT.CREATE on the HASH prefix using an HNSW VECTOR field
16
+ * with COSINE distance. Created lazily on first save/search.
17
+ *
18
+ * Failure policy (defensive-first)
19
+ * ──────────────────────────────────
20
+ * Every Redis operation is wrapped in try/catch. If Redis is unreachable,
21
+ * times out, or returns an unexpected payload:
22
+ * • search() returns null → middleware falls back to the real LLM.
23
+ * • save() is a no-op → the miss is simply not cached; no crash.
24
+ *
25
+ * Dependencies:
26
+ * npm i @upstash/redis uuid
27
+ * npm i -D @types/node @types/uuid
28
+ */
29
+ import { Redis } from "@upstash/redis";
30
+ import type { VectorStoreAdapter } from "./semantic-cache-middleware";
31
+ export interface RedisVectorAdapterOptions {
32
+ /**
33
+ * Pre-configured @upstash/redis client instance.
34
+ * Pass this if you want to share a client across your application.
35
+ * Mutually exclusive with `redisUrl` + `redisToken`.
36
+ */
37
+ client?: Redis;
38
+ /**
39
+ * Upstash Redis REST endpoint URL.
40
+ * Required when `client` is not provided.
41
+ * Example: "https://your-db.upstash.io"
42
+ */
43
+ redisUrl?: string;
44
+ /**
45
+ * Upstash Redis REST API token.
46
+ * Required when `client` is not provided.
47
+ */
48
+ redisToken?: string;
49
+ /**
50
+ * Optional TTL in seconds. When set, each cache entry will automatically
51
+ * expire after this many seconds via Redis EXPIRE.
52
+ * Omit (or set to 0) to keep entries indefinitely.
53
+ */
54
+ ttlSeconds?: number;
55
+ /**
56
+ * Maximum number of milliseconds to wait for a Redis command before
57
+ * treating the operation as a failure and falling back to the LLM.
58
+ * @default 2000
59
+ */
60
+ connectionTimeoutMs?: number;
61
+ }
62
+ /**
63
+ * A {@link VectorStoreAdapter} backed by Redis with the RediSearch (Redis
64
+ * Stack) vector similarity extension.
65
+ *
66
+ * Compatible with:
67
+ * • Upstash Redis (recommended — serverless, REST-based)
68
+ * • Redis Stack (self-hosted or Redis Cloud with Search enabled)
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * const adapter = new RedisVectorAdapter({
73
+ * redisUrl: process.env.UPSTASH_REDIS_REST_URL!,
74
+ * redisToken: process.env.UPSTASH_REDIS_REST_TOKEN!,
75
+ * ttlSeconds: 60 * 60 * 24, // 24-hour cache expiry
76
+ * });
77
+ *
78
+ * const model = wrapLanguageModel({
79
+ * model: openai("gpt-4o"),
80
+ * middleware: SemanticCacheMiddleware({
81
+ * vectorStore: adapter,
82
+ * similarityThreshold: 0.92,
83
+ * }),
84
+ * });
85
+ * ```
86
+ */
87
+ export declare class RedisVectorAdapter implements VectorStoreAdapter {
88
+ private readonly redis;
89
+ private readonly ttlSeconds;
90
+ private readonly timeoutMs;
91
+ /**
92
+ * Whether the RediSearch HNSW index has been confirmed to exist this
93
+ * process lifetime. Avoids issuing FT.CREATE on every request.
94
+ */
95
+ private indexReady;
96
+ constructor(options: RedisVectorAdapterOptions);
97
+ /**
98
+ * Lazily creates the RediSearch HNSW index on first use.
99
+ * The operation is idempotent — if the index already exists Redis returns
100
+ * an error that we swallow silently.
101
+ */
102
+ private ensureIndex;
103
+ /**
104
+ * Performs a KNN-1 cosine similarity search against the HNSW index.
105
+ *
106
+ * Cosine *distance* (returned by Redis) = 1 − cosine *similarity*.
107
+ * Therefore the `threshold` parameter (expressed as similarity, e.g. 0.92)
108
+ * is converted: `maxDistance = 1 − threshold` (e.g. 0.08).
109
+ *
110
+ * If Redis is unavailable or returns an error, `null` is returned and the
111
+ * middleware gracefully falls through to a live LLM call.
112
+ *
113
+ * @param vector - The float32 embedding of the incoming prompt.
114
+ * @param threshold - Minimum cosine similarity score in [0, 1].
115
+ * @returns The cached LLM response string, or `null` on a miss / error.
116
+ */
117
+ search(vector: number[], threshold: number): Promise<string | null>;
118
+ /**
119
+ * Stores a prompt embedding and its LLM response as a Redis HASH.
120
+ *
121
+ * Failures are logged but never re-thrown — caching is best-effort.
122
+ *
123
+ * @param promptVector - The float32 embedding of the prompt.
124
+ * @param response - The LLM-generated text to cache.
125
+ */
126
+ save(promptVector: number[], response: string): Promise<void>;
127
+ /**
128
+ * Deletes ALL semantic cache entries from Redis.
129
+ * Useful for testing or manual cache invalidation.
130
+ *
131
+ * This scans the keyspace — avoid on large production databases.
132
+ * Prefer TTL-based expiry for automatic eviction in production.
133
+ */
134
+ flush(): Promise<number>;
135
+ }
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+ /**
3
+ * redis-vector-adapter.ts
4
+ *
5
+ * Concrete implementation of VectorStoreAdapter using Upstash Redis
6
+ * (compatible with any Redis instance that has the RediSearch / Redis Stack
7
+ * module enabled — the same FT.* command surface is used in both cases).
8
+ *
9
+ * Storage layout
10
+ * ─────────────
11
+ * Key prefix : sc:<uuid> (Redis HASH)
12
+ * Fields : response — raw LLM response text
13
+ * vector — Float32 embedding encoded as a binary string
14
+ * createdAt — ISO-8601 timestamp for observability
15
+ *
16
+ * Index : FT.CREATE on the HASH prefix using an HNSW VECTOR field
17
+ * with COSINE distance. Created lazily on first save/search.
18
+ *
19
+ * Failure policy (defensive-first)
20
+ * ──────────────────────────────────
21
+ * Every Redis operation is wrapped in try/catch. If Redis is unreachable,
22
+ * times out, or returns an unexpected payload:
23
+ * • search() returns null → middleware falls back to the real LLM.
24
+ * • save() is a no-op → the miss is simply not cached; no crash.
25
+ *
26
+ * Dependencies:
27
+ * npm i @upstash/redis uuid
28
+ * npm i -D @types/node @types/uuid
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.RedisVectorAdapter = void 0;
32
+ const redis_1 = require("@upstash/redis");
33
+ const uuid_1 = require("uuid");
34
+ // ---------------------------------------------------------------------------
35
+ // Constants
36
+ // ---------------------------------------------------------------------------
37
+ /** Prefix for every cache entry stored as a Redis HASH. */
38
+ const KEY_PREFIX = "sc:";
39
+ /** Name of the RediSearch index created over the cache entries. */
40
+ const INDEX_NAME = "semantic_cache_idx";
41
+ /**
42
+ * Dimensionality of the all-MiniLM-L6-v2 embedding space.
43
+ * Must match the model used in the middleware (384 for MiniLM-L6).
44
+ */
45
+ const VECTOR_DIM = 384;
46
+ /**
47
+ * Number of HNSW graph neighbours to explore at query time.
48
+ * Higher = more accurate recall, higher latency. 32 is a safe default.
49
+ */
50
+ const HNSW_EF_RUNTIME = 32;
51
+ // ---------------------------------------------------------------------------
52
+ // Helpers — raw command dispatch
53
+ // ---------------------------------------------------------------------------
54
+ /**
55
+ * Dispatches a raw Redis command through the @upstash/redis client.
56
+ *
57
+ * The @upstash/redis SDK does not expose a public `sendCommand` /
58
+ * `executeCommand` method for RediSearch (FT.*) or other low-level commands.
59
+ * Internally every client forwards a command array via its private request
60
+ * pipeline. We funnel ALL raw commands through this single helper so the
61
+ * cast to the internal shape is isolated to one place rather than scattered
62
+ * across the class.
63
+ *
64
+ * @param redis - The @upstash/redis client instance.
65
+ * @param args - The command name followed by its arguments, as a flat array.
66
+ * @returns The decoded command result, typed by the caller via `T`.
67
+ */
68
+ async function rawCommand(redis, args) {
69
+ // The internal `request` method accepts `{ command: [...] }` and returns
70
+ // `{ result }`. It is not part of the public type surface, so we cast here
71
+ // deliberately and keep the cast confined to this helper.
72
+ const client = redis;
73
+ const { result } = await client.request({ command: args });
74
+ return result;
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // Helpers — vector encoding / decoding
78
+ // ---------------------------------------------------------------------------
79
+ /**
80
+ * Encodes a JS number[] into a Buffer of raw Float32 bytes.
81
+ * Redis vector fields require the embedding as a binary blob.
82
+ */
83
+ function encodeVector(vector) {
84
+ const float32 = new Float32Array(vector);
85
+ return Buffer.from(float32.buffer);
86
+ }
87
+ /**
88
+ * Decodes a Base64 string back into a Buffer containing raw Float32 bytes.
89
+ * Used when converting the stored value back for a KNN query blob.
90
+ */
91
+ function base64ToBuffer(b64) {
92
+ return Buffer.from(b64, "base64");
93
+ }
94
+ /**
95
+ * Wraps a promise with a timeout.
96
+ * Rejects with a timeout error if the promise does not settle within
97
+ * `ms` milliseconds.
98
+ */
99
+ function withTimeout(promise, ms) {
100
+ return new Promise((resolve, reject) => {
101
+ const timer = setTimeout(() => reject(new Error(`Redis operation timed out after ${ms}ms`)), ms);
102
+ promise.then((value) => { clearTimeout(timer); resolve(value); }, (err) => { clearTimeout(timer); reject(err); });
103
+ });
104
+ }
105
+ /**
106
+ * A {@link VectorStoreAdapter} backed by Redis with the RediSearch (Redis
107
+ * Stack) vector similarity extension.
108
+ *
109
+ * Compatible with:
110
+ * • Upstash Redis (recommended — serverless, REST-based)
111
+ * • Redis Stack (self-hosted or Redis Cloud with Search enabled)
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * const adapter = new RedisVectorAdapter({
116
+ * redisUrl: process.env.UPSTASH_REDIS_REST_URL!,
117
+ * redisToken: process.env.UPSTASH_REDIS_REST_TOKEN!,
118
+ * ttlSeconds: 60 * 60 * 24, // 24-hour cache expiry
119
+ * });
120
+ *
121
+ * const model = wrapLanguageModel({
122
+ * model: openai("gpt-4o"),
123
+ * middleware: SemanticCacheMiddleware({
124
+ * vectorStore: adapter,
125
+ * similarityThreshold: 0.92,
126
+ * }),
127
+ * });
128
+ * ```
129
+ */
130
+ class RedisVectorAdapter {
131
+ redis;
132
+ ttlSeconds;
133
+ timeoutMs;
134
+ /**
135
+ * Whether the RediSearch HNSW index has been confirmed to exist this
136
+ * process lifetime. Avoids issuing FT.CREATE on every request.
137
+ */
138
+ indexReady = false;
139
+ constructor(options) {
140
+ const { client, redisUrl, redisToken, ttlSeconds = 0, connectionTimeoutMs = 2000 } = options;
141
+ if (client) {
142
+ this.redis = client;
143
+ }
144
+ else if (redisUrl && redisToken) {
145
+ this.redis = new redis_1.Redis({
146
+ url: redisUrl,
147
+ token: redisToken,
148
+ });
149
+ }
150
+ else {
151
+ throw new Error("RedisVectorAdapter: supply either `client` or both `redisUrl` and `redisToken`.");
152
+ }
153
+ this.ttlSeconds = ttlSeconds;
154
+ this.timeoutMs = connectionTimeoutMs;
155
+ }
156
+ // =========================================================================
157
+ // Index bootstrap
158
+ // =========================================================================
159
+ /**
160
+ * Lazily creates the RediSearch HNSW index on first use.
161
+ * The operation is idempotent — if the index already exists Redis returns
162
+ * an error that we swallow silently.
163
+ */
164
+ async ensureIndex() {
165
+ if (this.indexReady)
166
+ return;
167
+ try {
168
+ await withTimeout(rawCommand(this.redis, [
169
+ "FT.CREATE", INDEX_NAME,
170
+ "ON", "HASH",
171
+ "PREFIX", "1", KEY_PREFIX,
172
+ "SCHEMA",
173
+ // ── Full-text field (for human inspection / future hybrid search) ──
174
+ "response", "TEXT",
175
+ "createdAt", "TEXT",
176
+ // ── Vector field — HNSW with Cosine distance ──────────────────────
177
+ "vector", "VECTOR", "HNSW",
178
+ "6", // number of attribute/value pairs below
179
+ "TYPE", "FLOAT32",
180
+ "DIM", String(VECTOR_DIM),
181
+ "DISTANCE_METRIC", "COSINE",
182
+ ]), this.timeoutMs);
183
+ }
184
+ catch (err) {
185
+ // Redis returns "Index already exists" as an error string; swallow it.
186
+ // Any other error is also swallowed here — if the index genuinely fails
187
+ // to create, FT.SEARCH will error too and be caught in search().
188
+ const message = err instanceof Error ? err.message : String(err);
189
+ if (!message.includes("already exists") && !message.includes("Index already exists")) {
190
+ console.warn("[RedisVectorAdapter] FT.CREATE warning (non-fatal):", message);
191
+ }
192
+ }
193
+ // Mark as ready regardless — subsequent failures will be caught per-call.
194
+ this.indexReady = true;
195
+ }
196
+ // =========================================================================
197
+ // VectorStoreAdapter — search
198
+ // =========================================================================
199
+ /**
200
+ * Performs a KNN-1 cosine similarity search against the HNSW index.
201
+ *
202
+ * Cosine *distance* (returned by Redis) = 1 − cosine *similarity*.
203
+ * Therefore the `threshold` parameter (expressed as similarity, e.g. 0.92)
204
+ * is converted: `maxDistance = 1 − threshold` (e.g. 0.08).
205
+ *
206
+ * If Redis is unavailable or returns an error, `null` is returned and the
207
+ * middleware gracefully falls through to a live LLM call.
208
+ *
209
+ * @param vector - The float32 embedding of the incoming prompt.
210
+ * @param threshold - Minimum cosine similarity score in [0, 1].
211
+ * @returns The cached LLM response string, or `null` on a miss / error.
212
+ */
213
+ async search(vector, threshold) {
214
+ try {
215
+ await this.ensureIndex();
216
+ // Convert similarity threshold → cosine distance upper bound.
217
+ const maxDistance = 1 - threshold;
218
+ // Encode the query vector as a binary buffer for the KNN parameter.
219
+ const queryBlob = encodeVector(vector);
220
+ // ── FT.SEARCH KNN query ──────────────────────────────────────────────
221
+ const rawResult = await withTimeout(rawCommand(this.redis, [
222
+ "FT.SEARCH", INDEX_NAME,
223
+ `*=>[KNN 1 @vector $vec AS __score]`,
224
+ "PARAMS", "2",
225
+ "vec", queryBlob.toString("binary"), // binary string for the REST layer
226
+ "RETURN", "2", "__score", "response",
227
+ "SORTBY", "__score", "ASC",
228
+ "DIALECT", "2",
229
+ ]), this.timeoutMs);
230
+ // rawResult layout (RESP2): [ totalCount, key1, [field, value, ...], ... ]
231
+ const totalCount = rawResult[0];
232
+ if (totalCount === 0) {
233
+ return null; // no entries in the index yet
234
+ }
235
+ // The first result's field-value array is at index 2.
236
+ const fields = rawResult[2];
237
+ // Parse the flat field-value array into a map.
238
+ const fieldMap = {};
239
+ for (let i = 0; i < fields.length; i += 2) {
240
+ fieldMap[fields[i]] = fields[i + 1];
241
+ }
242
+ const score = parseFloat(fieldMap["__score"] ?? "1");
243
+ const response = fieldMap["response"] ?? null;
244
+ // `score` is cosine distance; reject if it exceeds the max allowed distance.
245
+ if (score > maxDistance || response === null) {
246
+ return null;
247
+ }
248
+ return response;
249
+ }
250
+ catch (err) {
251
+ // ── Defensive fallback ────────────────────────────────────────────────
252
+ console.error("[RedisVectorAdapter] search() failed (falling back to LLM):", err instanceof Error ? err.message : err);
253
+ return null;
254
+ }
255
+ }
256
+ // =========================================================================
257
+ // VectorStoreAdapter — save
258
+ // =========================================================================
259
+ /**
260
+ * Stores a prompt embedding and its LLM response as a Redis HASH.
261
+ *
262
+ * Failures are logged but never re-thrown — caching is best-effort.
263
+ *
264
+ * @param promptVector - The float32 embedding of the prompt.
265
+ * @param response - The LLM-generated text to cache.
266
+ */
267
+ async save(promptVector, response) {
268
+ try {
269
+ await this.ensureIndex();
270
+ const key = `${KEY_PREFIX}${(0, uuid_1.v4)()}`;
271
+ const vectorBuf = encodeVector(promptVector);
272
+ // Store the vector as a binary string field so RediSearch can index it.
273
+ await withTimeout(rawCommand(this.redis, [
274
+ "HSET", key,
275
+ "vector", vectorBuf.toString("binary"),
276
+ "response", response,
277
+ "createdAt", new Date().toISOString(),
278
+ ]), this.timeoutMs);
279
+ // Apply optional TTL — Redis will remove the key after `ttlSeconds`.
280
+ if (this.ttlSeconds > 0) {
281
+ await withTimeout(rawCommand(this.redis, ["EXPIRE", key, String(this.ttlSeconds)]), this.timeoutMs);
282
+ }
283
+ }
284
+ catch (err) {
285
+ // ── Defensive fallback ────────────────────────────────────────────────
286
+ // A failed cache write must never degrade the primary LLM response
287
+ // that was already returned to the user.
288
+ console.error("[RedisVectorAdapter] save() failed (cache write skipped):", err instanceof Error ? err.message : err);
289
+ }
290
+ }
291
+ // =========================================================================
292
+ // Utility — explicit teardown (optional, for graceful shutdown)
293
+ // =========================================================================
294
+ /**
295
+ * Deletes ALL semantic cache entries from Redis.
296
+ * Useful for testing or manual cache invalidation.
297
+ *
298
+ * This scans the keyspace — avoid on large production databases.
299
+ * Prefer TTL-based expiry for automatic eviction in production.
300
+ */
301
+ async flush() {
302
+ try {
303
+ let cursor = "0";
304
+ let deletedCount = 0;
305
+ do {
306
+ const [nextCursor, keys] = await withTimeout(rawCommand(this.redis, [
307
+ "SCAN", cursor, "MATCH", `${KEY_PREFIX}*`, "COUNT", "100"
308
+ ]), this.timeoutMs);
309
+ cursor = nextCursor;
310
+ if (keys.length > 0) {
311
+ await withTimeout(rawCommand(this.redis, ["DEL", ...keys]), this.timeoutMs);
312
+ deletedCount += keys.length;
313
+ }
314
+ } while (cursor !== "0");
315
+ return deletedCount;
316
+ }
317
+ catch (err) {
318
+ console.error("[RedisVectorAdapter] flush() failed:", err instanceof Error ? err.message : err);
319
+ return 0;
320
+ }
321
+ }
322
+ }
323
+ exports.RedisVectorAdapter = RedisVectorAdapter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-semantic-cache",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "**Cut your OpenAI / Anthropic bill. Slash latency to ~50 ms. Compute embeddings for free — locally, on every request.**",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -11,7 +11,10 @@
11
11
  "dist/index.js",
12
12
  "dist/index.d.ts",
13
13
  "dist/semantic-cache-middleware.js",
14
- "dist/semantic-cache-middleware.d.ts"
14
+ "dist/semantic-cache-middleware.d.ts",
15
+ "dist/redis-vector-adapter.js",
16
+ "dist/redis-vector-adapter.d.ts",
17
+ "README.md"
15
18
  ],
16
19
  "keywords": [],
17
20
  "author": "Prateek Chaturvedi",