longcache 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,155 @@
1
+ """
2
+ Local embedding engine for LongCache.
3
+
4
+ Uses sentence-transformers to generate embeddings locally with no API calls.
5
+ The default model (all-MiniLM-L6-v2) produces 384-dimensional vectors
6
+ in under 5ms on modern hardware.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import threading
13
+ from typing import Optional
14
+
15
+ import numpy as np
16
+
17
+ from longcache.core.base_embedder import BaseEmbedder
18
+
19
+ _log = logging.getLogger(__name__)
20
+
21
+
22
+ class LocalEmbedder(BaseEmbedder):
23
+ """
24
+ Local embedding engine using sentence-transformers.
25
+
26
+ Loads the model lazily on first use to avoid import penalties at module level.
27
+ Thread-safe — model loading is protected by a lock.
28
+
29
+ Usage:
30
+ embedder = LocalEmbedder()
31
+ vector = embedder.embed("What is your refund policy?")
32
+ # vector.shape = (384,) for MiniLM-L6-v2
33
+ """
34
+
35
+ # Class-level cache for model instances (singleton per model name)
36
+ _instances: dict[str, LocalEmbedder] = {}
37
+ _lock = threading.Lock()
38
+
39
+ def __new__(cls, model_name: str = "BAAI/bge-small-en-v1.5"):
40
+ """
41
+ Singleton pattern: one embedder instance per model name.
42
+
43
+ This avoids loading the same model multiple times in memory.
44
+
45
+ Args:
46
+ model_name: The sentence-transformers model identifier.
47
+
48
+ Returns:
49
+ The singleton LocalEmbedder instance for the given model.
50
+ """
51
+ with cls._lock:
52
+ if model_name not in cls._instances:
53
+ instance = super().__new__(cls)
54
+ instance._model_name = model_name
55
+ instance._model = None
56
+ instance._dimension = None
57
+ instance._model_lock = threading.Lock()
58
+ cls._instances[model_name] = instance
59
+ return cls._instances[model_name]
60
+
61
+ def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5"):
62
+ """
63
+ Initialize the embedder. Model is loaded lazily on first embed() call.
64
+
65
+ Args:
66
+ model_name: The sentence-transformers model identifier.
67
+ Defaults to all-MiniLM-L6-v2 (384-dim, sub-5ms).
68
+ """
69
+ # __new__ handles actual initialization; __init__ just ensures
70
+ # the model name is set (idempotent for singleton)
71
+ self._model_name = model_name
72
+
73
+ def _load_model(self):
74
+ """
75
+ Lazily load the sentence-transformers model.
76
+
77
+ This is called on the first embed() invocation, not at import time,
78
+ so importing longcache does not trigger a heavy model download.
79
+ """
80
+ if self._model is not None:
81
+ return
82
+
83
+ with self._model_lock:
84
+ if self._model is not None:
85
+ return
86
+
87
+ try:
88
+ from sentence_transformers import SentenceTransformer
89
+ except ImportError:
90
+ raise ImportError(
91
+ "sentence-transformers is required for LocalEmbedder. "
92
+ "Install with: uv pip install longcache"
93
+ )
94
+
95
+ _log.info("Loading embedding model: %s", self._model_name)
96
+ self._model = SentenceTransformer(self._model_name)
97
+ self._dimension = self._model.get_embedding_dimension()
98
+ _log.info("Embedding model loaded: dim=%d", self._dimension)
99
+
100
+ @property
101
+ def dimension(self) -> int:
102
+ """
103
+ Get the embedding dimension for this model.
104
+
105
+ Loads the model if not already loaded.
106
+
107
+ Returns:
108
+ The dimensionality of the embedding vectors.
109
+ """
110
+ self._load_model()
111
+ return self._dimension
112
+
113
+ def embed(self, text: str) -> np.ndarray:
114
+ """
115
+ Generate an embedding vector for the input text.
116
+
117
+ The model is loaded lazily on the first call. Subsequent calls
118
+ use the cached model instance.
119
+
120
+ Args:
121
+ text: The input text to embed.
122
+
123
+ Returns:
124
+ L2-normalized numpy float32 array of shape (dimension,).
125
+ Normalization ensures cosine similarity equals inner product
126
+ when using FAISS IndexFlatIP.
127
+ """
128
+ self._load_model()
129
+ vector = self._model.encode(text, normalize_embeddings=True)
130
+ return vector.astype(np.float32)
131
+
132
+ def embed_batch(self, texts: list[str]) -> np.ndarray:
133
+ """
134
+ Generate embedding vectors for multiple texts in a single batch.
135
+
136
+ More efficient than calling embed() in a loop for multiple texts
137
+ because the model can parallelize computation across the batch.
138
+
139
+ Args:
140
+ texts: List of input texts to embed.
141
+
142
+ Returns:
143
+ numpy float32 array of shape (len(texts), dimension).
144
+ Each row is L2-normalized.
145
+ """
146
+ if not texts:
147
+ return np.array([], dtype=np.float32)
148
+
149
+ self._load_model()
150
+ vectors = self._model.encode(texts, normalize_embeddings=True, batch_size=32)
151
+ return vectors.astype(np.float32)
152
+
153
+ def __repr__(self) -> str:
154
+ model_status = "loaded" if self._model is not None else "not loaded"
155
+ return f"LocalEmbedder(model={self._model_name!r}, status={model_status})"
@@ -0,0 +1,122 @@
1
+ """
2
+ Cache entry data model.
3
+
4
+ Represents a single cached query-response pair with metadata
5
+ for TTL, namespace isolation, and hit tracking.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Optional
13
+
14
+ import numpy as np
15
+
16
+
17
+ @dataclass
18
+ class CacheEntry:
19
+ """
20
+ A single cache entry storing a query, its embedding vector, and the cached response.
21
+
22
+ Attributes:
23
+ id: Unique identifier for the cache entry.
24
+ namespace: Namespace this entry belongs to (for isolation).
25
+ query: The original query text.
26
+ vector: The embedding vector for the query (numpy float32 array).
27
+ response: The cached LLM response. Can be any serializable Python object.
28
+ created_at: Unix timestamp when the entry was created.
29
+ expires_at: Unix timestamp when the entry expires. None means no expiry.
30
+ hit_count: Number of times this entry has been served from cache.
31
+ """
32
+
33
+ id: Optional[int] = None
34
+ namespace: str = "default"
35
+ query: str = ""
36
+ vector: Optional[np.ndarray] = None
37
+ response: Any = None
38
+ created_at: float = field(default_factory=time.time)
39
+ expires_at: Optional[float] = None
40
+ hit_count: int = 0
41
+ model: str = ""
42
+ system_prompt: str = ""
43
+ temperature: float = -1.0
44
+ tags: list[str] = field(default_factory=list)
45
+
46
+ def is_expired(self) -> bool:
47
+ """
48
+ Check if this cache entry has expired.
49
+
50
+ Returns:
51
+ True if the entry has expired, False otherwise.
52
+ Entries with expires_at=None never expire.
53
+ """
54
+ if self.expires_at is None:
55
+ return False
56
+ return time.time() > self.expires_at
57
+
58
+ def increment_hit(self) -> None:
59
+ """Increment the hit count for this cache entry."""
60
+ self.hit_count += 1
61
+
62
+ def to_dict(self) -> dict:
63
+ """
64
+ Serialize the cache entry to a dictionary.
65
+
66
+ The vector is converted to bytes for storage compatibility.
67
+ The response is stored as-is (should be JSON-serializable by backends).
68
+
69
+ Returns:
70
+ Dictionary representation of the cache entry.
71
+ """
72
+ return {
73
+ "id": self.id,
74
+ "namespace": self.namespace,
75
+ "query": self.query,
76
+ "vector": self.vector.tobytes() if self.vector is not None else None,
77
+ "response": self.response,
78
+ "created_at": self.created_at,
79
+ "expires_at": self.expires_at,
80
+ "hit_count": self.hit_count,
81
+ "model": self.model,
82
+ "system_prompt": self.system_prompt,
83
+ "temperature": self.temperature,
84
+ "tags": list(self.tags or []),
85
+ }
86
+
87
+ @classmethod
88
+ def from_dict(cls, data: dict, vector_dim: Optional[int] = None) -> CacheEntry:
89
+ """
90
+ Deserialize a cache entry from a dictionary.
91
+
92
+ The vector field is converted from bytes back to a numpy array
93
+ if vector_dim is provided.
94
+
95
+ Args:
96
+ data: Dictionary with cache entry fields.
97
+ vector_dim: Expected dimension of the vector array.
98
+ Required if the vector is stored as raw bytes.
99
+
100
+ Returns:
101
+ CacheEntry instance.
102
+ """
103
+ vector = data.get("vector")
104
+ if isinstance(vector, bytes) and vector_dim is not None:
105
+ vector = np.frombuffer(vector, dtype=np.float32).copy()
106
+ if vector_dim and len(vector) != vector_dim:
107
+ vector = vector.reshape(vector_dim) if vector.size == vector_dim else vector
108
+
109
+ return cls(
110
+ id=data.get("id"),
111
+ namespace=data.get("namespace", "default"),
112
+ query=data.get("query", ""),
113
+ vector=vector,
114
+ response=data.get("response"),
115
+ created_at=data.get("created_at", time.time()),
116
+ expires_at=data.get("expires_at"),
117
+ hit_count=data.get("hit_count", 0),
118
+ model=data.get("model", ""),
119
+ system_prompt=data.get("system_prompt", ""),
120
+ temperature=float(data.get("temperature", -1.0)),
121
+ tags=list(data.get("tags") or []),
122
+ )
@@ -0,0 +1,272 @@
1
+ """
2
+ Similarity index for LongCache using FAISS.
3
+
4
+ Provides fast approximate nearest neighbor search over embedding vectors.
5
+ Uses IndexFlatIP (inner product) for exact search, which works well
6
+ for caches with up to ~100K entries. For larger caches, consider
7
+ switching to an IVF index.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import threading
14
+ from typing import Optional
15
+
16
+ import numpy as np
17
+
18
+ from longcache.core.entry import CacheEntry
19
+
20
+
21
+ class SimilarityIndex:
22
+ """
23
+ FAISS-based similarity index for cache lookups.
24
+
25
+ Maintains an in-memory FAISS index alongside a mapping from
26
+ internal FAISS IDs to CacheEntry objects. All operations are
27
+ thread-safe via a reentrant lock.
28
+
29
+ Usage:
30
+ index = SimilarityIndex(backend, config)
31
+ index.add(vector, entry_id)
32
+ result = index.find_similar(query_vector, threshold=0.92)
33
+ """
34
+
35
+ def __init__(self, backend, config):
36
+ """
37
+ Initialize the similarity index.
38
+
39
+ Args:
40
+ backend: The cache backend (used for retrieving entries by ID).
41
+ config: CacheConfig instance with similarity_threshold and other settings.
42
+ """
43
+ self._backend = backend
44
+ self._config = config
45
+ self._lock = threading.RLock()
46
+ self._async_lock: Optional[asyncio.Lock] = None
47
+ self._faiss_index = None
48
+ self._id_map: dict[int, int] = {} # faiss_internal_id -> entry_id
49
+ self._next_faiss_id = 0
50
+ self._dimension = None
51
+ self._orphan_count = 0 # vectors in FAISS with no id_map entry
52
+ self._REBUILD_THRESHOLD = 0.20 # rebuild when 20% of index is orphaned
53
+ self._removed_entry_ids: set[int] = set() # entry ids explicitly removed via remove()
54
+
55
+ def _get_async_lock(self) -> asyncio.Lock:
56
+ """Lazily create asyncio.Lock on first async use."""
57
+ if self._async_lock is None:
58
+ self._async_lock = asyncio.Lock()
59
+ return self._async_lock
60
+
61
+ def _ensure_index(self, dimension: int):
62
+ """
63
+ Lazily create the FAISS index with the given dimension.
64
+
65
+ Called automatically on first add() with a vector of the
66
+ appropriate dimension.
67
+
68
+ Args:
69
+ dimension: The dimensionality of the embedding vectors.
70
+ """
71
+ if self._faiss_index is not None:
72
+ return
73
+
74
+ import faiss
75
+
76
+ index_type = getattr(self._config, "faiss_index_type", "flat")
77
+ if index_type == "hnsw":
78
+ m = getattr(self._config, "hnsw_m", 32)
79
+ ef = getattr(self._config, "hnsw_ef_search", 64)
80
+ # HNSW with inner product metric (cosine for normalized vectors)
81
+ self._faiss_index = faiss.IndexHNSWFlat(dimension, m, faiss.METRIC_INNER_PRODUCT)
82
+ self._faiss_index.hnsw.efSearch = ef
83
+ else:
84
+ # Default: exact flat index
85
+ self._faiss_index = faiss.IndexFlatIP(dimension)
86
+
87
+ self._dimension = dimension
88
+
89
+ def add(self, vector: np.ndarray, entry_id: int) -> None:
90
+ """
91
+ Add an embedding vector to the index.
92
+
93
+ Creates the FAISS index on first call, inferring dimension
94
+ from the input vector.
95
+
96
+ Args:
97
+ vector: L2-normalized embedding vector of shape (dimension,).
98
+ entry_id: The database ID of the corresponding CacheEntry.
99
+ """
100
+ with self._lock:
101
+ if self._faiss_index is None:
102
+ self._ensure_index(vector.shape[0])
103
+
104
+ # FAISS expects 2D arrays
105
+ vector_2d = vector.reshape(1, -1).astype(np.float32)
106
+ faiss_id = self._next_faiss_id
107
+ # Assign id_map BEFORE calling FAISS to keep mapping consistent
108
+ self._id_map[faiss_id] = entry_id
109
+ self._faiss_index.add(vector_2d)
110
+ self._next_faiss_id += 1
111
+
112
+ async def async_add(self, vector: np.ndarray, entry_id: int) -> None:
113
+ """Async-safe version of add() for use in async contexts."""
114
+ async with self._get_async_lock():
115
+ self.add(vector, entry_id)
116
+
117
+ def find_similar(
118
+ self,
119
+ vector: np.ndarray,
120
+ threshold: float = 0.92,
121
+ namespace: str = "default",
122
+ ) -> Optional[CacheEntry]:
123
+ """
124
+ Find the most similar cached entry for the given query vector.
125
+
126
+ Searches the FAISS index for the nearest neighbor, then verifies
127
+ the entry exists in the backend, belongs to the correct namespace,
128
+ and meets the similarity threshold.
129
+
130
+ Args:
131
+ vector: L2-normalized query embedding vector.
132
+ threshold: Minimum cosine similarity for a cache hit.
133
+ namespace: Only return entries from this namespace.
134
+
135
+ Returns:
136
+ CacheEntry if a similar entry is found above the threshold,
137
+ None otherwise.
138
+ """
139
+ with self._lock:
140
+ if self._faiss_index is None or self._faiss_index.ntotal == 0:
141
+ return None
142
+
143
+ # Search for top-k (k=1) nearest neighbors
144
+ vector_2d = vector.reshape(1, -1).astype(np.float32)
145
+ similarities, indices = self._faiss_index.search(vector_2d, 1)
146
+
147
+ if len(indices) == 0 or indices[0][0] < 0:
148
+ return None
149
+
150
+ best_similarity = float(similarities[0][0])
151
+ best_faiss_id = int(indices[0][0])
152
+
153
+ if best_similarity < threshold:
154
+ return None
155
+
156
+ # Look up the entry ID from our mapping
157
+ entry_id = self._id_map.get(best_faiss_id)
158
+ if entry_id is None:
159
+ return None
160
+
161
+ # Retrieve the full entry from the backend
162
+ entry = self._backend.retrieve(entry_id)
163
+ if entry is None:
164
+ return None
165
+
166
+ # Verify namespace
167
+ if entry.namespace != namespace:
168
+ return None
169
+
170
+ # Verify not expired
171
+ if entry.is_expired():
172
+ return None
173
+
174
+ # Update hit count
175
+ entry.increment_hit()
176
+ self._backend.update_hit_count(entry_id)
177
+
178
+ return entry
179
+
180
+ def remove(self, faiss_id: int) -> None:
181
+ """
182
+ Remove an entry from the ID map by its FAISS internal ID.
183
+
184
+ Note: FAISS IndexFlatIP does not support efficient removal.
185
+ The entry is removed from the ID map, so it won't be returned
186
+ in future searches even though the vector remains in the index.
187
+ For production use with high churn, consider rebuilding the index
188
+ periodically.
189
+
190
+ Args:
191
+ faiss_id: The internal FAISS index ID to remove.
192
+ """
193
+ with self._lock:
194
+ if faiss_id in self._id_map:
195
+ entry_id = self._id_map.pop(faiss_id)
196
+ self._removed_entry_ids.add(entry_id)
197
+ self._orphan_count += 1
198
+ self._auto_rebuild_if_needed()
199
+
200
+ def _auto_rebuild_if_needed(self) -> None:
201
+ """Rebuild FAISS index if orphan ratio exceeds threshold."""
202
+ total = self._faiss_index.ntotal if self._faiss_index else 0
203
+ if total > 0 and self._orphan_count / total >= self._REBUILD_THRESHOLD:
204
+ import logging
205
+
206
+ logging.getLogger(__name__).info(
207
+ "Auto-rebuilding FAISS index: %d orphaned vectors (%.0f%%)",
208
+ self._orphan_count,
209
+ (self._orphan_count / total) * 100,
210
+ )
211
+ entries = self._backend.list_entries(self._config.namespace)
212
+ entries = [e for e in entries if e.id not in self._removed_entry_ids]
213
+ self.rebuild(entries)
214
+ self._orphan_count = 0
215
+
216
+ def rebuild(self, entries: list[CacheEntry]) -> None:
217
+ """
218
+ Rebuild the FAISS index from a list of cache entries.
219
+
220
+ Useful for compacting the index after many removals, or for
221
+ loading the index from persistent storage on startup.
222
+
223
+ Args:
224
+ entries: List of CacheEntry objects with their vectors populated.
225
+ """
226
+ with self._lock:
227
+ import faiss
228
+
229
+ valid_entries = [
230
+ e for e in (entries or []) if e.vector is not None and e.id is not None
231
+ ]
232
+
233
+ if not valid_entries:
234
+ self._faiss_index = None
235
+ self._id_map = {}
236
+ self._next_faiss_id = 0
237
+ self._orphan_count = 0
238
+ self._removed_entry_ids.clear()
239
+ return
240
+
241
+ dimension = valid_entries[0].vector.shape[0]
242
+ self._ensure_index(dimension)
243
+
244
+ # Reset index
245
+ self._faiss_index = faiss.IndexFlatIP(dimension)
246
+ self._id_map = {}
247
+ self._next_faiss_id = 0
248
+
249
+ # Add all valid entries
250
+ vectors = []
251
+ for entry in valid_entries:
252
+ vectors.append(entry.vector)
253
+ self._id_map[self._next_faiss_id] = entry.id
254
+ self._next_faiss_id += 1
255
+
256
+ if vectors:
257
+ vectors_array = np.stack(vectors).astype(np.float32)
258
+ self._faiss_index.add(vectors_array)
259
+
260
+ self._orphan_count = 0
261
+ self._removed_entry_ids.clear()
262
+
263
+ @property
264
+ def size(self) -> int:
265
+ """Return the number of entries currently in the index."""
266
+ with self._lock:
267
+ return self._faiss_index.ntotal if self._faiss_index is not None else 0
268
+
269
+ def __repr__(self) -> str:
270
+ size = self.size
271
+ dim = self._dimension or "uninitialized"
272
+ return f"SimilarityIndex(size={size}, dimension={dim})"
@@ -0,0 +1,32 @@
1
+ """Integrations with LLM frameworks."""
2
+
3
+ from longcache.integrations.langchain import CachedLLM, CachedChain
4
+
5
+ __all__ = ["CachedLLM", "CachedChain"]
6
+
7
+
8
+ # Lazy imports for optional integrations
9
+ def get_llamaindex_wrapper():
10
+ """Import and return CachedQueryEngine. Raises ImportError if llama-index is not installed."""
11
+ try:
12
+ from longcache.integrations.llamaindex import CachedQueryEngine
13
+
14
+ return CachedQueryEngine
15
+ except ImportError:
16
+ raise ImportError(
17
+ "LlamaIndex integration requires the 'llamaindex' extra. "
18
+ "Install with: uv pip install longcache[llamaindex]"
19
+ )
20
+
21
+
22
+ def get_openai_wrapper():
23
+ """Import and return CachedOpenAI. Raises ImportError if openai is not installed."""
24
+ try:
25
+ from longcache.integrations.openai import CachedOpenAI
26
+
27
+ return CachedOpenAI
28
+ except ImportError:
29
+ raise ImportError(
30
+ "OpenAI integration requires the 'openai' extra. "
31
+ "Install with: uv pip install longcache[openai]"
32
+ )