Design Principles
Seven principles guide every decision in indx, from the data models to the packaging matrix. The rest of the codebase exists to protect them — when two designs are in tension, the principle wins.
To explore the system top-down, start with the architecture overview. To contribute code, note that these principles are codified as enforceable rules in the coding standards.
1. Protocol-first design
Section titled “1. Protocol-first design”Behaviour is defined as a typed Protocol before any implementation exists. Code in core/ depends on interfaces, never on a concrete backend.
The interfaces are Parser, LLM, VLM, Embedder, Store, OutputWriter, and the Stage protocol. indx uses structural typing (typing.Protocol, not abstract base classes). A third-party object that matches a signature drops straight in — no importing or subclassing anything from indx.
Why it matters. This is the technical backbone of no lock-in. A new vector store or LLM can be added without touching core/, and the same protocol surface is what the public API and the docs are built on.
See the full interface contracts in the protocols reference and the extension recipe in Bring Your Own Stack.
2. Dependency-light core
Section titled “2. Dependency-light core”pip install indx must install fast and run air-gapped — no network, no GPU. The core carries only Typer, Rich, Click, Pydantic v2, and pydantic-settings; TOML parsing uses stdlib tomllib.
Every heavy or vendor-specific dependency — Docling, Torch, a Qdrant client, cloud SDKs — lives behind an optional extra and is imported lazily inside the method that needs it. A missing dependency never crashes at import time; it raises a MissingDependencyError with the exact pip install indx[...] hint.
Why it matters. A light core keeps installs fast and offline operation possible.
The shipped default stack is cloud-backed and needs OPENAI_API_KEY — docling, openai:gpt-5-mini, openai:text-embedding-3-small, and qdrant. Offline is always available, two ways:
- Local profile —
pip install "indx[local]". - Zero-dependency core path — the built-in
plaintextparser,hashembedder,jsonlstore,noneVLM, and.indx+jsonlwriters. A complete run needs no extras at all.
See the air-gapped guide, the extras reference, and dependency layering.
# 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)3. Fail loud, with context
Section titled “3. Fail loud, with context”indx never swallows an error or returns a half-built result silently. Everything it raises descends from a single base, IndxError, so callers can catch the library cleanly.
Typed subtypes include ConfigError, MissingDependencyError, StageError, ParseError, EmbedError, and StoreError. Every message states what failed, where (which file and stage), and what to do about it.
Why it matters. Ingestion runs touch thousands of untrusted files, and a vague ValueError("bad input") three stages downstream is worthless. Actionable, typed errors are a hard requirement — and a direct payoff for regulated, auditable estates.
See the full hierarchy and the matching shell statuses in errors and exit codes.
4. Determinism and reproducibility
Section titled “4. Determinism and reproducibility”Same inputs + same config + same versions ⇒ a byte-stable index.json. Randomness is seeded and model identifiers are pinned.
Provenance is recorded into the archive manifest: the input set, component versions, the embedder name and dim, and the resolved config. Serialization is fixed and diff-friendly:
- stable field ordering,
- sorted keys for free-form maps,
- UTC ISO-8601 timestamps,
- fixed float representation, and
- a top-level
schema_version.
Why it matters. Producing trustworthy serialized artifacts is the product’s core job, so determinism is a hard requirement.
The guardrail is the golden-file tests, which assert byte-equality of index.json against a committed fixture. Because the embedder’s name and dim live in the manifest, a consumer can detect that vectors came from a different model before querying. Inherently non-deterministic LLM enrichment is flagged and seeded where the model permits.
See the reproducibility guide, the index.json reference, and the .indx archive format.
5. Config is a contract, not magic
Section titled “5. Config is a contract, not magic”Configuration is an explicit, validated, typed Pydantic object. There is no implicit globals layer and no “it sometimes does X”.
Each indx.toml section is organized by slot, and backend-specific sub-tables are passed to the selected adapter. An unknown backend or a missing required option fails fast with a precise error at load time.
Precedence is explicit: code arguments / .use() > CLI flag > INDX_* environment variable > indx.toml > built-in default. Secrets come from environment variables, never the committed file, and are never logged or serialized.
Why it matters. What the config says is exactly what happens — and exactly what gets recorded into the manifest for reproducibility (principle 4). That predictability is what lets you audit and re-create a knowledge space.
See the configuration guide and the full configuration reference.
6. The SDK is the CLI with handles
Section titled “6. The SDK is the CLI with handles”The SDK is the product, and the CLI is a thin Typer view over it. Any capability in the CLI exists in the SDK, and vice versa — the two never drift.
Every command maps to a public SDK call. indx ./docs --out ./ai-ready is exactly DirectoryPipeline().run("./docs", "./ai-ready"). The CLI parses arguments, calls the SDK, and renders the result with Rich; it contains no business logic the SDK lacks.
CLI option names mirror config and SDK parameter names, and a parity test asserts the mapping. New features land in the SDK first, with the CLI command added in the same change.
Why it matters. Notebook, automation, and shell users share one mental model. Moving from a prototype to an automated pipeline never means relearning the tool.
See the SDK reference and the CLI reference.
# cli.py stays thin — the SDK does the work, Rich does the view@app.command()def query(space: Path, text: str, k: int = 5) -> None: hits = KnowledgeSpace.load(space).search(text, k=k) render_hits(hits)7. Legible to a person and to an LLM
Section titled “7. Legible to a person and to an LLM”Output, errors, and logs are written to be read by both a human reviewer and a model. indx prefers explicit, structured, self-describing data.
A .indx archive is a plain Zip with a readable manifest.json and index.json. It is inspectable with ubiquitous tooling (unzip) and free of Python-only constructs like pickled blobs. No vendor types leak into core models, so a Document never stores a raw provider response — the artifact stays neutral and portable.
Why it matters. Legibility is what makes the knowledge space shippable. A teammate, an auditor, or a downstream agent can open the artifact and understand it without indx in the loop. That underpins both portability and the no-lock-in promise.
The data models reference and .indx archive reference document exactly what that legible output looks like.
At a glance
Section titled “At a glance”| # | Principle | One-line meaning |
|---|---|---|
| 1 | Protocol-first | Depend on typed interfaces, never on concrete backends. |
| 2 | Dependency-light core | Install fast, run air-gapped; heavy deps are lazy optional extras. |
| 3 | Fail loud, with context | Typed IndxError with what failed, where, and the fix. |
| 4 | Determinism | Same inputs + config + versions ⇒ byte-stable index.json. |
| 5 | Config is a contract | Validated, typed config; no hidden env behaviour. |
| 6 | SDK = CLI with handles | Full parity; the CLI is a thin view of the SDK. |
| 7 | Legible output | Self-describing data, readable by a person and an LLM. |