Skip to content

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.

Every backend follows the same path. The steps below are normative, and the pull-request checklist enforces them.

#StepWhy it matters
1Implement the Protocol exactlyStructural typing means any object that fits “drops in”
2Convert at the edgeVendor types never leak into core models
3Lazy-import the heavy dependencypip install indx stays light and air-gapped
4Declare the extra in pyproject.tomlUsers get one clear install command
5Register via entry pointNo edits to core/; resolves by name
6Write the adapter contract testProves the Protocol fit and the round-trip

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:

src/indx/store/qdrant.py
from __future__ import annotations
from indx.core.chunk import Chunk
from 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 here
def 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)

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.

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 groupSlotProtocol
indx.parsersparserParser
indx.llmsllmLLM
indx.vlmsvlmVLM
indx.embeddersembedderEmbedder
indx.storesstoreStore
indx.writersoutputOutputWriter
indx.stagespipelineStage

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.

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 Store
from 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].id

For 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.

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.

pyproject.toml
[project.optional-dependencies]
qdrant = ["qdrant-client>=1.7"]
[project.entry-points."indx.stores"]
qdrant = "indx.store.qdrant:QdrantStore" # registry key -> class
src/indx/store/qdrant.py
from __future__ import annotations
from indx.core.chunk import Chunk
from indx.utils.lazy import require_extra
from 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 Store structurally and never leaks qdrant_client types 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.

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__ (raising MissingExtraError) with a pip 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 offline pytest suite all pass.