Skip to content

Python API

The public surface is deliberately small. rebasis itself exports two names, because the hot path should not have to import a package that pulls in a store client.

The query path

rebasis.Bridge

Maps new-model query vectors into an existing index's space.

input_dim property

input_dim: int

Dimensionality this bridge expects from the new model.

output_dim property

output_dim: int

Dimensionality of the index this bridge targets.

adapter_type property

adapter_type: str

Which adapter is inside, for reports and diagnostics.

calibrator property

calibrator: ScoreCalibrator | None

The score calibrator, for a caller that has to merge two rankings.

Exposed because merging results from two embedding spaces needs the calibrator itself rather than the mapping :meth:calibrate_scores applies — :class:~rebasis.serve.mixed.MixedSpaceSearch hands it to calibrated_merge, which decides per document rather than per array. None when the adapter carries no calibrator, which is the signal to fall back to rank fusion.

has_calibrator property

has_calibrator: bool

Whether score calibration is available.

manifest property

manifest: AdapterManifest

The full manifest — models, direction, dimensions, fingerprints.

load classmethod

load(
    path: Path | str,
    *,
    expected_old: EncodingProfile | None = None,
    expected_new: EncodingProfile | None = None,
    verify: bool = False,
) -> Bridge

Load an adapter and validate it — the only place validation happens.

Parameters:

Name Type Description Default
path Path | str

The .rbs directory.

required
expected_old EncodingProfile | None

Profile of the model the index was built with. Passing it turns a wrong adapter into a load failure instead of silently degraded retrieval.

None
expected_new EncodingProfile | None

Profile of the model now producing query vectors.

None
verify bool

Recompute every tensor hash. Milliseconds for a few megabytes, and worth it when an adapter has travelled between machines.

False

to_index_space

to_index_space(
    vectors: FloatArray, *, normalize: bool = True
) -> FloatArray

Map new-model vectors into the index's space — the hot path.

No validation, no logging, no dictionary construction. A dimension mismatch surfaces as a numpy error rather than a rebasis one, because checking on every query would cost more than the mapping itself — and :meth:load has already established that the dimensions agree.

Parameters:

Name Type Description Default
vectors FloatArray

Shape (n, d_new) or (d_new,).

required
normalize bool

Renormalise the output. Leave it on unless the caller normalises downstream; the index expects unit vectors.

True

calibrate_scores

calibrate_scores(scores: FloatArray) -> FloatArray

Map similarity scores back onto the pre-migration scale.

Ranking is unaffected — the calibrator is monotone — so this is only needed by pipelines that compare scores against a fixed threshold. Those pipelines need it badly: M0 measured that every adapter shifts the score distribution far enough to break such a filter.

Returns the scores unchanged when the adapter carries no calibrator.

describe

describe() -> dict[str, Any]

A compact summary for logs and reports. Never called on the hot path.

Querying a half-migrated index

For the window between starting a migration and finishing it, when the collection holds both models' vectors and no single query is correct against all of it. See Migration and rollback.

rebasis.serve.MixedSpaceSearch

Search an index that a migration has left holding two spaces.

Parameters:

Name Type Description Default
store VectorStore

The collection being migrated. Read only — this never writes.

required
bridge Bridge

The adapter, for the un-migrated half.

required
job_id str

Which migration split the index. Its queue is what says which records have moved.

required
state_dir Path | str | None

Where that job's manifest lives; defaults to the same project-local .rebasis/ everything else uses.

None
Example
search = MixedSpaceSearch(store, bridge, job_id="job-8f2a1c4e0b73")
hits = search.search(new_model.encode(["how do I deploy?"])[0], k=10)

over_fetch property

over_fetch: float

Hits the last query actually retrieved, over the k it returned.

A measurement, not the plan: it counts what the store handed back, which is what the depth cost. The two differ whenever a side is asked for more than the index holds — asking for 400 of a 300-record collection costs 300, not 400 — and reporting the request would overstate the bill in exactly the case a user is most likely to hit.

