# Semantic Search + Preference Learning — Implementation

Date: 2026-06-28
Status: **Implemented and tested**

## Embedding Model

**Model:** `vllm/harrier-oss-v1-0.6b` (noris)
- 1024 dimensions, multilingual (German-capable)
- Dedicated embedding model (NOT an LLM), 0.6B params
- Endpoint: `POST https://ai.noris.de/v1/embeddings`
- Auth: Same noris API key from `config.yaml`
- Batch: Up to 64 inputs per call, ~31 recipes/sec throughput

### Discovery

Initial investigation found that norris does NOT serve embeddings via standard LLM models (glm, gemma, qwen all return 404). The model list included `vllm/harrier-oss-v1-0.6b` which was the only embedding model that actually works. Other embedding names (bge-m3, e5-large, gte-large) are recognized by the Bifrost gateway but not activated on any API key.

### Quality Validation

Cosine similarity matrix (higher = more similar):

|              | Curry | Salat | Eintopf | Hähnchen | Wintergericht |
|-------------|-------|-------|---------|----------|---------------|
| Curry        | 1.000 | 0.647 | 0.677   | **0.812** | 0.660         |
| Salat        | 0.647 | 1.000 | 0.715   | 0.667    | 0.661         |
| Eintopf      | 0.677 | 0.715 | 1.000   | 0.696    | **0.829**     |
| Hähnchen     | 0.812 | 0.667 | 0.696   | 1.000    | 0.694         |
| Wintergericht| 0.660 | 0.661 | 0.829   | 0.694    | 1.000         |

Curry↔Hähnchen highest (both chicken dishes), Eintopf↔Wintergericht high (both warm/hearty), Salat↔Eintopf lower (different concepts). Working as expected.

## Database Schema

### `recipes.embedding` (JSON column)

Added via `ALTER TABLE recipes ADD COLUMN embedding JSON NULL`.
Stores 1024-dim embedding as JSON array: `[0.023, -0.054, ...]`.

### `meal_decisions` table

```sql
CREATE TABLE meal_decisions (
  id INT AUTO_INCREMENT PRIMARY KEY,
  decision_date DATE NOT NULL,
  recipe_id VARCHAR(255) NOT NULL,
  recipe_title VARCHAR(500),
  recipe_category VARCHAR(200),
  accepted BOOLEAN NOT NULL DEFAULT FALSE,
  context_temp FLOAT NULL,           -- outdoor temp °C
  context_season VARCHAR(20) NULL,   -- spring/summer/autumn/winter
  context_weekday VARCHAR(20) NULL,
  context_last_meals TEXT NULL,       -- JSON array of recent meal categories
  reason TEXT NULL,                   -- free-text rejection reason
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```

### `preference_rules` table

```sql
CREATE TABLE preference_rules (
  id INT AUTO_INCREMENT PRIMARY KEY,
  rule_type ENUM('context_category','context_tag','variety','semantic') NOT NULL,
  condition_json TEXT NOT NULL,  -- {"temp_min":20, "temp_max":99}
  effect_json TEXT NOT NULL,     -- {"penalty_category":"Eintopf"}
  weight FLOAT DEFAULT 1.0,      -- grows with confirmations (capped at 10)
  confirmations INT DEFAULT 1,
  rejections INT DEFAULT 0,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```

## Scripts

All in `profiles/nutrition-coach/scripts/`:

### `generate_embeddings.py`
- Batch-generates embeddings for recipes where `embedding IS NULL`
- Builds embedding text from: `title + category + tags[:10] + ingredients[:20] + description[:200]` (capped at 1000 chars)
- Batch size: 64 recipes per API call
- Rate limit: 0.2s between calls
- Run: `python3 scripts/generate_embeddings.py`
- Should be run after scraper fills new recipes (could be chained in cronjob)

### `semantic_search.py`
Core module with these functions:

