Extracting receipt values with clj-llm
Intro
After reading "25 Years of Eggs", where a careful multi-stage pipeline of SAM3, PaddleOCR, and Claude handled 11,345 receipts, I was curious how a smaller vision-enabled model would fare going directly from image to structured data, with no preprocessing at all.
Using clj-llm with Gemini Flash:
clj-llm with Gemini Flash:I put together a quick example babashka script for receipt extraction using clj-llm:
(def ai (openrouter/backend
{:defaults {:model "google/gemini-3-flash-preview"}}))
(def ReceiptSchema
[:map
[:currency :string]
[:items [:vector
[:map
[:name {:description "expand all abbreviations, convert to english"} :string]
[:category {:description "eg. dairy, produce, meat etc"} :string]
[:value [:map
[:unit {:description "eg. kg, per-unit etc"} :string]
[:price :double]]]
[:quantity-of-value :double]
[:total-price :double]]]]
[:total :double]
[:date :string]
[:store :string]])
(def system-prompt "Extract structured data from the receipt image and return it in the specified schema format.")
(llm/generate ai
{:schema ReceiptSchema
:system-prompt system-prompt}
[(content/image (first *command-line-args*)) {:max-width 512}])
I was able to get clean, structured output from a variety of test receipts. Obviously this is just a simple muck-around and getting it up to the level the original author had would take far more structured test input and iteration, but I think it's a promising start.
Example input/outputs
Lidl, no date

- Store:
Lidl Limerick, Caherdavin - Date:
not-found - Total:
43.79 EUR
| Name | Category | Qty | Unit | Price | Total |
|---|---|---|---|---|---|
| Fresh Orange Juice | beverage | 2.00 | per-unit | 1.45 | 2.90 |
| Goat Cheese | dairy | 0.23 | kg | 11.99 | 2.72 |
| Greek Style Yoghurt | dairy | 1.00 | per-unit | 1.99 | 1.99 |
| Christmas CD's 23359 | entertainment | 1.00 | per-unit | 3.99 | 3.99 |
| Non Bio Washing | household | 1.00 | per-unit | 6.99 | 6.99 |
| Lobster | meat/fish | 1.00 | per-unit | 4.99 | 4.99 |
| Bio Instant Coffee | pantry | 1.00 | per-unit | 2.99 | 2.99 |
| Mustard with Guinness | pantry | 1.00 | per-unit | 1.69 | 1.69 |
| Passata | pantry | 2.00 | per-unit | 0.59 | 1.18 |
| Bananas | produce | 0.56 | kg | 1.15 | 0.64 |
| Organic Bananas | produce | 1.00 | per-unit | 1.49 | 1.49 |
| Turnip | produce | 1.00 | per-unit | 0.89 | 0.89 |
| Onions | produce | 1.00 | per-unit | 0.79 | 0.79 |
| Mushrooms Chestnut | produce | 1.00 | per-unit | 0.99 | 0.99 |
| Garlic | produce | 1.00 | per-unit | 1.19 | 1.19 |
| Stem Cherry Tomatoes | produce | 1.00 | per-unit | 2.49 | 2.49 |
| Chocolate Bar 74% | snacks | 2.00 | per-unit | 0.85 | 1.70 |
| Chocolate Lollies | snacks | 1.00 | per-unit | 1.29 | 1.29 |
| Nestle Blue Riband | snacks | 1.00 | per-unit | 1.69 | 1.69 |
| Gingerbread Rounds | snacks | 1.00 | per-unit | 1.19 | 1.19 |
Target

- Store:
TARGET - Date:
12/09/2017 - Total:
29.04 USD
| Name | Category | Qty | Unit | Price | Total |
|---|---|---|---|---|---|
| KISSES | GROCERY | 1.00 | per-unit | 1.00 | 1.00 |
| BUTTERFINGER | GROCERY | 3.00 | per-unit | 3.00 | 9.00 |
| FOOD COLOR | GROCERY | 1.00 | per-unit | 2.99 | 2.99 |
| UP UP | HEALTH-BEAUTY-COSMETICS | 1.00 | per-unit | 3.59 | 3.59 |
| CONAIR HEADB | HEALTH-BEAUTY-COSMETICS | 1.00 | per-unit | 3.99 | 3.99 |
| CONAIR CLIPS | HEALTH-BEAUTY-COSMETICS | 1.00 | per-unit | 4.79 | 4.79 |
| CHAPSTICK 2 | HEALTH-BEAUTY-COSMETICS | 1.00 | per-unit | 1.98 | 1.98 |
Japanese supermarket

