Adding a Backend
This page is the checklist for adding a new backend — a Parser, LLM, VLM, Embedder, Store, or OutputWriter. It is the most common contribution to indx, and the recipe is fixed. Follow the six steps and your adapter drops into the registry, resolves by name, and keeps the core dependency-light.
The design rests on two ideas:
- Structural typing. Backends satisfy a typed Protocol by structure, with no subclassing.
- Lazy heavy deps. Heavy dependencies live behind optional extras and are imported lazily.
To ship an adapter as a separate PyPI package, see Authoring a Plugin.
The six-step recipe
Section titled “The six-step recipe”Every backend follows the same path. The steps below are normative, and the pull-request checklist enforces them.
| # | Step | Why it matters |
|---|---|---|
| 1 | Implement the Protocol exactly | Structural typing means any object that fits “drops in” |
| 2 | Convert at the edge | Vendor types never leak into core models |
| 3 | Lazy-import the heavy dependency | pip install indx stays light and air-gapped |
| 4 | Declare the extra in pyproject.toml | Users get one clear install command |
| 5 | Register via entry point | No edits to core/; resolves by name |
| 6 | Write the adapter contract test | Proves the Protocol fit and the round-trip |
1. Implement the Protocol — exactly
Section titled “1. Implement the Protocol — exactly”Match the method signatures in core protocols exactly. indx uses typing.Protocol for structural typing, not abstract base classes — you don’t subclass anything. You write a class whose methods satisfy the interface.
For a Store, that means implementing upsert, search, and delete:
from __future__ import annotations
from indx.core.chunk import Chunkfrom indx.store.base import SearchHit
class QdrantStore: # satisfies the Store protocol structurally """Vector store backed by Qdrant. Registry key: 'qdrant'.""" name = "qdrant"
def __init__(self, url: str = "http://localhost:6333") -> None: self.url = url
def upsert(self, chunks: list[Chunk]) -> None: ...
def search(self, vector: list[float], k: int = 5) -> list[SearchHit]: ...
def delete(self, chunk_ids: list[str]) -> None: ...2. Convert at the edge — never leak vendor types
Section titled “2. Convert at the edge — never leak vendor types”Your adapter accepts and returns only core domain types: ParsedDoc, Chunk, vectors as list[float], and so on. The vendor SDK exists only inside your adapter module. A Document must never store a qdrant_client.PointStruct, and a Protocol method must never return a raw provider response.
This rule keeps the dependency graph a DAG pointing inward at core/. At the adapter boundary, convert core types into vendor types on the way in, and vendor results back into core types on the way out.
# ✅ vendor type built here, at the edge, and discarded heredef upsert(self, chunks: list[Chunk]) -> None: from qdrant_client.models import PointStruct # vendor type, local to this method points = [ PointStruct( id=chunk.id, vector=chunk.embedding, payload={"doc_id": chunk.doc_id, "text": chunk.text}, ) for chunk in chunks if chunk.embedding is not None ] self._client.upsert(collection_name="indx", points=points)3. Lazy-import the heavy dependency
Section titled “3. Lazy-import the heavy dependency”The bare install must work with no network and no GPU. So import the vendor SDK inside the method that needs it, never at module top level.
A missing dependency then raises MissingExtraError carrying the exact pip install indx[<extra>] hint (the extra is named after the registry key). In practice, first-party adapters do not hand-write the check — they call the require_extra helper from indx.utils.lazy as the first line of __init__, which raises MissingExtraError for you. MissingDependencyError is a kept alias (subclass) of MissingExtraError, so either name works.
from indx.utils.lazy import require_extra
class QdrantStore: name = "qdrant"
def __init__(self, url: str = "http://localhost:6333") -> None: # First line of __init__: raises MissingExtraError with a # `pip install indx[qdrant]` hint if the extra is absent. require_extra("store", "qdrant", "qdrant", "qdrant_client") self.url = url
def connect(self) -> None: from qdrant_client import QdrantClient # lazy import self._client = QdrantClient(url=self.url)Because the import is deferred to runtime, plugin discovery never fails just because a backend’s package is absent. The error surfaces only when that slot is actually selected.
4. Declare the extra in pyproject.toml
Section titled “4. Declare the extra in pyproject.toml”Add an entry under [project.optional-dependencies], keyed by the registry key. List the packages your adapter needs:
[project.optional-dependencies]qdrant = ["qdrant-client>=1.7"]Now pip install indx[qdrant] pulls exactly what the Qdrant store needs, and nothing more. See Extras for the full install matrix and the defaults / all bundles.
5. Register via entry point — don’t hard-wire it
Section titled “5. Register via entry point — don’t hard-wire it”indx resolves backends by name through per-slot registries. This recipe targets third-party plugins shipped as their own PyPI package: wire your class in with an entry point so it resolves by name, and never hard-code it into core/.
A first-party adapter living in this repository registers in registry/builtins.py instead — entry points are reserved for out-of-tree plugins. See Authoring a Plugin for the standalone-package path.
[project.entry-points."indx.stores"]qdrant = "indx.store.qdrant:QdrantStore" # registry key -> "module:Class"Each slot has its own entry-point group:
| Entry-point group | Slot | Protocol |
|---|---|---|
indx.parsers | parser | Parser |
indx.llms | llm | LLM |
indx.vlms | vlm | VLM |
indx.embedders | embedder | Embedder |
indx.stores | store | Store |
indx.writers | output | OutputWriter |
indx.stages | pipeline | Stage |
Once registered, the backend is usable by name anywhere a built-in is: in indx.toml, on the CLI, or in code.
[store]backend = "qdrant"from indx import DirectoryPipeline
DirectoryPipeline(store="qdrant")See Registry and Defaults for resolution order and how first-party built-ins relate to discovered plugins.
6. Write the adapter contract test
Section titled “6. Write the adapter contract test”Every Protocol implementation ships with a contract test that proves two things: the adapter satisfies the Protocol, and it round-trips core types. The test layout mirrors src/, so src/indx/store/qdrant.py is tested by tests/store/test_qdrant.py (unit tests under tests/unit/).
Network and model calls are mocked — real provider calls never run in the default offline suite. Use a fake client or a recorded HTTP cassette, and seed any randomness so the test is deterministic.
from indx.store import Storefrom indx.store.qdrant import QdrantStore
def test_qdrant_satisfies_store_protocol(): store = QdrantStore(url="http://localhost:6333") assert isinstance(store, Store) # @runtime_checkable protocol
def test_qdrant_round_trips_core_types(fake_qdrant_client, sample_chunks): store = QdrantStore() store._client = fake_qdrant_client # network mocked store.upsert(sample_chunks) hits = store.search([0.1, 0.2], k=1) assert hits[0].chunk.id == sample_chunks[0].idFor Store adapters specifically, exercise upsert, search, and delete — including chunks without embeddings and deterministic ordering for tied scores. See Testing for the full contract-test conventions, fakes, and golden-file rules.
Full Qdrant example
Section titled “Full Qdrant example”Putting the pieces together, here is the minimal shape of a backend — the pyproject.toml declarations paired with the adapter skeleton, including its lazy import and edge conversion.
[project.optional-dependencies]qdrant = ["qdrant-client>=1.7"]
[project.entry-points."indx.stores"]qdrant = "indx.store.qdrant:QdrantStore" # registry key -> classfrom __future__ import annotations
from indx.core.chunk import Chunkfrom indx.utils.lazy import require_extrafrom indx.store.base import SearchHit
class QdrantStore: # satisfies the Store protocol structurally """Vector store backed by Qdrant. Registry key: 'qdrant'.""" name = "qdrant"
def __init__(self, url: str = "http://localhost:6333") -> None: require_extra("store", "qdrant", "qdrant", "qdrant_client") self.url = url self._client = None
def _connect(self) -> None: from qdrant_client import QdrantClient # lazy import self._client = QdrantClient(url=self.url)
def upsert( self, chunks: list[Chunk], ) -> None: if self._client is None: self._connect() from qdrant_client.models import PointStruct # vendor type, local # convert core chunks -> vendor PointStruct *here*, never in core/ points = [ PointStruct(id=c.id, vector=c.embedding, payload={"doc_id": c.doc_id, "text": c.text}) for c in chunks if c.embedding is not None ] self._client.upsert(collection_name="indx", points=points)
def search( self, vector: list[float], k: int = 5, ) -> list[SearchHit]: if self._client is None: self._connect() results = self._client.search( collection_name="indx", query_vector=vector, limit=k ) # convert vendor results -> SearchHit objects at the edge return [self._hit_from_vendor(r) for r in results]
def _hit_from_vendor(self, result: object) -> SearchHit: """Convert one vendor result into a core SearchHit.""" ...
def delete(self, chunk_ids: list[str]) -> None: ...That is the entire contract. The adapter:
- satisfies
Storestructurally and never leaksqdrant_clienttypes out of the module, - imports the SDK lazily with an actionable hint,
- declares its extra and registers by entry point, and
- is covered by a contract test.
Pull-request checklist
Section titled “Pull-request checklist”Before a new backend can merge, the following must be true:
- The class satisfies its Protocol exactly (signatures match the protocols reference).
- Only core types cross the boundary; vendor types stay inside the adapter module.
- The heavy dependency is imported lazily, and the extra is gated with
require_extra(...)as the first line of__init__(raisingMissingExtraError) with apip install indx[<extra>]hint. - The extra is declared under
[project.optional-dependencies], named after the registry key. - The class is registered via the correct
[project.entry-points."indx.<group>"]. - An adapter contract test proves the Protocol fit and core-type round-trip, with network/model mocked.
-
ruff,mypy --strict, pyright (strict), and the offlinepytestsuite all pass.
See also
Section titled “See also”- Component protocols reference — the exact signatures every backend must satisfy.
- Authoring a Plugin — ship a backend as a standalone PyPI package.
- Testing — contract tests, fakes, and golden files.
- Registry and Defaults — how names resolve to classes.
- Extras — the optional-dependency install matrix.