DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Sixty percent of our model backlog was never a question for a model

Munchable reads an ingredients list off a barcode or a label photo and decides whether the product suits your gut condition. The rules that decide are deterministic and they run on your phone. None of that is the hard part.

The hard part is vocabulary. Labels are written by marketing departments in thirty-odd languages, with abbreviations, regional spellings, glued-together headers and whatever the optical character recognition made of a crease in the plastic. When a word arrives that our engine cannot name, it cannot score it, and a paying user gets a hedge instead of an answer.

The obvious fix is to put a language model behind it. That works, and it is also how you end up with a per-scan cost that scales with how badly your data is doing.

We did something duller first: we counted.

Sixty percent was never a question for a model

Before spending anything, we measured the open backlog of words the engine could not place. As of last month:

  • 13 percent was label boilerplate. "May contain", "best before", a phone number. Not ingredients at all.
  • 17 percent was a word our multilingual lexicon already knew, filed under the wrong language prefix.
  • 4 percent was a known ingredient wearing a modifier: dried, organic, kibbled, Madagascan.
  • 21 percent was an English compound whose head noun we already knew.

That is 55 percent of the backlog closed with string handling. Add OCR repair, where a word is one letter away from exactly one name we know, and glued header tags where a class word is stuck to its ingredient, and roughly six words in ten never needed a model at all.

If we had wired the model in first, it would have worked, we would have paid for it monthly, and we would never have learned that most of the bill was for answering questions we already had answers to.

Measure the backlog before you buy a solution for it. It is not a satisfying insight but it was worth a lot of money.

A cascade where the first layer to answer wins

What came out of that measurement is an ordered set of free layers. A word walks down them and stops at the first one that can place it:

overlay      the database already knows this word
header       "emulsifier: E471" glued into one tag
boilerplate  text that is not an ingredient at all
e-number     "150d" resolves to E150d
lexicon      the bare word through the synonym table
singular     the same, minus a plural ending
modifiers    the same, minus preparation and origin words
ocr          one letter away from exactly one name we know
head-noun    an English compound, resolved through its head
unreadable   nothing above can touch it, and it is not a word
Enter fullscreen mode Exit fullscreen mode

Only what survives the whole cascade costs money. The same function runs in the nightly job and on the request path, so a scan that hits an unknown word gets the free layers first and spends a model call only on the remainder.

The last layer is easy to skip and shouldn't be. Some inputs are not words: a long stretch of OCR wreckage from a photo of a crumpled seam. Without an explicit layer that closes those, they sit in the backlog forever, and every pass through offers them to the model again. A queue needs a way to say "this will never be answerable" or it becomes a permanent tax.

The list we deleted so the database could learn

We used to keep a hardcoded list of supermarket and brand names, so that a retailer's name printed in the ingredients block would not be mistaken for an ingredient.

That list is gone. A brand name the free layers cannot place goes to the model exactly once. The model files it as not-an-ingredient, that decision is written into the curated overlay, and every later sighting is answered for free, in any label language.

Here is the whole idea as a test:

// Nothing knows the word: it goes to the model, which is the one cost.
assert.equal(resolve('en:tesco', 'en'), null);

// What the model writing "not an ingredient" puts in the overlay.
loadOverlay({ noise: ['tesco'] });

// From now on the free layers answer it, in any label language.
assert.equal(resolve('en:tesco', 'en')?.layer, 'overlay');
assert.equal(resolve('pl:tesco', 'pl')?.layer, 'overlay');
Enter fullscreen mode Exit fullscreen mode

A hardcoded list only ever saves you the first sighting of a name somebody already thought of, and growing it means an edit and a deploy. Writing the answer back into the layer that is free means the expensive path is charged once per new thing in the world, and the cheap path gets permanently better without anyone opening the editor.

That inverts the usual instinct, which is to cache the model's output for a while. A cache expires. This is not a cache, it is the model teaching the deterministic layer, and the lesson does not expire.

AI proposes, the engine disposes

None of the above is allowed near a verdict. That separation is the reason any of it is safe.

A model call in this system produces one thing: a candidate identity for a word. It never produces a judgement about whether something suits a condition. The rules engine on the device does that, from curated data, deterministically, and it is the only thing that does.

Every proposal is then validated the same way regardless of where it came from, a model or one of the free layers. The guards check lineage, health relevance, and that nothing is quietly redefining an ingredient the rules depend on.

The guard I like best is the one that reads the source phrase rather than the tidy answer. Suppose a layer proposes that "fully refined soya bean oil" is simply an alias for oil. That is defensible chemistry and a terrible thing to do to someone with a soya allergy, because the word soya would disappear from the product's ingredient graph. So the proposal carries the original phrase, the guard scans every word of it, finds soya, and refuses. The clever simplification loses to the boring safety rule, every time.

Two things we deleted for the same reason, in the opposite direction:

  • A flag that made the request path resolve a word and then not keep the result, leaving something on a clock to re-derive the identical answer later.
  • A "provisional" status that withheld a model's answer until a second sighting confirmed it.

Both encoded a distrust the guards already handle. What makes a row safe to serve is the validation it passed, not which process happened to produce it, and not how many times we have seen the word. If you do not trust a result enough to use it, the fix is a better guard, not a waiting period.

What none of this looks like from the app

There is no "we do not recognise this ingredient, can you help us?" prompt anywhere in Munchable. An unfamiliar word is our problem, not a question to hand to someone standing in a supermarket. It goes to the backlog, it is resolved there, and the next scan of that product by anybody resolves it for free.

You can see the output of all this on the public pages, where every verdict is produced by the engine rather than written by hand:

If you are adding an LLM to a data pipeline, the question worth asking is not which model. It is: when the model answers, where does the answer go so that nothing ever has to ask again?

Top comments (0)