Store Protocols — Stability Contract
PaveDB’s store stack has two pluggable seams:
Embedder— turns text into vectors.VectorBackend— stores vectors and returns ranked record IDs.
This document is the authoritative contract for both. Where the
descriptive “Layer contracts” section of
PLAN-STORE.md disagrees with this file
(it predates several refactors), this file wins.
Audience: backend/embedder authors and downstream tooling that depends on these seams staying stable across minor versions.
Scope
In scope: the Embedder and VectorBackend protocols, their method
semantics, and the convention for registering a new embedder type.
Out of scope: every layer above the seams — BaseStore / LocalStore,
CollectionDB, the catalog, service.py, and factory internals. Those
are implementation detail and may change at any version (see
Internal — not the contract).
Frozen protocols (v1.0)
Embedder
pave/embedders/base.py:
class Embedder(Protocol):
def encode(self, texts: list[str]) -> NDArray[np.float32]:
"""(N, dim) matrix of embedding vectors."""
@property
def dim(self) -> int:
"""Embedding dimensionality (e.g. 384)."""
Semantics:
encode(texts)returns afloat32array of shape(len(texts), dim). Rowiis the embedding fortexts[i]; input order is preserved.- Embeddings need not be pre-normalized. The backend owns its own
normalization (
FaissBackendL2-normalizes onaddandsearch), so embedders return raw model output. dimis a positive int, constant for the embedder’s lifetime, and equals the second axis ofencode’s output. It must be known at construction (FAISS index creation needs it up front).- A missing model file, API key, or unreachable model server raises
EmbedderUnavailableError(aRuntimeErrorsubclass). The service layer maps it to theembedder_unavailableerror code rather than the generic failure envelope. This error type is part of the contract.
VectorBackend
pave/backends/base.py:
SearchHit = tuple[str, float]
class VectorBackend(Protocol):
def initialize(self) -> None: ...
def add(self, rids: list[str], vectors: NDArray[np.float32]) -> None: ...
def search(self, vector: NDArray[np.float32], k: int) -> list[SearchHit]: ...
def delete(self, rids: list[str]) -> None: ...
def flush(self) -> None: ...
def close(self) -> None: ...
Semantics:
initialize()loads/prepares persisted state from the backend’s own constructor settings. It is idempotent, must be called beforeadd/search, and is a no-op when nothing is persisted yet.add(rids, vectors)requireslen(rids) == vectors.shape[0]; vectors arefloat32of shape(N, dim). Re-adding an existingridreplaces its vector (upsert semantics — delete + reinsert).search(vector, k)takes afloat32query of shape(dim,)or(1, dim)and returns up tok(rid, score)pairs in descending score order. An empty index returns[]. Scores are backend-defined similarity;FaissBackendreturns cosine similarity in[-1, 1].delete(rids)removes vectors by record ID. Unknown IDs are ignored.flush()persists pending state for local backends, or is a no-op for remote backends.close()releases resources; local backends flush first.
Construction is not part of the protocol. Persistence and connection
details are backend-specific constructor keyword arguments — e.g.
FaissBackend(dimension, *, storage_dir=...) — never generic protocol
members. There is no storage_dir (or any path) attribute on the
protocol itself.
Implementations:
| Class | Module | Status |
|---|---|---|
FaissBackend | pave/backends/faiss.py | Default; persistent IndexIDMap2 over IndexFlatIP. |
Embedder authoring convention
Adding an embedder type <type> requires no edits to
factory.py. The factory imports pave.embedders.<type> by
convention and reads module-level attributes. A conforming module
exports:
build_embedder(cfg, *, model=None, embedder_config=None, api_key=None)returning anEmbedder.CONFIG_KEYS: tuple[str, ...]— every config key the type accepts.SPACE_KEYS: tuple[str, ...]— the subset ofCONFIG_KEYSthat changes the vector space (e.g.dim).- Optional
validate_spec(resolved_config: dict) -> dictfor normalizing or rejecting config before construction.
Identity split: vector_space_key derives from
(type, model, SPACE_KEYS subset); the runtime cache key uses the full
resolved config. A per-instance api_key is carried alongside the spec
and never participates in vector-space identity.
OllamaEmbedder is the reference example of a thin module added this way.
Stability tiers
| Tier | Meaning | Examples |
|---|---|---|
| Frozen | Signature + semantics locked at v1.0. A breaking change is a v2.0 event. | Embedder.encode, Embedder.dim, VectorBackend.search / add / delete / initialize / flush / close, EmbedderUnavailableError |
| Stable | Will not break existing callers, but may gain optional keyword arguments (with defaults) at a minor version. | build_embedder overrides; backend constructor kwargs |
| Internal | Not part of the contract. Authors must not depend on these. | BaseStore / LocalStore, CollectionDB, service.py, factory internals (_embedder_module, resolve_embedder_spec, …), backend private attrs |
Additivity rules
PaveDB types these seams structurally (typing.Protocol), not by
inheritance — there is no shared ABC that supplies defaults. Therefore:
- Adding a required method to a frozen protocol breaks every existing implementor and is a v2.0 change.
- Additive growth happens two ways, both minor-version safe:
- New optional keyword arguments (with defaults) on a Stable surface, never changing existing call sites.
- A new capability protocol (e.g. a future
BackendCapabilities) that backends opt into and callers probe via duck typing. Introducing a brand-new protocol is always additive.
- Renaming a frozen method or changing its signature/semantics is v2.0.
Internal — not the contract
The store orchestrator (LocalStore), CollectionDB, the catalog, and
service.py are deliberately excluded. They compose the seams above but
are free to change shape at any minor version. Backend and embedder
authors should depend only on the two protocols documented here.
Plugin policy
There is no third-party plugin entry point at v1.0 — both seams are in-tree only. This freeze is forward-preparation: when an entry point lands (the v1.6 ingest plugin architecture is the precedent), the stability rules already predate the first external implementor.
Change policy
Any PR that touches pave/embedders/base.py or pave/backends/base.py
must add a CHANGELOG.md entry under a Protocol contract header,
kept distinct from internal store/service changes so contract movement is
auditable at a glance.