embedflow 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.
- embedflow/__init__.py +25 -0
- embedflow/__main__.py +3 -0
- embedflow/analysis.py +192 -0
- embedflow/cache/__init__.py +4 -0
- embedflow/cache/base.py +28 -0
- embedflow/cache/persistent_cache.py +198 -0
- embedflow/cli.py +1200 -0
- embedflow/compatibility/__init__.py +28 -0
- embedflow/compatibility/candidate_gap.py +105 -0
- embedflow/compatibility/containment.py +17 -0
- embedflow/compatibility/evaluate.py +319 -0
- embedflow/compatibility/metrics.py +75 -0
- embedflow/compatibility/migration_depth.py +67 -0
- embedflow/compatibility/probe.py +34 -0
- embedflow/compatibility/report.py +102 -0
- embedflow/compatibility/t2.py +64 -0
- embedflow/config.py +455 -0
- embedflow/data/__init__.py +1 -0
- embedflow/data/registry/__init__.py +1 -0
- embedflow/data/registry/benchmark_profiles.jsonl +3 -0
- embedflow/data/registry/checksums.sha256 +4 -0
- embedflow/data/registry/migrations.jsonl +15 -0
- embedflow/data/registry/registry_manifest.json +16 -0
- embedflow/data/registry/research_summaries.json +55 -0
- embedflow/data/registry/schema_version.json +5 -0
- embedflow/frozen/T2_V1_FROZEN_SPEC.md +71 -0
- embedflow/frozen/T2_V1_FROZEN_SPEC.sha256 +1 -0
- embedflow/indexes/__init__.py +5 -0
- embedflow/indexes/base.py +60 -0
- embedflow/indexes/faiss_backend.py +240 -0
- embedflow/indexes/qdrant_backend.py +225 -0
- embedflow/metrics/__init__.py +3 -0
- embedflow/metrics/latency.py +50 -0
- embedflow/migration/__init__.py +3 -0
- embedflow/migration/compatibility.py +156 -0
- embedflow/migration/facade.py +312 -0
- embedflow/migration/materializer.py +190 -0
- embedflow/migration/planner.py +78 -0
- embedflow/migration/state.py +81 -0
- embedflow/models/__init__.py +4 -0
- embedflow/models/base.py +31 -0
- embedflow/models/huggingface.py +226 -0
- embedflow/registry/__init__.py +47 -0
- embedflow/registry/loader.py +785 -0
- embedflow/registry/matcher.py +197 -0
- embedflow/registry/schema.py +266 -0
- embedflow/runtime.py +115 -0
- embedflow/serving/__init__.py +3 -0
- embedflow/serving/api.py +161 -0
- embedflow/serving/engine.py +222 -0
- embedflow/serving/factory.py +3 -0
- embedflow/serving/schemas.py +39 -0
- embedflow-0.1.0.dist-info/METADATA +210 -0
- embedflow-0.1.0.dist-info/RECORD +64 -0
- embedflow-0.1.0.dist-info/WHEEL +5 -0
- embedflow-0.1.0.dist-info/entry_points.txt +2 -0
- embedflow-0.1.0.dist-info/licenses/LICENSE +178 -0
- embedflow-0.1.0.dist-info/top_level.txt +2 -0
- src/__init__.py +1 -0
- src/embed.py +123 -0
- src/probe_features.py +24 -0
- src/storage.py +51 -0
- src/t2_v1.py +21 -0
- src/utils.py +53 -0
embedflow/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""EmbedFlow: progressive embedding-model migration for existing indexes."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .config import EmbedFlowConfig, load_config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def migrate(*args, **kwargs):
|
|
9
|
+
"""Start progressive migration over an existing FAISS/Qdrant index.
|
|
10
|
+
|
|
11
|
+
Imported lazily to keep the lightweight configuration package free of
|
|
12
|
+
model-serving dependencies at import time. See ``embedflow.migration``
|
|
13
|
+
for the ``MigrationSession`` type.
|
|
14
|
+
"""
|
|
15
|
+
from .migration.facade import migrate as _migrate
|
|
16
|
+
return _migrate(*args, **kwargs)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def analyze_migration(*args, **kwargs):
|
|
20
|
+
"""Run the leakage-safe no-target-index analysis programmatically."""
|
|
21
|
+
from .analysis import analyze_migration as _analyze_migration
|
|
22
|
+
return _analyze_migration(*args, **kwargs)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = ["EmbedFlowConfig", "load_config", "migrate", "analyze_migration", "__version__"]
|
embedflow/__main__.py
ADDED
embedflow/analysis.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Programmatic Mode-B migration analysis.
|
|
2
|
+
|
|
3
|
+
The CLI is the most discoverable interface, but applications and notebooks
|
|
4
|
+
often already hold a parsed EmbedFlowConfig. This module delegates to the
|
|
5
|
+
same runtime and frozen T2-v1 implementation used by ``embedflow analyze``;
|
|
6
|
+
it does not introduce a second scoring path.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .compatibility.report import report_markdown, write_report
|
|
18
|
+
from .config import EmbedFlowConfig, from_dict, load_config, save_config
|
|
19
|
+
from .migration.compatibility import run_probe, save_probe
|
|
20
|
+
from .registry import load_evidence, match_config
|
|
21
|
+
from .runtime import load_documents, open_engine
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _query_rows(value: str | Path | Iterable[tuple[str, str]]) -> list[tuple[str, str]]:
|
|
25
|
+
if isinstance(value, (str, Path)):
|
|
26
|
+
path = Path(value)
|
|
27
|
+
if not path.exists():
|
|
28
|
+
raise FileNotFoundError(path)
|
|
29
|
+
rows: list[tuple[str, str]] = []
|
|
30
|
+
seen: set[str] = set()
|
|
31
|
+
with path.open(encoding="utf-8") as handle:
|
|
32
|
+
for number, line in enumerate(handle, 1):
|
|
33
|
+
if not line.strip():
|
|
34
|
+
continue
|
|
35
|
+
try:
|
|
36
|
+
item = json.loads(line)
|
|
37
|
+
except json.JSONDecodeError as exc:
|
|
38
|
+
raise ValueError(f"invalid query JSON at {path}:{number}") from exc
|
|
39
|
+
if not isinstance(item, Mapping):
|
|
40
|
+
raise ValueError(f"query row {number} in {path} must be a JSON object")
|
|
41
|
+
query_id = str(item.get("id", item.get("query_id", number)))
|
|
42
|
+
text = item.get("text", item.get("query"))
|
|
43
|
+
if query_id in seen:
|
|
44
|
+
raise ValueError(f"duplicate query ID {query_id!r} in {path}")
|
|
45
|
+
if not isinstance(text, str) or not text.strip():
|
|
46
|
+
raise ValueError(f"query {query_id!r} has no text")
|
|
47
|
+
seen.add(query_id); rows.append((query_id, text))
|
|
48
|
+
if not rows:
|
|
49
|
+
raise ValueError(f"query file is empty: {value}")
|
|
50
|
+
return rows
|
|
51
|
+
try:
|
|
52
|
+
rows = [(str(query_id), text) for query_id, text in value]
|
|
53
|
+
except (TypeError, ValueError) as exc:
|
|
54
|
+
raise ValueError("probe_queries must be an iterable of (query_id, text) pairs") from exc
|
|
55
|
+
if len({query_id for query_id, _ in rows}) != len(rows):
|
|
56
|
+
raise ValueError("probe_queries must not contain duplicate query IDs")
|
|
57
|
+
if not rows or any(not isinstance(text, str) or not text.strip() for _, text in rows):
|
|
58
|
+
raise ValueError("probe_queries must contain non-empty query text")
|
|
59
|
+
return rows
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _registry_values(match: Any) -> list[dict[str, Any]]:
|
|
63
|
+
"""Return only the canonical measurements explicitly reused by a match."""
|
|
64
|
+
values: list[dict[str, Any]] = []
|
|
65
|
+
for row in match.records:
|
|
66
|
+
data = row.to_dict()
|
|
67
|
+
values.append({
|
|
68
|
+
"evidence_id": row.evidence_id,
|
|
69
|
+
"dataset": data.get("dataset", {}).get("name"),
|
|
70
|
+
"candidate_gap": data.get("candidate_gap", {}),
|
|
71
|
+
"containment": data.get("containment", {}),
|
|
72
|
+
"source_quality": data.get("source_quality"),
|
|
73
|
+
"native_target_quality": data.get("native_target_quality"),
|
|
74
|
+
"restricted_target_quality": data.get("restricted_target_quality", {}),
|
|
75
|
+
"observed_migration_depth": data.get("observed_migration_depth"),
|
|
76
|
+
"ci_certified_migration_depth": data.get("ci_certified_migration_depth"),
|
|
77
|
+
"epsilon": data.get("epsilon"),
|
|
78
|
+
"provenance": data.get("provenance", {}),
|
|
79
|
+
})
|
|
80
|
+
return values
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def analyze_migration(
|
|
84
|
+
config: str | Path | EmbedFlowConfig | Mapping[str, Any],
|
|
85
|
+
*,
|
|
86
|
+
probe_queries: str | Path | Iterable[tuple[str, str]] | None = None,
|
|
87
|
+
device: str | None = None,
|
|
88
|
+
demo: bool = False,
|
|
89
|
+
corpus_name: str | None = None,
|
|
90
|
+
corpus_fingerprint: str | None = None,
|
|
91
|
+
use_registry: bool = False,
|
|
92
|
+
output_dir: str | Path | None = None,
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
"""Run leakage-safe no-target-index analysis and return a JSON report.
|
|
95
|
+
|
|
96
|
+
``use_registry`` only succeeds for an exact contract-and-corpus match. A
|
|
97
|
+
prior or related row is displayed in the returned ``registry`` field but
|
|
98
|
+
never converted into a current-corpus compatibility decision.
|
|
99
|
+
"""
|
|
100
|
+
temporary_config: Path | None = None
|
|
101
|
+
if isinstance(config, (str, Path)):
|
|
102
|
+
config_path = Path(config).expanduser().resolve()
|
|
103
|
+
cfg = load_config(config_path)
|
|
104
|
+
else:
|
|
105
|
+
cfg = config if isinstance(config, EmbedFlowConfig) else from_dict(dict(config))
|
|
106
|
+
cfg.validate()
|
|
107
|
+
# open_engine intentionally has one configuration-loading path.
|
|
108
|
+
# Serialize an in-memory config to a short-lived file so this API
|
|
109
|
+
# inherits all of its validation, model, and index checks.
|
|
110
|
+
cfg.resolve_paths(Path.cwd().resolve())
|
|
111
|
+
handle = tempfile.NamedTemporaryFile(prefix="embedflow-analysis-", suffix=".yaml", delete=False)
|
|
112
|
+
handle.close()
|
|
113
|
+
temporary_config = Path(handle.name)
|
|
114
|
+
save_config(cfg, temporary_config)
|
|
115
|
+
config_path = temporary_config
|
|
116
|
+
if probe_queries is None:
|
|
117
|
+
probe_queries = cfg.probe.queries
|
|
118
|
+
if probe_queries is None:
|
|
119
|
+
raise ValueError("probe_queries or config.probe.queries is required")
|
|
120
|
+
engine = None
|
|
121
|
+
try:
|
|
122
|
+
docs = load_documents(cfg)
|
|
123
|
+
registry = match_config(
|
|
124
|
+
cfg,
|
|
125
|
+
corpus_fingerprint=corpus_fingerprint,
|
|
126
|
+
corpus_name=corpus_name,
|
|
127
|
+
corpus_size=docs.size(),
|
|
128
|
+
records=load_evidence(),
|
|
129
|
+
)
|
|
130
|
+
if use_registry and registry.level != "EXACT REGISTRY MATCH":
|
|
131
|
+
raise ValueError("--use-registry requires an EXACT REGISTRY MATCH")
|
|
132
|
+
engine = open_engine(config_path, device=device, demo=demo, start_worker=False)
|
|
133
|
+
result = run_probe(
|
|
134
|
+
engine.source_model,
|
|
135
|
+
engine.target_model,
|
|
136
|
+
engine.source_index,
|
|
137
|
+
docs,
|
|
138
|
+
_query_rows(probe_queries),
|
|
139
|
+
kmax=cfg.probe.kmax,
|
|
140
|
+
seed=cfg.probe.seed,
|
|
141
|
+
limit=cfg.probe.limit,
|
|
142
|
+
)
|
|
143
|
+
report: dict[str, Any] = {
|
|
144
|
+
"schema_version": "0.1",
|
|
145
|
+
"source_model": cfg.source.model,
|
|
146
|
+
"target_model": cfg.target.model,
|
|
147
|
+
"corpus_documents": docs.size(),
|
|
148
|
+
"diagnostic": result["diagnostic"],
|
|
149
|
+
"recommended_initial_k": result.get("recommended_k"),
|
|
150
|
+
"observed_k_epsilon": None,
|
|
151
|
+
"epsilon": cfg.probe.epsilon,
|
|
152
|
+
"ann_status": "UNKNOWN",
|
|
153
|
+
"native_target_index_used": False,
|
|
154
|
+
"registry": registry.to_dict(),
|
|
155
|
+
"registry_reused": bool(use_registry),
|
|
156
|
+
"registry_reuse": {
|
|
157
|
+
"used": bool(use_registry),
|
|
158
|
+
"evidence_ids": [row.evidence_id for row in registry.records] if use_registry else [],
|
|
159
|
+
"reused_fields": ["candidate_gap", "containment", "native_target_quality", "source_quality", "observed_migration_depth", "ci_certified_migration_depth"] if use_registry else [],
|
|
160
|
+
"note": "Canonical values are prior measurements; the current-corpus probe remains the deployment analysis." if use_registry else "Canonical rows were displayed but not reused.",
|
|
161
|
+
},
|
|
162
|
+
"registry_reused_values": _registry_values(registry) if use_registry else [],
|
|
163
|
+
"registry_evidence": [row.to_dict() for row in registry.records],
|
|
164
|
+
"probe": result,
|
|
165
|
+
"candidate_gap_curve": [],
|
|
166
|
+
"t2_warning": result.get("warning"),
|
|
167
|
+
"recommendation": (
|
|
168
|
+
"Progressive migration is a reasonable candidate for further deployment validation."
|
|
169
|
+
if str(result.get("diagnostic", "")).upper() == "SAFE"
|
|
170
|
+
else "Further validation is required before relying on progressive migration."
|
|
171
|
+
),
|
|
172
|
+
"limitations": [
|
|
173
|
+
"No native target index or qrels were used; this is Mode B deployment analysis.",
|
|
174
|
+
"Recommended initial K is not observed K*.",
|
|
175
|
+
"ANN fidelity is UNKNOWN until an exact/reference source comparison is supplied.",
|
|
176
|
+
],
|
|
177
|
+
}
|
|
178
|
+
if output_dir is not None:
|
|
179
|
+
destination = Path(output_dir).expanduser().resolve()
|
|
180
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
save_probe(result, destination / "probe_result.json")
|
|
182
|
+
write_report(destination / "migration_report.json", report)
|
|
183
|
+
(destination / "report.md").write_text(report_markdown(report), encoding="utf-8")
|
|
184
|
+
return report
|
|
185
|
+
finally:
|
|
186
|
+
if engine is not None:
|
|
187
|
+
engine.close()
|
|
188
|
+
if temporary_config is not None:
|
|
189
|
+
temporary_config.unlink(missing_ok=True)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
__all__ = ["analyze_migration"]
|
embedflow/cache/base.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TargetVectorCache(ABC):
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def get(self, document_ids: list[str]) -> dict[str, np.ndarray]:
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def put(self, document_ids: list[str], vectors: np.ndarray) -> None:
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def contains(self, document_ids: list[str]) -> set[str]:
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def stats(self) -> dict[str, Any]:
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def close(self) -> None:
|
|
28
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .base import TargetVectorCache
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CacheCorruptionError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SQLiteVectorCache(TargetVectorCache):
|
|
20
|
+
"""Persistent target-vector cache keyed by document and model fingerprint."""
|
|
21
|
+
|
|
22
|
+
# SQLite builds differ in their maximum number of bound variables. Keep
|
|
23
|
+
# lookups below the conservative default so prewarming large corpora works
|
|
24
|
+
# on older and embedded SQLite versions as well as on modern builds.
|
|
25
|
+
_LOOKUP_BATCH_SIZE = 900
|
|
26
|
+
|
|
27
|
+
def __init__(self, path: str | Path, model_fingerprint: str, dimension: int,
|
|
28
|
+
dtype: str = "float32"):
|
|
29
|
+
p = Path(path)
|
|
30
|
+
if p.suffix in {".sqlite", ".sqlite3", ".db"}:
|
|
31
|
+
self.db_path = p
|
|
32
|
+
self.root = p.parent
|
|
33
|
+
else:
|
|
34
|
+
self.root = p
|
|
35
|
+
self.db_path = p / "cache.sqlite3"
|
|
36
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self.model_fingerprint = str(model_fingerprint).strip()
|
|
38
|
+
if not self.model_fingerprint:
|
|
39
|
+
raise ValueError("model_fingerprint must be non-empty")
|
|
40
|
+
try:
|
|
41
|
+
parsed_dimension = int(dimension)
|
|
42
|
+
exact = float(dimension) == parsed_dimension
|
|
43
|
+
except (TypeError, ValueError, OverflowError):
|
|
44
|
+
parsed_dimension, exact = 0, False
|
|
45
|
+
if isinstance(dimension, bool) or not exact or parsed_dimension < 1:
|
|
46
|
+
raise ValueError("cache dimension must be a positive integer")
|
|
47
|
+
self.dimension = parsed_dimension
|
|
48
|
+
try:
|
|
49
|
+
self.dtype = np.dtype(dtype)
|
|
50
|
+
except TypeError as exc:
|
|
51
|
+
raise ValueError(f"unsupported cache dtype: {dtype!r}") from exc
|
|
52
|
+
if self.dtype.kind not in {"f", "i", "u"}:
|
|
53
|
+
raise ValueError("cache dtype must be a numeric vector dtype")
|
|
54
|
+
self._lock = threading.RLock()
|
|
55
|
+
self._db = None
|
|
56
|
+
try:
|
|
57
|
+
self._db = sqlite3.connect(str(self.db_path), check_same_thread=False, timeout=30)
|
|
58
|
+
self._db.execute("PRAGMA busy_timeout=30000")
|
|
59
|
+
self._db.execute("PRAGMA journal_mode=WAL")
|
|
60
|
+
self._db.execute("PRAGMA synchronous=NORMAL")
|
|
61
|
+
self._db.execute("""CREATE TABLE IF NOT EXISTS target_vectors (
|
|
62
|
+
document_id TEXT NOT NULL,
|
|
63
|
+
model_fingerprint TEXT NOT NULL,
|
|
64
|
+
dimension INTEGER NOT NULL,
|
|
65
|
+
dtype TEXT NOT NULL,
|
|
66
|
+
vector BLOB NOT NULL,
|
|
67
|
+
checksum TEXT NOT NULL,
|
|
68
|
+
created_at REAL NOT NULL,
|
|
69
|
+
accessed_at REAL NOT NULL,
|
|
70
|
+
PRIMARY KEY(document_id, model_fingerprint)
|
|
71
|
+
)""")
|
|
72
|
+
self._db.execute("CREATE INDEX IF NOT EXISTS idx_target_vectors_model ON target_vectors(model_fingerprint)")
|
|
73
|
+
self._db.execute("""CREATE TABLE IF NOT EXISTS cache_counters (
|
|
74
|
+
model_fingerprint TEXT PRIMARY KEY,
|
|
75
|
+
hits INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
misses INTEGER NOT NULL DEFAULT 0
|
|
77
|
+
)""")
|
|
78
|
+
self._db.execute("""CREATE TABLE IF NOT EXISTS cache_access_events (
|
|
79
|
+
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
80
|
+
model_fingerprint TEXT NOT NULL,
|
|
81
|
+
hit INTEGER NOT NULL,
|
|
82
|
+
accessed_at REAL NOT NULL
|
|
83
|
+
)""")
|
|
84
|
+
self._db.execute("CREATE INDEX IF NOT EXISTS idx_cache_events_model ON cache_access_events(model_fingerprint, event_id)")
|
|
85
|
+
self._db.execute("INSERT OR IGNORE INTO cache_counters(model_fingerprint) VALUES (?)", (self.model_fingerprint,))
|
|
86
|
+
self._db.commit()
|
|
87
|
+
except sqlite3.DatabaseError as exc:
|
|
88
|
+
if self._db is not None:
|
|
89
|
+
self._db.close()
|
|
90
|
+
self._db = None
|
|
91
|
+
raise CacheCorruptionError(f"invalid SQLite target-vector cache: {self.db_path}") from exc
|
|
92
|
+
self._closed = False
|
|
93
|
+
|
|
94
|
+
def _ensure_open(self) -> None:
|
|
95
|
+
if self._closed or self._db is None:
|
|
96
|
+
raise RuntimeError("target vector cache is closed")
|
|
97
|
+
|
|
98
|
+
def _decode(self, row: tuple[Any, ...]) -> np.ndarray:
|
|
99
|
+
document_id, _, dimension, dtype, blob, checksum, *_ = row
|
|
100
|
+
if int(dimension) != self.dimension or np.dtype(dtype) != self.dtype:
|
|
101
|
+
raise CacheCorruptionError(f"cache contract mismatch for document {document_id}")
|
|
102
|
+
if hashlib.sha256(blob).hexdigest() != checksum:
|
|
103
|
+
raise CacheCorruptionError(f"cache checksum mismatch for document {document_id}")
|
|
104
|
+
value = np.frombuffer(blob, dtype=self.dtype).copy()
|
|
105
|
+
if value.size != self.dimension or not np.isfinite(value).all():
|
|
106
|
+
raise CacheCorruptionError(f"invalid cached vector for document {document_id}")
|
|
107
|
+
return value.astype("float32", copy=False)
|
|
108
|
+
|
|
109
|
+
def get(self, document_ids: list[str]) -> dict[str, np.ndarray]:
|
|
110
|
+
ids = [str(x) for x in document_ids]
|
|
111
|
+
if not ids: return {}
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._ensure_open()
|
|
114
|
+
rows: list[tuple[Any, ...]] = []
|
|
115
|
+
for start in range(0, len(ids), self._LOOKUP_BATCH_SIZE):
|
|
116
|
+
chunk = ids[start:start + self._LOOKUP_BATCH_SIZE]
|
|
117
|
+
placeholders = ",".join("?" for _ in chunk)
|
|
118
|
+
rows.extend(self._db.execute(
|
|
119
|
+
f"SELECT document_id,model_fingerprint,dimension,dtype,vector,checksum,created_at,accessed_at "
|
|
120
|
+
f"FROM target_vectors WHERE model_fingerprint=? AND document_id IN ({placeholders})",
|
|
121
|
+
[self.model_fingerprint, *chunk]).fetchall())
|
|
122
|
+
now = time.time()
|
|
123
|
+
values = {str(row[0]): self._decode(row) for row in rows}
|
|
124
|
+
if rows:
|
|
125
|
+
self._db.executemany("UPDATE target_vectors SET accessed_at=? WHERE document_id=? AND model_fingerprint=?",
|
|
126
|
+
[(now, str(row[0]), self.model_fingerprint) for row in rows])
|
|
127
|
+
# Count accesses, rather than unique rows, so the hit-rate remains
|
|
128
|
+
# meaningful when a caller supplies duplicate IDs.
|
|
129
|
+
hits = sum(document_id in values for document_id in ids)
|
|
130
|
+
misses = len(ids) - hits
|
|
131
|
+
self._db.execute("UPDATE cache_counters SET hits=hits+?,misses=misses+? WHERE model_fingerprint=?",
|
|
132
|
+
(hits, misses, self.model_fingerprint))
|
|
133
|
+
self._db.executemany("INSERT INTO cache_access_events(model_fingerprint,hit,accessed_at) VALUES (?,?,?)",
|
|
134
|
+
[(self.model_fingerprint, int(document_id in values), now) for document_id in ids])
|
|
135
|
+
# Keep the persistent telemetry table bounded while retaining a
|
|
136
|
+
# useful recent-hit-rate window for status and dashboards.
|
|
137
|
+
self._db.execute("""DELETE FROM cache_access_events
|
|
138
|
+
WHERE model_fingerprint=? AND event_id NOT IN
|
|
139
|
+
(SELECT event_id FROM cache_access_events WHERE model_fingerprint=? ORDER BY event_id DESC LIMIT 1000)""",
|
|
140
|
+
(self.model_fingerprint, self.model_fingerprint))
|
|
141
|
+
self._db.commit()
|
|
142
|
+
return values
|
|
143
|
+
|
|
144
|
+
def put(self, document_ids: list[str], vectors: np.ndarray) -> None:
|
|
145
|
+
ids = [str(x) for x in document_ids]
|
|
146
|
+
values = np.asarray(vectors, dtype=self.dtype)
|
|
147
|
+
if values.ndim != 2 or values.shape != (len(ids), self.dimension):
|
|
148
|
+
raise ValueError(f"vectors must have shape ({len(ids)}, {self.dimension})")
|
|
149
|
+
if len(set(ids)) != len(ids):
|
|
150
|
+
raise ValueError("duplicate IDs in cache write")
|
|
151
|
+
if not np.isfinite(values).all():
|
|
152
|
+
raise ValueError("cannot cache non-finite vectors")
|
|
153
|
+
now = time.time(); records = []
|
|
154
|
+
for document_id, value in zip(ids, values):
|
|
155
|
+
blob = np.ascontiguousarray(value).tobytes()
|
|
156
|
+
records.append((document_id, self.model_fingerprint, self.dimension, self.dtype.name, sqlite3.Binary(blob),
|
|
157
|
+
hashlib.sha256(blob).hexdigest(), now, now))
|
|
158
|
+
with self._lock:
|
|
159
|
+
self._ensure_open()
|
|
160
|
+
self._db.executemany("""INSERT INTO target_vectors
|
|
161
|
+
(document_id,model_fingerprint,dimension,dtype,vector,checksum,created_at,accessed_at)
|
|
162
|
+
VALUES (?,?,?,?,?,?,?,?)
|
|
163
|
+
ON CONFLICT(document_id,model_fingerprint) DO UPDATE SET
|
|
164
|
+
dimension=excluded.dimension,dtype=excluded.dtype,vector=excluded.vector,
|
|
165
|
+
checksum=excluded.checksum,accessed_at=excluded.accessed_at""", records)
|
|
166
|
+
self._db.commit()
|
|
167
|
+
|
|
168
|
+
def contains(self, document_ids: list[str]) -> set[str]:
|
|
169
|
+
return set(self.get(document_ids))
|
|
170
|
+
|
|
171
|
+
def stats(self) -> dict[str, Any]:
|
|
172
|
+
with self._lock:
|
|
173
|
+
self._ensure_open()
|
|
174
|
+
count = self._db.execute("SELECT COUNT(*) FROM target_vectors WHERE model_fingerprint=?", (self.model_fingerprint,)).fetchone()[0]
|
|
175
|
+
total = self._db.execute("SELECT COUNT(*) FROM target_vectors").fetchone()[0]
|
|
176
|
+
counters = self._db.execute("SELECT hits,misses FROM cache_counters WHERE model_fingerprint=?",
|
|
177
|
+
(self.model_fingerprint,)).fetchone() or (0, 0)
|
|
178
|
+
recent = self._db.execute("""SELECT SUM(hit),COUNT(*) FROM cache_access_events
|
|
179
|
+
WHERE model_fingerprint=? AND event_id IN
|
|
180
|
+
(SELECT event_id FROM cache_access_events WHERE model_fingerprint=? ORDER BY event_id DESC LIMIT 100)""",
|
|
181
|
+
(self.model_fingerprint, self.model_fingerprint)).fetchone()
|
|
182
|
+
total_hits, total_misses = int(counters[0]), int(counters[1])
|
|
183
|
+
recent_hits, recent_count = int(recent[0] or 0), int(recent[1] or 0)
|
|
184
|
+
return {"cached_target_vectors": int(count), "all_model_vectors": int(total),
|
|
185
|
+
"model_fingerprint": self.model_fingerprint, "dimension": self.dimension,
|
|
186
|
+
"cache_path": str(self.db_path), "total_cache_hits": total_hits,
|
|
187
|
+
"total_cache_misses": total_misses,
|
|
188
|
+
"recent_hit_rate": (recent_hits / recent_count) if recent_count else 0.0}
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
with self._lock:
|
|
192
|
+
if not self._closed:
|
|
193
|
+
self._db.close()
|
|
194
|
+
self._db = None
|
|
195
|
+
self._closed = True
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
__all__ = ["SQLiteVectorCache", "CacheCorruptionError"]
|