Skip to content

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" in indx.toml) — see authoring a plugin.
  • Add a stage rather than a component — see writing a custom stage.

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:

SlotProtocolKey method(s)Default
parserParserparse(path) -> ParsedDocdocling
llmLLMcomplete(prompt, *, system=, max_tokens=, temperature=) -> stropenai:gpt-5-mini
vlmVLMdescribe(image, *, prompt=) -> strnone
embedderEmbedderdim: int; embed(texts) -> list[list[float]]openai:text-embedding-3-small (dim 1536)
storeStoreupsert(chunks), search(vector, k=5), delete(chunk_ids)qdrant
outputOutputWritername: 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.

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 DirectoryPipeline
from 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 instance
space = 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 DirectoryPipeline
from 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 an LLM or VLM. 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.

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 construction
pipeline = 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 default

A BYO object passed in code always wins over config or defaults. See the configuration guide and configuration reference for the full resolution rules.

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.

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)]

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.

The writer, not the store, materializes the portable embeddings/ layout. So a custom Store has one job:

  • Accept Chunk objects whose .embedding field is already populated.
  • Store enough data to answer search(vector, k).
  • Return SearchHit objects that point back to core Chunk instances.

Don’t hide vendor payloads inside chunks, and don’t require the backing database to reopen a sealed .indx archive.