It rises as the migration approaches either end: at 5% done most of what the new-space search returns belongs to the other half and is discarded. Reported rather than hidden because it is the running cost of a mixed index, and the cheapest way to lower it is to finish the migration.

Bounded by twice :data:MAX_OVER_FETCH — that ceiling is per side, and both sides are searched everywhere except at the two ends, where one is skipped and this falls to about 1.

Written by :meth:search and read here, so it describes a recent query rather than a specific one when several threads share an instance.

progress

progress() -> float

Fraction of the job's records now in the new model's space.

Reads the queue, which is O(job size) — 12 ms over 100,000 rows and 251 ms over two million. :meth:search therefore uses a cached reading (:data:PROGRESS_TTL_SECONDS); this asks the manifest every time, because a caller who calls it is asking for the current answer.

search

search(
    vector: FloatArray, k: int = 10, **kwargs: Any
) -> list[Hit]

Retrieve from both halves of the index and merge them.

Parameters:

Name Type Description Default
vector FloatArray

The query under the new model. The old-space query is derived from it by the bridge; asking the caller for both would be asking them to hold a detail this exists to hide.

required
k int

How many results to return.

10
**kwargs Any

Passed through to the store's search.

{}

Returns:

Type Description
list[Hit]

Up to k hits. Fewer only when the index genuinely holds fewer

list[Hit]

matching records than asked for, or when the over-fetch ceiling was

list[Hit]

reached on a very lopsided migration — :attr:over_fetch reports

list[Hit]

which.

close

close() -> None

Release the manifest handle.

rebasis.serve.calibrated_merge

calibrated_merge(
    old_hits: Sequence[Hit],
    new_hits: Sequence[Hit],
    *,
    k: int,
    calibrator: ScoreCalibrator | None = None,
) -> list[Hit]

Merge old-index and new-index results into one ranking.

With a calibrator the old-space scores are mapped onto the new-space distribution and the two are merged by score. Without one this falls back to :func:reciprocal_rank_fusion, because comparing raw scores across spaces would let the space with the wider distribution win regardless of relevance.

A document appearing in both sets keeps its better score rather than being counted twice: it is one document, and during migration overlap is expected rather than exceptional.

rebasis.serve.reciprocal_rank_fusion

reciprocal_rank_fusion(
    *result_sets: Sequence[Hit], k: int, rrf_k: int = RRF_K
) -> list[Hit]

Merge result sets by rank alone.

Used when no calibrator is available. Ranks are all that can be compared honestly across two embedding spaces, so this deliberately throws the scores away rather than pretending they are commensurable.

Two-stage retrieval

The bridge as a recall stage, with the new model reranking its candidate set. Measured in the bridge as a recall stage; the cache is part of the design, because re-embedding N documents per query is what the arrangement costs.

rebasis.serve.Cascade

The bridge recalls; the new model ranks.

Parameters:

Name Type Description Default
store VectorStore

The index, as it already is. Read only — nothing here writes to it, which is what keeps the arrangement free of risk: stopping using it is the rollback.

required
bridge Bridge

The adapter, which turns the new model's query into a query the index can answer.

required
embedder Embedder

The model being adopted. It re-embeds candidate documents, so it must be the same model whose vectors the caller passes to :meth:search — a mismatch would score a query against documents from a different space and raise nothing.

required
candidates int

Depth of the candidate set. See :data:CANDIDATES.

CANDIDATES
cache VectorCache | None

Where re-embedded vectors are kept. Defaults to :class:MemoryVectorCache; :class:DiskVectorCache survives a restart.

None

Raises:

Type Description
CapabilityMissing

When the store cannot return document text. A store that cannot is one where the cache can never be filled, so every query would silently return the bridged order forever. Refusing at construction beats failing on the first query in production.

Example
cascade = Cascade(store, bridge, new_model, cache=DiskVectorCache())
hits = cascade.search(new_model.encode(["how do I deploy?"], kind="query")[0])
print(cascade.stats.to_dict())

stats property

stats: CascadeStats

The live measurement. See :class:CascadeStats.

candidates property

candidates: int

