The Sophisticated Recipe Processing Pipeline
How we process 35,000+ recipes with 99.9% accuracy
The Sophisticated Recipe Processing Pipeline
Processing 35,000+ French recipes isn’t just about running them through a few algorithms. It’s about building a sophisticated, multi-stage pipeline that can handle the chaos of real-world recipe data while maintaining perfect safety standards and optimizing costs at scale. This is the story of how we engineered a processing system that combines rule-based precision, machine learning intelligence, and human-like validation.
The Challenge: Real-World Recipe Data is Messy
When we started processing recipes from Elle à table, we quickly discovered that real-world recipe data is fundamentally messy:
- Inconsistent formatting: “2 c. à soupe” vs “2 cuillères à soupe” vs “2 tbsp”
- Missing information: 40% of recipes lack cooking times or serving sizes
- Complex expressions: “quelques pincées de sel” (a few pinches of salt)
- Cultural nuances: French culinary terminology doesn’t translate well
- Malformed structures: Broken JSON, missing fields, inconsistent units
A simple “parse and store” approach would never work. We needed something much more sophisticated.
Our Solution: The 9-Stage Intelligent Pipeline
We designed a 9-stage processing pipeline with intelligent routing based on data quality assessment. Each stage has a specific purpose, and recipes can take different paths through the system based on their characteristics.
Stage 1-2: Missing Metadata Inference
Many recipes arrive incomplete. Before any processing begins, we need to fill in the gaps using Gemini 2.5 Flash Lite:
Time Inference
When cooking or preparation times are missing, we analyze the recipe title and instructions to infer realistic durations. The LLM considers the cooking methods mentioned, ingredient quantities, and recipe complexity to estimate both prep and cook times.
Servings Inference
Similarly, when portion sizes are missing, we analyze ingredient quantities and recipe context to infer the number of servings. The model looks at typical quantities per person (e.g., 400g pasta usually serves 4 people) to make reasonable estimates.
These lightweight LLM calls only run when metadata is actually missing, avoiding unnecessary API costs for recipes that already have complete information.
Stage 3: The Critical Quality Assessment
This is where our pipeline gets intelligent. We perform a comprehensive quality assessment that determines the entire processing path:
Quality Scoring Algorithm
interface QualityScore {
overall: number;
ingredients: {
nameQuality: number; // 50% weight
quantityQuality: number; // 30% weight
unitQuality: number; // 20% weight
};
instructions: number;
metadata: number;
}
// Scoring logic
const calculateQuality = (recipe: Recipe): QualityScore => {
const ingredientScores = recipe.ingredients.map(ing => ({
nameQuality: ing.name.length > 2 ? 1.0 : 0.5,
quantityQuality: !isNaN(ing.quantity) ? 1.0 : 0.3,
unitQuality: VALID_UNITS.includes(ing.unit) ? 1.0 : 0.7
}));
return {
overall: weightedAverage(ingredientScores),
ingredients: averageScores(ingredientScores),
// ... other metrics
};
};
Intelligent Routing Decision
// The critical routing decision
if (qualityAssessment.overall >= 0.75 &&
qualityAssessment.ingredients.nameQuality >= 0.7) {
// HIGH QUALITY PATH: Skip expensive NLP processing
return await DirectApiConversionStrategy.execute(recipe);
} else {
// LOW QUALITY PATH: Full SpaCy NLP pipeline
return await SpacyPipelineStrategy.execute(recipe);
}
This single decision saves us 60-70% on NLP processing costs while maintaining 99.9% accuracy.
Stage 4: The Dual-Path Ingredient Processing
Path A: Direct API Conversion (High-Quality Data)
For recipes with clean, structured data, we bypass expensive NLP processing and use direct conversion with intelligent cross-referencing against our local datasets:
Unit Normalization with Tokenization
The normalizeUnit function cross-references units against our MEASUREMENTS dataset using a two-pass approach:
- Exact match: Try matching the full unit string (e.g., “cuillère à soupe”)
- Tokenized match: Split the unit into words using a word tokenizer and match individual tokens (handles variations like “c. à soupe”, “cs”, “cuil. à soupe”)
This ensures we capture all variations of French measurements and map them to canonical unit names (e.g., all variations → “cuillère à soupe”).
Preparation Method Extraction with Stemming
The extractPreparation function identifies cooking methods in ingredient names using French stemming:
- Cross-reference: Check each word against the COOKING_METHODS dataset
- Stemming: Use Porter Stemmer for French to handle gender/plural variations (haché/hachée/hachés/hachées → “hacher”)
- Extract: Pull out cooking methods from ingredient names (e.g., “oignon haché” → preparation: “hacher”, name: “oignon”)
The cleanIngredientName function then removes these cooking method terms, leaving clean ingredient names.
Quantity Parsing
The parseQuantity function handles various French quantity formats:
- Simple numbers: “200” → 200
- Ranges: “1-2” or “1 à 2” → 1.5 (average)
- Fractions: “1/2” → 0.5
- Modifiers: “environ 200” → 200 (strips “environ”)
LLM Validation
After this conversion, we validate the results with an LLM to catch any edge cases or anomalies the rule-based system might miss. This gives us the best of both worlds: fast, deterministic processing with intelligent oversight.
Path B: SpaCy NLP Pipeline via IPC (Complex Data)
For messy, unstructured data, we communicate with our Python NLP service via inter-process communication (IPC). This architecture keeps the heavy spaCy processing isolated in a dedicated Python environment while the TypeScript processor remains lightweight and fast.
IPC Architecture: TypeScript ↔ Python Communication
The TypeScript processor spawns a dedicated Python process at startup using Bun’s spawn functionality. The Python server loads the spaCy French transformer model (fr_dep_news_trf) and vocabularies (measurements, cooking methods) once at initialization, then stays alive to handle requests.
Communication happens via bidirectional JSON messages over stdin/stdout:
- TypeScript → Python: Sends JSON requests via stdin (action: parse_recipe, load_vocabularies, ping)
- Python → TypeScript: Sends JSON responses via stdout (parsed ingredients, logs, progress updates)
Each request includes a unique ID, allowing the Python server to handle multiple requests concurrently using a ThreadPoolExecutor (4 workers by default). This means while one recipe is being parsed, others can be queued and processed in parallel, maximizing CPU utilization.
The TypeScript side maintains a request-response mapping, awaiting specific response IDs to match requests with their corresponding results. This keeps the IPC simple, fast, and resilient.
SpaCy Entity Recognition and Cross-Referencing
The spaCy pipeline uses the French transformer model (fr_dep_news_trf) combined with custom entity patterns to parse complex ingredient expressions. The process works in several sophisticated stages:
Stage 1: Entity Extraction The EntityEngine uses spaCy’s EntityRuler with custom patterns to extract:
- Quantities: Numbers, fractions (1/2, 2/3), ranges (1-2), modifiers (“environ”, “quelques”)
- Units: Multi-word measurements (“cuillère à soupe”, “c. à soupe”, “verre à bordeaux”) cross-referenced against our MEASUREMENTS vocabulary
- Ingredients: Complex noun phrases (“coeurs de laitue romaine”, “filets de poulet”)
- Preparations: Cooking methods (“haché”, “émincé”, “coupé en dés”) matched against COOKING_METHODS vocabulary
Stage 2: Ingredient Line Parsing Each ingredient line is processed through spaCy’s NLP pipeline to extract structured data (quantity, unit, ingredient name, preparation). The parser uses lemmatization to normalize ingredient names (e.g., “tomates” → “tomate”).
Stage 3: Instruction Parsing for Quantified Mentions The parser also analyzes instruction text to find “quantified mentions” - ingredients mentioned in the cooking steps with specific quantities (e.g., “ajouter 200g de farine”). These mentions are crucial for the next stage.
Stage 4: Cross-Referencing with Lemma Matching Here’s where it gets interesting: Many French recipes don’t include quantities in the ingredient list, expecting readers to find them in the instructions. The parser performs intelligent cross-referencing:
-
Lemma matching: Unresolved ingredients (missing quantities) are matched against quantified mentions from instructions using lemmatized forms. For example, if the ingredient list says “tomates” but instructions say “ajouter 4 tomates coupées”, the parser connects them via the lemma “tomate”.
-
Semantic similarity fallback: For remaining unresolved ingredients, spaCy’s word vectors compute semantic similarity between ingredient names and mentions. This catches cases where the exact lemma doesn’t match but the meaning is close (e.g., “volaille” matching “poulet”).
This multi-stage approach achieves high accuracy even with messy, incomplete recipe data where quantities are scattered across ingredients and instructions.
Stage 5-6: Safety-First Allergen and Dietary Detection
This is where our safety-first philosophy becomes critical. We never trust LLMs with allergen detection – we use deterministic rule-based systems.
Multi-Layer Allergen Detection
interface AllergenRule {
allergen: string;
keywords: string[];
derivatives: string[];
hiddenSources: string[];
falsePositives: string[];
eNumbers?: string[];
}
const ALLERGEN_RULES: AllergenRule[] = [
{
allergen: "gluten",
keywords: ["blé", "seigle", "orge", "avoine", "épeautre"],
derivatives: ["gluten de blé", "protéine de blé", "malt d'orge"],
hiddenSources: ["sauce soja", "bouillon cube", "bière"],
falsePositives: ["lait de coco", "farine de coco"],
eNumbers: ["E1400", "E1410", "E1412"] // Modified starches
},
// ... 13 more allergens
];
Confidence-Based Detection
const detectAllergens = (ingredient: string): AllergenDetectionResult[] => {
const results: AllergenDetectionResult[] = [];
for (const rule of ALLERGEN_RULES) {
const detection = analyzeAllergen(ingredient, rule);
if (detection.matched) {
results.push({
allergen: rule.allergen,
confidence: detection.confidence,
source: detection.source, // "direct" | "derived" | "possible_contamination"
matchedTerms: detection.terms
});
}
}
return results;
};
const analyzeAllergen = (ingredient: string, rule: AllergenRule) => {
const normalized = ingredient.toLowerCase();
// Direct keyword matching (high confidence)
if (rule.keywords.some(kw => normalized.includes(kw))) {
return { confidence: "high", source: "direct" };
}
// Derivative detection (medium confidence)
if (rule.derivatives.some(der => normalized.includes(der))) {
return { confidence: "medium", source: "derived" };
}
// Hidden source detection (low confidence)
if (rule.hiddenSources.some(hidden => normalized.includes(hidden))) {
return { confidence: "low", source: "possible_contamination" };
}
return { matched: false };
};
Comprehensive Dietary Classification
const DIETARY_RULES = {
vegetarian: {
excludes: ["viande", "poisson", "volaille", "charcuterie"],
allows: ["lait", "œuf", "fromage"],
exceptions: ["gélatine végétale"]
},
vegan: {
excludes: ["viande", "poisson", "volaille", "lait", "œuf", "miel"],
allows: [],
exceptions: []
},
halal: {
excludes: ["porc", "alcool", "viande non halal"],
allows: [],
exceptions: ["gélatine halal"]
}
// ... more dietary rules
};
Stage 6: Instruction Formatting with LLM
After processing ingredients, we enhance recipe instructions using an LLM to extract structured metadata. This step takes the recipe title, parsed ingredient list, and original instruction text, then generates structured output that includes:
- Ingredient references: Which ingredients are used in each step
- Timing information: Cooking durations, temperatures, and timing cues
- Cooking methods: Techniques mentioned in each step
- Original text preservation: Keeps the chef’s voice while adding structure
This structured data powers rich UI features like ingredient highlighting during cooking, progress timers, and step-by-step guidance. The LLM contextualizes instructions against the parsed ingredients to ensure accurate cross-referencing.
Stage 7: LLM Anomaly Validation
The final validation stage uses an LLM to perform quality assurance on the processed recipe. The LLM receives both the original recipe data and the fully processed output, then checks for:
- Ingredient consistency: Do the parsed ingredients match what’s described in the instructions?
- Realistic cooking times: Are prep and cook times reasonable for this recipe type?
- Missing allergens: Could any common allergens be present that our rule-based detector missed?
- Quantity anomalies: Do ingredient quantities make sense for the number of servings?
When anomalies are detected, they’re logged for review. The LLM can suggest corrections, but critical safety data (allergens, dietary restrictions) always defers to our deterministic rule-based systems - the LLM only flags potential gaps, it never overrides safety rules.
Stage 8: Dual-Vector Embedding Generation
One of our key design strategies is the dual-vector embedding approach. Instead of a single embedding, we generate two specialized vectors for each recipe:
Strategy 1: Semantic Embedding
Captures the essence of what makes a recipe unique:
const semanticText = `
${recipe.title}
${recipe.headline || ""}
${dishType ? `Type: ${dishType}` : ""}
${categoryNames ? `Catégories: ${categoryNames}` : ""}
${ingredientCategories ? `Ingrédients: ${ingredientCategories}` : ""}
Ingrédients: ${ingredients.map(i => i.name).join(", ")}
${mainIngredients ? `Focus: ${mainIngredients}` : ""}
${region ? `Région: ${region}` : ""}
${season ? `Saison: ${season}` : ""}
`.trim();
Use case: “I want Italian recipes from the summer” → searches on region + season
Strategy 2: Ingredient-Only Embedding
Pure ingredient similarity matching:
const ingredientsText = recipe.parsedIngredientGroups
.flatMap(g => g.ingredients)
.map(i => i.name)
.join(", ");
Use case: “I have tomatoes and basil” → searches only on ingredient presence
Parallel Generation for Efficiency
const result = await embedMany({
model: embeddingModel,
values: [semanticText, ingredientsText]
});
const [semanticEmbedding, ingredientsEmbedding] = result.embeddings;
return {
semantic: semanticEmbedding,
ingredients: ingredientsEmbedding
};
This dual approach allows our search to intelligently choose the right vector based on query type, dramatically improving search relevance.
Stage 9: Lightweight Qdrant + Full PostgreSQL Storage
Our storage strategy separates concerns for optimal performance and cost:
Qdrant: Lightweight Filtering Metadata
We store only the minimal data needed for vector search and filtering:
await qdrant.upsert({
id: recipe.id,
vectors: {
semantic: embeddings.semantic,
ingredients: embeddings.ingredients
},
payload: {
// ONLY essential filtering fields
title: recipe.title,
cookingTime: recipe.cookingTime,
preparationTime: recipe.preparationTime,
servings: recipe.servings,
difficultyId: recipe.difficultyId,
priceId: recipe.priceId,
allergens: recipe.allergens,
dietaryTypes: recipe.dietaryTypes,
seasonId: recipe.seasonId,
dishTypeId: recipe.dishTypeId
}
});
Total payload size: ~500 bytes per recipe (vs 5KB+ with full data)
PostgreSQL: Complete Recipe Data
Full recipe details with ingredients, instructions, images, etc:
await db.insert(recipes).values({
id: recipe.id,
title: recipe.title,
ingredients: recipe.parsedIngredientGroups, // Full nested structure
instructions: recipe.formattedInstructions, // Complete instructions
images: recipe.images, // Image URLs
metadata: recipe.processingMetadata,
// ... all other rich data
});
Post-Search Enrichment Flow
// 1. Search Qdrant with minimal data
const searchResults = await qdrant.search(vector, limit, filter);
const recipeIds = searchResults.points.map(p => p.id);
// 2. Enrich with full data from PostgreSQL
const fullRecipes = await db.select()
.from(recipes)
.where(inArray(recipes.id, recipeIds));
return fullRecipes; // Complete recipes with all details
Benefits:
- Reduced Qdrant costs: 90% smaller payloads
- Faster vector search: Less data to transfer
- PostgreSQL for complex queries: Rich joins and aggregations
- Clear separation: Vector search vs relational data
Pipeline Versioning and Modular Re-Processing
A critical feature of our architecture is the version-aware pipeline that allows us to reprocess recipes when algorithms improve.
Version Tracking
interface ProcessingMetadata {
processedAt: Date;
version: string; // e.g., "1.3.0"
processingMethod: "direct_conversion" | "spacy" | "spacy_with_llm";
ingredientQualityScore: number;
}
// Environment configuration
const PROCESSOR_VERSION = "1.3.0";
Intelligent Reprocessing Logic
const RecipePipelineOrchestrator = {
async shouldReprocess(recipe: Recipe): Promise<boolean> {
const [pgVersion, qdrantVersion] = await Promise.all([
db.select({ version: recipes.processingMetadata.version })
.where(eq(recipes.id, recipe.id)),
qdrantClient.retrieve(recipe.id)
]);
// Reprocess if versions don't match
return pgVersion !== PROCESSOR_VERSION ||
qdrantVersion !== PROCESSOR_VERSION;
}
};
Modular Pipeline Execution
Our architecture allows running specific stages on the entire database:
// Rerun only allergen detection on all recipes
await runPipelineStage({
stage: 'allergen_detection',
filter: { processingVersion: { lt: '1.3.0' } },
batchSize: 100
});
// Rerun only embedding generation
await runPipelineStage({
stage: 'embedding_generation',
filter: { embeddings: { exists: false } },
batchSize: 50
});
Real-world use case: When we improved our allergen detection rules in v1.3.0, we reprocessed 35,000+ recipes in 4 hours, updating only the allergen data without re-running expensive NLP processing.
What Makes This Pipeline Special
1. IPC-Based Architecture
Isolates heavy Python NLP processing from lightweight TypeScript orchestration via bidirectional JSON-based communication, allowing efficient concurrent processing.
2. Dual-Vector Embedding Strategy
Separate embeddings for semantic search (regions, methods) vs ingredient-based search (tomatoes + basil), dramatically improving search relevance.
3. Intelligent Quality-Based Routing
Quality assessment saves 60-70% on processing costs by routing high-quality data through faster direct conversion paths.
4. Hybrid NLP Approach
Combines rule-based systems (allergens), spaCy NLP (complex parsing), and LLMs (validation, formatting) exactly where each excels.
5. Safety-First Allergen Detection
100% deterministic rule-based allergen detection – never trust probabilistic models with health-critical functions.
6. Lightweight Qdrant + Full PostgreSQL
Qdrant stores only essential filtering metadata (~500 bytes/recipe), PostgreSQL stores complete recipe data, with post-search enrichment for optimal performance.
7. Version-Aware Modular Pipeline
Intelligent versioning allows reprocessing specific stages (e.g., only allergen detection) across 35,000+ recipes without full reprocessing.
8. Instruction Formatting Enhancement
LLM-based extraction of ingredient references and timing from instructions enables rich UI features like progress bars and ingredient highlighting.
Results: 99.9% Accuracy at Optimal Cost
Our sophisticated pipeline delivers:
- 99.9% parsing accuracy on complex French culinary expressions
- 100% allergen detection reliability through deterministic rules
- 60-70% cost reduction through intelligent routing and IPC architecture
- Sub-2 second processing time per recipe on average
- 90% smaller Qdrant payloads through lightweight metadata strategy
- Dual-vector search with semantic + ingredient embeddings
- Zero data loss through comprehensive error recovery
- Modular reprocessing of 35,000+ recipes in hours, not days
This isn’t just a recipe processing system – it’s a production-grade data processing pipeline that balances accuracy, cost, and reliability at scale. The combination of IPC-based architecture, dual-vector embeddings, intelligent routing, and modular versioning makes it a standout example of modern data engineering for AI applications.