How it scales (Beta)

Two different questions get asked with the word scaling, and they vary different things:

  • How much data do I need? β€” accuracy as the training set grows. This decides whether you can start before you have a dataset.
  • How big can my database get? β€” latency and memory as the corpus grows. This decides what a tenant costs to run at ten million rows.

This page answers both on the expense-categorization invoices, with one fixed 2,000-row hold-out scored at every point. The first axis runs every method against the same rows: Aito with both engines, Random Forest, LightGBM and FLAML AutoML. The second is Aito against itself, both engines, because that is the question it answers.

The training-size corpus grows the way a SaaS grows β€” by adding tenants, not by giving one tenant a longer memory. At every size it holds one organization per ~250 invoices, tenants of Zipf-distributed sizes, a standard chart of accounts shared across them, and each tenant's own tail of specialist vendors on top of the shared generalists. Ten times the rows is ten times the tenants.

Every figure is a build-time token resolved from benchmarks/results/ β€” nothing here is hand-typed.

Provenance. benchmarks/scale_sweep.py generates a deterministic corpus per scale and hands the same one to every method, Aito included. The hold-out is pinned at a fixed offset and excluded from every training set, so a 1M corpus scores the same 2,000 invoices as a 1k one and never trains on them. Corpora at or above 1M are cached on disk and verified against their manifest before reuse.

How much data do I need?

Real AP data spans a huge range: a brand-new tenant has a handful of invoices, a large bureau has millions. A method is only useful if it works across that range β€” and the honest finding is that no single method wins across it.

Top-1 accuracy by training-set size, every method on the same corpus and the same 2,000 held-out rows at each scale. At 1 000 rows the corpus has four organizations; at a million, four thousand β€” each with a few hundred invoices, its own specialist vendors, and the shared chart of accounts:

MethodN=1N=10N=100N=1 000N=10 000N=100 000N=1 000 000
Aito v22%10%28%64%79%83%84%
Aito v12%10%30%60%79%82%85%
Random Forest2%6%30%57%73%85%87%
LightGBMβœ—5%15%56%77%86%88%
FLAML AutoML (60 s)βœ—βœ—βœ—56%75%83%42%

How to read. βœ— = the method could not run at that size, not a low score: LightGBM needs β‰₯2 samples (N=1); FLAML needs β‰₯2 classes, which a 1/10/100-row training set does not reliably contain. FLAML gets a fixed 60 s budget at every size; read its curve as "a fixed budget does not scale". Bold marks the best method in a column. The corpus caps accuracy at 90% by construction (10% label noise).

This is a crossover, and we publish it as one. Every rung is scored on the same held-out rows, so which method is ahead is settled by the rows where two methods DISAGREE, not by two overlapping intervals β€” and on this hold-out all four rungs are settled.

Training rowsBest AitoBest baselineDifferenceRows only AitoRows only baseline
1 000v2 63.5%RF 56.8%+6.8 pt349213p < 0.0001
10 000v1 79.2%LightGBM 76.6%+2.6 pt175123p = 0.0031
100 000v2 82.8%LightGBM 85.9%-3.0 pt39100p < 0.0001
1 000 000v1 85.3%LightGBM 87.7%-2.4 pt1563p < 0.0001

Aito wins the small end and loses the large end, and both halves are real. With no training step it is ahead by +6.8 points at a thousand rows and +2.6 at ten thousand. Past that the boosted trees pass it: -3.0 points at a hundred thousand and -2.4 at a million. The crossover is between 10 000 and 100 000 rows. Earlier revisions of this page reported the same shape from a 200-row hold-out, where none of these four differences were distinguishable from chance; they are now, in both directions.

Why the crossover happens. This corpus identifies each of 1 200 invoice types by a combination of description tokens drawn from a vocabulary of only 238. Aito scores evidence per token and per learned conjunction, and it calibrates each piece from the data it has β€” which is exactly why it wins when there are a handful of examples per type: independent evidence is the best estimate you can make from that little. With hundreds of examples per type a tree model can learn which tokens co-occur, and the remaining ties β€” the true class and a near-neighbour sharing the same strong combination, decided by one leftover token β€” go the tree's way. We tested this rather than assumed it: a naive-Bayes classifier on the identical evidence reproduces Aito's curve to within a point at every scale, and giving it token pairs is what makes it climb again at a million rows. The ceiling is the combining rule, and it is the same property that makes the low-data lead.

What each side pays for its accuracy on this curve:

At N=1 000 000Training runPredict latency
Random Forest775 s0.2 ms
LightGBM2332 s0.3 ms
Aito v10 s90 ms
Aito v20 s151 ms

Latency on this table is from the sweep, on a shared machine, single predicts, 2,000 rows. The capacity section below is the certified measurement; read these as ratios.

The two engines cross over too: v2 is stronger below ~1 000 rows; from 10 000 rows they are within a point or two of each other, with v1 ahead at a million.

Not comparable to the single-scale table on the expense page. The sweep scores one fixed hold-out at every scale; that is a different 2,000-row sample from the one the single-scale comparison uses, so the N=10 000 column here will not match it exactly. Within the sweep every cell is comparable; across the two tables, read the shape.

How big can my database get?

The second axis holds the task fixed and grows the corpus to ten million rows. It is measured on a single-tenant corpus β€” forty organizations at every size, so ten million rows is one very large customer rather than forty thousand small ones β€” because what it measures is what the database costs to hold and query, not how the evidence is distributed. The questions are what a prediction costs and what the database costs to hold β€” accuracy is reported because we have it, but on this axis it is the minor figure: the same invoices are being predicted against more history.

