Engineering
How your shopping list sorts itself by aisle
Nimblist guesses which aisle every item belongs to using a small text classifier. Here is how it works, and why it sometimes says nothing at all.

Type “milk” into Nimblist and it lands under Dairy. Type “2 x 500g free-range eggs” and it also lands under Dairy, next to the milk. Nobody picked a category, and there’s no lookup table with “2 x 500g free-range eggs” in it.
The thing doing the guessing is a small text classifier, and it’s one of the older parts of the app. Here’s what it actually does.
The problem isn’t really groceries
It’s that people type shopping lists the way they think, not the way a database
would like them to. The same item arrives as Milk, milk 2l, semi skimmed milk, 2 pints milk, or mlik. A rule like “if the name contains ‘milk’”
gets you a surprising distance and then falls over on milk chocolate, which is
not dairy.
So the model doesn’t see the raw text. It sees a cleaned version:
- Lowercased.
- Size and quantity tokens stripped —
500g,2l,750ml,6 pack,pack of 12,x4. This happens before punctuation removal, so word boundaries still exist when it runs. - Punctuation removed, whitespace normalised.
- Plurals lemmatised:
eggs → egg,tomatoes → tomato,berries → berry,loaves → loaf.
2 x 500g free-range eggs becomes free range egg, which is a much easier
thing to classify than what was typed.
A deliberately boring model
It’s logistic regression over TF-IDF features. Not a neural network, not an LLM call. Two things make it work better than that description suggests.
The first is that the features are a union of word 1–2 grams and character 3–5 grams. Word features handle the normal cases. Character features are what rescue the awkward ones — a typo, an unusual brand name, or a compound word that the word vocabulary has never seen still overlaps at the character level with things it has. Adding the character branch moved held-out accuracy from 0.872 to 0.881, and every sub-category model improved.
The second is data augmentation by truncation. The training catalogue is full of long, tidy product names. Real users type short ones. So for each name, training also generates up to two left-truncated versions:
organic whole milk → whole milk → milk
The model ends up trained on text that looks like what people actually type, rather than only what a product database contains.
When it says nothing
This is the part I’d argue matters most, and it’s the least clever bit of the whole thing.
The service uses predict_proba, not predict. If the top class probability
comes in under a confidence threshold — 0.35 today — it returns nothing
rather than its best guess. No category. The item just sits uncategorised until
you say otherwise.
That sounds like a downgrade. It isn’t. A confidently wrong category is more annoying than no category: you don’t notice it, and then you’re standing in the wrong aisle. An ambiguous two-word input genuinely doesn’t carry enough signal, and pretending otherwise is worse than admitting it.
There’s a small amount of engineering in the app that exists purely to make this honest. The API treats a null category as a real answer rather than substituting a sentinel like “Unknown” or “Other”, so an uncategorised item looks uncategorised the whole way through.
Your corrections train it
When you re-shelve an item into a different category, that correction is recorded. Once a month, an automated job pulls the whole feedback export, merges it with the base catalogue, retrains, and opens a pull request with the updated model files. A human reviews and merges it; nothing deploys itself.
Two details keep that loop from going wrong:
- Corrections are majority-voted per item name. The export has one row per user per item, and different households genuinely disagree about where things belong. Taking the most-voted answer means one account’s latest edit can’t unilaterally overturn a standing consensus.
- The evaluation is leak-free. A fraction of the feedback is held out before the oversampling and augmentation steps, and any held-out row whose cleaned name also appears in training is dropped. Get this wrong — augment first, split second — and duplicates straddle the split, the model memorises the test set, and the accuracy number becomes a lie that looks like progress.
The retrain is gated: if held-out accuracy on the primary model falls below a floor, the job fails and no pull request appears.
It’s not on the critical path
Classification never blocks adding an item. The API dispatches it off the request path and the item appears immediately; the category arrives a moment later over the same real-time channel that syncs your list to everyone else in the household. Results are memoised in Redis for a week, keyed by the model’s own file hash — so a retrain naturally invalidates the cache rather than serving week-old predictions from a model that no longer exists.
None of this is visible when it’s working. You type “milk”, it goes under Dairy, and the list is grouped the way the shop is laid out when you get there. That’s the whole feature.
If you want to see it in practice, start a list — shared lists are free and unlimited — or read the guide on shared shopping lists for households.
Nimblist is free for unlimited shared lists. Shopping lists, recipes and meal planning, in sync across your household.
Get started free →