Isotrieve Documentation
Migrate a vector database to a new embedding model without re-embedding. Fits a linear map from ~2K calibration texts; 86–92% retrieval retention on BEIR.
What is Isotrieve?
Different embedding models produce vectors in incompatible geometric spaces. Even when two models output the same dimensionality, their axes mean different things — raw cross-space nDCG@10 is typically 0.0.
Isotrieve fits a ridge regression from paired calibration texts: embed the same ~2K documents with both models, learn a linear map W, then either transform stored vectors offline or map new-model queries into the legacy space at serve time. You keep the index you already paid for and still move to a better model.
Key Features
- 86–92% retrieval retention on BEIR SciFact vs full re-embedding
- Score recalibration — isotonic regression so thresholds stay meaningful
- Confidence flags — per-query high / medium / low from score margins
- ChromaDB + LangChain + Qdrant adapters for drop-in use
- Quality gate — data-driven PASS / WARN / FAIL before you ship
Installation
Install the core package from PyPI. Adapter extras pull in optional dependencies only when you need them — keep the core install small for CI and production images that only load a saved mapping.
Use isotrieve[chroma], isotrieve[langchain], or isotrieve[qdrant] for a single store, or isotrieve[all] when you want every adapter available in one environment (local demos, notebooks).
- Core — mapping, serve, gate, CLI
- chroma / langchain / qdrant — store-specific wrappers
- all — core + every adapter
# Core only
pip install isotrieve
# With adapters
pip install "isotrieve[chroma]" # ChromaDB
pip install "isotrieve[langchain]" # LangChain
pip install "isotrieve[qdrant]" # Qdrant
pip install "isotrieve[all]" # Everything
Requires Python ≥ 3.10, numpy ≥ 1.24, and scikit-learn ≥ 1.3. After install, confirm the package imports and print the version — this is the quickest sanity check before calibrating.
If import fails, check that you are in the intended virtualenv and that the Python on PATH matches the one used for pip install.
python -c "import isotrieve; print(isotrieve.__version__)"
# 0.2.0
Quick Start
Three steps: fit a map from calibration pairs, reuse it at query time, or transform the store offline. The snippets below use synthetic matrices so you can run them without embedding models; swap in real X / Y from your corpus for production.
1. Fit a mapping
Calibration pairs are the same texts embedded with the source and target models. Aim for at least ~2K in-domain examples for a PASS gate verdict; fewer often works for prototyping but retention drops.
RidgeMapping with alpha="auto" tunes regularization via leave-one-out GCV. After fit, save writes a .isotrieve file (forward map, optional inverse, metadata). transform returns L2-normalized vectors in the target space.
- Works for same-dim and rectangular (
d_src ≠ d_tgt) pairs - Seed the fit for reproducibility across machines
- Reuse the saved file in serve mode or offline migrate
import numpy as np
from isotrieve import RidgeMapping
rng = np.random.default_rng(0)
K, d_src, d_tgt = 500, 32, 48
X = rng.normal(size=(K, d_src))
Y = X @ rng.normal(size=(d_src, d_tgt))
m = RidgeMapping(alpha="auto", seed=0)
m.fit(X, Y)
m.save("mapping.isotrieve")
Z = m.transform(X[:10]) # (10, d_tgt), L2-normalized
2. Serve mode (zero corpus writes)
Serve mode leaves the existing index untouched. Embed the query with the new model, then map it into the legacy space with the inverse transform and search the store you already have.
This is the lowest-cost path when re-embedding millions of documents is expensive or when you want to A/B a new model before committing to an offline migration. The mapping file must include a fitted inverse (QueryAdapter.load will error if it does not).
- No corpus rewrites; only query-path latency for the map
- Same
.isotrieveartifact as offline transform - Optional score recalibration if you fitted one
from isotrieve.serve import QueryAdapter
qa = QueryAdapter.load("mapping.isotrieve")
legacy_vec = qa.map_query(new_model_embed(query))
3. Offline migration
When you are ready to move the corpus into the new space, transform stored vectors in batch and write a new store. Queries then use the new model directly with no per-request map.
Prefer offline migration once serve-mode quality looks good and you want simpler serving (no inverse at query time). Keep the old store until the quality gate and spot-checks pass on the new collection.
- One-time cost proportional to corpus size
- Serve path becomes a normal new-model embed + search
- Use adapters (
migrate_collection, etc.) for store-native I/O
from isotrieve.migrate import migrate_store
migrate_store(
source_dir="./old_store",
target_dir="./new_store",
mapping=m,
)
CLI
The CLI covers the same lifecycle as the Python API without writing a script: estimate cost and shape of a migration, fit from numpy dumps, transform a directory of vectors, and inspect a saved artifact.
plan is useful before you spend on embeddings — pass source/target model names and corpus size to get a rough sense of work. calibrate expects paired .npy matrices. transform walks a source directory and writes mapped vectors. inspect prints header metadata (dims, type, whether an inverse exists).
plan— migration sketch from model names + corpus sizecalibrate— fitX.npy/Y.npy→.isotrievetransform— batch-map a vector directoryinspect— dump artifact metadata
# Plan migration
isotrieve plan --source-model ada-002 --target-model te3-large --corpus-size 1000000
# Fit mapping
isotrieve calibrate --source-vectors X.npy --target-vectors Y.npy -o map.isotrieve
# Transform corpus
isotrieve transform --mapping map.isotrieve --source-dir ./old --target-dir ./new
# Inspect
isotrieve inspect map.isotrieve
Mapping Classes
Mappings learn a linear transform between source and target embedding spaces. Ridge is the default for production; Procrustes is available when you need an orthogonal square map.
RidgeMapping (default)
Ridge regression minimizes ||X W − Y||² + α||W||². With alpha="auto", α is chosen by leave-one-out GCV. An affine bias column is included by default so a pure scale/shift between spaces is absorbed cleanly.
Rectangular pairs (e.g. MiniLM 384d → bge-large 1024d) are first-class: the matrix shape is (d_src (+1), d_tgt). The inverse is fitted separately with its own α so serve mode does not reuse a poorly conditioned transpose.
- Handles rectangular mappings (
d_src ≠ d_tgt) - Bias column for the affine term
- Independent inverse α (fitted separately)
- Default choice on BEIR (higher retention than MLP / low-rank)
from isotrieve import RidgeMapping
m = RidgeMapping(alpha="auto", seed=0)
m.fit(X_cal, Y_cal)
m.save("mapping.isotrieve")
Z = m.transform(corpus) # (N, d_tgt)
X_hat = m.inverse_transform(Z) # back to source
OrthogonalProcrustesMapping
Orthogonal Procrustes finds the nearest rotation (orthogonal matrix) aligning two point clouds of the same dimensionality. Use it when you want a volume-preserving map and can guarantee d_src == d_tgt.
It will not handle rectangular migrations. On same-dim BEIR pairs it is competitive but usually slightly behind Ridge; prefer Ridge unless you have a geometric reason to constrain to SO(d) / O(d).
- Requires
d_src == d_tgt - Orthogonal constraint — no shear / scale freedom beyond rotation
- Good for interpretability experiments; Ridge for default quality
from isotrieve.mapping.linear import OrthogonalProcrustesMapping
m = OrthogonalProcrustesMapping(seed=0)
m.fit(X_cal, Y_cal) # Requires d_src == d_tgt
Serve Mode
QueryAdapter loads a .isotrieve file and exposes query-time mapping into the legacy space. Embed with the new model in your app, call map_query / map_queries, then search the existing index.
Inspect d_src / d_tgt to assert wiring, and has_recalibrator before calling recalibrate_scores. If the inverse was not fitted at calibrate time, load raises — re-fit with inverse enabled rather than shipping a half-complete artifact.
map_query— single vector;map_queries— batch- Zero corpus writes; only the query path changes
- Same artifact used for offline
transform
from isotrieve.serve import QueryAdapter
qa = QueryAdapter.load("mapping.isotrieve")
legacy_vec = qa.map_query(new_model_embed(query_text))
legacy_vecs = qa.map_queries(new_model_embed_batch(query_texts))
print(qa.d_src, qa.d_tgt) # dimensions
print(qa.has_recalibrator) # True if fitted
Score Recalibration
Linear maps preserve neighborhood structure better than absolute score scales. Thresholds tuned on the original model (e.g. “accept if cosine > 0.82”) often shift after mapping. Fit an isotonic recalibrator on held-out pairs so serve-time scores land back on a comparable scale.
Call fit_recalibrator after fit, then save so the recalibrator travels with the mapping. At serve time, run retrieval as usual and pass raw scores through recalibrate_scores before applying product thresholds.
MAE and margin compression on SciFact held-outs:
| Pair | MAE | Margin Compression |
|---|---|---|
| bge->e5 (same-dim) | 0.095 | 0.83x |
| MiniLM->bge (rectangular) | 0.364 | 0.85x |
m = RidgeMapping(alpha="auto", seed=0).fit(X, Y)
m.fit_recalibrator(X_heldout, Y_heldout)
m.save("mapping.isotrieve")
# At serve time
qa = QueryAdapter.load("mapping.isotrieve")
calibrated = qa.recalibrate_scores(raw_scores)
Confidence Scoring
Not every mapped query is equally trustworthy. ConfidenceScorer looks at top-score margins and labels the result high, medium, or low so you can fall back to re-embed, escalate to a stronger ranker, or hide low-confidence hits.
Margins below are tuned from SciFact runs; start there and retune on your domain if the high/low split does not match your risk tolerance. is_reliable is a convenience for “high only” gates.
| Pair | High-conf R@10 | Low-conf R@10 | Gap |
|---|---|---|---|
| bge->e5 | 0.955 | 0.637 | 0.318 |
| MiniLM->bge | 0.875 | 0.651 | 0.224 |
from isotrieve.reranking import ConfidenceScorer
scorer = ConfidenceScorer(margin_high=0.955, margin_low=0.637)
result = scorer.score(query_vector, top_scores)
print(result.flag) # "high", "medium", or "low"
print(result.is_reliable) # True if "high"
Quality Gate
Before you cut over production traffic, run the quality gate on held-out source/target samples. It predicts retrieval retention and returns PASS, WARN, or FAIL so you do not discover a broken map only after users complain.
Verdicts are driven by a model trained across benchmark runs (including leave-one-pair-out style evaluation). Treat FAIL as a hard stop: gather more or better calibration text, or re-embed. WARN means proceed with monitoring and tighter confidence thresholds.
| Verdict | Predicted Retention | Typical K |
|---|---|---|
| PASS | ≥ 0.75 | K ≥ 2000 |
| WARN | 0.55 – 0.75 | K = 500–1000 |
| FAIL | < 0.55 | Broken mapping |
from isotrieve.quality.gate import QualityGate
gate = QualityGate()
report = gate.evaluate(mapping, sample_source, sample_target)
print(report.verdict) # "PASS", "WARN", or "FAIL"
print(report.predicted_retention) # estimated retention
ChromaDB Adapter
IsotrieveChromaFunction wraps your new-model embedder so Chroma queries are mapped into the space of the collection you already built. Point an existing collection at this embedding function and keep serving without rewriting rows.
Pass the fitted mapping plus a callable (or object) that embeds text with the new model. Chroma calls that path on query; the adapter applies the inverse map before the distance computation against stored legacy vectors.
- Drop-in for serve mode on an existing collection
- No need to duplicate the corpus under a new name first
- Pair with the quality gate before flipping production
from isotrieve.adapters.chroma import IsotrieveChromaFunction
ef = IsotrieveChromaFunction(mapping, new_model_embedder=my_embed_fn)
col = client.get_collection("docs", embedding_function=ef)
results = col.query(query_texts=["..."], n_results=10)
Offline Migration
When you want the corpus itself in the new space, migrate_collection reads the source collection in batches, applies transform, and writes a new collection. Tune batch_size for memory; 1000 is a reasonable default for medium-dim vectors.
Keep the old collection until spot-checks and the gate look good on docs_v2. The report’s rows_processed count is useful for ops logging and resume checks.
- Creates
new_collection; does not mutate the source in place - Batch size trades throughput vs peak RAM
- Use after serve-mode validation when possible
from isotrieve.adapters.chroma import migrate_collection
report = migrate_collection(
client, "docs", mapping,
new_collection="docs_v2",
batch_size=1000,
)
print(f"Migrated {report.rows_processed} rows")
LangChain Adapter
IsotrieveEmbeddings implements the LangChain embeddings interface around a base embedder and your fitted map. Document and query paths both go through the adapter so vector stores built with LangChain stay consistent.
Use it when your stack already centers on LangChain retrievers / Chroma / FAISS wrappers. Sync and async embed methods are supported; prefer the same mapping artifact you would use with the plain Python API.
embed_documents(texts)— corpus / indexing pathembed_query(text)— single queryaembed_documents(texts)— async batch
from isotrieve.adapters.langchain import IsotrieveEmbeddings
ae = IsotrieveEmbeddings(mapping, base_embeddings)
db = Chroma.from_documents(docs, embedding=ae)
results = db.similarity_search("query", k=10)
Qdrant Adapter
QdrantStore connects to a running Qdrant instance and streams vectors in batches so you can apply m.transform without loading the whole collection into memory.
Typical offline flow: iterate batches, stack to a matrix, transform, upsert into a new collection (or overwrite after validation). Choose batch_size based on vector dim and available RAM — 1024 is a starting point for ~1k-d embeddings.
- Streaming iterator — safe for large collections
- Works with any fitted mapping that implements
transform - Pair with gate + spot queries before deleting the old collection
from isotrieve.stores.qdrant_store import QdrantStore
store = QdrantStore.connect(url="http://localhost:6333")
for batch in store.iter_vectors(batch_size=1024):
Z = m.transform(np.stack([r.vector for r in batch]))
.isotrieve File Format
A .isotrieve file is a JSON header followed by float64 matrix payloads. The header records format version, mapping type, dimensions, whether a bias column was used, whether an inverse exists, and the forward matrix shape.
Payloads carry the forward W and, when present, W_inv. Optional recalibrator state is stored alongside so serve mode can restore score calibration without a second file. Treat the artifact as opaque for day-to-day use — prefer Mapping.load / inspect over hand-editing.
- Header is human-readable; matrices are binary float64
- Forward and inverse travel together when fitted
- Bump-aware via
format_version
{
"format_version": 1,
"mapping_type": "ridge",
"d_src": 1536,
"d_tgt": 3072,
"bias": true,
"has_inverse": true,
"matrix_shape": [1537, 3072]
}
# Payload: float64 matrices (forward W, inverse W_inv)
When NOT to Use
Isotrieve is a cost/quality tradeoff, not a free lunch. Skip it (or re-embed instead) when:
- Maximum quality matters more than cost — full re-embedding still wins on nDCG when budget allows
- Calibration domain mismatches the corpus — out-of-domain pairs learn the wrong map
- Quality gate returns FAIL — do not ship; fix calibration or re-embed
- K < 2000 — often lands in WARN territory; gather more pairs first
Anti-patterns
These mistakes show up repeatedly in broken migrations:
- Don't mix vectors from different models in one collection — the index assumes one geometry
- Don't assume same dimensionality = compatibility — equal dim still yields ~0.0 cross-space nDCG
- Don't skip the quality gate — catch FAIL before cutover
- Don't default to MLP — on SciFact rectangular, Ridge ~0.866 vs MLP ~0.719
Security
Mapped vectors remain sensitive artifacts of the underlying text. Handle them with the same care as embeddings from the source model.
- Inversion attacks: Treat mapped vectors with the same sensitivity as source text
- API keys: Environment variables only — never commit keys into notebooks or mapping sidecars
- Calibration: Prefer non-sensitive texts for pairing; avoid shipping PII in calibration dumps