- Published on
Don't Enrich Rows. Enrich Hashes: LLM Nutrition on DynamoDB
- Authors
- Name
- Motions Technologies
Don't Enrich Rows. Enrich Hashes
A food-delivery catalog already has the dishes. What it usually lacks is structured nutrition: kcal, macros, CFIA-style allergens, estimated ingredients. Scraped calories sit on a tagline. Everything else is an LLM estimate — labeled as such, never presented as clinical fact.
The wrong algorithm is “call the model once per catalog row.” The right algorithm is unique-hash, then fan-out. DynamoDB is already cheap here. The only meter that matters is unique LLM work.
The data is not one item per dish
On this platform, a store is one DynamoDB item. The menu is nested:
STORE#{uuid} / STORE#{uuid}
menu[].catalogName
menu[].catalogItems[].title
menu[].catalogItems[].nutrition
A catalog of 277 stores and ~21,900 dishes is 277 reads and at most 277 writes, not 21,900 UpdateItem calls. BatchWriteItem does not change that picture. The store document is the grain.
| Layer | Cardinality | Cost driver |
|---|---|---|
| DynamoDB store items | 277 | On-demand GetItem / UpdateItem |
| Flattened dishes | ~21,900 | Local memory |
Unique (brand, name, description, category) | ~20,300 | LLM tokens |
| Already-enriched resume set | ~1,000–18,000 | Skip |
If you iterate stores sequentially and send every dish to the gateway, you pay wall-clock and tokens for copies of the same Teen Burger. The store write was never the bottleneck.
Phase 1 — Extract and hash
One paginated scan lists store IDs. Each store GetItem projects menu, title, and name. Flatten catalogItems, skip rows whose provenance.source is already openrouter or merged and that already have macros, allergens, or ingredients.
The unique key is content-addressed:
def brand_key(store: dict) -> str:
title = (store.get("title") or store.get("name") or "").replace("&", "&").strip()
title = PAREN_SUFFIX.sub("", title).strip() # "McDonald's (200 Bloor St W)"
title = DASH_SUFFIX.sub("", title).strip() # "Summerhill Market - Annex"
return title.lower()
def dish_hash(brand, name, description, category) -> str:
raw = f"{brand}\n{name}\n{description}\n{category}".encode()
return hashlib.sha256(raw).hexdigest()[:16]
Merchant titles on a scraped marketplace almost always carry a location suffix in parentheses or after an em dash. Hashing the raw title makes every location a different brand. Strip the suffix first. Do not treat a location-specific slug as the brand if it still contains the street.
Dry-run prints the bill before any gateway call:
stores=277 dishes=21923 already_enriched=1048
cache_hits=138 unique_pending=20261
llm_batches=1014 estimated_usd=4.0560 workers=8
--dry-run is the default. --apply writes. --confirm is the only switch that may spend.
Phase 2 — Unique batches, local cache, parallel I/O
Keep the model cheap and structured: google/gemini-2.5-flash-lite through an OpenRouter-compatible gateway, response_format: json_object, 20 dishes per request. Coerce kcal and macros through a number helper — models return {min, max} objects often enough that int(row["kcal"]) will throw.
The cache is an append-only JSONL file keyed by hash. Each successful batch is flushed before the next store write. A crash is a resume, not a re-spend.
class NutritionCache:
def put(self, digest: str, row: dict) -> None:
with self.lock:
if digest in self.rows:
return
self.rows[digest] = row
self.path.open("a").write(json.dumps({"hash": digest, "row": row}) + "\n")
Seed the cache from rows Dynamo already marked enriched. Sister locations then hit cache at apply time.
Fan the unique batches with a thread pool. Eight workers is enough for this gateway; keep the existing 429 / 5xx backoff.
chunks = [pending[i:i + 20] for i in range(0, len(pending), 20)]
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(enrich_unique_chunk, key, chunk, cache) for chunk in chunks]
for future in as_completed(futures):
saved, missed = future.result()
Measured on this catalog: 20,261 unique dishes, 1,014 batches, eight workers, about eighteen minutes, on the order of $4. Sequential 20-dish batches against the same unique set is the same dollar cost and several times the wall clock. Parallelism does not multiply tokens. Dedup is what cuts the bill.
Phase 3 — Fan-out, one write per store
Apply is a local join. Every dish looks up its hash. The LLM row merges onto parsed tagline calories (source: merged when energy already existed, else openrouter). Then one SET #m = :m per store.
UpdateExpression: SET #m = :m
That is the whole persistence story for scraped menus. Partnered stores with an empty embedded menu stay on the existing menu-item API PUT — a different grain, same cache.
The 400 KB ceiling
DynamoDB’s item limit is 400 KB. A large nested menu plus per-dish nutrition can cross it. A nested SET menu[i].catalogItems[j].nutrition does not help: the item still has to fit.
The production fallback:
- Try the full menu write.
- On
ValidationException, drop bulkyingredients.estimatedarrays and retry. - If it still fails, skip that store, keep the rows in the cache, continue the rest of the fleet.
On this run, 218 stores took the resume write in one pass. One store stayed over the limit even after slimming. Those hashes remain in the cache for a later split — a child item, a side table, or a trimmed menu document. Do not block 276 merchants on one oversized document.
Resume numbers
| Metric | Value |
|---|---|
| Stores scanned | 277 |
| Flattened dishes | 21,923 |
| Unique pending at first dry-run | 20,261 |
| Estimated unique spend | ~$4.06 |
| Workers | 8 |
| Unique LLM batches | 1,014 |
| Enriched in Dynamo after apply | 17,877 |
| Unique pending after apply | 0 |
| Oversized store skipped | 1 |
--all --dry-run after apply reports unique_pending=0 and estimated_usd=0.0000. Re-runs are cache hits.
What this pattern is not
It is not live quotes from another marketplace. It is not a clinical allergen database. The client must keep the estimated disclaimer and must not render empty macros as “safe.” Calories from a scraped tagline stay scraped; the model fills gaps.
It is also not a new AWS table, a provisioned Dynamo mode, or an always-on enrichment service. A script, a gitignored JSONL cache, on-demand Dynamo, and a confirm flag are the whole runtime.
Checklist
- Default to dry-run. Print unique count and dollars before
--confirm. - Hash
(brand, name, description, category)after stripping location suffixes. - Persist every LLM row to a local content-addressed cache before any
UpdateItem. - Write one store document per merchant, not one item per dish.
- Parallelize unique batches. Do not parallelize spend.
- Treat 400 KB as a hard skip-and-continue, not a job-killer.
- Never send a catalog row whose hash is already in the cache.
The catalog will grow. The unique set grows slower than the row count if you keep the hash stable. That is the algorithm.