Aller au contenu
Dimitri Robiere PerrietD. R. PerrietCaen, FR — --:--:--FR · EN

Recherche sémantique d’aliments avec Cloudflare Vectorize

Recherche sémantique d’aliments avec Cloudflare Vectorize — illustration
Fig. 01 — illustration
(00) — En bref

Comment l’API de Welva croise Ciqual 2025 et Open Food Facts avec des embeddings Workers AI dans Vectorize, et ce qu’il a fallu corriger en production.

Dans Welva, la saisie d’un repas commence presque toujours par un champ de recherche. L’utilisateur tape « pâtes », « skyr », « pasteque » sans accent, parfois une marque. Derrière, il y a deux sources qui n’ont rien en commun : la table Ciqual 2025 de l’ANSES, qui décrit des aliments génériques avec des valeurs mesurées, et Open Food Facts, qui décrit des produits de marque saisis par une communauté. Voici comment l’API de Welva, un Worker Cloudflare, fait répondre les deux dans une seule liste, et ce que j’ai dû corriger en production.

Deux sources, deux licences, une règle

Première contrainte, qui a façonné tout le reste : Ciqual est sous Licence Ouverte Etalab 2.0, Open Food Facts sous ODbL. Je ne voulais pas me retrouver à redistribuer une base fusionnée sous une licence ambiguë. Les deux sources restent donc séparées partout : fichiers distincts, tables D1 distinctes, namespaces Vectorize distincts (ciqual et off-hot). La fusion n’a lieu qu’au moment de classer une réponse, en mémoire, et elle n’est jamais stockée.

Deuxième règle : aucun modèle ne produit de calories. Un modèle peut comprendre « un bol de pâtes » ; les kcal, protéines, glucides et lipides viennent toujours de la fiche retenue, pour 100 g. Ciqual est la source primaire, Open Food Facts la source secondaire. Ce choix explique la plupart des arbitrages de classement que je décris plus bas.

De l’XML de l’ANSES à l’index Vectorize

Ciqual 2025 est publié en XML sur Recherche Data Gouv. Un script Node télécharge les deux exports (aliments et composition), les parse et écrit deux artefacts déterministes : une publication versionnée (3 484 aliments) et un bundle compact au format tableau de tableaux, embarqué dans le Worker. L’horodatage est un argument obligatoire, pour que les mêmes entrées produisent exactement les mêmes fichiers. Les valeurs publiées « < x », « traces » ou « - » deviennent 0 : utiliser la moitié d’un seuil de détection reviendrait à inventer une mesure que l’ANSES n’a pas fournie.

L’import se fait en deux temps, D1 d’abord, Vectorize ensuite, par lots de 64. Côté D1, les UPSERT rendent le rejeu sûr, et la version n’est marquée courante qu’après le dernier lot, avec un comptage de contrôle : une interruption ne publie jamais une table partielle. Côté Vectorize, chaque aliment devient un vecteur dont l’identifiant est le code Ciqual :

scripts/seed-vectorize.tsts
const texts = rows.map(([, nameFr, nameEn]) => `FR: ${nameFr}\nEN: ${nameEn}`);
const { data } = await env.AI.run("@cf/qwen/qwen3-embedding-0.6b", {
  documents: texts,
});

await env.FOOD_INDEX.upsert(
  rows.map((row, i) => ({
    id: String(row[0]), // code Ciqual
    namespace: "ciqual",
    values: data[i], // 1 024 dimensions
    metadata: {
      source: "ciqual",
      row: JSON.stringify(row),
      ciqualVersion: "2025",
    },
  }))
);

Le modèle d’embedding est @cf/qwen/qwen3-embedding-0.6b de Workers AI, qui sort 1 024 dimensions ; l’index est créé en similarité cosinus. J’indexe le nom français et le nom anglais dans le même document pour que la recherche fonctionne dans les deux langues de l’app sans doubler l’index. La ligne Ciqual complète (nom, macros, groupe) voyage en métadonnée : un résultat Vectorize suffit pour afficher une fiche, sans aller relire D1.