#### `semantic_search(query, filters, top_k, min_rating)`
- Gets query embedding from norris
- Loads up to 5000 candidate recipes from DB (ordered by rating DESC)
- Computes cosine similarity in Python
- Blends similarity with rating: `score = similarity * (1 + rating/10)`
- Supports filters: `category`, `source`, `exclude_categories`, `max_calories`

#### `record_decision(recipe_id, accepted, context)`
- Logs to `meal_decisions` table
- Auto-creates preference rule if rejection reason mentions weather/temperature
- Example: `record_decision("hf_123", False, {"temp": 28, "season": "summer", "reason": "zu heiß für Eintopf"})`
- Auto-rule: temp>20°C → penalize category from the rejected recipe

#### `get_preference_adjustments(context)`
- Returns `{boost_categories, penalty_categories, boost_tags, penalty_tags}` based on matching rules
- Each entry includes the rule's weight

#### `rank_with_preferences(recipes, context)`
- Re-ranks a list of recipe dicts by applying:
  - Category penalties/boosts from learned rules (-0.1 × weight per match)
  - Tag penalties/boosts (-0.05 × weight per match)
  - Variety penalty: -0.15 if same category as recent meals
- Checks `category + title + tags` concatenated (not just category field — see pitfall below)

#### `mine_rules(min_confidence=2)`
- Analyzes `meal_decisions` for statistical patterns
- Groups by `(category, temperature_bucket)` and `(category, season)`
- If rejection rate > 50% with enough samples → creates/upgrades rule
- Should run periodically as decisions accumulate

## Tested Examples

### Semantic Search
```
Query: "schnelles Hähnchengericht"
→ Hähnchenbrust in Joghurt-Parmesan-Salbei-Panade (sim: 0.622, ★5.0)
→ Hähnchenschnitzel auf Muttis Art (sim: 0.626, ★4.9)
→ Hühnchenschnitzel (sim: 0.614, ★5.0)
```

### Preference Learning
```
1. record_decision("ck_Erbsensuppe", False, {"temp": 28, "reason": "zu heiß für Eintopf"})
   → Auto-rule created: temp>20°C → penalize "Eintopf" (weight: 1.0)

2. semantic_search("Eintopf mit Kartoffeln", top_k=10)
   → rank_with_preferences(results, {"temp": 28, "season": "summer"})
   
Result: All Eintopf recipes get Δ=-0.2000 (two matching rules × 0.1 weight each)
  0.7365 (Δ-0.2000) | Möhren-Kartoffel-Eintopf (Kochen)
  0.7304 (Δ-0.2000) | Eintopf mit Spitzkohl, Hackfleisch (Kartoffeln)
  0.7226 (Δ-0.2000) | Möhreneintopf alla Isa (Eintopf)
```

## Key Pitfalls

1. **Category field mismatch:** Most "Eintopf" recipes have category "Kochen" or "Kartoffeln", not "Eintopf". Rule matching must check `title + tags + category` as a concatenated lowercase string.

2. **Decimal type from MySQL:** `rating` comes back as `decimal.Decimal`, not `float`. Must cast with `float(rating or 0)` before arithmetic.

3. **Auto-rule with non-existent recipe IDs:** `record_decision("test_reject_1", ...)` creates a rule with empty category because the recipe doesn't exist in the DB. Always use real recipe IDs from the DB.

4. **Duplicate rules from auto-creation + mining:** Both `_auto_create_rule()` and `mine_rules()` can create the same rule. `_upsert_rule()` handles this with matching on `(rule_type, condition_json, effect_json)` and increments weight/confirmations.

## Integration with Meal Planner

The meal planner (`nutrition-plan-generator-v4.py`) should be updated to:
1. Call `semantic_search()` instead of linear JSONL scan
2. Call `rank_with_preferences()` with weather context
3. Call `record_decision()` when user accepts/rejects proposals in Phase 2
4. Periodically call `mine_rules()` to discover new patterns

Context for decisions:
- Temperature: From HA weather sensor or Tibber API
- Season: Derived from date
- Weekday: From date
- Last meals: From previous week's finalized plan
