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 +74 -0
- dynavec/cache.py +182 -0
- dynavec/client.py +566 -0
- dynavec/config.py +84 -0
- dynavec/credentials.py +90 -0
- dynavec/embeddings/__init__.py +47 -0
- dynavec/embeddings/base.py +32 -0
- dynavec/embeddings/bedrock.py +71 -0
- dynavec/embeddings/gemini.py +56 -0
- dynavec/embeddings/openai.py +58 -0
- dynavec/embeddings/sentence_transformers.py +51 -0
- dynavec/exceptions.py +40 -0
- dynavec/graph.py +120 -0
- dynavec/ingest.py +153 -0
- dynavec/integrations/__init__.py +1 -0
- dynavec/integrations/langchain.py +143 -0
- dynavec/integrations/llamaindex.py +90 -0
- dynavec/integrations/tools.py +87 -0
- dynavec/metadata.py +99 -0
- dynavec/metrics.py +90 -0
- dynavec/models.py +62 -0
- dynavec/namespace.py +51 -0
- dynavec/provisioning.py +94 -0
- dynavec/quantization.py +140 -0
- dynavec/retrieval.py +118 -0
- dynavec/stores/__init__.py +6 -0
- dynavec/stores/dynamodb.py +108 -0
- dynavec/stores/s3vectors.py +144 -0
- dynavec/transforms.py +106 -0
- dynavec/utils.py +103 -0
- dynavec-0.2.0.dist-info/METADATA +317 -0
- dynavec-0.2.0.dist-info/RECORD +34 -0
- dynavec-0.2.0.dist-info/WHEEL +4 -0
- dynavec-0.2.0.dist-info/licenses/LICENSE +190 -0
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
|
+
)
|