Depth of the candidate set — what bounds this arrangement's recall.

search

search(
    vector: FloatArray, k: int = 10, **kwargs: Any
) -> list[Hit]

Retrieve k documents, ranked in the new model's space.

Parameters:

Name Type Description Default
vector FloatArray

The query under the new model, (d_new,) or (1, d_new). The old-space query is derived from it here; asking the caller for both would be asking them to hold the detail this exists to hide.

required
k int

How many results to return.

10
**kwargs Any

Passed through to the store's search — a metadata filter belongs on the candidate search, where it can still narrow anything.

{}

Returns:

Type Description
list[Hit]

Up to k hits, best first. The score of a hit the new model

list[Hit]

scored is a cosine in the new space; the score of one it could not

list[Hit]

(see :attr:CascadeStats.kept_bridged) is the store's own score in

list[Hit]

the old space. The two are not on one scale — M0 measured a median

list[Hit]

KS distance of 0.924 between them — which is why the ordering here

list[Hit]

is positional rather than by score, and why a pipeline that filters

list[Hit]

on a fixed threshold should read

list[Hit]

attr:CascadeStats.kept_bridged before trusting one.

describe

describe() -> dict[str, Any]

A compact summary for logs and reports. Never called per query.

rebasis.serve.CascadeStats dataclass

What this arrangement has cost so far.

Cumulative over the life of a :class:Cascade, because the number that matters — the hit rate — is a property of a stream of queries rather than of one. :meth:reset starts a fresh window.

The counters are updated without a lock. Under concurrency an increment can be lost, which is acceptable for what this is: an instrument, read to decide whether the arrangement is affordable, not a ledger anything depends on.

cache_misses property

cache_misses: int

Candidates the cache did not hold.

hit_rate property

hit_rate: float

Fraction of candidates the cache answered.

nan before the first query: a cache that has been asked nothing has no hit rate, and reporting 0.0 would read as a cache that is not working.

seconds property

seconds: float

Total time in the three stages. embed_seconds is inside the third.

per_query_seconds property

per_query_seconds: float

Mean latency of one query — the number a serving budget is set from.

reset

reset() -> None

Start a fresh measurement window.

to_dict

to_dict() -> dict[str, float]

Serialisable form, for a report or a metrics exporter.

rebasis.serve.MemoryVectorCache

A bounded LRU held in this process — the default.

The default because it is the one that is always correct: it needs no directory, no permissions and no cleanup, and its cost is bounded by capacity rather than by how long the process has been running. What it cannot do is survive a restart, so the first query after every deploy pays the full re-embedding cost again. That is what :class:DiskVectorCache is for, and it is a deliberate default rather than an oversight: writing into a user's project directory is not something a library should start doing unasked.

get

get(keys: Sequence[str]) -> dict[str, FloatArray]

Return the vectors held for keys, refreshing their recency.

put

put(vectors: Mapping[str, FloatArray]) -> None

Store vectors, evicting the least recently used to stay in bounds.

rebasis.serve.DiskVectorCache

A cache under .rebasis/cache/, which outlives the process.

One file per vector, named by the digest of its key and written through :func:rebasis.storage.atomic.atomic_write_bytes — so an entry is either complete or absent, and a crash mid-write leaves neither a truncated vector nor a damaged neighbour. The file holds the raw float32 bytes and nothing else: the name already carries the identity, because the key it hashes contains the model's profile fingerprint.

It has no eviction policy of its own, on purpose. rebasis gc already has one for this directory — a cache file untouched for 30 days is a candidate, and the whole category is listed and freed without confirmation. Reading a file updates its atime and not its mtime, so gc can collect an entry that queries are still hitting; the cost of that is re-embedding one document once a month, against one write syscall per cache hit to prevent it. Turning every read into a write to defend a cache is the wrong trade.

