dynavec 0.2.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.
dynavec/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """dynavec — serverless hybrid vector database on DynamoDB + Amazon S3 Vectors.
2
+
3
+ Quick start
4
+ -----------
5
+ from dynavec import Dynavec, DynavecConfig
6
+ from dynavec.embeddings import OpenAIEmbedder
7
+
8
+ cfg = DynavecConfig(
9
+ vector_bucket="my-vectors",
10
+ index="docs",
11
+ table="dynavec_docs",
12
+ dimension=1536,
13
+ auto_provision=True,
14
+ )
15
+ db = Dynavec(cfg, embedder=OpenAIEmbedder(model="text-embedding-3-small"))
16
+
17
+ db.upsert([{"id": "a", "text": "hello world", "metadata": {"lang": "en"}}])
18
+ hits = db.search("greetings", top_k=3, filter={"lang": "en"})
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from .cache import DynamoDBCache, RedisCache, SemanticCache
24
+ from .client import Dynavec
25
+ from .config import DynavecConfig
26
+ from .credentials import AWSCredentials
27
+ from .exceptions import (
28
+ ConfigurationError,
29
+ DimensionMismatchError,
30
+ DynavecError,
31
+ EmbeddingError,
32
+ MissingDependencyError,
33
+ NotFoundError,
34
+ ProvisioningError,
35
+ )
36
+ from .graph import GraphStore
37
+ from .models import Document, SearchResult, UpsertResult
38
+ from .namespace import NamespaceView
39
+ from .quantization import ProductQuantizer
40
+ from .retrieval import (
41
+ maximal_marginal_relevance,
42
+ reciprocal_rank_fusion,
43
+ )
44
+ from .transforms import LambdaTransform, TransformContext, TransformPipeline
45
+
46
+ __version__ = "0.2.0"
47
+
48
+ __all__ = [
49
+ "Dynavec",
50
+ "DynavecConfig",
51
+ "AWSCredentials",
52
+ "Document",
53
+ "SearchResult",
54
+ "UpsertResult",
55
+ "NamespaceView",
56
+ "ProductQuantizer",
57
+ "GraphStore",
58
+ "SemanticCache",
59
+ "DynamoDBCache",
60
+ "RedisCache",
61
+ "reciprocal_rank_fusion",
62
+ "maximal_marginal_relevance",
63
+ "TransformPipeline",
64
+ "TransformContext",
65
+ "LambdaTransform",
66
+ # exceptions
67
+ "DynavecError",
68
+ "ConfigurationError",
69
+ "ProvisioningError",
70
+ "EmbeddingError",
71
+ "DimensionMismatchError",
72
+ "NotFoundError",
73
+ "MissingDependencyError",
74
+ ]
dynavec/cache.py ADDED
@@ -0,0 +1,182 @@
1
+ """Query caching so repeated / similar searches skip the vector DB.
2
+
3
+ Three backends, pick per your infra:
4
+
5
+ * :class:`SemanticCache` — in-process LRU that returns a cached answer when a
6
+ new query is **cosine-similar enough** to a recent one. Zero infra, per-process.
7
+ * :class:`DynamoDBCache` — exact-match cache in your DynamoDB table with native
8
+ **TTL** expiry. No extra service ("if we can do it with DynamoDB, good").
9
+ * :class:`RedisCache` — shared, sub-millisecond cache on Redis / **AWS
10
+ ElastiCache**, great across many workers/hosts.
11
+
12
+ All expose the same ``get`` / ``put`` interface, keyed on
13
+ (namespace, query vector, top_k, filter).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import time
21
+ from abc import ABC, abstractmethod
22
+ from collections import OrderedDict
23
+
24
+ import numpy as np
25
+
26
+ from .exceptions import MissingDependencyError
27
+ from .models import SearchResult
28
+
29
+
30
+ def _signature(namespace: str, top_k: int, filter: dict | None) -> str:
31
+ payload = json.dumps(
32
+ {"ns": namespace, "k": top_k, "f": filter or {}}, sort_keys=True
33
+ )
34
+ return hashlib.sha256(payload.encode()).hexdigest()[:24]
35
+
36
+
37
+ def _vec_key(query_vector, ndigits: int = 3) -> str:
38
+ rounded = [round(float(x), ndigits) for x in query_vector]
39
+ return hashlib.sha256(json.dumps(rounded).encode()).hexdigest()[:24]
40
+
41
+
42
+ def _serialize(results: list[SearchResult]) -> str:
43
+ return json.dumps([r.to_dict() for r in results])
44
+
45
+
46
+ def _deserialize(blob: str) -> list[SearchResult]:
47
+ return [
48
+ SearchResult(
49
+ id=d["id"], score=d["score"], distance=d.get("distance"),
50
+ text=d.get("text"), metadata=d.get("metadata", {}),
51
+ )
52
+ for d in json.loads(blob)
53
+ ]
54
+
55
+
56
+ class BaseCache(ABC):
57
+ @abstractmethod
58
+ def get(self, namespace, query_vector, top_k, filter) -> list[SearchResult] | None:
59
+ ...
60
+
61
+ @abstractmethod
62
+ def put(self, namespace, query_vector, top_k, filter, results) -> None:
63
+ ...
64
+
65
+
66
+ class SemanticCache(BaseCache):
67
+ """In-process cache that also serves *near-duplicate* queries.
68
+
69
+ A new query reuses a cached result when its cosine similarity to a cached
70
+ query (with the same namespace/top_k/filter) is >= ``threshold``. This trades
71
+ a little exactness for a large latency win on paraphrases and repeats.
72
+ """
73
+
74
+ def __init__(self, threshold: float = 0.97, max_size: int = 2048) -> None:
75
+ self.threshold = threshold
76
+ self.max_size = max_size
77
+ # key: signature -> OrderedDict[vec_key -> (unit_vec, results)]
78
+ self._buckets: dict[str, OrderedDict[str, tuple]] = {}
79
+
80
+ @staticmethod
81
+ def _unit(v: np.ndarray) -> np.ndarray:
82
+ return v / (np.linalg.norm(v) + 1e-12)
83
+
84
+ def get(self, namespace, query_vector, top_k, filter):
85
+ sig = _signature(namespace, top_k, filter)
86
+ bucket = self._buckets.get(sig)
87
+ if not bucket:
88
+ return None
89
+ q = self._unit(np.asarray(query_vector, dtype=np.float32))
90
+ best_key, best_sim = None, -1.0
91
+ for vk, (vec, _res) in bucket.items():
92
+ sim = float(vec @ q)
93
+ if sim > best_sim:
94
+ best_sim, best_key = sim, vk
95
+ if best_key is not None and best_sim >= self.threshold:
96
+ vec, res = bucket.pop(best_key)
97
+ bucket[best_key] = (vec, res) # move to MRU
98
+ return res
99
+ return None
100
+
101
+ def put(self, namespace, query_vector, top_k, filter, results):
102
+ sig = _signature(namespace, top_k, filter)
103
+ bucket = self._buckets.setdefault(sig, OrderedDict())
104
+ vk = _vec_key(query_vector)
105
+ bucket[vk] = (self._unit(np.asarray(query_vector, dtype=np.float32)), results)
106
+ bucket.move_to_end(vk)
107
+ while sum(len(b) for b in self._buckets.values()) > self.max_size:
108
+ # evict the globally oldest entry
109
+ for b in self._buckets.values():
110
+ if b:
111
+ b.popitem(last=False)
112
+ break
113
+
114
+
115
+ class DynamoDBCache(BaseCache):
116
+ """Exact-match cache persisted in the dynavec table with TTL expiry.
117
+
118
+ Enable DynamoDB TTL on the ``ttl`` attribute of your table for automatic
119
+ eviction (items are also treated as expired client-side as a safety net).
120
+ """
121
+
122
+ def __init__(self, config, boto_session=None, ttl_seconds: int = 3600) -> None:
123
+ import boto3
124
+
125
+ session = boto_session or boto3.Session()
126
+ self._table = session.resource("dynamodb", region_name=config.region).Table(config.table)
127
+ self.ttl_seconds = ttl_seconds
128
+
129
+ @staticmethod
130
+ def _pk(namespace, query_vector, top_k, filter) -> str:
131
+ return f"__cache__#{_signature(namespace, top_k, filter)}#{_vec_key(query_vector)}"
132
+
133
+ def get(self, namespace, query_vector, top_k, filter):
134
+ resp = self._table.get_item(
135
+ Key={"pk": self._pk(namespace, query_vector, top_k, filter)}
136
+ )
137
+ item = resp.get("Item")
138
+ if not item:
139
+ return None
140
+ if item.get("ttl") and int(item["ttl"]) < int(time.time()):
141
+ return None # expired but not yet reaped
142
+ return _deserialize(item["results"])
143
+
144
+ def put(self, namespace, query_vector, top_k, filter, results):
145
+ self._table.put_item(
146
+ Item={
147
+ "pk": self._pk(namespace, query_vector, top_k, filter),
148
+ "kind": "querycache",
149
+ "results": _serialize(results),
150
+ "ttl": int(time.time()) + self.ttl_seconds,
151
+ }
152
+ )
153
+
154
+
155
+ class RedisCache(BaseCache):
156
+ """Shared exact-match cache on Redis / AWS ElastiCache."""
157
+
158
+ def __init__(self, url: str = "redis://localhost:6379/0", ttl_seconds: int = 3600, client=None):
159
+ if client is not None:
160
+ self._r = client
161
+ else:
162
+ try:
163
+ import redis
164
+ except ImportError as exc: # pragma: no cover
165
+ raise MissingDependencyError("RedisCache", "redis", "redis") from exc
166
+ self._r = redis.Redis.from_url(url)
167
+ self.ttl_seconds = ttl_seconds
168
+
169
+ @staticmethod
170
+ def _key(namespace, query_vector, top_k, filter) -> str:
171
+ return f"dynavec:{_signature(namespace, top_k, filter)}:{_vec_key(query_vector)}"
172
+
173
+ def get(self, namespace, query_vector, top_k, filter):
174
+ blob = self._r.get(self._key(namespace, query_vector, top_k, filter))
175
+ return _deserialize(blob) if blob else None
176
+
177
+ def put(self, namespace, query_vector, top_k, filter, results):
178
+ self._r.set(
179
+ self._key(namespace, query_vector, top_k, filter),
180
+ _serialize(results),
181
+ ex=self.ttl_seconds,
182
+ )