The problem
Dutch shoppers choose between more than a dozen supermarket chains, each with its own prices, promotions and product names. Working out where a whole shopping list is cheapest means checking every chain by hand.
Next: What I didWhat I did
I built the product end to end and run it alone. Three parts carry it:
Refreshes ~150k listings from 13 chains every night into PostgreSQL. Price history is written only when a price or promotion changes.
Barcodes (EAN) match 67.5%; synthetic keys, pgvector embeddings and an LLM handle the rest. Only 2.4% are matched by hand.
Takes a shopping list and finds the cheapest way to fill it across stores.
Matching is the hard part: every chain names and sizes the same product differently. Barcodes settle most of it, so the embedding and LLM tiers only decide the products a barcode can't link.
Next: The resultThe result
Splitting a basket across up to three stores saves 4–9% in simulation. With the app's default of €3 per extra stop, a typical 20-item basket comes out about 4.4% cheaper.
Those are upper bounds: the simulation assumes perfect picks and ignores pack sizes and promotions.
Next: What I'd do nextWhat I'd do next
Measure match precision. I record how every product was matched, but not yet how often a match is wrong.
Harden the weakest crawler. Albert Heijn causes most failed runs; fewer of them means more nights with every store fresh.
Track real savings. Logging baskets compared would let me report what users actually save, not a simulation.
