Dependency Rules
This page documents indx’s internal dependency rules: the direction of the dependency graph, the per-package import budget, and the mechanics that enforce them.
For the bigger picture of how packages fit together, see the architecture overview. To act on these rules as a contributor, see adding a backend.
Dependency direction: inward, always
Section titled “Dependency direction: inward, always”The graph has exactly one sink: core/. It depends on nothing else in the package — only on Pydantic v2 and the standard library. Everything else depends, directly or transitively, on core/ and on the slot protocols (the base.py modules), never the other way around.
Concretely:
- Protocols are central. Each swappable slot declares its contract as a
typing.Protocolin that sub-package’sbase.py. Implementations import that protocol andcore, and nothing else internal. - Implementations are leaves. A
DoclingParserknows about theParserprotocol and core types. It does not know thatQdrantStoreorBGEM3Embedderexist. - Wiring lives at the edges. Only
registry/resolves names to concrete classes. Onlypipeline/andcli/orchestrate them, and they do so exclusively through protocols and the registry.
Every edge points toward the protocols and core. There are no cycles, and a change to one backend cannot ripple into another.
Package responsibilities and import budget
Section titled “Package responsibilities and import budget”The table below is the authoritative “may import” list. If a package imports something not in its row, the build is wrong.
| Package | Responsibility | May import (internal) |
|---|---|---|
core/ | Domain model (Pydantic v2) | (nothing internal) |
utils/ | Cross-cutting helpers, require_extra() | errors |
errors.py | Exception hierarchy | (nothing) |
*/base.py | Slot protocols (Parser, LLM, VLM, Embedder, Store, OutputWriter) | core |
parsers/* | Parser implementations | parsers.base, core, utils |
llm/* | LLM implementations | llm.base, core, utils |
vlm/* | VLM implementations | vlm.base, core, utils |
embed/* | Embedder implementations | embed.base, core, utils |
store/* | Vector store implementations | store.base, core, utils |
output/* | Output writers | output.base, core, archive (indx writer only), utils |
archive/ | .indx read/write | core, utils |
config/ | indx.toml schema + loader | core, errors |
registry/ | name to class resolution + plugin discovery | all */base.py; lazy-imports impls; config, errors |
pipeline/ | Stage orchestration | core, all */base.py, registry, config, utils |
cli/ | Typer/Rich UI | pipeline, config, registry, archive, core, utils |
The pattern: each row may reach toward core and protocols, never sideways into a peer. The output writer’s permission to touch archive/ is the one extra edge, and only the default .indx writer uses it.
The dependency rules
Section titled “The dependency rules”These four rules are conventions the project holds itself to. They are not enforced by an automated tool; reviewers check them on every pull request.
1. core/ imports nothing from elsewhere in indx. It is the leaf everyone depends on, so no backend, registry, or config may leak into the domain model. Core models also never store a vendor type such as a qdrant_client.PointStruct; adapters convert at their own edge.
2. Implementations import only their own base.py plus core (and utils). A parser must not import a store, and an LLM adapter must not import an embedder — no sibling implementation, no other slot. Structural typing makes this painless: an implementation satisfies its protocol without inheriting anything.
3. The registry is the only place that imports concrete implementation classes, and it does so lazily. registry/builtins.py holds the name-to-class registration table. The actual import of, say, QdrantStore happens only when that slot is selected, so a missing extra never breaks an unrelated code path.
4. pipeline/ and cli/ depend on protocols, obtaining concretes only through the registry. They are written entirely against Parser, Store, Embedder, and friends — they never import indx.store.qdrant. Code review enforces this contract.
No heavy import at module top level
Section titled “No heavy import at module top level”Keeping the core light is not just about which packages exist — it is also about when their dependencies load. Optional backends (a vendor SDK, torch, a database client) must be imported inside the method that needs them, never at module top level. Importing any module must succeed even when no extra is installed.
A bare pip install indx pulls only Typer, Rich, Click, Pydantic v2, and pydantic-settings; TOML parsing uses stdlib tomllib.
When the heavy import does run and the extra is absent, raise a MissingDependencyError carrying the exact pip install hint.
# ❌ top-level import of an optional backend — breaks the light coreimport qdrant_client # ImportError on a clean `pip install indx`
# ✅ lazy import inside the method, with an actionable errordef connect(self) -> None: try: from qdrant_client import QdrantClient except ModuleNotFoundError as exc: raise MissingDependencyError(slot="store", name="qdrant", extra="qdrant") from exc self._client = QdrantClient(url=self.url)The require_extra() helper in utils/lazy.py centralizes this pattern, so every adapter raises the same actionable message. The registry surfaces it only when that slot is actually selected. An unrelated run never trips over a backend you did not ask for.
Why this DAG means “add a backend without touching core”
Section titled “Why this DAG means “add a backend without touching core””The payoff of the inward DAG is composability. Three properties combine to make it work:
- backends depend only on a protocol,
- the registry resolves names lazily, and
- nothing in
core/,pipeline/, orcli/names a concrete class.
So a new parser, embedder, or store is a leaf you bolt on, not a change you thread through the system:
- First-party backends register in
registry/builtins.py. - Third-party backends register via Python entry points — groups like
indx.parsers,indx.stores, andindx.embedders— and are discovered at runtime. Installing a plugin package is enough to makestore = "weaviate"work inindx.toml, with no edit to indx itself.
You can also test a backend without a single heavy dependency. Unit tests substitute a protocol-typed fake for any slot, so they never import a real backend; a fake that satisfies the protocol is a drop-in.
The same property protects you in reverse. Because the graph has no cycles and no sideways edges, adding or upgrading one backend cannot silently perturb another. The blast radius of any change is exactly one leaf.
Where to go next
Section titled “Where to go next”- Adding a backend — the step-by-step recipe that satisfies every rule on this page.
- Component protocols — the exact interfaces your implementation must satisfy.
- Architecture overview — how the packages and stages fit together end to end.
- Design principles — the values these rules serve.