isotrieve 0.2.1__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.
- isotrieve/__init__.py +22 -0
- isotrieve/adapters/__init__.py +6 -0
- isotrieve/adapters/base.py +127 -0
- isotrieve/adapters/chroma.py +281 -0
- isotrieve/adapters/langchain.py +117 -0
- isotrieve/adapters/llamaindex_store.py +208 -0
- isotrieve/adapters/pinecone.py +180 -0
- isotrieve/adapters/qdrant.py +173 -0
- isotrieve/calibration/__init__.py +17 -0
- isotrieve/calibration/calib_v1.py +150 -0
- isotrieve/calibration/corpus.py +81 -0
- isotrieve/calibration/planner.py +87 -0
- isotrieve/cli.py +323 -0
- isotrieve/cli_doctor.py +183 -0
- isotrieve/cli_gate.py +205 -0
- isotrieve/cli_report.py +52 -0
- isotrieve/mapping/__init__.py +29 -0
- isotrieve/mapping/base.py +331 -0
- isotrieve/mapping/linear.py +547 -0
- isotrieve/mapping/mlp.py +316 -0
- isotrieve/mapping/registry.py +165 -0
- isotrieve/migrate.py +185 -0
- isotrieve/providers/__init__.py +17 -0
- isotrieve/providers/base.py +33 -0
- isotrieve/providers/cached.py +121 -0
- isotrieve/providers/cohere.py +68 -0
- isotrieve/providers/factory.py +92 -0
- isotrieve/providers/gemini.py +64 -0
- isotrieve/providers/openai.py +84 -0
- isotrieve/providers/sentence_transformers.py +52 -0
- isotrieve/providers/voyage.py +61 -0
- isotrieve/py.typed +0 -0
- isotrieve/quality/__init__.py +19 -0
- isotrieve/quality/gate.py +283 -0
- isotrieve/quality/gate_model_v1.json +52 -0
- isotrieve/quality/metrics.py +198 -0
- isotrieve/quality/thresholds.json +11 -0
- isotrieve/quality/thresholds.yaml +16 -0
- isotrieve/recalibration.py +238 -0
- isotrieve/reporting/__init__.py +1 -0
- isotrieve/reporting/html_report.py +62 -0
- isotrieve/reranking.py +217 -0
- isotrieve/serve.py +208 -0
- isotrieve/stores/__init__.py +6 -0
- isotrieve/stores/base.py +41 -0
- isotrieve/stores/numpy_files.py +168 -0
- isotrieve/stores/qdrant_store.py +181 -0
- isotrieve/wrappers/__init__.py +31 -0
- isotrieve/wrappers/llamaindex.py +161 -0
- isotrieve/wrappers/openai_shim.py +118 -0
- isotrieve/wrappers/telemetry.py +79 -0
- isotrieve-0.2.1.dist-info/METADATA +283 -0
- isotrieve-0.2.1.dist-info/RECORD +56 -0
- isotrieve-0.2.1.dist-info/WHEEL +4 -0
- isotrieve-0.2.1.dist-info/entry_points.txt +2 -0
- isotrieve-0.2.1.dist-info/licenses/LICENSE +17 -0
isotrieve/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Isotrieve — Embedding migration without re-embedding.
|
|
2
|
+
|
|
3
|
+
Learn a linear (or optional shallow non-linear) mapping between source and
|
|
4
|
+
target embedding spaces from a small calibration sample, then transform stored
|
|
5
|
+
vectors in place instead of re-embedding an entire corpus.
|
|
6
|
+
|
|
7
|
+
This package does **not** claim algorithmic novelty. It productizes known
|
|
8
|
+
linear-mapping techniques with a quality gate, providers, store adapters, and
|
|
9
|
+
a reproducible benchmark harness. See README "Prior Art & Research Basis".
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from isotrieve.mapping.base import Mapping, ValidationReport
|
|
13
|
+
from isotrieve.mapping.linear import OrthogonalProcrustesMapping, RidgeMapping
|
|
14
|
+
|
|
15
|
+
__version__ = "0.2.1"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Mapping",
|
|
18
|
+
"RidgeMapping",
|
|
19
|
+
"OrthogonalProcrustesMapping",
|
|
20
|
+
"ValidationReport",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Core adapter abstractions.
|
|
2
|
+
|
|
3
|
+
``EmbeddingAdapter`` wraps any embedding model.
|
|
4
|
+
``VectorStoreAdapter`` wraps any vector DB with serve + offline modes.
|
|
5
|
+
``MigrationReport`` captures what happened during offline migration.
|
|
6
|
+
|
|
7
|
+
All mapping/gate/recalibration logic is imported from isotrieve core;
|
|
8
|
+
DB-specific code stays thin.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from isotrieve.mapping.base import Mapping, l2_normalize
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class MigrationReport:
|
|
24
|
+
"""Result of an offline migration pass."""
|
|
25
|
+
|
|
26
|
+
rows_processed: int = 0
|
|
27
|
+
elapsed_seconds: float = 0.0
|
|
28
|
+
sampled_recall_at_10: float | None = None
|
|
29
|
+
mapping_checksum: str = ""
|
|
30
|
+
source_collection: str = ""
|
|
31
|
+
target_collection: str = ""
|
|
32
|
+
errors: list[str] = field(default_factory=list)
|
|
33
|
+
idempotent: bool = True # False if double-migration detected
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"rows_processed": self.rows_processed,
|
|
38
|
+
"elapsed_seconds": self.elapsed_seconds,
|
|
39
|
+
"sampled_recall_at_10": self.sampled_recall_at_10,
|
|
40
|
+
"mapping_checksum": self.mapping_checksum,
|
|
41
|
+
"source_collection": self.source_collection,
|
|
42
|
+
"target_collection": self.target_collection,
|
|
43
|
+
"errors": self.errors,
|
|
44
|
+
"idempotent": self.idempotent,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EmbeddingAdapter(ABC):
|
|
49
|
+
"""Wraps any embedding model; source of truth for model identity."""
|
|
50
|
+
|
|
51
|
+
model_id: str
|
|
52
|
+
dim: int
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def embed(self, texts: list[str]) -> np.ndarray:
|
|
56
|
+
"""Embed a list of texts. Returns (n, dim) array."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class VectorStoreAdapter(ABC):
|
|
60
|
+
"""Thin per-DB shell. All mapping logic lives in isotrieve core.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
mapping:
|
|
65
|
+
A fitted Isotrieve Mapping (with inverse + optional recalibrator).
|
|
66
|
+
mode:
|
|
67
|
+
``"serve"`` = transform queries on-the-fly, corpus untouched.
|
|
68
|
+
``"migrated"`` = corpus already transformed, no query mapping needed.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self, mapping: Mapping, mode: Literal["serve", "migrated"] = "serve"
|
|
73
|
+
) -> None:
|
|
74
|
+
self._mapping = mapping
|
|
75
|
+
self._mode = mode
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def has_recalibrator(self) -> bool:
|
|
79
|
+
return self._mapping.has_recalibrator
|
|
80
|
+
|
|
81
|
+
def recalibrate_scores(self, scores: np.ndarray) -> np.ndarray:
|
|
82
|
+
"""Apply score recalibration (WS-A) to raw similarity scores."""
|
|
83
|
+
return self._mapping.recalibrate_scores(scores)
|
|
84
|
+
|
|
85
|
+
def preflight(self, sample_vectors: np.ndarray, target_vectors: np.ndarray) -> Any:
|
|
86
|
+
"""Run quality gate on a sample before serve/migrate.
|
|
87
|
+
|
|
88
|
+
Returns a GateReport. Caller should check verdict before proceeding.
|
|
89
|
+
"""
|
|
90
|
+
from isotrieve.quality.gate import QualityGate
|
|
91
|
+
|
|
92
|
+
gate = QualityGate()
|
|
93
|
+
return gate.evaluate(self._mapping, sample_vectors, target_vectors)
|
|
94
|
+
|
|
95
|
+
def _map_query(self, vec: np.ndarray) -> np.ndarray:
|
|
96
|
+
"""Map a single query vector from new-model space to legacy space."""
|
|
97
|
+
return l2_normalize(self._mapping.inverse_transform(vec.reshape(1, -1)).ravel())
|
|
98
|
+
|
|
99
|
+
def _map_queries(self, vecs: np.ndarray) -> np.ndarray:
|
|
100
|
+
"""Batch map query vectors."""
|
|
101
|
+
return l2_normalize(self._mapping.inverse_transform(vecs))
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
def query(
|
|
105
|
+
self,
|
|
106
|
+
query_vectors: np.ndarray,
|
|
107
|
+
k: int = 10,
|
|
108
|
+
**kwargs: Any,
|
|
109
|
+
) -> list[list[dict[str, Any]]]:
|
|
110
|
+
"""Serve-mode query: map queries, search, return results.
|
|
111
|
+
|
|
112
|
+
Returns a list (per query) of lists of result dicts with keys
|
|
113
|
+
``id``, ``score``, ``metadata``.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
def migrate(
|
|
118
|
+
self,
|
|
119
|
+
batch_size: int = 1000,
|
|
120
|
+
dry_run: bool = False,
|
|
121
|
+
new_collection: str | None = None,
|
|
122
|
+
) -> MigrationReport:
|
|
123
|
+
"""Offline migration: transform corpus vectors and write to new collection.
|
|
124
|
+
|
|
125
|
+
Never modifies the source collection. If ``new_collection`` is None,
|
|
126
|
+
appends ``_migrated`` to the source name.
|
|
127
|
+
"""
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""ChromaDB adapter for Isotrieve.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
- **Serve mode**: ``IsotrieveChromaFunction`` — a Chroma ``EmbeddingFunction`` that
|
|
5
|
+
transparently maps new-model queries into legacy space before searching.
|
|
6
|
+
- **Offline migration**: ``migrate_collection()`` — transforms stored vectors
|
|
7
|
+
into the new model's space and writes to a new collection.
|
|
8
|
+
|
|
9
|
+
Requires: ``pip install isotrieve[chroma]`` or ``pip install chromadb``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from isotrieve.adapters.base import MigrationReport
|
|
21
|
+
from isotrieve.mapping.base import Mapping, l2_normalize
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _require_chroma():
|
|
25
|
+
try:
|
|
26
|
+
import chromadb
|
|
27
|
+
from chromadb import Documents, EmbeddingFunction, Embeddings
|
|
28
|
+
|
|
29
|
+
return chromadb, Documents, EmbeddingFunction, Embeddings
|
|
30
|
+
except ImportError:
|
|
31
|
+
raise ImportError(
|
|
32
|
+
"ChromaDB adapter requires chromadb. Install with: pip install chromadb"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class IsotrieveChromaFunction:
|
|
37
|
+
"""Chroma ``EmbeddingFunction`` that applies an Isotrieve mapping.
|
|
38
|
+
|
|
39
|
+
In serve mode, ``__call__`` embeds with the new model then maps into
|
|
40
|
+
legacy space, so the legacy index is searched without modification.
|
|
41
|
+
|
|
42
|
+
Usage::
|
|
43
|
+
|
|
44
|
+
from isotrieve.adapters.chroma import IsotrieveChromaFunction
|
|
45
|
+
from isotrieve.mapping.base import Mapping
|
|
46
|
+
|
|
47
|
+
mapping = Mapping.load("ada002_to_te3.isotrieve")
|
|
48
|
+
ef = IsotrieveChromaFunction(mapping, new_model_embedder=my_embed_fn)
|
|
49
|
+
col = client.get_collection("docs", embedding_function=ef)
|
|
50
|
+
results = col.query(query_texts=["..."], n_results=10)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
mapping: Mapping,
|
|
56
|
+
new_model_embedder: Any = None,
|
|
57
|
+
*,
|
|
58
|
+
recalibrate: bool = True,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
mapping:
|
|
64
|
+
Fitted Isotrieve mapping with inverse direction.
|
|
65
|
+
new_model_embedder:
|
|
66
|
+
Callable that takes ``list[str]`` and returns ``np.ndarray``
|
|
67
|
+
of shape ``(n, d_new)``. If None, the EF embeds with the
|
|
68
|
+
new model (caller must provide this).
|
|
69
|
+
recalibrate:
|
|
70
|
+
If True and the mapping has a recalibrator, post-process
|
|
71
|
+
query scores.
|
|
72
|
+
"""
|
|
73
|
+
self._mapping = mapping
|
|
74
|
+
self._embedder = new_model_embedder
|
|
75
|
+
self._recalibrate = recalibrate
|
|
76
|
+
|
|
77
|
+
def __call__(self, input: list[str]) -> list[list[float]]:
|
|
78
|
+
"""Embed texts into legacy space (for querying legacy index)."""
|
|
79
|
+
if self._embedder is None:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
"No new_model_embedder provided. Pass a callable that "
|
|
82
|
+
"embeds text with the new model."
|
|
83
|
+
)
|
|
84
|
+
# Embed with new model
|
|
85
|
+
new_vecs = np.asarray(self._embedder(input), dtype=np.float64)
|
|
86
|
+
# Map to legacy space
|
|
87
|
+
legacy_vecs = l2_normalize(self._mapping.inverse_transform(new_vecs))
|
|
88
|
+
return legacy_vecs.tolist()
|
|
89
|
+
|
|
90
|
+
def embed_query(self, input: str) -> list[float]:
|
|
91
|
+
"""Embed a single query into legacy space."""
|
|
92
|
+
return self.__call__([input])[0]
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def has_recalibrator(self) -> bool:
|
|
96
|
+
return self._mapping.has_recalibrator
|
|
97
|
+
|
|
98
|
+
def default_space(self) -> str:
|
|
99
|
+
return "cosine"
|
|
100
|
+
|
|
101
|
+
def name(self) -> str:
|
|
102
|
+
return "isotrieve_chroma"
|
|
103
|
+
|
|
104
|
+
def get_config(self) -> dict[str, Any]:
|
|
105
|
+
return {
|
|
106
|
+
"mapping_type": self._mapping.mapping_type,
|
|
107
|
+
"d_src": self._mapping._d_src,
|
|
108
|
+
"d_tgt": self._mapping._d_tgt,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _hash_mapping(mapping: Mapping) -> str:
|
|
113
|
+
"""Short checksum of the mapping matrix for metadata."""
|
|
114
|
+
if mapping._W is None:
|
|
115
|
+
return "unfitted"
|
|
116
|
+
h = hashlib.sha256(mapping._W.tobytes()).hexdigest()[:12]
|
|
117
|
+
return h
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def migrate_collection(
|
|
121
|
+
client: Any,
|
|
122
|
+
collection_name: str,
|
|
123
|
+
mapping: Mapping,
|
|
124
|
+
*,
|
|
125
|
+
new_collection: str | None = None,
|
|
126
|
+
batch_size: int = 1000,
|
|
127
|
+
dry_run: bool = False,
|
|
128
|
+
new_model_embedder: Any = None,
|
|
129
|
+
) -> MigrationReport:
|
|
130
|
+
"""Migrate a Chroma collection from legacy to new model space.
|
|
131
|
+
|
|
132
|
+
Reads all vectors from ``collection_name``, applies the forward
|
|
133
|
+
mapping (legacy → new), and writes to ``new_collection``.
|
|
134
|
+
Source collection is never modified.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
client:
|
|
139
|
+
``chromadb.Client`` instance.
|
|
140
|
+
collection_name:
|
|
141
|
+
Source collection (legacy embeddings).
|
|
142
|
+
mapping:
|
|
143
|
+
Fitted Isotrieve mapping (forward direction: legacy → new).
|
|
144
|
+
new_collection:
|
|
145
|
+
Target name. Default: ``{collection_name}_migrated``.
|
|
146
|
+
batch_size:
|
|
147
|
+
Rows per read/write batch.
|
|
148
|
+
dry_run:
|
|
149
|
+
If True, read and transform but don't write.
|
|
150
|
+
new_model_embedder:
|
|
151
|
+
If provided, re-embed texts with new model instead of mapping
|
|
152
|
+
(for comparison / ground-truth baseline).
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
MigrationReport with rows processed, timing, and sampled recall.
|
|
157
|
+
"""
|
|
158
|
+
chromadb, _, _, _ = _require_chroma()
|
|
159
|
+
|
|
160
|
+
t0 = time.perf_counter()
|
|
161
|
+
target_name = new_collection or f"{collection_name}_migrated"
|
|
162
|
+
report = MigrationReport(
|
|
163
|
+
source_collection=collection_name,
|
|
164
|
+
target_collection=target_name,
|
|
165
|
+
mapping_checksum=_hash_mapping(mapping),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Open source collection
|
|
169
|
+
src = client.get_collection(collection_name)
|
|
170
|
+
total = src.count()
|
|
171
|
+
|
|
172
|
+
if dry_run:
|
|
173
|
+
# Just read a batch and report dimensions
|
|
174
|
+
sample = src.get(
|
|
175
|
+
limit=min(batch_size, total),
|
|
176
|
+
include=["embeddings", "metadatas", "documents"],
|
|
177
|
+
)
|
|
178
|
+
if sample["embeddings"]:
|
|
179
|
+
dims = len(sample["embeddings"][0])
|
|
180
|
+
report.rows_processed = len(sample["embeddings"])
|
|
181
|
+
report.elapsed_seconds = time.perf_counter() - t0
|
|
182
|
+
report.errors.append(
|
|
183
|
+
f"DRY RUN: would migrate {total} vectors of dim {dims}"
|
|
184
|
+
)
|
|
185
|
+
return report
|
|
186
|
+
|
|
187
|
+
# Check for double-migration: does the source already have isotrieve metadata?
|
|
188
|
+
sample_meta = src.get(limit=1, include=["metadatas"])
|
|
189
|
+
if sample_meta["metadatas"] and "isotrieve_mapping_id" in (
|
|
190
|
+
sample_meta["metadatas"][0] or {}
|
|
191
|
+
):
|
|
192
|
+
report.idempotent = False
|
|
193
|
+
report.errors.append(
|
|
194
|
+
"Source collection already has isotrieve_mapping_id metadata. "
|
|
195
|
+
"This may be a double migration. Proceeding anyway."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Create target collection with correct dimensions
|
|
199
|
+
# Read first batch to determine dims
|
|
200
|
+
first_batch = src.get(
|
|
201
|
+
limit=batch_size, include=["embeddings", "metadatas", "documents"]
|
|
202
|
+
)
|
|
203
|
+
if not first_batch["embeddings"]:
|
|
204
|
+
report.errors.append("Source collection is empty")
|
|
205
|
+
return report
|
|
206
|
+
|
|
207
|
+
# Create target
|
|
208
|
+
try:
|
|
209
|
+
client.delete_collection(target_name)
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
target = client.create_collection(
|
|
213
|
+
name=target_name,
|
|
214
|
+
metadata={"hnsw:space": "cosine"},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Process in batches
|
|
218
|
+
offset = 0
|
|
219
|
+
rows_written = 0
|
|
220
|
+
sampled_correct = 0
|
|
221
|
+
sampled_total = 0
|
|
222
|
+
|
|
223
|
+
while offset < total:
|
|
224
|
+
batch = src.get(
|
|
225
|
+
offset=offset,
|
|
226
|
+
limit=batch_size,
|
|
227
|
+
include=["embeddings", "metadatas", "documents", "ids"],
|
|
228
|
+
)
|
|
229
|
+
if not batch["ids"]:
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
ids = batch["ids"]
|
|
233
|
+
metadatas = batch["metadatas"] or [{} for _ in ids]
|
|
234
|
+
documents = batch["documents"] or [None for _ in ids]
|
|
235
|
+
embeddings = np.array(batch["embeddings"], dtype=np.float64)
|
|
236
|
+
|
|
237
|
+
# Transform: legacy → new space
|
|
238
|
+
mapped = mapping.transform(embeddings)
|
|
239
|
+
mapped_list = mapped.tolist()
|
|
240
|
+
|
|
241
|
+
# Add Isotrieve metadata to each row
|
|
242
|
+
for i, meta in enumerate(metadatas):
|
|
243
|
+
if meta is None:
|
|
244
|
+
meta = {}
|
|
245
|
+
metadatas[i] = meta
|
|
246
|
+
meta["isotrieve_mapping_id"] = _hash_mapping(mapping)
|
|
247
|
+
meta["isotrieve_format_version"] = 1
|
|
248
|
+
meta["isotrieve_source_collection"] = collection_name
|
|
249
|
+
|
|
250
|
+
# Write to target
|
|
251
|
+
target.add(
|
|
252
|
+
ids=ids,
|
|
253
|
+
embeddings=mapped_list,
|
|
254
|
+
metadatas=metadatas,
|
|
255
|
+
documents=documents,
|
|
256
|
+
)
|
|
257
|
+
rows_written += len(ids)
|
|
258
|
+
|
|
259
|
+
# Sample recall check (every 10th batch)
|
|
260
|
+
if rows_written % (batch_size * 10) == 0 or rows_written >= total:
|
|
261
|
+
sample_n = min(50, len(ids))
|
|
262
|
+
src_sample = embeddings[:sample_n]
|
|
263
|
+
tgt_sample = mapped[:sample_n]
|
|
264
|
+
# Recall@10: for each mapped vector, how many of its top-10
|
|
265
|
+
# nearest in mapped space match its top-10 in source space
|
|
266
|
+
src_sims = l2_normalize(src_sample) @ l2_normalize(src_sample).T
|
|
267
|
+
tgt_sims = l2_normalize(tgt_sample) @ l2_normalize(tgt_sample).T
|
|
268
|
+
for i in range(sample_n):
|
|
269
|
+
src_top10 = set(np.argsort(-src_sims[i])[:10])
|
|
270
|
+
tgt_top10 = set(np.argsort(-tgt_sims[i])[:10])
|
|
271
|
+
sampled_correct += len(src_top10 & tgt_top10)
|
|
272
|
+
sampled_total += 10
|
|
273
|
+
|
|
274
|
+
offset += batch_size
|
|
275
|
+
|
|
276
|
+
report.rows_processed = rows_written
|
|
277
|
+
report.elapsed_seconds = time.perf_counter() - t0
|
|
278
|
+
if sampled_total > 0:
|
|
279
|
+
report.sampled_recall_at_10 = sampled_correct / sampled_total
|
|
280
|
+
|
|
281
|
+
return report
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""LangChain embeddings adapter for Isotrieve.
|
|
2
|
+
|
|
3
|
+
``IsotrieveEmbeddings`` is a drop-in ``langchain_core.embeddings.Embeddings``
|
|
4
|
+
shim that transparently applies an Isotrieve mapping. No index migration
|
|
5
|
+
needed — query vectors are mapped on-the-fly.
|
|
6
|
+
|
|
7
|
+
Requires: ``pip install isotrieve[langchain]`` or ``pip install langchain-core``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from isotrieve.mapping.base import Mapping, l2_normalize
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _require_langchain():
|
|
20
|
+
try:
|
|
21
|
+
from langchain_core.embeddings import Embeddings
|
|
22
|
+
|
|
23
|
+
return Embeddings
|
|
24
|
+
except ImportError:
|
|
25
|
+
raise ImportError(
|
|
26
|
+
"LangChain adapter requires langchain-core. "
|
|
27
|
+
"Install with: pip install langchain-core"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IsotrieveEmbeddings:
|
|
32
|
+
"""Drop-in ``Embeddings`` that applies an Isotrieve mapping.
|
|
33
|
+
|
|
34
|
+
Usage::
|
|
35
|
+
|
|
36
|
+
from langchain_chroma import Chroma
|
|
37
|
+
from isotrieve.adapters.langchain import IsotrieveEmbeddings
|
|
38
|
+
from isotrieve.mapping.base import Mapping
|
|
39
|
+
|
|
40
|
+
mapping = Mapping.load("ada002_to_te3.isotrieve")
|
|
41
|
+
base_embed = OpenAIEmbeddings(model="text-embedding-3-small")
|
|
42
|
+
ae = IsotrieveEmbeddings(mapping, base_embed)
|
|
43
|
+
|
|
44
|
+
# Works with any LangChain vector store
|
|
45
|
+
db = Chroma.from_documents(docs, embedding=ae)
|
|
46
|
+
results = db.similarity_search("query", k=10)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
mapping: Mapping,
|
|
52
|
+
base_embeddings: Any,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
mapping:
|
|
58
|
+
Fitted Isotrieve mapping. Forward direction should be
|
|
59
|
+
``legacy → new`` so that ``mapping.inverse_transform()``
|
|
60
|
+
maps new-model vectors back to legacy space.
|
|
61
|
+
base_embeddings:
|
|
62
|
+
A ``langchain_core.embeddings.Embeddings`` instance for the
|
|
63
|
+
new model (e.g. ``OpenAIEmbeddings``).
|
|
64
|
+
"""
|
|
65
|
+
_require_langchain() # side effect: raises ImportError if langchain missing
|
|
66
|
+
# Validate that base_embeddings satisfies the interface
|
|
67
|
+
if not hasattr(base_embeddings, "embed_documents"):
|
|
68
|
+
raise TypeError("base_embeddings must implement embed_documents()")
|
|
69
|
+
self._mapping = mapping
|
|
70
|
+
self._base = base_embeddings
|
|
71
|
+
|
|
72
|
+
def _map_vectors(self, vecs: np.ndarray) -> np.ndarray:
|
|
73
|
+
"""Map new-model vectors to legacy space."""
|
|
74
|
+
return l2_normalize(self._mapping.inverse_transform(vecs))
|
|
75
|
+
|
|
76
|
+
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
|
77
|
+
"""Embed documents into legacy space (for legacy index)."""
|
|
78
|
+
new_vecs = np.asarray(self._base.embed_documents(texts), dtype=np.float64)
|
|
79
|
+
legacy_vecs = self._map_vectors(new_vecs)
|
|
80
|
+
return legacy_vecs.tolist()
|
|
81
|
+
|
|
82
|
+
def embed_query(self, text: str) -> list[float]:
|
|
83
|
+
"""Embed a single query into legacy space."""
|
|
84
|
+
new_vec = np.asarray(self._base.embed_query(text), dtype=np.float64)
|
|
85
|
+
legacy_vec = self._map_vectors(new_vec.reshape(1, -1)).ravel()
|
|
86
|
+
return legacy_vec.tolist()
|
|
87
|
+
|
|
88
|
+
# Async variants — delegate to base + map
|
|
89
|
+
async def aembed_documents(self, texts: list[str]) -> list[list[float]]:
|
|
90
|
+
if hasattr(self._base, "aembed_documents"):
|
|
91
|
+
new_vecs = np.asarray(
|
|
92
|
+
await self._base.aembed_documents(texts), dtype=np.float64
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
new_vecs = np.asarray(self._base.embed_documents(texts), dtype=np.float64)
|
|
96
|
+
legacy_vecs = self._map_vectors(new_vecs)
|
|
97
|
+
return legacy_vecs.tolist()
|
|
98
|
+
|
|
99
|
+
async def aembed_query(self, text: str) -> list[float]:
|
|
100
|
+
if hasattr(self._base, "aembed_query"):
|
|
101
|
+
new_vec = np.asarray(await self._base.aembed_query(text), dtype=np.float64)
|
|
102
|
+
else:
|
|
103
|
+
new_vec = np.asarray(self._base.embed_query(text), dtype=np.float64)
|
|
104
|
+
legacy_vec = self._map_vectors(new_vec.reshape(1, -1)).ravel()
|
|
105
|
+
return legacy_vec.tolist()
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def has_recalibrator(self) -> bool:
|
|
109
|
+
return self._mapping.has_recalibrator
|
|
110
|
+
|
|
111
|
+
def __repr__(self) -> str:
|
|
112
|
+
base_cls = type(self._base).__name__
|
|
113
|
+
return (
|
|
114
|
+
f"IsotrieveEmbeddings(base={base_cls}, "
|
|
115
|
+
f"mapping={self._mapping.mapping_type}, "
|
|
116
|
+
f"d_src={self._mapping._d_src}, d_tgt={self._mapping._d_tgt})"
|
|
117
|
+
)
|