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:

  1. catalogue text โ€” what a search index reads;
  2. billing history โ€” every wording this SKU has been invoiced under before, and by whom;
  3. 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.

MethodTop-1Top-5
BM25 โ€” catalogue text only64.4%91.2%
BM25 โ€” matched training wordings only88.8%98.4%
BM25 โ€” both (the fair baseline)89.2%98.8%
Aito โ€” one _predict query, no training90.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 right213
Aito right, BM25 wrong13
BM25 right, Aito wrong10
neither14

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 linecorrect SKUAito pickedfair BM25 picked
bauhaus pora bit sarja 50mm v2 โ€” Ranta TukkuBauhaus Drill Bit Set 50mm v2 (Riga) (32x billed)correct ยท p=0.943correct
Marimekko Thro 150x Natu โ€” Hansa Trading OyMarimekko Throw 150x200 Natural (Tallinn) (56x billed)correct ยท p=0.998correct
drill bit set 25mm โ€” Ranta TukkuDrill Bit Set 25mm (Riga) (28x billed)correct ยท p=0.312correct

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 linecorrect SKUAito pickedfair BM25 picked
tyyny 50cm valkoinen mk2 hd kouvola standard โ€” Pohjoinen Kauppa OyCushion 50cm White Mk2 HD (Kouvola) (106x billed)correct ยท p=0.926Cushion 50cm White HD (Oulu) (28x billed)
tv 55" qled v2 โ€” Kukkatukku Salo OyTV 55" QLED v2 (Turku) (52x billed)correct ยท p=0.525TV 55" QLED Graphite v2 (Oulu) (37x billed)
juusto 250g mk2 4-pack โ€” Meridian SupplyCheese 250g Mk2 4-pack (Oulu) (35x billed)correct ยท p=0.995Cheese 250g Mk2 4-pack (Turku) (88x billed)
fazer konfektyr yogurt 6-pack โ€” Oulun ErikoistukkuFazer Konfektyr Yogurt 6-pack (Oulu) (61x billed)correct ยท p=0.996Fazer Konfektyr Yogurt Mk3 6-pack (Turku) (84x billed)
bauhaus mitta nauha 8m kromi โ€” Kukkatukku Salo OyBauhaus Tape Measure 8m Chrome (Turku) (50x billed)correct ยท p=0.515Bauhaus 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 linecorrect SKUAito pickedfair BM25 picked
Bread Mk3 6-pack tallinn standard โ€” Vellamo WholesaleBread Mk3 6-pack (Tallinn) (10x billed)Bread Mk2 6-pack (Turku) (55x billed) ยท p=0.339correct
T-shirt S Navy Pro riga premium โ€” Variantti OyT-shirt S Navy Pro (Riga) (5x billed)T-shirt S Navy (Oulu) (86x billed) ยท p=0.966correct
tikkurila maali tela 10cm kromi pro tallinn budget โ€” Turun Seudun TukkuTikkurila Paint Roller 10cm Chrome Pro (Tallinn) (66x billed)Tikkurila Paint Roller 10cm Black Pro (Riga) (35x billed) ยท p=0.410correct
Wrench Set 8-19mm HD โ€” Pohjola Tukku OyWrench Set 8-19mm HD (Kouvola) (82x billed)Bauhaus Wrench Set 8-19mm Black HD (Kouvola) (102x billed) ยท p=0.375correct
bauhaus mitta measure 3m hd โ€” Ranta TukkuBauhaus Tape Measure 3m HD (Riga) (31x billed)Bauhaus Tape Measure 3m Black Mk2 HD (Turku) (74x billed) ยท p=0.384correct

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 linecorrect SKUAito pickedfair BM25 picked
marimekko dress l beige โ€” Pohjoinen Kauppa OyMarimekko Dress L Beige (Oulu) (63x billed)Marimekko Dress L Beige HD (Tampere) (31x billed) ยท p=0.282Marimekko Dress L Beige HD (Tampere) (31x billed)
Tikkurila Dril Bit Set 25mm Yell 10pk riga budget โ€” Pohjanmaan Tukku OyTikkurila Drill Bit Set 25mm Yellow 10pk (Riga) (40x billed)Tikkurila Drill Bit Set 25mm Yellow HD (Oulu) (63x billed) ยท p=0.956Tikkurila Drill Bit Set 25mm Yellow HD (Oulu) (63x billed)
Marimekko Cand Vani Blac tampere premium โ€” Frontier Goods LtdMarimekko Candle Vanilla Black (Tampere) (32x billed)Marimekko Candle Vanilla v2 (Tallinn) (56x billed) ยท p=0.234Marimekko 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:

regimems
first request, cold caches3380 ms
still warming โ€” mean of the first 25 requests344 ms
steady state โ€” mean of the last 25199 ms
mean over all 250, ramp included215 ms
p50 / p90 over all 250196 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 no basedOn, 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 settingtop-1
default (no basedOn)91.8%
basedOn: ["supplier"] โ€” what the demo sends91.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-demo generator, 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.