Why this exists alongside :class:rebasis.storage.EmbeddingCache, which solves a near-identical problem: the two are the same idea at opposite scales, and merging them would have made one of them worse. Here a query touches a hundred documents and entries accumulate lazily over a query log, so a small file per vector needs no schema and no connection and lets gc expire one document at a time. probe embeds ten thousand documents in a single pass, where a file per vector is ten thousand files for gc to stat and ten thousand fsyncs to pay. They also key different things — record ids here, texts there — and expire on different clocks. Either can be handed to :class:Cascade; a process whose working set has grown past what a directory of small files handles comfortably should hand it the other one.

Parameters:

Name Type Description Default
directory Path | str | None

Where to keep the files. Defaults to :func:default_cache_dir, which honours REBASIS_CACHE_DIR and REBASIS_STATE_DIR.

None

directory property

directory: Path

Where this cache keeps its files.

get

get(keys: Sequence[str]) -> dict[str, FloatArray]

Read the vectors held for keys. An unreadable file is a miss.

put

put(vectors: Mapping[str, FloatArray]) -> None

Write each vector to its own file, atomically.

A failed write increments :attr:write_failures and is not raised. A cache exists to make queries cheaper, and one that can take a query down is worse than no cache at all — the search has already succeeded by the time this is called, and the only thing lost is that the next query pays for the same documents again.

The directory is not fsynced. A cache entry a power cut loses is a cache miss, and paying a directory fsync per write to prevent one would cost more than re-embedding the document it protects.

Probing a store

rebasis.probe.session.probe_store

probe_store(
    store: VectorStore,
    new_embedder: Embedder,
    *,
    old_embedder: Embedder | None = None,
    sample: CorpusSample | None = None,
    query_log: QueryLog | None = None,
    size: int = 10000,
    heldout: int = 1000,
    strategy: str = "stratified",
    k: int = 10,
    seed: int = 0,
    methods: Sequence[str] | None = None,
    with_csls: bool = True,
    batch_size: int = 1000,
    synth_queries: str | None = None,
    audit: AuditWriter | None = None,
    store_uri: str = "",
    old_model: str = "",
    device: str = "cpu",
    cache_dir: Path | str | None = None,
    fit_migration: bool = False,
    access_counts: Mapping[str, float] | None = None,
    cascade_k: int | None = CASCADE_N,
    on_stage: Callable[[str], None] | None = None,
) -> tuple[ProbeResult, CorpusSample]

Probe a live store: sample it, re-embed it, and decide.

old_embedder is optional and only used at T1. At T0 the query proxies are documents that are already in the index, so their old-model vectors are read rather than recomputed — which is both faster and exactly what the index holds. With a real query log there is no such shortcut: the queries are text that was never indexed, and answering "how well does the current model do?" means encoding them with it.

cache_dir names a directory in which embeddings this run computes are kept for the next one, one file per model profile. None, the default, means nothing is cached and nothing is written; rebasis probe passes :func:~rebasis.storage.default_embedding_cache_dir. See the module docstring for why the default is off here and on there.

access_counts weights which sampled records become query proxies, so ARR describes the questions people actually send rather than a uniform draw over the corpus. It changes what is being estimated and says so in the result; docs/access-weighting.md has what it is worth and what it costs.

cascade_k is the candidate depth the two-stage arrangement is measured at. It is a parameter rather than a constant because candidate depth is bound to whatever reranking budget the caller has, and the report names the depth it measured so two runs at different depths cannot be read as one.

fit_migration adds a second fit in the opposite direction — the map rebasis migrate rewrites an index with — scored on what a completed migration would deliver rather than on what a bridged query retrieves. Off by default because the two are different questions and most runs are asking the first; see :mod:rebasis.probe.migration.

rebasis.probe.session.draw_corpus_sample

draw_corpus_sample(
    store: VectorStore,
    *,
    size: int = 10000,
    heldout: int = 1000,
    strategy: str = "stratified",
    seed: int = 0,
    batch_size: int = 1000,
    need_text: bool = True,
    access_counts: Mapping[str, float] | None = None,
) -> CorpusSample

Draw a sample of the index and read back its vectors and text.

access_counts maps record id to how often it was read, and weights which sampled records become query proxies — leaving the sample itself uniform, for the reason :func:~rebasis.sample.split_disjoint gives. Records the log does not mention count as read once.

