167 lines
7.1 KiB
Markdown
167 lines
7.1 KiB
Markdown
# 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
|