Invoice line โ product matching (Beta)
Task: an ERP receives a purchase-invoice line reading
983668 Fazer Konfektyr 1kg, 24 kpl from a supplier. The catalogue holds
thousands of SKUs, a dozen of them Fazer Konfektyr variants. Which SKU is this
line?
This is matching, not classification, and the difference is what the benchmark is about:
- the answer set is a live table that gains and loses rows every week, so there is no fixed label space to train on;
- every candidate has its own attributes (name, supplier, unit of measure, price) that are themselves evidence;
- the same product is billed under a different wording by every supplier, so the line text alone often cannot separate siblings.
The industry default is a lexical index (BM25 / Elasticsearch) over the
catalogue, with a rules layer bolted on for the article numbers. Aito answers
it as a cross-table link prediction โ predict: "sku" on the invoice-line
table โ and that single query reads three kinds of evidence at once:
- catalogue text โ what a search index reads;
- billing history โ every wording this SKU has been invoiced under before, and by whom;
- the line's structured fields โ supplier, unit of measure, unit price, quantity โ each with its own learned lift.
Accuracy against BM25
A search baseline is only honest if it reads the same evidence. BM25 is therefore run in three strengths: over the catalogue text only; over the training wordings only (each SKU as the bag of lines it was billed on); and over both, which is the fair one and the row to compare against.
| Method | Top-1 | Top-5 |
|---|---|---|
| BM25 โ catalogue text only | 64.4% | 91.2% |
| BM25 โ matched training wordings only | 88.8% | 98.4% |
| BM25 โ both (the fair baseline) | 89.2% | 98.8% |
Aito โ one _predict query, no training | 90.4% | 98.0% |
Provenance. dataset erp-line-matching โ the aito-erp-demo aurora
catalogue (generated by a published, seed-stable generator) ยท task: invoice
line โ catalogue SKU, a cross-table link target ยท engine v2 (rep2 /
CollectionDb) ยท hold-out by line ยท config.ai = "and" ยท
n = 250 held-out lines for the
head-to-head. Produced by the ErpMatchingBenchmark demoQueryCost booktest
case, which refuses to publish at all if the sample is short or the machine
was loaded. Every figure here is a build-time metric token resolved from that
run's committed metrics.json โ nothing on this page is hand-typed.
The catalogue-only row is the headline. A lexical index over the catalogue โ the thing most matching systems actually ship โ reaches 64.4%. Aito reaches 90.4% on the same lines and the same candidates. The gap is what reading the billing history is worth, and it is the largest effect on this page.
The fair row is the honest one. Give BM25 the history too โ index every past wording against its SKU โ and it closes most of that gap, to 89.2%. Aito is still ahead at top-1, but by around a point: read that as "at least parity with a well-built lexical baseline", not as a rout. At top-5 the fair baseline is ahead (98.8% vs 98.0%) โ Aito converts more of its shortlist into a correct first answer, BM25 gets the right SKU into the shortlist slightly more often. If your workflow shows a human five candidates, that difference matters more than the top-1 row.
So the claim this page supports is not "Aito is a better ranker than BM25". It is: BM25 needs the history-indexing layer built, maintained and rebuilt; Aito's is the database.
Where the two disagree
Top-1 agreement against the fair BM25 baseline, over the same 250 lines:
| lines | |
|---|---|
| both right | 213 |
| Aito right, BM25 wrong | 13 |
| BM25 right, Aito wrong | 10 |
| neither | 14 |
The two methods agree on the overwhelming majority and are close overall, but they are not interchangeable: they fail on different lines. Aito's exclusive wins are lines where the wording is the supplier's own and only the history can resolve it; BM25's are lines where the wording quotes the catalogue and popularity has pulled Aito toward a better-attested sibling. The disagreeing set is small enough here that the split between those two columns is suggestive, not decisive โ the regime story is what the per-band analysis in the booktest is for.
What a win and a loss look like
Counts say how often each method wins. They do not say which half of your workload lands where โ so here are the actual lines, taken from the measured set in order rather than chosen, with each SKU's training billing count beside it. Warmth is the variable the whole comparison turns on, so it is printed rather than described.
Both right โ the bulk of the work. The supplier quotes enough of the catalogue name that the text settles it. Neither method is doing anything clever, and on a workload made only of these lines a search index is the right tool.
| invoice line | correct SKU | Aito picked | fair BM25 picked |
|---|---|---|---|
bauhaus pora bit sarja 50mm v2 โ Ranta Tukku | Bauhaus Drill Bit Set 50mm v2 (Riga) (32x billed) | correct ยท p=0.943 | correct |
Marimekko Thro 150x Natu โ Hansa Trading Oy | Marimekko Throw 150x200 Natural (Tallinn) (56x billed) | correct ยท p=0.998 | correct |
drill bit set 25mm โ Ranta Tukku | Drill Bit Set 25mm (Riga) (28x billed) | correct ยท p=0.312 | correct |
Aito right, BM25 wrong โ what the history buys. The wording names a family and the catalogue holds several members; the text cannot choose between them. What chooses is who is billing, at what unit and price โ fields a lexical index has no way to use, because they are not text to match against. Look at what BM25 answers instead: never something unrelated, always a near-identical variant โ a different Mk, a different pack size, the same product from a different warehouse. The text genuinely does not separate them, and the fair baseline has nothing left to break the tie with.
| invoice line | correct SKU | Aito picked | fair BM25 picked |
|---|---|---|---|
tyyny 50cm valkoinen mk2 hd kouvola standard โ Pohjoinen Kauppa Oy | Cushion 50cm White Mk2 HD (Kouvola) (106x billed) | correct ยท p=0.926 | Cushion 50cm White HD (Oulu) (28x billed) |
tv 55" qled v2 โ Kukkatukku Salo Oy | TV 55" QLED v2 (Turku) (52x billed) | correct ยท p=0.525 | TV 55" QLED Graphite v2 (Oulu) (37x billed) |
juusto 250g mk2 4-pack โ Meridian Supply | Cheese 250g Mk2 4-pack (Oulu) (35x billed) | correct ยท p=0.995 | Cheese 250g Mk2 4-pack (Turku) (88x billed) |
fazer konfektyr yogurt 6-pack โ Oulun Erikoistukku | Fazer Konfektyr Yogurt 6-pack (Oulu) (61x billed) | correct ยท p=0.996 | Fazer Konfektyr Yogurt Mk3 6-pack (Turku) (84x billed) |
bauhaus mitta nauha 8m kromi โ Kukkatukku Salo Oy | Bauhaus Tape Measure 8m Chrome (Turku) (50x billed) | correct ยท p=0.515 | Bauhaus Tape Measure 8m Chrome Mk2 10pk (Turku) (89x billed) |
Aito wrong, BM25 right โ where we lose. Two different failures sit in this cell, and they need different fixes. Most are warm-candidate domination: the text names a thinly-billed SKU and Aito answers with a far more familiar sibling, confidently. The rest are attribute confusions between two comparably-billed variants, where a colour or a pack size got dropped โ the opposite of a popularity problem.
| invoice line | correct SKU | Aito picked | fair BM25 picked |
|---|---|---|---|
Bread Mk3 6-pack tallinn standard โ Vellamo Wholesale | Bread Mk3 6-pack (Tallinn) (10x billed) | Bread Mk2 6-pack (Turku) (55x billed) ยท p=0.339 | correct |
T-shirt S Navy Pro riga premium โ Variantti Oy | T-shirt S Navy Pro (Riga) (5x billed) | T-shirt S Navy (Oulu) (86x billed) ยท p=0.966 | correct |
tikkurila maali tela 10cm kromi pro tallinn budget โ Turun Seudun Tukku | Tikkurila Paint Roller 10cm Chrome Pro (Tallinn) (66x billed) | Tikkurila Paint Roller 10cm Black Pro (Riga) (35x billed) ยท p=0.410 | correct |
Wrench Set 8-19mm HD โ Pohjola Tukku Oy | Wrench Set 8-19mm HD (Kouvola) (82x billed) | Bauhaus Wrench Set 8-19mm Black HD (Kouvola) (102x billed) ยท p=0.375 | correct |
bauhaus mitta measure 3m hd โ Ranta Tukku | Bauhaus Tape Measure 3m HD (Riga) (31x billed) | Bauhaus Tape Measure 3m Black Mk2 HD (Turku) (74x billed) ยท p=0.384 | correct |
Neither โ the task's own floor. Truncated and abbreviated wordings over a
catalogue of near-identical variants. Both methods often land on the same
wrong SKU, which is the tell: the line does not contain what would separate
them. Lines like these are why the workflow needs a human reviewer and why
$why earns its latency โ no ranking change will fix a line that does not say
which warehouse it means.
| invoice line | correct SKU | Aito picked | fair BM25 picked |
|---|---|---|---|
marimekko dress l beige โ Pohjoinen Kauppa Oy | Marimekko Dress L Beige (Oulu) (63x billed) | Marimekko Dress L Beige HD (Tampere) (31x billed) ยท p=0.282 | Marimekko Dress L Beige HD (Tampere) (31x billed) |
Tikkurila Dril Bit Set 25mm Yell 10pk riga budget โ Pohjanmaan Tukku Oy | Tikkurila Drill Bit Set 25mm Yellow 10pk (Riga) (40x billed) | Tikkurila Drill Bit Set 25mm Yellow HD (Oulu) (63x billed) ยท p=0.956 | Tikkurila Drill Bit Set 25mm Yellow HD (Oulu) (63x billed) |
Marimekko Cand Vani Blac tampere premium โ Frontier Goods Ltd | Marimekko Candle Vanilla Black (Tampere) (32x billed) | Marimekko Candle Vanilla v2 (Tallinn) (56x billed) ยท p=0.234 | Marimekko Candle Vanilla v2 (Tallinn) (56x billed) |
Where Aito is still weak
Two things on this corpus, stated plainly.
Warm-candidate domination. There are
18
lines that the catalogue-only index โ a model with no history at all โ gets
right and Aito gets wrong. In most of them the text names a thinly-billed SKU
and Aito answers with a sibling it has seen ten to twenty times as often
(T-shirt S Navy Pro, billed 5 times, loses to T-shirt S Navy, billed 86 โ
one row of the worked-example tables the same booktest emits, which list every
line in this cell with both billing counts).
It is a real defect and the one we are working on: a correct SKU seen five
times should not lose to a sibling seen five hundred when the text names the
first one. The remainder of that cell is a different problem โ confusions
between two comparably-billed variants, where a colour or a pack size is
what got dropped, and occasionally a pair whose catalogue names are identical
apart from a warehouse the invoice line never mentions.
No history, no advantage. Every gain on this page comes from the billing history. On public entity-matching corpora where the gold pairs have no transaction history at all โ two product catalogues to be reconciled, nothing ever bought โ Aito has no history to read and measures well behind BM25. Those runs are not in this page's metrics pipeline and no figures are quoted for them, but the direction is not in doubt and the reason is structural, not a tuning gap. If your matching problem is a one-off reconciliation of two static lists, use a search engine. If it is a recurring flow where the same things get matched over and over and people correct the results, that is the case this page measures.
Latency
The demo's exact query โ five where conditions, $why on, limit 5 โ
against the full catalogue:
| regime | ms |
|---|---|
| first request, cold caches | 3380 ms |
| still warming โ mean of the first 25 requests | 344 ms |
| steady state โ mean of the last 25 | 199 ms |
| mean over all 250, ramp included | 215 ms |
| p50 / p90 over all 250 | 196 ms / 272 ms |
There are three regimes here and only one of them is the number to plan with. The run is one open database with one view shared by every query, over the same 250 distinct lines as the accuracy tables โ so it warms as it goes, and the table says so instead of averaging it away:
- The first request pays 3380 ms while the caches fill. That is what you will see if you open a fresh database and send one query โ more than an order of magnitude above the steady state, so a deployment that matters should warm up rather than let a user meet it.
- Warming takes longer than one request. The first 25 average 344 ms, against 199 ms for the last 25.
- 199 ms is the serving figure โ the regime a live deployment runs in, and the one to size capacity against. The mean over all 250 is higher (215 ms) precisely because it still carries the ramp; it is reported here rather than quietly dropped.
These are single-node, single-threaded figures with $why on โ asking for the
explanation is part of the cost.
Provenance. Same run as the accuracy tables, on a machine the case checks was quiet โ the publication refuses above a 1-minute load threshold sampled during the latency window, because contention moves these numbers by more than anything this table reports.
For implementers
If you are building this on Aito, the shape below is the one that produced every number above.
Schema. The catalogue is one collection; the transaction collection links to it. That link is what makes this a prediction rather than a search.
PUT /api/v2/schema/products
{
"type": "collection",
"columns": {
"sku": { "type": "String" },
"name": { "type": "Text", "analyzer": "english" },
"supplier": { "type": "String" },
"category": { "type": "String" },
"unit_of_measure": { "type": "String" },
"unit_price": { "type": "Decimal" }
}
}
PUT /api/v2/schema/invoice_lines
{
"type": "collection",
"columns": {
"description": { "type": "Text", "analyzer": "english" },
"billing_supplier": { "type": "String" },
"unit_of_measure": { "type": "String" },
"unit_price_eur": { "type": "Decimal" },
"quantity": { "type": "Int" },
"sku": { "type": "String", "link": "products.sku" }
}
}
The query. Put the whole incoming line in where โ the free text and the
structured fields โ and predict the link column.
POST /api/v2/_predict
{
"from": "invoice_lines",
"where": {
"description": "983668 Fazer Konfektyr 1kg, 24 kpl",
"billing_supplier": "Baltic Trade House",
"unit_of_measure": "kg",
"unit_price_eur": 12.40,
"quantity": 24
},
"predict": "sku",
"config": { "ai": "and" },
"select": ["$p", "$value", "$why"],
"limit": 5
}
Four things that matter, each measured:
- Feed every field, not just the text. The structured fields carry real lift โ supplier especially, because who bills you narrows the catalogue before a single token is read.
config.ai: "and"treats co-occurring tokens as joint evidence rather than independent votes. It is what makes a multi-token wording behave like a phrase.- Do not add the catalogue name as candidate evidence. The obvious tuning
move is
basedOn: ["name"], so the product's own name scores against the line. On this corpus it costs about ten points of top-1 (table below). With nobasedOn, every linked sub-feature is already used; naming one narrows the evidence rather than adding to it. - Write the corrections back. Every accepted match becomes a training row the next query reads, with no reindex and no fit step. That is the loop the whole advantage rests on โ a deployment that never writes back its corrections is the catalogue-only row, not the Aito row.
Measured over the full 2000-line hold-out:
| query setting | top-1 |
|---|---|
default (no basedOn) | 91.8% |
basedOn: ["supplier"] โ what the demo sends | 91.5% |
basedOn: ["name"] | 81.9% |
Warm the database before you serve from it. The first request pays 3380 ms and the ramp runs past the first two dozen โ see latency and the warming strategies guide. This is a deployment detail, not a tuning one, and it is the most common reason a first trial feels slower than this page.
Ask for $why. Each hit carries the factors that produced its
probability โ which tokens fired, which structured field lifted it, the base
rate it started from. It costs latency (the numbers above include it) and it is
what makes a human reviewer's correction an informed one rather than a coin
flip.
Read honestly
- The comparison that flatters us is the wrong one. Against a catalogue-only search index the margin is large; against BM25 given the same history it is small. The second is the real comparison, and the second is what the page leads with.
- The advantage is history, not ranking. Take the history away and the advantage goes with it โ see above. This benchmark measures a recurring flow with feedback, which is the case Aito is for; it does not claim anything about static list reconciliation.
- Warm candidates still beat thin correct ones on most of the 18 lines a history-free index gets right and we do not. Open defect, not a tuning knob.
- The corpus is generated, one seed. It comes from the
aito-erp-demogenerator, which models real supplier behaviour โ each supplier writes the same product its own way, some quote the catalogue, some don't โ but it is not a customer's data, and a generator cannot surprise you the way real wording does. n = 250 for the head-to-head (the query-setting table runs the full 2000-line hold-out). At that size a point or two is inside the noise; read the direction and the size of the catalogue-only gap, not the last digit. - The numbers are produced, not typed. The booktest behind them refuses to publish a short run, or a run on a loaded machine, so a truncated diagnostic pass cannot quietly overwrite what this page shows.