Nouveau src/ollama-tech-step-poc.ts : même tâche/SYSTEM_PROMPT (exporté depuis llm-tech-step-poc.ts et réutilisé tel quel) que le moteur node-llama-cpp, mais via Ollama — une implémentation architecturalement différente plutôt qu'une redite : - Ollama tourne comme serveur HTTP local séparé (ollama serve), pas comme binding natif dans ce process — le paquet npm ollama n'a aucune dépendance native (rien à compiler à l'install, contrairement à node-llama-cpp). - Modèle géré par Ollama lui-même (ollama.pull(), cache dans ~/.ollama/models), pas par ce projet — progression de pull journalisée palier par palier plutôt que silencieuse. - Schéma JSON imposé via `format` (JSON Schema standard, `type: ["string","null"]` pour un champ nullable) — plus simple que le détour `oneOf` qu'exige la grammaire GBNF de node-llama-cpp. - initialize() échoue avec un message explicite si le serveur Ollama n'est pas joignable, plutôt que l'erreur fetch brute. - Caveat documenté en tête de fichier et rappelé avant le récapitulatif : la colonne RSS du harness ne mesure rien d'utile ici, l'inférence tourne dans le process ollama serve, pas dans ce script. OllamaStepAnalyzer.dispose() décharge le modèle du serveur (keep_alive: 0, best effort). Env vars OLLAMA_TECH_STEP_MODEL/OLLAMA_TECH_STEP_HOST, scripts pnpm bench:ollama. Vérifié en conditions réelles (Ollama tournait déjà dans l'environnement) : pull + inférence structurée + parsing JSON fonctionnels, latence nettement inférieure à node-llama-cpp sur les mêmes phrases (748-1260 ms vs 3-13 s), delta RSS confirmé proche de zéro/bruit comme attendu. README mis à jour (4 moteurs, section Ollama avec tableau comparatif architectural, limites). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
12 KiB
Markdown
210 lines
12 KiB
Markdown
# PoC — détection d'actions culinaires : NLP, LLM local, hybride
|
|
|
|
Expérimentation autonome, **hors du monorepo pnpm** (`pnpm-workspace.yaml` ne
|
|
référence que `apps/*`/`packages/*`) : ce dossier a son propre
|
|
`package.json`/`tsconfig.json` et ne pollue ni les dépendances ni le build
|
|
Docker de `apps/api`.
|
|
|
|
Objectif : comparer, sur la même tâche (structurer une étape de recette en
|
|
séquence ordonnée d'actions culinaires) et le même jeu de 11 phrases, quatre
|
|
moteurs qui tournent tous 100 % en local :
|
|
|
|
| Script | Moteur | Ce qu'il apporte |
|
|
|---|---|---|
|
|
| `pnpm bench` | Mini LLM instruct via [`node-llama-cpp`](https://node-llama-cpp.withcat.ai/) (binding natif **dans ce process**), sortie JSON contrainte par schéma (GBNF grammar) | Généralise sans vocabulaire fixé à l'avance — au prix d'une latence de plusieurs secondes. |
|
|
| `pnpm bench:ollama` | Le même LLM (même `SYSTEM_PROMPT`, même tâche), mais via [Ollama](https://ollama.com/) — un serveur HTTP **local séparé** plutôt qu'un binding embarqué | Compare l'impact de l'architecture (client-serveur vs in-process) à sémantique identique, pas juste un autre modèle. |
|
|
| `pnpm bench:nlp` | Classifieur `node-nlp` frais (NER + découpage en clauses + classification d'intention), entraîné directement sur la taxonomie à 7 catégories de ce PoC | Rapide (centaines de ms), mais borné à son vocabulaire d'entraînement. |
|
|
| `pnpm bench:hybrid` | NLP d'abord, LLM (`node-llama-cpp`) en secours si le score NLP est trop faible | Le meilleur des deux : rapide sur le cas courant, généralise sur le cas difficile. |
|
|
|
|
Les quatre partagent le même code (`src/shared/`) : la taxonomie
|
|
`KitchenActionType`/`KitchenAction`/`RecipeStepAnalysis`
|
|
(`shared/kitchen-action.ts`), les 11 phrases de test
|
|
(`shared/test-sentences.ts`), et le harness de mesure/affichage
|
|
(`shared/benchmark-harness.ts`) — un seul jeu de phrases et un seul format
|
|
de sortie pour que les runs soient directement comparables, plutôt que
|
|
recopiés à la main dans chaque script (le défaut d'une toute première
|
|
version de ce PoC, où le pendant NLP vivait dans `apps/api` et copiait les
|
|
phrases manuellement). Les deux moteurs LLM (`node-llama-cpp`/Ollama)
|
|
partagent en plus le `SYSTEM_PROMPT` lui-même (exporté par
|
|
`llm-tech-step-poc.ts`, importé par `ollama-tech-step-poc.ts`) — même
|
|
sémantique testée sur les deux, seul le mécanisme de contrainte JSON change.
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
cd experiments/llm-tech-step-poc
|
|
pnpm install --ignore-workspace
|
|
```
|
|
|
|
`--ignore-workspace` est nécessaire : ce dossier n'étant pas dans les globs
|
|
de `pnpm-workspace.yaml` (`apps/*`/`packages/*`), un `pnpm install` normal
|
|
remonte jusqu'à la racine du monorepo et n'installe **rien** ici (aucune
|
|
erreur, juste un `node_modules` vide/inutilisable) — piège trouvé en écrivant
|
|
ce PoC.
|
|
|
|
`node-llama-cpp` télécharge/compile son binding natif llama.cpp à
|
|
l'installation (binaire prébuilt pour les plateformes courantes, sinon
|
|
compilation locale — nécessite alors un toolchain C++, voir sa doc
|
|
["Troubleshooting"](https://node-llama-cpp.withcat.ai/guide/troubleshooting)
|
|
en cas d'échec). `ollama` (le paquet npm) n'a lui aucune dépendance
|
|
native — c'est un simple client HTTP, rien à compiler.
|
|
|
|
## 1. Benchmark LLM seul — `pnpm bench`
|
|
|
|
```bash
|
|
pnpm bench
|
|
```
|
|
|
|
Télécharge le modèle GGUF choisi via `LLM_TECH_STEP_MODEL` (une seule fois,
|
|
mis en cache dans `experiments/llm-tech-step-poc/models/`, jamais commité),
|
|
le charge, fait un appel de warm-up (chronométré à part — le tout premier
|
|
appel d'inférence sur un contexte fraîchement créé paie un coût caché de
|
|
plusieurs secondes que le chargement du modèle ne couvre pas), puis lance 3
|
|
répétitions sur chacune des 7 phrases de test.
|
|
|
|
| Valeur (défaut en gras) | Modèle | Pourquoi |
|
|
|---|---|---|
|
|
| **`qwen2.5-1.5b`** | Qwen2.5-1.5B-Instruct, `Q4_K_M` | Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON — et, empiriquement (voir `Résultats obtenus` ci-dessous), aussi la latence la plus basse des deux sur ce benchmark, malgré ses ~50 % de paramètres en plus. |
|
|
| `llama-3.2-1b` | Llama-3.2-1B-Instruct, `Q4_K_M` | ~35 % de paramètres en moins, FR officiellement supporté, mais structuration JSON moins fiable à 1B — et pas plus rapide non plus dans les runs obtenus jusqu'ici. Conservé comme point de comparaison, pas comme choix "latence d'abord". |
|
|
|
|
> **Résultats obtenus** (Windows, backend Vulkan, une machine) : sur les 7
|
|
> phrases, Qwen2.5-1.5B a été systématiquement plus rapide que Llama-3.2-1B
|
|
> malgré sa taille plus grande — l'inverse de l'hypothèse a priori "moins de
|
|
> paramètres = plus rapide". Un seul run sur une seule machine/un seul
|
|
> backend ne généralise pas forcément (CUDA/CPU pur donneraient
|
|
> possiblement un classement différent).
|
|
|
|
```bash
|
|
LLM_TECH_STEP_MODEL=llama-3.2-1b pnpm bench
|
|
```
|
|
|
|
**Hors-ligne / CI** : `LLM_TECH_STEP_MODEL_PATH=/chemin/vers/un.gguf pnpm bench`
|
|
pointe directement vers un fichier déjà téléchargé, sans passer par la
|
|
résolution/téléchargement Hugging Face.
|
|
|
|
## 2. Benchmark LLM via Ollama — `pnpm bench:ollama`
|
|
|
|
Prérequis : [Ollama](https://ollama.com/download) installé, **et son serveur
|
|
lancé** (`ollama serve` dans un terminal séparé, ou l'app de bureau Ollama
|
|
qui le lance automatiquement) — ce script ne démarre pas le serveur
|
|
lui-même, contrairement à `pnpm bench` qui charge son modèle directement.
|
|
|
|
```bash
|
|
ollama serve # si pas déjà lancé (ou l'app de bureau Ollama)
|
|
pnpm bench:ollama
|
|
```
|
|
|
|
Même tâche, même `SYSTEM_PROMPT`, mêmes modèles (`OLLAMA_TECH_STEP_MODEL`,
|
|
mêmes valeurs `qwen2.5-1.5b`/`llama-3.2-1b` que `LLM_TECH_STEP_MODEL`) que
|
|
la section 1 — mais une **implémentation architecturalement différente**,
|
|
testée pour ça plutôt que comme une simple redite :
|
|
|
|
| | `node-llama-cpp` (section 1) | Ollama (cette section) |
|
|
|---|---|---|
|
|
| Où tourne l'inférence | Dans CE process Node (binding natif) | Dans le process `ollama serve`, séparé |
|
|
| Installation | Binaire natif compilé/téléchargé au `pnpm install` | Client HTTP pur, rien à compiler |
|
|
| Gestion du modèle | `resolveModelFile` télécharge le GGUF dans `models/` de ce projet | `ollama pull` — Ollama gère son propre cache (`~/.ollama/models`) |
|
|
| Schéma JSON nullable | `oneOf: [{type:"null"}, {type:"..."}]` (contrainte de la grammaire GBNF) | `type: ["string", "null"]` (JSON Schema standard, plus simple) |
|
|
| Mesure RSS du benchmark | Fiable — le binding alloue dans ce process | **Sans intérêt** — l'inférence tourne ailleurs, voir ci-dessous |
|
|
|
|
**La colonne `RSS moy.` du récapitulatif ne veut RIEN dire pour ce script** —
|
|
`process.memoryUsage()` mesure ce process Node, pas le process `ollama
|
|
serve` où l'inférence a réellement lieu. Le script l'affiche quand même
|
|
(même harness que les trois autres) mais rappelle ce point juste avant le
|
|
tableau.
|
|
|
|
```bash
|
|
OLLAMA_TECH_STEP_MODEL=llama-3.2-1b pnpm bench:ollama
|
|
```
|
|
|
|
**Hôte Ollama personnalisé** (serveur distant, port non standard) :
|
|
`OLLAMA_TECH_STEP_HOST=http://mon-serveur:11434 pnpm bench:ollama`
|
|
(défaut : `http://127.0.0.1:11434`).
|
|
|
|
## 3. Benchmark NLP seul — `pnpm bench:nlp`
|
|
|
|
```bash
|
|
pnpm bench:nlp
|
|
```
|
|
|
|
Aucun téléchargement, aucune base de données — tourne en quelques secondes.
|
|
`NlpTechStepClassifier` (`src/nlp-tech-step-poc.ts`) est un classifieur
|
|
`node-nlp` **frais**, écrit pour ce PoC plutôt qu'une réutilisation de
|
|
`TechStepClassifierService` (`apps/api/src/lib/recipe-matching/
|
|
tech-step-matcher.ts`) — deux raisons :
|
|
|
|
1. **Comparaison vraiment terme à terme** : `TechStepClassifierService`
|
|
classe sur la taxonomie fine à ~26 techniques de
|
|
`tech-step-training-data.ts` (DB-backed), pas sur les 7 catégories de
|
|
`KitchenActionType` que le LLM produit — les nombres de détections
|
|
n'étaient pas directement comparables. Ce classifieur-ci est entraîné
|
|
directement sur les 7 mêmes catégories.
|
|
2. **Un score de confiance exploitable** : `TechStepClassifierService`
|
|
masque son score en retombant silencieusement sur l'ancre NER dès qu'il
|
|
est sous son seuil interne — utile en prod, mais ça cache le signal dont
|
|
le pipeline hybride (section 3) a besoin pour décider quand basculer
|
|
vers le LLM. Ce classifieur-ci renvoie toujours le score BRUT.
|
|
|
|
Même pipeline NER → découpage en clauses → classification par clause que
|
|
`tech-step-matcher.ts`, implémentation propre à ce PoC (simplifiée : pas de
|
|
priorité aux frontières de phrase dans le découpage). Le corpus
|
|
d'entraînement (`TRAINING_DATA` dans `nlp-tech-step-poc.ts`) préfère un
|
|
synonyme mono-mot ("revenir") à une phrase figée ("faites revenir") quand
|
|
c'est possible — une leçon tirée d'un run antérieur de ce PoC : "faites
|
|
revenir" (2 mots) ratait "faites-**les**-revenir" (le pronom clitique
|
|
français insère un mot entre les deux et casse un matching de phrase
|
|
contiguë), un synonyme mono-mot matche quel que soit ce qui le précède.
|
|
|
|
## 4. Pipeline hybride — `pnpm bench:hybrid`
|
|
|
|
```bash
|
|
pnpm bench:hybrid
|
|
```
|
|
|
|
Combine les deux : le NLP analyse TOUJOURS en premier (chemin rapide) ; si
|
|
sa confiance globale (le minimum de confiance de ses clauses) est sous
|
|
`NLP_TRUST_THRESHOLD` (`0.6`, tunable dans `hybrid-tech-step-poc.ts`) ou
|
|
qu'il n'a rien trouvé du tout, son résultat est ENTIÈREMENT écarté et
|
|
l'étape est réanalysée par le LLM. Le récapitulatif affiche, par phrase,
|
|
quel moteur a répondu (`moteur`) et la confiance NLP qui a déclenché la
|
|
décision (`confiance NLP`) — de quoi ajuster le seuil en observant sur
|
|
quelles phrases le pipeline bascule.
|
|
|
|
Nécessite le modèle LLM (même téléchargement/options `LLM_TECH_STEP_MODEL`/
|
|
`LLM_TECH_STEP_MODEL_PATH` que la section 1) puisqu'il reste le moteur de
|
|
secours.
|
|
|
|
**Limite assumée** : quand le chemin NLP est pris, seuls `action`/`verb`
|
|
sont réellement connus — `ingredients`/`utensils` restent `[]` et
|
|
`durationMinutes`/`temperature` restent `null`, jamais inventés (le
|
|
classifieur NLP ne peut structurellement pas les extraire). Seul le chemin
|
|
LLM remplit tous les champs. Un vrai système hybride ferait probablement
|
|
remonter le champ `source` jusqu'à l'UI pour ne promettre que ce que chaque
|
|
chemin fournit réellement.
|
|
|
|
## Limites de ce PoC
|
|
|
|
- Pas de jeu d'évaluation étiqueté ni de métrique de précision automatisée
|
|
— les 11 phrases sont inspectées à l'œil, pas notées.
|
|
- La contrainte JSON (grammaire GBNF ou JSON Schema Ollama) ne garantit
|
|
qu'une syntaxe JSON conforme au schéma, jamais la justesse sémantique du
|
|
contenu.
|
|
- Le corpus du classifieur NLP frais est volontairement compact (PoC, pas un
|
|
remplacement du corpus production `tech-step-training-data.ts`) — des
|
|
formes non couvertes (conjugaisons, synonymes absents) manqueront, comme
|
|
pour n'importe quel corpus fini.
|
|
- `NLP_TRUST_THRESHOLD` (`0.6`) est un point de départ raisonnable, pas une
|
|
valeur empiriquement optimisée — à ajuster en observant la colonne
|
|
`moteur` du récapitulatif hybride sur des étapes réelles.
|
|
- Le delta de RSS process est une approximation de la RAM réellement utilisée
|
|
pour `node-llama-cpp`/`node-nlp` (un binding natif alloue dans le même
|
|
process, donc le RSS la capture, mais au bruit du GC/de l'allocateur près)
|
|
— pas une mesure isolée, et carrément **sans valeur** pour `bench:ollama`
|
|
(l'inférence tourne dans `ollama serve`, un process séparé, voir la
|
|
section 2).
|
|
- Latence `node-llama-cpp` mesurée en CPU pur (pas de configuration GPU dans
|
|
ce PoC) — un déploiement réel voudrait évaluer l'offload GPU (`gpuLayers`
|
|
dans les options `loadModel`) si la cible dispose d'un GPU. Ollama, lui,
|
|
détecte et utilise l'accélération matérielle disponible automatiquement —
|
|
une différence qui peut à elle seule expliquer un écart de latence entre
|
|
les deux moteurs LLM, indépendamment du modèle choisi.
|