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.
calibrator
property
¶
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.
manifest
property
¶
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 |
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 ¶
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 |
required |
normalize
|
bool
|
Renormalise the output. Leave it on unless the caller normalises downstream; the index expects unit vectors. |
True
|
calibrate_scores ¶
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 ¶
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 |
None
|
Example
over_fetch
property
¶
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 ¶
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 ¶
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 |
{}
|
Returns:
| Type | Description |
|---|---|
list[Hit]
|
Up to |
list[Hit]
|
matching records than asked for, or when the over-fetch ceiling was |
list[Hit]
|
reached on a very lopsided migration — :attr: |
list[Hit]
|
which. |
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 ¶
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: |
required |
candidates
|
int
|
Depth of the candidate set. See :data: |
CANDIDATES
|
cache
|
VectorCache | None
|
Where re-embedded vectors are kept. Defaults to
:class: |
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
candidates
property
¶
Depth of the candidate set — what bounds this arrangement's recall.
search ¶
Retrieve k documents, ranked in the new model's space.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
vector
|
FloatArray
|
The query under the new model, |
required |
k
|
int
|
How many results to return. |
10
|
**kwargs
|
Any
|
Passed through to the store's |
{}
|
Returns:
| Type | Description |
|---|---|
list[Hit]
|
Up to |
list[Hit]
|
scored is a cosine in the new space; the score of one it could not |
list[Hit]
|
(see :attr: |
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: |
describe ¶
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.
hit_rate
property
¶
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
¶
Total time in the three stages. embed_seconds is inside the third.
per_query_seconds
property
¶
Mean latency of one query — the number a serving budget is set from.
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.
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: |
None
|
get ¶
Read the vectors held for keys. An unreadable file is a miss.
put ¶
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 ¶
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
¶
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.
rebasis.probe.runner.CandidateMetrics
dataclass
¶
Everything measured about one candidate.
rebasis.probe.decision.DecisionResult
dataclass
¶
A decision together with everything needed to defend it later.
cascade_advantage
property
¶
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
¶
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.
Stores¶
rebasis.store.base.VectorStore ¶
Bases: Protocol
What every store backend must provide.
capabilities
property
¶
What this store can actually do — declared truthfully.
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 ¶
Nearest neighbours of vector.
upsert_vectors ¶
Replace the vectors of existing records.
The only write path in rebasis, and it never deletes.
rebuild_index ¶
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 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 |
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; |
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 a probe result as Markdown.
rebasis.report.render_html ¶
Render a probe result as a single self-contained HTML page.