Bringing Your Own Component
Every heavy-lifting component in indx is a swappable adapter behind a typed protocol. When the built-ins don’t fit, you supply your own object. There’s no base class to inherit, no fork of indx, and no edits to core/.
This page covers bring-your-own (BYO) components written in code — a one-off class you instantiate and pass into a DirectoryPipeline.
For other paths:
- Publish an adapter as an installable package that resolves by name (so others can write
store = "weaviate"inindx.toml) — see authoring a plugin. - Add a stage rather than a component — see writing a custom stage.
Structural typing: why no base class
Section titled “Structural typing: why no base class”indx interfaces are typing.Protocol definitions, not abstract base classes. That means indx uses structural typing (duck typing with type-checking): any object whose methods match the protocol’s signatures satisfies it.
You never import and subclass Parser. You implement parse(...) with the right shape, and your object drops straight in.
The six component slots and their protocols are:
| Slot | Protocol | Key method(s) | Default |
|---|---|---|---|
parser | Parser | parse(path) -> ParsedDoc | docling |
llm | LLM | complete(prompt, *, system=, max_tokens=, temperature=) -> str | openai:gpt-5-mini |
vlm | VLM | describe(image, *, prompt=) -> str | none |
embedder | Embedder | dim: int; embed(texts) -> list[list[float]] | openai:text-embedding-3-small (dim 1536) |
store | Store | upsert(chunks), search(vector, k=5), delete(chunk_ids) | qdrant |
output | OutputWriter | name: str; write(space, dest, *, name=...) -> None | .indx |
The Default column is the shipped zero-config cloud stack, which needs OPENAI_API_KEY. The all-local alternative is ollama:qwen2.5 plus bge-m3 (dim 1024) — the local profile, an opt-in you select via pip install "indx[local]" or explicit flags and config. See registry and defaults.
For the exact, complete signatures of every slot, see the protocols reference.
Example 1 — a custom Parser
Section titled “Example 1 — a custom Parser”A Parser turns one file into a ParsedDoc. The example below is a minimal Markdown parser that splits on blank lines into paragraph blocks. It’s a plain class that implements parse and returns a core ParsedDoc — nothing more.
from pathlib import Path
from indx import DirectoryPipelinefrom indx.core.parsed import Block, ParsedDoc
class MyMarkdownParser: """Custom Parser: satisfies the Parser protocol structurally."""
name = "my-markdown" version = "1"
def parse(self, path: Path) -> ParsedDoc: text = path.read_text(encoding="utf-8") return ParsedDoc( source_path=str(path), parser=self.name, parser_version=self.version, blocks=[ Block(kind="text", text=p.strip(), order=i) for i, p in enumerate(text.split("\n\n")) if p.strip() ], )
pipeline = DirectoryPipeline(embedder="bge-m3", store="chroma")pipeline.use(parser=MyMarkdownParser()) # BYO parser instancespace = pipeline.run("./notes", "./out")The path argument is the resolved Path for one walked file. Read it and return normalized blocks in a ParsedDoc. The Chunk stage then uses those blocks to split the document with its structure intact.
Example 2 — chaining .use(...) and .drop(...)
Section titled “Example 2 — chaining .use(...) and .drop(...)”use() returns the pipeline, so component swaps chain fluently. You can also remove a stage entirely — drop("enrich") skips all LLM/VLM work, which helps when you have no model available or simply don’t want enrichment.
from pathlib import Path
from indx import DirectoryPipelinefrom indx.core.parsed import Block, ParsedDoc
class MyMarkdownParser: name = "my-markdown" version = "1"
def parse(self, path: Path) -> ParsedDoc: text = path.read_text(encoding="utf-8") return ParsedDoc( source_path=str(path), parser=self.name, parser_version=self.version, blocks=[ Block(kind="text", text=p.strip(), order=i) for i, p in enumerate(text.split("\n\n")) if p.strip() ], )
pipeline = ( DirectoryPipeline(embedder="bge-m3", store="chroma") .use(parser=MyMarkdownParser()) # BYO parser instance .drop("enrich") # skip LLM enrichment entirely)space = pipeline.run("./notes", "./out")Each drop has a predictable effect:
drop("enrich")removes the only stage that would call anLLMorVLM. Documents keep their detected type, but get no LLM-derived topics, tags, or summaries.drop("embed-pack")instead produces a graph-only space with no vectors.
See the pipeline overview for what each stage contributes.
Passing components: construction vs use()
Section titled “Passing components: construction vs use()”There are two equivalent ways to bind a component. Both accept either an instance (your BYO object) or a name string (a registered adapter).
# (a) At constructionpipeline = DirectoryPipeline( parser=MyMarkdownParser(), # instance embedder="bge-m3", # name string store="chroma",)
# (b) Via use() after construction (chainable, returns self)pipeline = DirectoryPipeline(embedder="bge-m3", store="chroma")pipeline.use(parser=MyMarkdownParser())Both forms are interchangeable, so pick whichever reads better. The keyword names are identical either way: parser=, llm=, vlm=, embedder=, store=, output=.
For any slot you leave unset, indx resolves the effective component by this precedence:
explicit code argument / use() > CLI flag > indx.toml > documented defaultA BYO object passed in code always wins over config or defaults. See the configuration guide and configuration reference for the full resolution rules.
The adapter authoring contract
Section titled “The adapter authoring contract”Whether your component is a throwaway class or a future plugin, honour the same five-point contract. It keeps your adapter portable and import-safe, and gives a clear error when a dependency is missing.
1. Implement the protocol — exactly
Section titled “1. Implement the protocol — exactly”Match the method names and signatures in the protocols reference precisely.
With structural typing, a near-miss (a wrong argument name or return type) doesn’t raise at bind time — it fails mid-run instead. Run mypy or pyright against your adapter to catch mismatches early.
2. Convert at the edge — never leak vendor types into core models
Section titled “2. Convert at the edge — never leak vendor types into core models”A ParsedDoc, Chunk, or Document must never hold a vendor object: no raw provider response, no qdrant_client.PointStruct, no LangChain Document, and so on.
Do all conversion to and from core types inside your adapter, at its boundary. Core models then stay vendor-free, so the resulting .indx archive is portable regardless of which backend produced it.
from indx import Chunk
class MyStore: name = "my-store"
def upsert(self, chunks: list[Chunk]) -> None: from my_vendor_sdk import Point # vendor type stays local points = [ Point(id=c.id, vec=c.embedding, payload=c.metadata) for c in chunks if c.embedding is not None ] self._client.upsert(points) # convert here, not in core/3. Lazy-import heavy deps, with a MissingDependencyError hint
Section titled “3. Lazy-import heavy deps, with a MissingDependencyError hint”Never import a heavy or optional dependency at module top level — that breaks indx’s light, air-gapped core. Import it inside the method that needs it, and when it’s absent, raise MissingDependencyError with an actionable pip install hint.
from indx.errors import MissingDependencyError
class MyEmbedder: dim = 768
def embed(self, texts: list[str]) -> list[list[float]]: try: from sentence_transformers import SentenceTransformer # lazy except ModuleNotFoundError as exc: raise MissingDependencyError( "MyEmbedder requires sentence-transformers. " "Install it with: pip install sentence-transformers" ) from exc model = SentenceTransformer("my-model") return [list(v) for v in model.encode(texts)]4. Be import-safe
Section titled “4. Be import-safe”Importing your adapter module must never fail because a backend is absent or a service is unreachable. Defer all heavy work — SDK imports, network connections, model loading — to construction or method-call time, not module import.
This lets the registry discover adapters cheaply, without paying for backends you aren’t using.
5. For Store, keep vectors on chunks
Section titled “5. For Store, keep vectors on chunks”The writer, not the store, materializes the portable embeddings/ layout. So a custom Store has one job:
- Accept
Chunkobjects whose.embeddingfield is already populated. - Store enough data to answer
search(vector, k). - Return
SearchHitobjects that point back to coreChunkinstances.
Don’t hide vendor payloads inside chunks, and don’t require the backing database to reopen a sealed .indx archive.
Where to go next
Section titled “Where to go next”- Protocols reference — the complete, normative signature for every slot.
- Writing a custom stage — add or replace a whole pipeline phase, not just a component.
- Authoring a plugin — package your adapter so it resolves by name via entry points.
- Adding a backend — contribute an adapter upstream into indx itself.