Classifiers¶
A classifier decides which of your classes a document belongs to — invoice vs. policy vs. receipt, or whatever set you define. It's a config artifact, a sibling to a schema: you author it as YAML, version it, and test it by uploading documents.
The design goal is low cost. Classifying a document can be expensive — a scanned page needs OCR or a vision model — so the classifier spends the minimum to reach a confident label and stops. Most documents are classified for effectively nothing; only the hard tail pays for a model call, and even then the cost is bounded to the first few pages.
How it works: the cost cascade¶
Every document runs through increasingly expensive tiers. The first tier that produces a confident label wins; the rest never run.
| Tier | Signal | Cost |
|---|---|---|
| 0 metadata | MIME type, extension, page count | free |
| 1 text | Cheap text-layer read of the leading pages (no OCR) | free |
| 2 keyword | Deterministic keyword / regex match on that text | free |
| 3 llm | A model classifies from the extracted text | small |
| 4 vision | A vision model classifies from the rendered page image | bounded |
Two properties keep this cheap:
- Short-circuit. A class that clears the keyword threshold at tier 2 never pays for the LLM or vision tier.
- Cost is O(1) in page count. The classifier only ever reads the first few
pages (the
window), so a 100-page scanned packet costs the same to classify as a 2-page one.
Tiers 3 and 4 require a model endpoint configured in your
Model Catalog; tier 4 additionally needs a vision-capable
model. If a free tier decides the label, no model is needed at all. But if the
cheap tiers can't decide and no model endpoint can be reached, the classifier
fails rather than returning unknown — an outage must not be mistaken for
"looked and couldn't tell", because a classify step's unknown sends a
document down its pipeline's default route.
Input formats. Tier 1 reads page text from a PDF. For a text-like document
(.md, .txt, .csv, .json, .yaml, .html) the bytes are the text and
are read directly. For any other format the parse stage handles — .docx,
.xlsx — the classifier falls back to the text the pipeline already parsed. A
scanned PDF has no text layer, so it skips to the vision tier as designed.
Defining a classifier¶
A classifier is YAML with a classify block (cost controls) and a classes map:
name: inbound_mail
description: Route incoming documents by type
classify:
window: 3 # default leading pages to consider
scan: head # head | head_and_tail
max_tier: 4 # cost ceiling: 0 meta · 1 text · 2 keyword · 3 llm · 4 vision
on_unknown: return # return "unknown", or reject (422)
classes:
invoice:
description: A vendor bill
keywords: ["invoice", "amount due", "remit to"]
window: 2 # per-class cost dial — short docs, look at fewer pages
policy:
description: An insurance policy with declarations
keywords: ["declarations", "insuring agreement", "policy number"]
window: 5 # prone to a routing slip stapled on top — look deeper
certificate:
description: ACORD-style certificate of liability
keywords: ["certificate of liability"]
patterns: ["ACORD\\s*25"]
Classes¶
Each entry under classes is a label you can receive back. A class may declare:
| Field | Purpose |
|---|---|
description |
Human description; also given to the LLM/vision tiers as the class definition. |
keywords |
Case-insensitive keyword signals for the free keyword tier. Multi-word entries match as a phrase; single words match whole-word. |
patterns |
Regular-expression signals for the keyword tier (case-insensitive). |
exclude_keywords |
Disqualifying keywords. If any appears in the window text, this class is ruled out — it can't win the keyword tier and is removed from the LLM/vision candidate list. |
exclude_patterns |
Disqualifying regexes, same rule-out semantics as exclude_keywords. |
window |
Per-class override of how many leading pages to consider. |
Disqualifying signals¶
keywords/patterns say "this document might be class X." exclude_keywords/exclude_patterns say the opposite — "if the document has this, it is definitely not class X." An excluded class is a hard, deterministic gate across every tier: it can't score on keywords, and it isn't even offered to the LLM or vision model, so nothing can pick it.
This is how you route classes that share vocabulary with a class they must not be confused for. A standalone commercial umbrella and a package policy both mention "schedule of underlying insurance," so no positive keyword can separate them — but a package carries its own coverage-part declarations, which an umbrella never does. Exclude the umbrella class when those appear:
classes:
umbrella:
description: A standalone commercial umbrella / excess policy
exclude_keywords:
- "commercial property coverage part"
- "commercial general liability coverage part"
package:
description: A commercial package policy with its own property/GL coverage parts
Disqualification needs textual evidence: a scanned PDF with no text layer that reaches the vision tier has nothing to match, so no class is excluded there. The engine only matches the strings — which strings rule out which class is entirely your configuration, so nothing document-type-specific lives in the engine.
unknown is reserved — you can't name a class unknown, because it's the label
returned when nothing matches.
Cost controls (classify)¶
| Field | Default | Purpose |
|---|---|---|
window |
3 |
Default number of leading pages to read. |
scan |
head |
Where the window samples from — head, or head_and_tail when junk trails too. |
max_tier |
4 |
The cost ceiling. Set to 2 to stay entirely free and accept unknown for anything the deterministic tiers can't decide. |
on_unknown |
return |
return surfaces unknown; reject fails the request with 422 so a caller can hard-branch on it. |
window is your cost dial. You know your documents better than we do: set a
small window for short document types and a larger one for types that arrive with
cover sheets stapled on top. The cascade still short-circuits — a keyword hit
never pays for a model call regardless of the window.
Cover pages and junk on top¶
The first page isn't always the document — fax cover sheets, routing slips, and
sticky-note scans land on top. The classifier handles this generically: it ranks
the pages in the window by information density so a near-empty cover page sinks
below the real document, and the result reports the evidence page the label
came from — so a misclassification caused by a cover sheet is easy to spot. Use
scan: head_and_tail when junk sometimes trails the document too.
Scanned documents¶
A scanned document has no text layer, so the free tiers find nothing and the classifier escalates to the vision tier (4): it renders the leading pages to images and asks a vision-capable model. Cost stays bounded to the window — the classifier never renders the whole document just to label it.
Tier 4 needs two things: a model endpoint that accepts image input, and a parse
provider that can render pages. Both are platform capabilities and are always
available — configuring your own parse endpoint changes how text is extracted
and nothing else. When a tier can't run, the outcome says so in reason rather
than returning a bare unknown.
Testing a classifier¶
Testing runs the exact same cascade production would — nothing is simulated, and nothing is persisted.
Dashboard. Open the classifier, edit its config, and use the Test panel to upload a document. You'll see the label, confidence, the tier that produced it (so you can see what it cost), the evidence page, and the per-class deterministic scores.
CLI.
API. POST /api/classify with the document and either an inline config
or the slug of a registered classifier — see the
API Reference.
Referencing by slug runs the classifier's released version (add
classifier_version to pin one). Prefer it in production code: it is one round
trip instead of fetching yamlSource and posting it back, and re-tuning ships
via koji classify release with no consumer redeploy — which matters,
because tuning a classifier normally takes several versions. The response echoes
classifier and classifier_version so you can see exactly what ran.
For a document over the 4.5 MB request-body cap, upload it with the presigned
flow and pass storage_key instead of the bytes — and note the config field
takes a YAML string as well as an object, on both the multipart and JSON
forms. There is also no need to slice pages client-side: the cascade only reads
the pages window selects, so window: 1 reads one page no matter how long the
document is.
The response:
{
"label": "invoice",
"confidence": 0.9,
"method": "keyword",
"tier_used": 2,
"evidence_page": 2,
"scores": [
{ "id": "invoice", "score": 0.9, "hits": 3, "total": 3, "evidence_page": 2 }
]
}
method names the tier that decided (keyword, llm, vision, or unknown),
and tier_used is its numeric cost tier.
An unknown also carries a reason naming the tiers that couldn't run and what
was missing:
{
"label": "unknown",
"confidence": 0,
"method": "unknown",
"tier_used": 1,
"evidence_page": null,
"reason": "no extractable text layer, so the keyword and LLM tiers had nothing to read; vision tier not allowed by maxTier=3"
}
Read it before you tune keywords: "the classifier looked and couldn't tell"
needs a config change, while "the classifier never got to look" (no vision-
capable model on the endpoint, or a cost ceiling below tier 4) does not. In a
pipeline the same text lands on the classify step's output as reasoning.
Versioning¶
Classifiers version exactly like schemas — a draft you edit freely, release candidates, and a live released version:
koji classify versions inbound_mail # list released + candidate versions
koji classify release inbound_mail # release the current config directly
koji classify promote inbound_mail # graduate the latest candidate to live
The same lifecycle is available from the dashboard (Save draft, Save as candidate, Release, Promote) and the API. See the CLI Reference for the full command set.
Corpus & backtesting¶
A classifier can hold a corpus — documents labelled with the class they should be assigned — the same way a schema holds ground-truth documents. This is what lets you tune a classifier against real numbers instead of guessing: widen a class's keywords, re-run the corpus, and see whether recall on another class dropped.
A label is { label: "<class id>" }, where the id is one of the classifier's
released classes (or unknown — asserting a document should fall through,
which is exactly what an on_unknown: reject config needs to test).
Corpus documents live in a project-level pool shared with schema corpora, so
a PDF uploaded once can be labelled for a schema and for a classifier without
re-uploading — attach it by document_id:
# via the API
curl -X POST .../api/classifiers/inbound_mail/corpus \
-H 'content-type: application/json' \
-d '{ "document_id": "<pool doc id>", "label": "invoice" }'
See the API Reference for the full corpus
endpoints and GET /api/corpus/documents
to list the pool.
Bootstrap labeling — label by reviewing, not typing¶
Labeling a corpus from zero is the real cost of a backtest. Bootstrap does
the first pass for you: it runs the classifier at max_tier: 4 (the most
accurate cascade) over the project's unlabeled pool documents and writes each
result as a draft label. Labeling then becomes reviewing a list — confirm or
correct — instead of filling one in.
koji classify corpus bootstrap inbound_mail --limit 25 # propose draft labels
koji classify corpus ls inbound_mail # review: draft labels show as "invoice?"
koji classify corpus approve inbound_mail doc.pdf # accept the proposal
koji classify corpus approve inbound_mail other.pdf --label policy # correct, then accept
A draft is never scored by a backtest until you approve it — otherwise the
classifier would be graded against its own guesses, which measures nothing.
Approval promotes the draft into the scored ground truth. Bootstrap only touches
documents not already in the classifier's corpus, and is bounded per call (≤ 50
docs), so a large pool is labelled in review-sized batches — run it again to
continue. Draft rows are marked authored_via_agent, so an audit can always
tell a machine's first guess from a human's confirmation.
Running a backtest¶
Once the corpus is labelled, backtest a classifier version against it — from the CLI (labels the corpus and reads the result in the terminal):
koji classify corpus add inbound_mail invoice ./samples/*.pdf # label some docs
koji classify validate inbound_mail # backtest + render
koji classify validate inbound_mail --version v1.2.0 --check # pin a version; fail on regression
or directly over the API:
curl -X POST .../api/classifiers/inbound_mail/validate \
-H 'content-type: application/json' \
-d '{}'
The run classifies every labelled document through the same cascade production
uses and scores predicted vs. ground truth. By default it backtests the
released version; pass { "version": "v1.2.0" } (a semver label or a
version-id prefix) to pin a specific one — the same selector the classify run
and pipeline routes use, so a backtest and a live route agree on the same config.
The result carries the diagnostics you tune against:
accuracyand per-document counts (failed documents — provider outages — are excluded from the denominator, not scored as wrong).byClass— precision / recall / F1 per class.confusion— the expected→predicted matrix. With more than two classes, which class a document was mistaken for is the actionable signal: it points at the keywords to tighten.tierHistogramandescalationRate— the share of documents that needed the paid LLM/vision tail (tier ≥ 3), so raisingmax_tierhas a measured cost, not a guessed one.flips— fixed / regressed / churned vs. the previous run, so a change that lifts one class while quietly breaking another is visible.
For a large corpus, pass { "async": true }: the call returns 202 { runId }
immediately and fans the work out one document per job. Poll
GET /api/classifiers/{slug}/validate/runs/{runId} for progress and the final
result; GET /api/classifiers/{slug}/validate returns the most recent completed
run. See the API Reference.
Gating a promotion on no regressions¶
Tuning is a balancing act: widening one class's keywords to lift its recall can also make those keywords match a different class's documents — dropping the other class's recall and leaking cross-class false positives. Per-class metrics make that visible; a promotion gate makes it blocking, so a candidate that regresses a class you weren't watching can't quietly go live.
Gate a promotion on the candidate's latest backtest:
# refuse if ANY class dropped vs. the live release
koji classify promote inbound_mail --require-no-regressions
# refuse only if specific classes regressed
koji classify promote inbound_mail --must-not-regress policy --must-not-regress coi
# require an absolute floor, regardless of the baseline
koji classify promote inbound_mail --min-recall coi=0.95 --min-precision policy=0.9
The candidate is compared against the live release's most recent backtest (the "before"). If a guarded class regressed or fell under a floor, the promotion is refused and each offending class is listed with its before → after numbers:
✗ promotion blocked — inbound_mail would regress:
• coi recall 100% → 91%
• coi precision 100% → 80%
Fix the regression, re-validate, and promote again. The same gate is available
on the API (POST /api/classifiers/{slug}/promote with requireNoRegressions /
mustNotRegress / minRecall / minPrecision). A gate needs a completed
backtest of the candidate to evaluate — without one, the promotion is refused
rather than passed blindly. koji classify release is the explicit un-gated
path: it releases directly, skipping the candidate/backtest loop by design.
Managing classifiers¶
Create, edit, and version classifiers from the dashboard (Classifiers in the
sidebar), the koji classify CLI, or the /api/classifiers endpoints — whichever
fits your workflow. The config artifact is the single source of truth across all
three.
The classifier detail page carries three tabs, mirroring a schema:
- Config — edit the YAML, save drafts/candidates, release, promote.
- Corpus — label documents by picking a class from a dropdown: upload, attach one from the shared project pool, or auto-label the unlabeled pool (draft proposals to approve). See Corpus & backtesting.
- Validate — run a backtest and read the confusion matrix, per-class precision/recall, tier histogram + escalation rate, cost, and flips vs. the previous run.