Measured on the DEPLOYED configuration. These rows are an optimized (compacted) database, which is what a deployment runs. The same measurement before compaction is materially worse β€” at 10 000 000 rows Aito v1 reads 1 638 ms mean and 3 790 ms p90 as-ingested against 415 and 580 optimized β€” so an uncompacted figure would understate the product by four to six times. The invoice ladder on the speed page is measured the same way, so the two pages are comparable.

Accuracy on this axis is scored on 200 held-out rows (a Β±5 point interval); it is reported because we have it, and the ladder above is where accuracy is measured properly.

Aito v2, rows in the databaseWarm predict, medianWarm p90Mean incl. coldFirst query…whole invoice batchedTop-1
1 000 00062 ms116 ms88 ms120 ms125 ms82.5%
10 000 000279 ms823 ms601 ms160 ms369 ms82.0%
Aito v1, rows in the databaseWarm predict, medianWarm p90Mean incl. coldFirst query…whole invoice batchedTop-1
1 000 00052 ms95 ms107 ms101 ms97 ms82.0%
10 000 000210 ms417 ms355 ms354 ms429 ms83.0%

How to read. Warm is the second half of the queries, once caches and the JIT have settled β€” what a live server does all day, and the column to size a deployment from. Mean incl. cold averages that together with the first few requests after a restart, so it is pessimistic about steady state; both are published so the ramp is visible rather than hidden, and first query is the cost of that ramp. "Whole invoice" batched asks for all four fields β€” category, cost centre, approver, handler β€” in one /_batch request, which is what a real client does and what it should budget for; it is the minimum over timed rounds on a shared machine, because contention only ever adds time. Figures carry ~8% run-to-run spread; read ratios, not milliseconds.

What it costs to hold

Memory and disk for the same databases, read from the server's own /status endpoints β€” the same source the invoice footprint comes from, so the two are comparable by construction:

RowsEngineJVM heap after GCLive heap (reachable bytes)Memory-mappedOn disk
1 000 000Aito v2578 MB549 MB578 MB284 MB
1 000 000Aito v1961 MB831 MB1,540 MB770 MB
10 000 000Aito v21,720 MB1,560 MB5,766 MB2,829 MB
10 000 000Aito v12,603 MB2,423 MB15,506 MB7,761 MB

Two things stand out at 10M: the v2 engine holds the same database in substantially less heap than v1 and on a third of the disk, and it keeps far less of it on the heap at all β€” the mapped column is the data it serves from disk rather than resident memory. The latency trade that used to accompany that is now small: on the deployed configuration v1 answers a single predict in 355 ms against v2's 601 ms. Both are measured; neither is a target.

Why v1's mapped figure exceeds its on-disk size at 10M. optimize rewrites segments, and the superseded ones stay memory-mapped until they are released, so the mapped column double-counts for a while after compaction. Mapped bytes are virtual address space backed by files, not resident memory β€” the heap columns are what the process actually holds, and the on-disk figure is the one to size storage from.

How to read. "JVM heap after GC" is System.gc() then used heap β€” the figure the invoice ladder publishes, kept here so the two are like for like; under a concurrent collector it can overstate. "Live heap" is jmap -histo:live, the sum of reachable bytes, which uncollected garbage cannot inflate; when the two diverge, trust the second. Memory-mapped is the data the engine serves from disk without holding it on the heap β€” the reason the heap does not grow with the corpus.

The engine-to-engine ladder

The finer-grained curve β€” five corpus sizes from 1k to 10M on the linked invoice-routing corpus, both engines, with candidate retrieval and mean rank β€” is the capacity ladder. It is the evidence behind the 10M head-to-head on the speed page; it compares the engines to each other and carries no external baseline, which is why the expense corpus above leads.

What this page does not say

  • Synthetic corpus, single seed, single machine. Every cell is one binomial proportion on 2,000 held-out rows, which puts a 95% interval of about Β±2 points on it. Where we say one method is ahead of another, that comes from an exact McNemar test on the rows where the two DISAGREE, not from comparing two means; where the test does not separate them, the page says they are tied. This table was previously measured on 200 rows, where the interval was Β±5 points and none of its differences were resolved.
  • FLAML's column is a draw, not a fixed model. It searches for a wall-clock budget, so a loaded machine explores fewer configurations and returns a different model; the seed does not pin this. Each of its cells is the MEDIAN of 3 fits and benchmarks/results/ records every fit's top-1. The spread is small where the budget is comfortable (73.6–75.0% at N=10 000) and enormous where it is not: at N=1 000 000 the three fits came out 4.9–83.0%. That cell is not a measurement of AutoML so much as a demonstration that 60 seconds does not buy a model at a million rows β€” which is the point the column is here to make, and the reason we publish its range next to its median.
  • The corpus shares one chart of accounts across tenants. We also built a variant where a quarter of the codes are tenant-specific; there every method that combines evidence independently β€” Aito, Random Forest, naive Bayes β€” loses the relabelled rows and only boosted trees recover them. That is a property of the combining rule, recorded in the engine's tickets, and not a curve we publish as a benchmark.
  • The baselines were not run at 10M. Training Random Forest at 1M already costs 775 s; the capacity axis is about what Aito costs to run, not a tenth comparison.
  • Latency and memory are from a shared development machine, not a provisioned host; the numbers certify the shape, not a service level.