SB
articles
Article · LLM 2026-05 · 9 min de lecture

Matcher IA : combiner score déterministe et verdict LLM

Comment ApplyDesk note 100 offres d'emploi en une fraction de seconde côté navigateur, et garde un budget LLM raisonnable pour donner un verdict argumenté seulement sur celles qui le méritent. Retour d'expérience sur un side-project perso.

Le problème

Trier 100 offres par jour, sans flamber le budget LLM

ApplyDesk scrape une centaine d'offres d'emploi par jour via 5 sources (France Travail, Adzuna, HelloWork, Remotive, WeWorkRemotely). En tant qu'utilisatrice, je veux savoir immédiatement quelles offres méritent que je m'y attarde — et je ne veux pas attendre 30 secondes par offre que l'IA réfléchisse, ni payer un appel LLM pour chaque ligne.

Deux contraintes : rapide (sous la seconde) et explicable (je dois savoir pourquoi une offre matche). L'IA seule est ni l'un ni l'autre. Une heuristique seule manque de nuance. La réponse : combiner les deux.

L'archi

Deux couches, deux moments

1 — Score déterministe (front, synchrone)

Tourne en TypeScript dans le navigateur, sur chaque offre, à l'instant où elle s'affiche. 0 à 100 %, calcul transparent et debuggable. C'est ce qui anime les badges « 95 % », trie la liste et alimente le digest quotidien.

2 — Verdict LLM (back, async, à la demande)

Quand l'utilisatrice ouvre une offre intéressante, on appelle Gemini avec un prompt structuré qui retourne un verdict ternaire (postuler / vérifier / ignorer) + 3 raisons concrètes. Caché en base : on ne re-paie pas l'appel deux fois.

Le score

4 axes, des poids assumés

Le score déterministe pondère quatre axes :

55 %

Skills

Overlap pondéré entre les skills du CV (extraits par parsing IA, voir ApplyDesk) et le texte de l'offre. Les skills des 2 expériences les plus récentes pèsent 1.0, les plus anciennes 0.4, les catégories sans usage 0.2. Saturation à 8 matches : au-delà, +1 skill ne change plus le score.

25 %

Titre

Les rôles cibles (target_roles) du profil et les tokens du headline (moins les stop-words : « Full », « Stack », « Senior »…) doivent apparaître dans le titre de l'offre. Un match suffit pour saturer l'axe.

10 %

Lieu

Comparaison preferred_locationsoffer.location. Pas de préférence → neutre (0.5). Match → 1. Pas de match → 0.

10 %

Fit

Type de contrat (CDI prime, autres demi-points) et remote si proposé. C'est un signal faible mais utile pour distinguer 90 % entre eux.

Trois subtilités qui changent la qualité du résultat :

Recency bias

Une senior qui a fait du Java pendant 8 ans puis 2 ans de Python ne « cherche » pas Java — son profil récent est Python. Pondérer par récence évite de scorer haut une offre qui ne parle qu'à la version d'il y a 5 ans.

Normalisation accent-insensitive

« développeuse » match « developpeuse ». On normalise via NFD + strip diacritiques. Indispensable pour le marché FR où les offres mélangent les deux orthographes.

Boundaries sur les skills courts

Sans boundary, « Go » match « Google ». Solution : skills < 5 caractères → regex avec frontières non-alphanumériques. Skills ≥ 5 caractères → substring direct (javascript match javascripteur est OK).

Le verdict

Trois états, pas deux

Côté LLM, on aurait pu demander un score continu ("note cette offre sur 100"). Mauvaise idée : les LLMs sont mauvais en arithmétique fine et passent leur temps à hésiter entre 73 et 78. On demande à la place un verdict ternaire :

yes

fit franc, devrait postuler

maybe

fit mais avec doute (séniorité limite, stack partielle, lieu à clarifier)

no

mismatch clair (séniorité, stack principale, contrat, lieu)

Avec en bonus 1 à 3 raisons concrètes mélangeant points positifs et négatifs. C'est ce qui s'affiche sous chaque offre dans le digest quotidien : « Stack Spring Boot match parfait », « Mais salaire non précisé », etc.

Le prompt est structuré (Pydantic schema côté Python, JSON mode côté Gemini) : pas de regex sur du texte libre, pas de parsing fragile. On reçoit l'objet, on le sauvegarde, on l'affiche.

Cache

Le verdict, c’est en base — pas dans un cache mémoire

Le verdict est cher (~3-5 secondes, quelques centièmes par offre). On le persiste directement dans la table offer avec 3 colonnes : verdict, verdict_reasons, verdict_generated_at. Trois conséquences :

  • On ne paie qu'une fois par offre, jamais deux.
  • L'utilisateur retrouve son verdict après refresh, déconnexion, navigation.
  • On peut invalider sélectivement (verdict_generated_at > 7 jours → re-run) sans toucher au modèle de cache.
Lessons

Ce qu’on retient

  • Ne pas demander au LLM ce qu'une fonction pure peut faire. Compter des skills, normaliser une chaîne, comparer deux tableaux — tout ça c'est du JavaScript, pas un appel API à $0.0003 le token.
  • Le LLM brille sur la nuance, pas sur le calcul : « cette offre demande du Spring 6 mais ton CV s'arrête à Spring 3, ce qui peut passer si la team forme ». Aucune heuristique ne capture ça.
  • Structured outputs (JSON mode + schéma) changent la fiabilité. Plus de regex, plus de retry sur du texte qui ne parse pas.
  • Persister le verdict en base, pas en cache mémoire — ça survit au déploiement, au crash, au logout.
  • Le score déterministe est debuggable : on voit pourquoi une offre matche en lisant le breakdown. Le LLM seul est une boîte noire — combinés, on a une explication et une nuance.