Bring Your Own Parser, Embedder, and Store
Priya’s team keeps their internal knowledge in a custom line-oriented format — think structured plain text with a proprietary header block — that none of the built-in parsers recognize. She also has a Chroma instance already running in staging. She doesn’t want to maintain a fork, and she refuses to subclass something just to swap one stage.
The blocker isn’t capability; it’s friction. What she needs is a seam.
What you’ll build
Section titled “What you’ll build”A pipeline that:
- Parses her custom file format with a class she wrote in twenty lines.
- Embeds with
bge-m3(built-in, no extra wiring). - Stores into her existing
chromainstance. - Skips LLM enrichment entirely — her documents are already well-structured.
By the end you’ll have a KnowledgeSpace built from scratch, with every component either hers or named from the built-in registry.
The principle
Section titled “The principle”Every slot in DirectoryPipeline — parser, llm, vlm, embedder, store, output — is backed by a typed Protocol. Protocols use structural typing: if your class implements the right methods with the right signatures, it satisfies the protocol. You don’t subclass anything. You don’t register anything. You just pass an instance.
This makes adapters tiny — an adapter only needs to import the slot it implements, so there are no transitive dependencies on the rest of the pipeline.
The build
Section titled “The build”-
Install indx.
Priya’s team uses Chroma, so she pulls in the store extra. The core is always included.
Terminal window pip install "indx[chroma]"No LLM key needed for this story — she’s dropping the
enrichstage entirely. -
Write the custom Parser.
The
Parserprotocol requires one method:parse(self, file) -> ParsedDoc. ImportParsedDocandSourcefromindx, and importParserfromindx.parsersif you want to type-annotate the class — it is never required to inherit from it.from indx import DirectoryPipeline, ParsedDoc, Sourcefrom indx.parsers import Parserclass MyMarkdownParser:"""Custom Parser: satisfies the Parser protocol structurally."""def parse(self, file) -> ParsedDoc:text = file.read_text()return ParsedDoc(source=Source(path=file.path, folder=file.folder, type="markdown"),text=text,blocks=[{"kind": "paragraph", "text": p} for p in text.split("\n\n")],)That’s the whole adapter.
ParsedDoccarries aSource(path, folder, type), the raw fulltext, and ablockslist — each block is a dict with at least"kind"and"text". Paragraph splitting on blank lines is a valid and common starting point; replacetext.split("\n\n")with your own segmentation logic. -
Wire it into the pipeline — two equivalent ways.
Pass the instance at construction time:
pipeline = DirectoryPipeline(parser=MyMarkdownParser(),embedder="bge-m3",store="chroma",)Or start with a pipeline and swap the slot with
.use():pipeline = (DirectoryPipeline(embedder="bge-m3", store="chroma").use(parser=MyMarkdownParser()))Both forms are identical at runtime.
.use()returnsself, so it chains naturally. -
Drop the stages you don’t need.
Priya’s notes are already structured — she doesn’t need LLM enrichment.
.drop("enrich")removes that stage before the pipeline runs. Named stages are:walk,parse,chunk,relate,enrich,embed-pack.pipeline = (DirectoryPipeline(embedder="bge-m3", store="chroma").use(parser=MyMarkdownParser()).drop("enrich") # skip LLM enrichment entirely) -
Run the pipeline.
space = pipeline.run("./notes", "./out")indx ./notes → ./out01 walk 38 files, 4 folders02 parse 38 ok, 0 skipped (MyMarkdownParser)03 chunk 291 chunks04 relate 88 relations06 embed 291 vectors → chroma, sealed notes.indxdone: 291 chunks, 38 docs, embed_dim=1024 (3.2s)Stage
05 enrichis absent —.drop("enrich")took effect. The(MyMarkdownParser)annotation in the parse line confirms her class ran, not the built-in.
Mixing custom and built-in freely
Section titled “Mixing custom and built-in freely”Priya used a custom parser with built-in embedder and store. She could just as easily flip any other combination:
- Custom embedder, built-in parser and store — write a class with the
Embedderprotocol’sembed(self, texts) -> list[list[float]]method. - Custom store, everything else built-in — implement the
Storeprotocol; the slot accepts any instance that satisfies it. - All custom, no built-ins — pass instances for every slot; the pipeline becomes a typed orchestrator over your own stack.
The other protocols follow the exact same pattern as Parser:
| Slot | Protocol | Import path |
|---|---|---|
| parser | Parser | from indx.parsers import Parser |
| llm | LLM | from indx.llm import LLM |
| vlm | VLM | from indx.llm import VLM |
| embedder | Embedder | from indx.embed import Embedder |
| store | Store | from indx.store import Store |
| output | OutputWriter | from indx.output import OutputWriter |
Each protocol lives in its own sub-package so an adapter imports only what it needs — no transitive dependency on the rest of the pipeline.
The full example
Section titled “The full example”from indx import DirectoryPipeline, ParsedDoc, Sourcefrom indx.parsers import Parser
class MyMarkdownParser: """Custom Parser: satisfies the Parser protocol structurally.""" def parse(self, file) -> ParsedDoc: text = file.read_text() return ParsedDoc( source=Source(path=file.path, folder=file.folder, type="markdown"), text=text, blocks=[{"kind": "paragraph", "text": p} for p in text.split("\n\n")], )
pipeline = ( DirectoryPipeline(embedder="bge-m3", store="chroma") .use(parser=MyMarkdownParser()) # BYO parser instance .drop("enrich") # skip LLM enrichment entirely)space = pipeline.run("./notes", "./out")Twenty lines. No base class. No registration. No fork.
What you learned
Section titled “What you learned”- Every
DirectoryPipelineslot is a typed Protocol — satisfy it structurally and pass an instance; inheritance is never required. DirectoryPipeline(parser=..., embedder=..., store=...)and.use(parser=...)are equivalent; code-time assignment is the highest-precedence form..drop("enrich")(or any named stage) removes that stage cleanly; the pipeline adapts around the gap.- Custom and built-in components mix freely — name strings resolve to the built-in registry, instances bypass it entirely.