01
Smart Ingredient Analyzer
Photograph a food label: OCR reads it, hybrid retrieval looks each ingredient up in a committed Open Food Facts corpus, and every verdict has to cite the passage it came from — or the ingredient is reported as uncovered.
95%recall@5over 58 hand-labelled questions
- Problem
- The first version asked a language model “is this ingredient harmful?” and rendered the reply. For a food-safety tool that is the wrong shape of output: unattributable, different between identical requests, and confidently wrong about ingredients it knows nothing about.
- What I built
- Two retrievers fused with weighted Reciprocal Rank Fusion, both hand-rolled: BM25 over passage text plus aliases (labels write INS211 / E211 / E 211 for the same additive, so a lexical index matters), and dense cosine over all-MiniLM-L6-v2 embeddings. No vector database — 839 chunks × 384 dimensions is ~322k multiply-adds per query, smaller than the JSON parsing around it. A verdict citing a passage id that was never in the prompt is rejected and re-asked; an ingredient the corpus does not describe comes back in an `uncovered` list with the reason attached. Allergen flags and the health score are arithmetic, never asked of the model.
- Measured
- recall@5 of 95% over 58 hand-labelled questions — `npm run eval`, six seconds, no API key and no network
- Abstention precision 100% (0 of 40 answerable questions wrongly refused); recall 72% (13 of 18 out-of-corpus questions refused)
- The ablation is published including the result it did not want: hybrid matches BM25 alone at 83/95/95% for recall@1/@3/@5, and dense-only manages 48/73/78%
- OCR pre-processing raises Tesseract’s own confidence from 57 to 67 on the committed sample label — one image on one machine, not a benchmark
- 86 unit tests, and CI fails the build if hybrid recall@5 drops below 85%
- What I would change
- Citation validity, groundedness and post-generation abstention are unit-tested against a stubbed model but have never been measured against a live one, because no API key was available. And retrieval precision on generic words is still wrong — `sugar` reaches the sweetener passage instead of abstaining. The README says both, and the second is unfixed.
React · Vite · Node.js · Express · Tesseract.js · Transformers.js · Groq · Zod