Raises:

Type Description
StoreUnsupported

When the store cannot return text and text is needed — without it there is nothing to re-embed with the new model.

InsufficientSamples

When too few usable records came back.

rebasis.probe.session.CorpusSample dataclass

A sample of the index, with its own vectors and its text.

query_positions and fit_positions index into this sample, not into the corpus, and are disjoint by construction — a query the adapter was fitted on would make every ARR number meaningless.

clusters_of

clusters_of(positions: ndarray) -> np.ndarray | None

Cluster labels for a subset of this sample, if there are any.

rebasis.probe.session.QueryLog dataclass

A real query log with relevance judgements — the T1 tier.

qrels[i] holds the record ids judged relevant to queries[i]. Ids that are not in the drawn sample are dropped, and a query left with nothing relevant is dropped with it: scoring it would count a guaranteed miss against every candidate equally and drag every ARR toward zero.

judged property

judged: bool

Whether any query names a document that was judged relevant.

A log exported from a search box has no judgements, and that is the usual case rather than a broken file. It still measures retention; it just cannot measure the upgrade.

Results

rebasis.probe.runner.ProbeResult dataclass

The full outcome of a probe run.

to_dict

to_dict() -> dict[str, Any]

Serialisable form, for the report and the audit record.

rebasis.probe.runner.CandidateMetrics dataclass

Everything measured about one candidate.

to_dict

to_dict() -> dict[str, Any]

Serialisable form.

rebasis.probe.decision.DecisionResult dataclass

A decision together with everything needed to defend it later.

cascade_advantage property

cascade_advantage: float | None

The break-even for a two-stage arrangement.

The same product as :attr:bridge_advantage, with retention measured at candidate-set depth instead of at k. That is the right retention for an arrangement where the bridge produces candidates and the new model ranks them in its own space: what can be lost is a relevant document that never reached the candidate set, and nothing after that.

Systematically higher than bridge_advantage, and measured to be higher in a way that changes the answer — 1 of 48 runs against 36 of 48 (docs/cascade-band.md).

This now decides, where it did not before, and what changed is the cost side rather than the quality side. The objection was that the arrangement's price turns on how often a candidate is already cached, which is a property of a query distribution rather than of a corpus a probe can read. That objection does not survive --queries: a real query log is a sample of the distribution, and the overlap between the candidate sets it produces can be counted straight off a search the run already ran. :attr:candidate_reuse is that count, and it is a lower bound, so the arrangement is priced as more expensive than it will be. Where it cannot be counted the rule does not fire and the report says so.

Read as a threshold it is not the identity bridge_advantage turned out to be. That one collapses because both factors are read at the same cut-off on the same metric; this one puts retention at depth N against an upgrade measured at k, and predicts the nDCG@10 of a reranked list — three different quantities, and none of them cancels. docs/cascade-band.md §7 measures it against the constant rule rather than asserting the difference.

bridge_advantage property

bridge_advantage: float | None

Bridged quality divided by the current model's — the break-even.

ARR x upgrade_gain. Above 1.0 bridging retrieves better than changing nothing; below it, worse. Neither factor answers it alone: a high ARR against a small upgrade still loses, and so does a large upgrade bridged by a poor adapter. It is the comparison the decision rule makes against old_model_arr, stated as one figure because two numbers a reader has to divide in their head is not a number they will use.

The count this docstring used to quote was an identity. Read off one run's own scores, ARR x upgrade_gain is (bridged / reindex) x (reindex / status quo), which is bridged / status quo — the same inequality as "did bridging beat doing nothing". Scored that way it agrees always, and "14 of 14" measured nothing.

What is measured, over the 57 runs reports/band/ still holds: the estimate ranks runs by the margin they actually returned at Spearman rho = 0.60, p ~ 1e-6, so it carries real information about the size of the effect. Scored as a threshold against the outcome it agrees in 37 of 57, below the 54 of 57 a rule that always answered "do not bridge" would score — because 95% of those outcomes fall on one side, and an accuracy cannot separate a real rule from a constant there (docs/bridge-band.md, section 9).

