The cheapest classifier is a business rule

· 6 min · document ai · ml · pipelines

The order-intake pipeline reads a customer's PDF and turns it into a sales order: pull the line items, resolve each code against item master data, write the order.

The product code is the whole hinge. It is the key that maps an extracted line to a real item in the ERP, and without a match there is no sales order line to create. Everything downstream (pricing, availability, the order itself) hangs off getting that one string right.

The extraction itself is a document-understanding model, trained on the line-item pages, the ones where every field a line needs is present. On those it works well.

Then it started inventing line items.

The cause was mundane. Most of these documents end with a summary page: a compact table of totals. That page repeats the product codes, but it carries none of the fields a line item needs. The extractor had never been taught that this page is different, so it did exactly what it was built to do: it found product codes and reported them. The result was extra lines, thin on data, mixed in with the real ones and indistinguishable to anything downstream.

What fixed it was not a better model. It was two constraints that cost nothing to run, and then a decision about where those constraints get their numbers from, which turned out to matter more than the constraints did.

Three ways out

Train a page classifier. Teach a model to recognise which page it is looking at, and only extract from the pages that qualify. This is the answer the industry reaches for, and it is not wrong. It is just not free: someone labels pages, someone owns retraining, someone notices when a customer redesigns their template and accuracy quietly degrades. You have added a second model to maintain in order to protect the first.

Label the summary page as a negative class. Cheaper than a new model: teach the existing one that this page yields nothing. Still labelling, still retraining, still a model whose behaviour on an unseen template is a guess.

State the invariant. A product code appears at most once in a sales order.

That third one is not a model. It is a sentence about how the business works, and it happens to be exactly the thing that separates a real line from a phantom one. It costs nothing to run, it needs no training data, it behaves identically on a template nobody has ever seen, and when it rejects something it can say precisely why.

We shipped the third one.

The part the rule did not cover

Dedup removed the repeats. It did not remove everything, because the extractor was also reading across two rows of the summary table and returning the two codes joined into one string. That is not a duplicate. It is a code that never existed, and it sails straight past a uniqueness check.

So the second constraint: a product code has a fixed length. A value that is not that length is not a product code, whatever the extractor believes.

There is a fair objection here. Every surviving code is looked up in item master anyway, and a value stitched together from two rows will match nothing, so why check the length at all?

Because the two checks do not cost the same. The length test is arithmetic on a string, so it can run on every candidate for free. The lookup is a resolution against item master, and it is not one uniform query: which fields identify an item varies by customer, so resolving a code means doing per-customer work. Spending that on values already known to be impossible is paying the expensive check to learn what the cheap one would have said. Cheap filter first, expensive check second, and the expensive one only ever sees plausible input.

What extraction returns line-item pages 3 codes, all fields summary page 2 codes, no fields candidates RG-18Y-2010 PD-18W-0442 ER-14R-1188 RG-18Y-2010 PD-18W-0442ER-14R-1188 a repeat, and two rows read as one unique? once per order right length? from item master accepted 3 lines Neither check is a model. Both cost nothing to run, both explain their own rejections, and both behave the same on a template nobody has ever seen. What extraction returns line-item pages 3 codes, all fields summary page 2 codes, no fields candidates RG-18Y-2010 PD-18W-0442 ER-14R-1188 RG-18Y-2010 PD-18W-0442ER-14R-1188 a repeat, and two rows read as one unique? once per order right length? from item master accepted 3 lines Neither check is a model. Both cost nothing, and both explain their own rejections.
Two constraints, neither of them learned. The uniqueness rule removes the repeat; the length check removes a value that was never a product code at all.

Do not configure what you can derive

The fixed length is not a constant. It is fixed per customer: every customer has their own coding scheme, and each scheme has its own width.

The obvious next step is a configuration table: a row per customer, a length, maintained by whoever onboards the next one. That is better than a regex in the parser, and it is still a second copy of something the business already knows.

So we do not maintain it. Every valid product code already exists in the ERP's item master. It has to, because that is the table the pipeline maps against to create the line in the first place. The lengths are therefore derived from those codes and materialised into a Delta table that refreshes along with everything else.

The loop closes on itself, which is the part I like: the rule that decides whether a string is a product code is parameterised by the same data the pipeline already depends on to resolve one. There is no third place for the two to disagree.

The consequences are the whole point:

  • A new customer arrives with a coding scheme nobody has seen before. Their items are in item master the moment they are set up, so the rule already knows what a valid code looks like for them. There is no onboarding step, so there is no onboarding step to forget.
  • An existing customer changes their scheme. The table follows, because it is a projection of the source rather than a note about it.
  • Nobody edits a length, ever. There is no config row to get wrong and no deployment to schedule.

This is the same move as deriving a record's stage from its event log rather than storing a status column. When a fact already exists in a system of record, a second copy of it is not a convenience. It is a liability with a maintenance schedule attached.

The rule was the easy part. Where the rule's parameters come from is what decides whether it still works in a year, and whether it survives a customer nobody had met when it was written.

When to reach for the rule

The rule wins here because of a specific property: the invariant is cheaper to state than the pattern is to learn. "A code appears once" and "a code is N characters" are both one sentence, both exactly true, both checkable.

That will not always hold. If the thing you are trying to recognise genuinely needs perception (this page looks like a summary, this table resembles a header) then a model is the honest tool and a pile of heuristics will slowly become one, badly.

The test I use: can I write the rule down as a sentence a person in the business would immediately agree with? If yes, it is domain knowledge and it should be code. If I find myself writing "usually" or "most of the time", that is a model wearing a rule's clothing, and I should stop.

One honest caveat

A business rule is a bet on the business. "One code, once per order" is true right up until a customer has a reason to list the same product twice: two delivery dates, two destinations, two price bands.

So the rule gets written where it can be seen and argued with, named for what it asserts rather than what it does, and it rejects loudly rather than dropping quietly. The day that assumption stops holding, I want the pipeline to say so, not to silently discard a real line it has decided is a duplicate.

There is a second limit, and it is the sharper one. Deduplication only helps when the phantom row collides with a real row. If the line-item page is missing, or scanned badly enough that nothing was read from it, the summary row is the only row carrying that code, so there is nothing to deduplicate it against, and it is accepted as a genuine line with most of its fields empty. A uniqueness rule defends against repetition, not against absence, and those are different failures needing different checks.