Skip to content

DocumentClassifier

The port that says what a document is, when a request asks.

Protocols:

  • DocumentClassifier.id: ClassifierId
  • DocumentClassifier.classify(text: str) -> Mapping[str, tuple[LabelScore, ...]]
  • DocumentClassifierProvider.document_classifiers() -> tuple[DocumentClassifier, ...]

DocumentClassifier is the boundary for saying what a document is: its type, the unit that produced it, the industry it belongs to. It is asked once per document, after every reader has produced its pages, and it is handed text rather than bytes — the full text the document was read as.

The answer maps a facet to labels. A facet is a name the classifier chose (document_type, industry), and nothing in indx enumerates them; each label is a LabelScore, a name and a number in [0, 1], ordered highest first. A facet absent from the answer is “no opinion”, and the next classifier the request enabled is asked for it. Unlike a LanguageScore, this is a claim about subject matter — which is why it is a separate port under a separate reserved key.

Three distributions ship through the same entry-point group a third party uses: indx-classifier-words (word lists and thresholds, no extra, the one a default install carries), indx-classifier-zeroshot (a multilingual NLI model through onnxruntime, behind the zeroshot extra) and indx-classifier-llm (any model LiteLLM reaches, behind the llm extra and INDX_CLASSIFIER_LLM_MODEL).

A classifier is not a capability kind. CapabilityKind is closed, the ladder over it is closed, and a label routes nothing — it is the LanguageDetector shape: a port applied after reading, changing execution output rather than a routing decision.

It differs from a detector in one way that shapes the rest. A detector is free and runs whenever installed; a classifier costs a call — a model pass, a token, or an off-box request — so nothing runs that the request did not enable. That is why a classifier carries an id where a detector carries none, why EncodeRequest.classification names the IDs it wants in the order it wants them asked, and why the installed IDs are advertised on the capability snapshot so a caller can discover them. They ride the snapshot outside its content hash, like resolvable: enabling is per request, so installing a classifier changes no decision an outstanding plan made.

It is one of five ports rather than one of one. The grid is the unit handed in — document, page, chunk — against the return shape — labels or spans — and this port is the document-and-labels cell. The page and chunk classifiers are the same answer at a finer unit; the entity extractors are the other return shape. All five share one ID namespace, so no two may claim one string.

The input is the full text the document was read as, in page order. Sampling is not the contract’s: an implementation that needs a bound — a token window, a cost ceiling, a scan it does not want to run twice — applies its own, configured by its own settings model and using the dependency-free helper in indx-interfaces (ADR-0033). A classifier that reads everything it is given is paying for a 300-page filing what it does not pay for a three-page one, and that is now its own decision to make rather than one the request made for it.

A raise is a verdict about the classifier rather than about the source, so the executor logs it and asks the next one: an encode that already paid to read a document must not fail over an annotation asked for afterwards.

Two refusals fire before the source is fetched, both 422 invalid_classifier. An ID nothing installed declares is unknown_classifier, and the message enumerates the IDs that are. A classifier whose device is external is data_residency when the request carries that constraint — refused rather than skipped, because a classifier silently left out is an answer the caller believes was given and was not.

Four attributes sit outside the protocol deliberately, read with defaults: device (CPU when absent), cost_usd set after classify() the way VectorEncoder sets it and added to the run’s usage.cost_usd, builtin = True for one indx ships, and facets, a tuple[str, ...] naming what the classifier can answer.

facets is what makes “no facet outstanding” knowable. A request names classifiers and never facets, so without the declaration the executor had to ask every enabled classifier and bill the call even when the first one had already taken every facet the second could have answered. Absent means unknown, and an undeclared classifier is always asked. Declared, the classifier is skipped whole — no call, no cost, and not named in the trace — once every facet in the tuple is taken for that unit. The three shipped classifiers declare the facets of the taxonomy they loaded, so classification.document_ids = ["words", "llm"] over the shipped table pays for the LLM call only for the facets the word lists left open.

  • Answer per facet, and leave a facet out rather than guessing at it. A label under the classifier’s own floor is no label.
  • Order each facet descending, so a caller reading [0] reads the answer.
  • Report a number that means what the classifier can defend. The word-signature ratio and the NLI entailment probability are both self-reports, and neither is calibrated against a labelled corpus.
  • Keep module scope cheap. Discovery imports every provider module while building a snapshot, so the engine import belongs inside classify() and building the model behind the first call.
  • Advertise nothing when unable to run. Without its extra, or without a model configured, a distribution declares no classifier at all rather than a broken one — the hosted-embedding rule.

DocumentExecutor.encode resolves the enabled IDs and checks residency first, reads the document, asks each enabled classifier in request order, and writes the merged answer to the document block’s metadata under CLASSIFICATION_METADATA_KEY as {facet: [{"label", "confidence"}, ...]}. The first classifier with an opinion on a facet wins it; a document nothing could read is never classified and carries no key.

The key is reserved: EncodeRequest.metadata refuses classification outright, as it refuses languages, so a caller’s label is never silently replaced.

POLICY_VERSION did not move for any of this, and the capability snapshot ID is byte-identical before and after: a label is an annotation on output, and the snapshot field that names the classifiers is excluded from the hash by design.