The quantity is still what decides, and the bands are still where they were: what changed is the confidence a reader should take from the count, not the rule.

to_dict

to_dict() -> dict[str, Any]

Serialisable form, for the report and the audit record.

Stores

rebasis.store.base.VectorStore

Bases: Protocol

What every store backend must provide.

capabilities property

capabilities: StoreCapabilities

What this store can actually do — declared truthfully.

count

count() -> int

Number of records in the collection.

dimension

dimension() -> int

Vector dimensionality of the collection.

iter_records

iter_records(
    ids: Sequence[str] | None = None,
    *,
    with_vectors: bool = True,
    with_text: bool = True,
    batch_size: int = 1000,
) -> Iterator[Record]

Stream records. Must be lazy — never materialise the collection.

search

search(
    vector: FloatArray,
    k: int,
    where: dict[str, Any] | None = None,
) -> list[Hit]

Nearest neighbours of vector.

upsert_vectors

upsert_vectors(
    ids: Sequence[str], vectors: FloatArray
) -> None

Replace the vectors of existing records.

The only write path in rebasis, and it never deletes.

rebuild_index

rebuild_index() -> None

Rebuild the search structure from the vectors that are in it now.

Separate from writing, because they fail separately. A graph index picks a record's edges from the geometry of its neighbours at insert time, and an in-place vector update leaves those edges describing a neighbourhood that no longer exists: every vector correct, every count right, and recall down. Measured at up to 12 points on a 100,000-record Chroma collection — docs/index-health.md.

Only meaningful where can_rebuild_index is declared. A backend that searches exhaustively has no structure to rebuild and says so; a backend with a graph but no way to rebuild it says so too, which is the more important of the two — it means the loss is not recoverable through rebasis and the collection has to be rebuilt by its owner.

Raises:

Type Description
CapabilityMissing

When the backend cannot.

rebasis.store.open_store

open_store(
    uri: str | StoreURI, **kwargs: Any
) -> VectorStore

Open the store a URI points at.

The URI is logged in redacted form: the credential portion never reaches a log line or an audit record.

Adapters

rebasis.core.serialization.save_adapter

save_adapter(
    adapter: BaseAdapter,
    path: Path,
    *,
    direction: AdapterDirection,
    old_profile: EncodingProfile,
    new_profile: EncodingProfile,
    calibrator: ScoreCalibrator | None = None,
    evaluation: dict[str, Any] | None = None,
) -> Path

Write an adapter to a .rbs directory.

Every file goes through the atomic writer, so an interrupted save leaves the previous version intact rather than a half-written one.

rebasis.core.serialization.load_adapter

load_adapter(
    path: Path,
    *,
    expected_old: EncodingProfile | None = None,
    expected_new: EncodingProfile | None = None,
    verify: bool = False,
) -> tuple[
    BaseAdapter, AdapterManifest, ScoreCalibrator | None
]

Load an adapter, validating it before returning.

Parameters:

Name Type Description Default
path Path

The .rbs directory.

required
expected_old EncodingProfile | None

Profile of the model the index was built with. When given, a fingerprint mismatch refuses the load.

None
expected_new EncodingProfile | None

Profile of the model being adopted.

None
verify bool

Recompute every tensor hash. Off by default because the fast path costs microseconds and the full path milliseconds; adapter upgrade always turns it on.

False

Raises:

Type Description
SerializationError

When the directory is malformed or a hash fails.

IncompatibleAdapter

On a profile fingerprint mismatch.

AdapterSchemaVersion

When the schema is unreadable.

Reports

rebasis.report.render_markdown

render_markdown(
    result: ProbeResult,
    *,
    store_uri: str = "",
    title: str = "",
) -> str

Render a probe result as Markdown.

rebasis.report.render_html

render_html(
    result: ProbeResult,
    *,
    store_uri: str = "",
    title: str = "",
) -> str

Render a probe result as a single self-contained HTML page.