Creating an ingest pipeline plugin¶
An ingest pipeline turns a source document into a normalized IngestResult
that shared conversion writes into a .vera archive. vera-ingest-pymupdf
and vera-ingest-docling are both ordinary plugins built on this contract —
nothing in vera-ingest, vera-cli, or vera-app special-cases either one.
Write your own to support a new source format, a different parsing engine, or
an experimental chunking strategy. Name the package after the engine
(vera-ingest-example), not the file type; advertise extensions on
PipelineCapabilities.source_formats. Planned non-PDF ingest and Markdown
grounding are in Additional source formats and visual grounding.
Registry and descriptor APIs (register_ingest_pipeline,
register_ingest_pipeline_descriptor, PipelineDescriptor, and related
discovery helpers) are experimental and may change before 1.0.
See Architecture for how pipeline packages fit into the
rest of VERA, and Pipeline options for how
--pipeline-option and descriptors work from the user's side.
The contract¶
A pipeline is any callable matching this shape — a plain function, or an
object implementing __call__ if it needs to hold state. There is no base
class to inherit from:
For pre-0.3.x plugins, an object exposing a callable ingest(self, source_path,
options) method still works too — vera_ingest.pipeline.invoke_ingest_pipeline
calls that method when present and falls back to calling the pipeline itself
otherwise. New pipelines should prefer a plain callable; see
reference implementations below for when a
class (with __call__) is still the right choice.
IngestRequest carries the resolved variant, a cancellation token, and an
opaque pipeline_options mapping your pipeline owns and validates itself.
IngestResult returns:
pages— oneParsedPageper page (page_number,width,height,text);blocks— oneIngestBlockper layout unit (block_id,page_number,block_type,text, optionalbbox/heading_level/image bytes/regions);chunks— oneIngestChunkper retrievable unit (chunk_id,text,page_start/page_end,heading_path,token_count,block_ids);parser_name,parser_version,chunking_strategy— recorded in archive metadata forvera inspect;diagnostics— free-form dict recorded under archive metadata"ocr".
Shared conversion enforces a few invariants on the result before writing it:
block and chunk IDs must be non-empty and unique, every chunk's block_ids
must reference a real block, and every chunk's text must be non-empty.
Image blocks with image_bytes are stored as figure attachments. Convert
associates those attachments with search hits only through chunk.block_ids,
so include each image block ID on a nearby same-page chunk or figures are
saved but omitted from --figures.
Parsers often emit ParsedBlock (the same layout fields without a stable
block_id). Convert with IngestBlock.from_parsed(block_id, block) before
returning IngestResult.blocks. Pipelines that mint IDs while parsing can
construct IngestBlock directly.
Reusable chunking helpers¶
vera_ingest.chunking counts whitespace-split words, not characters.
build_chunks_from_blocks— heading-aware sliding windows over(block_id, ParsedBlock | IngestBlock)pairs. PyMuPDF uses this; custom pipelines with structured layout should too.chunk_pages/detect_heading— public helpers for custom pipelines that only haveParsedPage.text. First-party pipelines do not call them.
Both sliding-window helpers clamp overlap to chunk_size - 1 so carry never
overruns. PyMuPDF's descriptor still advertises overlap maximum 1000, which
can exceed a small chunk_size; the clamp is the runtime constraint.
Minimal example¶
This plugin ingests plain-text files as a single page/block/chunk — enough to exercise the full contract without a parsing dependency.
pipeline.py:
from __future__ import annotations
from pathlib import Path
from vera_ingest.types import (
IngestBlock,
IngestChunk,
IngestRequest,
IngestResult,
ParsedPage,
ensure_ingest_request,
)
from .options import ExampleOptions
def example_pipeline(source_path: str, options: IngestRequest) -> IngestResult:
"""Whole-file ingest pipeline for plain-text sources."""
request = ensure_ingest_request(options)
config = ExampleOptions.from_mapping(request.pipeline_options)
text = Path(source_path).read_text(encoding="utf-8", errors="replace")
block = IngestBlock(
block_id="block_000001",
page_number=1,
block_type="paragraph",
text=text,
)
chunk = IngestChunk(
chunk_id="chunk_000001",
text=text[: config.chunk_size] if config.chunk_size else text,
page_start=1,
page_end=1,
heading_path="",
token_count=len(text.split()),
block_ids=[block.block_id],
)
return IngestResult(
pages=[ParsedPage(page_number=1, width=None, height=None, text=text)],
blocks=[block],
chunks=[chunk],
parser_name="example",
parser_version="0.1.0",
chunking_strategy="whole_file",
)
options.py has two jobs: validate a raw pipeline_options dict into a typed
config (from_mapping), and describe that same config for CLI/GUI discovery
(describe_pipeline, see descriptors).
Doing both from one dataclass — instead of writing every setting's name,
default, and description out twice — is what dataclasses.field(metadata=...)
buys you below: metadata is a plain dict dataclasses let you attach to a
field; it does nothing on its own, but fields_from_dataclass (used in
describe_pipeline) reads it back out to build the descriptor, and
vera_ingest.pipeline_options.PipelineOptions reads the same metadata to
validate pipeline_options — so a straightforward pipeline like this one
doesn't need to write from_mapping at all:
from __future__ import annotations
from dataclasses import dataclass, field
from vera_ingest.descriptors import PipelineCapabilities, PipelineDescriptor, fields_from_dataclass
from vera_ingest.pipeline_options import PipelineOptions
@dataclass(frozen=True)
class ExampleOptions(PipelineOptions):
# `default=2000` is the real dataclass default. `metadata={...}` is
# inert until something reads it: `fields_from_dataclass` (used in
# `describe_pipeline` below) reads it to build the CLI/GUI descriptor,
# and the inherited `from_mapping` reads it to validate `pipeline_options`.
chunk_size: int = field(
default=2000,
metadata={
"label": "Chunk size",
"description": "Maximum characters kept from the file.",
"minimum": 100,
"maximum": 3000,
},
)
def describe_pipeline(variant: str = "") -> PipelineDescriptor:
return PipelineDescriptor(
provider="example",
variant="",
spec="example",
label="example — plain-text pipeline",
description="Whole-file ingest for plain-text sources.",
capabilities=PipelineCapabilities(
chunk_unit="characters",
overlap_supported=False,
ocr_supported=False,
ocr_dpi_supported=False,
source_formats=("txt",),
),
fields=fields_from_dataclass(ExampleOptions),
)
Traced through concretely:
ExampleOptions.from_mapping(None)→ExampleOptions(chunk_size=2000)(falls back to the dataclass default).ExampleOptions.from_mapping({"chunk_size": 500})→ExampleOptions(chunk_size=500).ExampleOptions.from_mapping({"chunk_size": 0})→ raisesValueError("chunk_size must be between 100 and 3000") —from_mappingenforces advertisedmetadata["minimum"]/metadata["maximum"], not just "must be a positive integer."ExampleOptions.from_mapping({"chunk_size": 99999})→ the same range error.ExampleOptions.from_mapping({"typo": 1})→ raisesValueError("Unknown Example option(s): 'typo'"), since"typo"isn't a real field.fields_from_dataclass(ExampleOptions)walks the onechunk_sizefield and returns onePipelineField(key="chunk_size", type="integer", default=2000, label="Chunk size", description="Maximum characters kept from the file.", minimum=100, maximum=3000)—key/type/defaultcame from the field itself (typeinferred from theintannotation); only the human-facinglabel,description, and bounds needed to be spelled out, and only once.
When not to inherit PipelineOptions¶
PipelineOptions.from_mapping only knows how to validate four field shapes:
a bool, an int (bounded by metadata["minimum"] / metadata["maximum"]
when those are set; otherwise non-negative), a str restricted to
metadata["choices"] (unless allow_custom is set), or free-text str. Both
real pipelines (vera-ingest-pymupdf, vera-ingest-docling) fit entirely
within those four shapes and inherit PipelineOptions as-is, with no
from_mapping of their own. If a future field ever needs something those four shapes can't express
— type conversion beyond bool/int/str, a cross-field check, or normalizing a
value rather than just validating it — override from_mapping on your own
subclass and write it with the vera_doc.option_parsing helpers directly,
the same way both plugins' Options classes worked before PipelineOptions
existed. Two class attributes cover the common customizations without an
override: options_label (the name used in error messages) and
ignored_keys (legacy pipeline_options keys to silently drop instead of
rejecting as unknown — see DoclingOptions.ignored_keys for overlap/
ocr_dpi, PyMuPDF-only legacy CLI aliases Docling doesn't have fields for).
prepare_pipeline_options also withholds Tesseract-shaped convert()/CLI
aliases (ocr_language, ocr_dpi, ocr_download) when
capabilities.ocr_engine is not "tesseract", even if the plugin advertises
an ocr_language field with a different vocabulary (Docling/RapidOCR).
__init__.py exposes the entry-point factories. create_pipeline returns the
bare function itself — there's nothing to instantiate:
from __future__ import annotations
from vera_ingest.descriptors import PipelineDescriptor
from vera_ingest.pipeline import IngestPipeline, UnknownIngestPipelineError
from .options import describe_pipeline
from .pipeline import example_pipeline
__all__ = ["create_descriptor", "create_pipeline", "example_pipeline"]
def create_pipeline(variant: str = "") -> IngestPipeline:
if variant not in {"", "default"}:
raise UnknownIngestPipelineError(f"Unknown 'example' pipeline variant {variant!r}.")
return example_pipeline
def create_descriptor(variant: str = "") -> PipelineDescriptor:
return describe_pipeline(variant)
pyproject.toml registers both under VERA's entry-point groups:
[project]
name = "vera-ingest-example"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["vera-ingest"]
[project.entry-points."vera.ingest_pipelines"]
example = "vera_ingest_example:create_pipeline"
[project.entry-points."vera.ingest_pipeline_descriptors"]
example = "vera_ingest_example:create_descriptor"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Install it editable and it's immediately available by name:
python -m pip install -e ./vera-ingest-example
vera convert "notes.txt" "notes.vera" --parser example
vera convert "notes.txt" "notes.vera" --parser example --pipeline-option chunk_size=500
An unresolved --parser name fails before parsing and lists installed
providers, which doubles as a quick check that discovery picked up your
plugin:
A broken entry point does not raise during discovery. Import failures are
logged as warnings and omitted from that installed-providers list. Inspect
them with list_ingest_pipeline_load_errors() after list_ingest_pipelines()
is empty of your provider, then fix the import and restart the process
(entry-point load errors are recorded once until
reset_ingest_pipeline_registry()).
Try it without publishing a package¶
Entry points require an installed distribution. For a quick local experiment,
register a pipeline in-process instead — useful in a script, notebook, or
test, but only for the lifetime of that process. Both register_ingest_pipeline
and register_ingest_pipeline_descriptor also work as decorators when you
omit the factory argument:
from vera_ingest.pipeline import register_ingest_pipeline, register_ingest_pipeline_descriptor
from vera_ingest import convert
from vera_ingest_example.pipeline import example_pipeline
from vera_ingest_example.options import describe_pipeline
@register_ingest_pipeline("example")
def create_pipeline(variant: str = "") -> IngestPipeline:
return example_pipeline
register_ingest_pipeline_descriptor("example", describe_pipeline)
convert("notes.txt", "notes.vera", parser="example")
Pass replace=True (@register_ingest_pipeline("example", replace=True), or
the equivalent plain-call form) if you're iterating on the same provider name
across repeated calls, for example in a REPL.
Advertise configuration with a descriptor¶
A PipelineDescriptor (returned by describe_pipeline/create_descriptor)
is what lets generic code — vera convert's help text, the desktop app's
PipelineConfigForm, vera inspect — describe your pipeline's settings
without knowing anything provider-specific. Keep the package import and
create_descriptor free of optional runtime libraries so discovery can
advertise the pipeline without importing optional runtime dependencies.
vera-ingest-docling sets installed=False when docling / docling_core
are missing, so Convert shows the install hint instead of a plugin load error.
capabilities(PipelineCapabilities) tells clients which legacy CLI aliases apply.field_keys()from yourfieldstuple determines which ofconvert()'s compatibility kwargs (chunk_size,overlap,ocr_mode, ...) get forwarded intopipeline_optionsfor your provider — omit a field and that alias is silently dropped for you rather than leaking in unexpected. Omittedconvert()kwargs (Python APINonedefaults) are not forwarded at all, so yourOptionsdefaults apply; the CLI still passes its argparse defaults.fields(PipelineFieldtuple) drives generated forms: type, default, bounds, and enum choices.
vera_ingest.descriptors.fields_from_dataclass builds that fields tuple
directly from your Options dataclass instead of a hand-maintained, parallel
list: a field's key and default come from the dataclass field itself, and
its metadata mapping supplies the rest (label, description, unit,
minimum/maximum/step, choices, allow_custom, placeholder). A
field's type is inferred from its annotation (int → "integer", bool →
"boolean", str → "string") unless metadata["type"] overrides it — used
for "enum" fields, which are plain str at the type level but have a fixed
choices list. A dataclass field with no metadata is treated as internal
and omitted from the descriptor. vera-ingest-pymupdf and
vera-ingest-docling both build their descriptors this way — see their
options.py for larger examples with enum and boolean fields.
from_mapping (inherited from PipelineOptions in ExampleOptions above,
or written by hand for pipelines that need more) is what validates
pipeline_options, using the same field metadata — including
minimum/maximum — that fields_from_dataclass copies into the descriptor
for CLI/GUI forms.
Validate and compare against a baseline¶
Test the pipeline directly first, without touching the registry:
from vera_ingest.types import IngestRequest
from vera_ingest_example.pipeline import example_pipeline
result = example_pipeline("notes.txt", IngestRequest(pipeline_options={"chunk_size": 100}))
assert result.chunks
Then convert and inspect/validate the archive end to end:
vera convert "notes.txt" "notes.vera" --parser example
vera inspect "notes.vera" --json
vera validate "notes.vera" --json
For a visual layout check against a PDF pipeline, use the contributor
vera-lab tool (workspace dev extra). It runs the
pipeline without writing an archive and emits an HTML report with block/chunk
overlays and layout lint:
vera-lab "manual.pdf" -o report.html --parser pymupdf
vera-lab "manual.pdf" -o compare.html --parser pymupdf --parser docling
Desktop app plugins¶
Source-run and packaged builds use one sidecar interpreter. Extra ingest and embedding plugins are pip packages in the same environment.
- Install
vera-docandvera-ingest0.3.x plus your plugin (python -m pip install …orpython -m pip install -e <clone>). From a checkout,uv sync --extra app --extra onnxcovers the desktop sidecar MiniLM path. Add--extra mlfor other Sentence Transformers models and--extra doclingonly for CLI Docling tests. - Install every import the sidecar will need in that interpreter. MiniLM
(
onnxruntime+tokenizers) is theonnxextra for CLI and source-run (uv sync --extra onnx). The packaged desktop app already freezes it and vendors a VERA-exported MiniLM graph; hashing needs no extra install. Other Hub Sentence Transformers models still needuv sync --extra ml. - Restart the desktop app so Convert reloads
describe_ingest_pipelines. Bundledpymupdfwins on duplicate names. The 0.3.0 sidecar does not listdocling. RawPYTHONPATHfolders are not discovered.
See Plugins in the same environment.
To judge whether a new pipeline (or a chunking change within one) actually
retrieves better, run the same query set through vera eval against archives
produced by each pipeline and compare hit rate / MRR — see
Evaluation.
Reference implementations¶
vera-ingest-pymupdf— the simplest real pipeline: a plainpymupdf_pipeline(source_path, options)function, deterministic parsing, shared sliding-window chunking (build_chunks_from_blocks;chunk_pagesis available for custom page-text pipelines), selective Tesseract OCR — and itsPyMuPDFOptionsinheritsPipelineOptionsforfrom_mapping, same as this guide's example. Start here.vera-ingest-docling— a more involved pipeline:DoclingHybridPipelineis a class implementing__call__(needed because its recovery/fallback logic is decomposed into helper modules —mapping.py,converter.py,recovery.py— not because it holds state across calls), with its own chunker, layout/table/figure mapping, and page-level failure recovery.DoclingOptionsinheritsPipelineOptionstoo, usingignored_keysfor the PyMuPDF-only legacy aliases it doesn't support. Use it as a model once your pipeline needs more than a single function.
See also¶
- Architecture — package boundaries and where pipeline plugins fit.
- Convert documents — the user-facing side
of
--parserand--pipeline-option. - Additional source formats and visual grounding —
planned non-PDF ingest,
source_formats, and Markdown/PDF viewer surfaces. vera_ingestAPI reference —IngestPipeline,IngestRequest/IngestResult, registry, and descriptor types.