- Store:
Fresh Daimaru L. Takaraya Store - Date:
2023-05-14 - Total:
3169.0 JPY
| Name | Category | Qty | Unit | Price | Total |
|---|---|---|---|---|---|
| Iwasaki Food Hand-cut Kishimen Noodles | Bakery/Pasta | 1.00 | per-unit | 69.00 | 69.00 |
| Whole Grain Granola Morning | Cereal/Grains | 1.00 | per-unit | 499.00 | 499.00 |
| S&B Grated Raw Garlic | Condiments | 1.00 | per-unit | 99.00 | 99.00 |
| Ebara Sukiyaki Sauce 500g | Condiments/Sauces | 1.00 | per-unit | 259.00 | 259.00 |
| Hokkaido Special Selection Tokachi Milk 1000ml | Dairy/Beverage | 1.00 | per-unit | 185.00 | 185.00 |
| Beef Shoulder Slices (Medium) | Meat | 1.00 | per-unit | 524.00 | 524.00 |
| Beef Shoulder Slices (Medium) | Meat | 1.00 | per-unit | 509.00 | 509.00 |
| Lopia Bag 5 Yen | Miscellaneous | 1.00 | per-unit | 5.00 | 5.00 |
| Yokoo Aku-nuki Country Konjac | Prepared Food/Konjac | 1.00 | per-unit | 99.00 | 99.00 |
| Takato Chikuwabu 1pc | Processed Food/Seafood Product | 1.00 | per-unit | 99.00 | 99.00 |
| Chinese Cabbage | Produce | 1.00 | per-unit | 128.00 | 128.00 |
| Narita Bean Sprouts | Produce | 1.00 | per-unit | 35.00 | 35.00 |
| Niigata Enoki Mushroom | Produce | 1.00 | per-unit | 78.00 | 78.00 |
| Takano Foods Addictive Pickled Vegetables | Produce/Prepared Food | 1.00 | per-unit | 89.00 | 89.00 |
| Sanriku Capelin with Roe 3P | Seafood/Prepared Food | 1.00 | per-unit | 199.00 | 199.00 |
| Hagiwara Hokkaido Soybean Momen Tofu | Soy Products | 1.00 | per-unit | 59.00 | 59.00 |
Note how we get English translations "for free" because of the LLM.
Cost considerations
Even more promising is the difference in cost - using the small gemini model, a single receipt costs only around $0.002.
{:cost-details
{:upstream-inference-cost 0.00214,
:upstream-inference-prompt-cost 6.01E-4,
:upstream-inference-completions-cost 0.001539},
:finish-reason "tool_calls",
:completion-tokens 513,
:prompt-tokens 1202,
:prompt-tokens-details
{:cached-tokens 0, :cache-write-tokens 0, :audio-tokens 0, :video-tokens 0},
:cost 0.00214,
:is-byok false,
:total-tokens 1715,
:completion-tokens-details
{:reasoning-tokens 0, :image-tokens 0, :audio-tokens 0},
:model "google/gemini-3-flash-preview"}
Making the total for the original article's 11,345 receipts come out to $22.69. A fair reduction from his original $1,591.
Other benefits
We get some other nice benefits too:
- Translation is an LLM's bread and butter. We can normalize to English easily.
- Abbreviated terms can be expanded by the LLM without too much fuss.
Making the script more robust
The script would benefit from testing with a larger sampling of receipts, and using an enum to keep the categories regular across different stores. Notice how the target splitting of GROCERIES and HEALTH-BEAUTY-COSMETICS has a large influence on the way the categories are output. Adjusting the schema with a better description can help, but this is better achieved with a sampling of your common receipts and use of an enum to reign-in the required categoires you need.
Example with adjusted description field
Updating the description for the "categories" field in the schema to:
eg. sweets, dairy, produce, meat etc. ON AN INDIVIDUAL ITEM BASIS. Ignore on-receipt categories/groupings. Be precise, not general.
Gave slightly more informative output for the Target receipt:
| Name | Category | Qty | Unit | Price | Total |
|---|---|---|---|---|---|
| FOOD COLOR | baking supplies | 1.00 | per-unit | 2.99 | 2.99 |
| CONAIR HEADB | hair accessories | 1.00 | per-unit | 3.99 | 3.99 |
| CONAIR CLIPS | hair accessories | 1.00 | per-unit | 4.79 | 4.79 |
| CHAPSTICK 2 | lip care | 1.00 | per-unit | 1.98 | 1.98 |
| UP UP | personal care | 1.00 | per-unit | 3.59 | 3.59 |
| KISSES | sweets | 1.00 | per-unit | 1.00 | 1.00 |
| BUTTERFINGER | sweets | 3.00 | per-unit | 3.00 | 9.00 |