Qwen3-Embedding est asymétrique. Les documents sont encodés nus, les requêtes avec une instruction : « Given a multilingual food search query, retrieve relevant generic foods and branded food products that match it. » Sans cette instruction, les requêtes courtes se rapprochent moins bien des libellés Ciqual, qui sont longs et descriptifs (« Pâtes sèches standard, cuites, non salées »).

Le couple modèle et index est un contrat indivisible. Si je change de modèle, même pour un autre qui sort aussi 1 024 dimensions, il faut réindexer les deux namespaces avant de déployer le code. Sinon requêtes et documents ne vivent plus dans le même espace, et la recherche se dégrade sans lever la moindre erreur. C’est écrit en commentaire au-dessus de la constante, parce que c’est le genre de piège qu’on oublie six mois plus tard.

Le namespace off-hot se remplit au fil de l’usage : chaque produit Open Food Facts qu’un utilisateur scanne ou choisit est encodé (« nom — marque ») et inséré, avec sa fiche en métadonnée. Les produits réellement consommés deviennent ainsi trouvables par le sémantique, sans importer les millions de produits d’Open Food Facts.

Une requête, trois canaux

Une recherche lance trois canaux en parallèle et en fusionne les résultats :

  1. Sémantique : un embedding de la requête, puis une interrogation des deux namespaces (topK entre 12 et 24).
  2. Open Food Facts : l’API Search-a-licious, avec une liste de champs restreinte et un délai maximal de 5,8 s.
  3. Lexical Ciqual : un parcours en mémoire du bundle embarqué, synchrone, qui ne peut pas échouer.
src/services/food-search.tsts
const [semantic, off] = await Promise.all([
  settle(semanticCandidates(runtime, input, signal)),
  settle(withTimeout(() => runtime.searchOff(input.query), 5_800)),
]);

const candidates = [
  ...(semantic.ok ? semantic.value : []),
  ...(off.ok ? off.value : []),
  ...lexicalCiqualCandidates(input.query, input.langs),
];

return {
  results: rankFoodCandidates(input.query, candidates).slice(0, input.pageSize),
  partial: !off.ok,
};

Si un namespace Vectorize tombe, l’autre suffit ; si Open Food Facts ne répond pas, la réponse part avec partial: true. Seule une réponse complète est mise en cache.

La normalisation reste volontairement simple : décomposition Unicode, suppression des accents, minuscules, tout ce qui n’est ni lettre ni chiffre devient une espace. Par-dessus, un stemming français très léger, réservé aux comparaisons : un « s » final puis un « e » final sautent, sauf sur les mots de trois lettres ou moins (riz, blé, thé restent intacts). « Lardons » rejoint ainsi la fiche « Lardon ».

Je n’ai pas écrit de dictionnaire de synonymes. Les formulations familières sont laissées à l’embedding multilingue, et la saisie en langage libre (« Décris ton repas ») passe par un autre chemin : Llama 3.3 70B découpe la phrase en aliments via une sortie JSON Schema, puis chaque aliment repasse par cette même recherche. Tout le plan est encodé en un seul appel d’embedding, puis les requêtes Vectorize partent en parallèle.

Classer sans reranker

Il n’y a pas de modèle de reranking dans cette recherche. Le classement part du score brut (similarité cosinus, score Search-a-licious, ou score de base du canal lexical) et ajoute des bonus déterministes :

SignalBonus
Le nom commence par les mots de la requête+0,18
Part des mots de la requête présents dans le nomjusqu’à +0,12
Un mot de la requête correspond à la marque+0,30
Aliment « cru » (fiche générique de base)+0,015

Les doublons sont fusionnés par code Ciqual ou par code-barres, en gardant le meilleur score brut. Les produits Open Food Facts sans aucune valeur nutritionnelle sont écartés. Pour une requête d’un ou deux mots, si Ciqual et Open Food Facts sont à moins de 0,05 l’un de l’autre, Ciqual passe devant : quelqu’un qui tape « yaourt » veut en général un yaourt générique, pas le premier produit de marque venu.

J’ai préféré des règles lisibles à un reranker pour une raison pratique : quand un utilisateur me signale un mauvais résultat, je peux écrire un test qui reproduit le cas et le corriger sans ajouter un appel de modèle sur chaque frappe.

Ce qui n’a pas marché

Les mots courts. Le premier jour, « pasteque » ne renvoyait aucune pastèque. Parmi les voisins sémantiques, rien : un mot isolé, sans accent, donne un vecteur trop pauvre face à des libellés longs. D’où le canal lexical, ajouté dans un second temps : toute fiche Ciqual dont chaque mot de la requête préfixe un mot du nom devient candidate, avec un score de base modeste, entre 0,55 et 0,60, dégressif selon la longueur du nom. Ce canal garantit la présence du bon candidat, pas sa position. Le dégressif sert à départager : sans lui, « Pomme, pulpe, crue » et « Pomme de terre dauphine, surgelée, crue » finissaient ex æquo, et l’ordre alphabétique décidait. Le canal est plafonné à 24 entrées pour les requêtes courtes qui matchent trop de lignes.

La variance de l’embedding sur les quasi-égalités. Dans un repas décomposé, « pâtes cuites » ressortait tantôt « Pâtes sèches standard, cuites », tantôt « Pâtes fraîches farcies (ex : raviolis, tortellinis), cuites », selon l’appel. Quand deux fiches Ciqual sont trop proches, la similarité ne veut plus rien dire. Sous un écart de score, je départage désormais par les mots : le nom qui commence exactement par le premier mot demandé gagne, puis celui qui a le moins de mots manquants (chacun coûte deux points) et de mots étrangers. Le seuil était de 0,03 au premier correctif ; il est passé à 0,08 avec la refonte qui a introduit le stemming, et le départage a gagné un rang : le match exact du premier mot, avant stemming, passe devant le match stemmé. « Pâtes » (le féculent) et « Pâte feuilletée » (la pâtisserie) ne diffèrent que par le pluriel, que le stemming efface.

Open Food Facts est lent. La recherche attendait parfois 5,8 s avant de répondre. J’ai ajouté un mode instant : la route sert le sémantique et le lexical Ciqual en quelques centaines de millisecondes, avec partial: true, sous une clé de cache à part (15 minutes, contre une heure pour les réponses complètes). L’app lance les deux requêtes en parallèle et remplace la liste quand la complète arrive.

Le cold start de Workers AI. Mesuré en production : le premier embedding après une période calme peut prendre environ 5 s. Le mode instant devenait alors plus lent que la réponse complète. Il est maintenant borné :

src/services/food-search.tsts
const semanticTask = options.skipOff
  ? withTimeout(
      (signal) => semanticCandidates(runtime, input, signal),
      700, // au-delà, le lexical Ciqual répond seul
      options.signal
    )
  : semanticCandidates(runtime, input, options.signal);

Passé 700 ms, seul le lexical répond. Sans le filet lexical, cette borne aurait été impossible.

Le cache qui fige les anciens classements. Chaque réponse complète est mise en cache une heure, par requête normalisée et par langue. Après un changement de classement, les utilisateurs continuaient de voir l’ancien ordre. La clé porte maintenant un numéro de version que j’incrémente à chaque évolution du classement ; on en est à la version 3.

Ce que je referais / ce que je changerais

Je referais la séparation stricte des sources. Elle m’a coûté un peu de code de fusion, mais elle rend la question des licences triviale et elle m’a obligé à garder Ciqual comme référence. Je referais aussi le canal lexical dès le premier jour, au lieu de le découvrir sur une pastèque : une recherche sémantique sans filet lexical échoue exactement sur les requêtes les plus simples.

Je changerais trois choses. D’abord, il me manque un jeu de requêtes réelles annotées pour mesurer le classement, au lieu de corriger cas par cas à coups de tests unitaires. Ensuite, les bonus (+0,18, +0,12, +0,30) sont réglés à la main ; avec ce jeu de mesure, je les calibrerais. Enfin, l’absence de synonymes est un pari sur l’embedding que je n’ai pas vérifié systématiquement. Une petite table pour les abréviations courantes ne coûterait presque rien, et je la mettrais en place dès que les journaux de recherche montreront des requêtes sans résultat utile.

Étude de cas liéeWelva, app iOS de nutrition et musculation en SwiftUI