
Comment nous avons construit la recherche d’images dans PostgreSQL
Comme une requête de recherche d’images porte sur des éléments dérivés des pixels (métadonnées, texte généré, embeddings ou hashs perceptuels) et non sur les pixels bruts, l’indexation compte plus qu’il n’y paraît : le classement ne peut pas récupérer un terme ou un signal utile qui n’a jamais atteint un index.
Chez freedam, la couche de recherche s’exécute dans la même base PostgreSQL que les enregistrements de médias, avec pg_textsearch pour le classement BM25 et pgvector pour le stockage vectoriel et la recherche HNSW. Les modèles d’embedding tournent dans un sidecar local, tandis que les vecteurs obtenus et les deux index de recherche vivent dans PostgreSQL : il n’y a donc ni Elasticsearch ni base vectorielle séparée à garder synchronisée.
Cet article décrit ce chemin de production (comment nous construisons l’index, combinons les résultats lexicaux et sémantiques, contournons les limites de candidats et vérifions qu’un ensemble de résultats est complet) à partir de valeurs d’une vraie photothèque et d’une suite de tests de pertinence reproductible. Pour une présentation moins technique, consultez la page recherche.
Pourquoi la recherche d’images est différente
Une image ne contient aucun mot cherchable
Une ligne de commande a un nom de client, un ticket de support a un corps de message, et un PDF a souvent une couche de texte, alors qu’un JPEG nous donne des pixels et un peu d’EXIF, mais aucun terme à comparer à une requête. Nous devons donc construire une ou plusieurs représentations cherchables de l’image ; en pratique, nous en utilisons quatre :
Il est tentant de choisir une représentation et de construire le système autour d’elle : les produits de « recherche IA » partent souvent des embeddings, tandis que les anciennes photothèques tendent à s’appuyer sur les métadonnées. Les deux peuvent produire une démonstration convaincante, mais les vrais logs de requêtes contiennent plusieurs types de recherches différents :
- Requêtes d’identifiant :
4471-RD,SS26-HERO-03, un GAID, un numéro de facture. Elles demandent une correspondance lexicale exacte, car la recherche vectorielle tend à renvoyer d’autres identifiants qui se ressemblent dans l’espace d’embedding. - Requêtes de nomenclature :
galaxy a52 case,taper plate,biodegradable mailer. Elles demandent une correspondance lexicale sur un vocabulaire contrôlé, avec une tolérance sur la façon dont le terme a été écrit. - Requêtes de description :
team celebrating outdoors,something calm for the newsletter header. Il se peut que personne n’ait tagué le média avec ces mots : c’est là que les embeddings doivent aider. - Requêtes par l’exemple : « d’autres images comme celle-ci », « avons-nous déjà acquis la licence de cette photographie ? ». Aucun texte n’intervient, ni d’un côté ni de l’autre.
Une seule méthode de recherche ne peut pas bien traiter ces quatre cas : le problème pratique est donc de les combiner sans affaiblir les correspondances exactes, et de savoir quand des limites de candidats ont écarté des résultats valides.
Où chaque approche échoue
Les compromis sont assez prévisibles :
La recherche par mots-clés a besoin de mots qui correspondent. Elle fonctionne bien pour les identifiants et la terminologie connue, mais seulement quand ces termes ont atteint l’index, et elle rate de simples changements de formulation lorsque, par exemple, le photographe a écrit autumn alors que le marketeur cherche fall.
La recherche vectorielle est faible sur l’exactitude. Elle est utile pour les requêtes descriptives mais médiocre pour les codes produit et autres identifiants. Comme une requête de plus proches voisins renvoie k lignes même si aucune n’est une bonne correspondance, une requête courante d’un seul mot comme chair peut remplir la page d’intérieurs vaguement liés quand le seuil est trop lâche.
L’étiquetage manuel est coûteux et dérive avec le temps. Un vocabulaire contrôlé est précieux, mais il est rarement complet et finit par s’éloigner du langage que les gens emploient pour chercher.
Un LLM analyse mieux la requête qu’il ne classe le corpus. Faire passer un modèle sur 200 000 médias à chaque recherche serait lent, coûteux et difficile à reproduire : nous l’utilisons donc avant la recherche, où il peut transformer une phrase en filtres structurés.
Notre chemin de recherche textuelle a donc deux index sur le même document : BM25 pour les correspondances lexicales et un embedding pour les correspondances sémantiques. Nous fusionnons leurs rangs, ajoutons un chemin distinct pour les cas qui demandent de meilleures garanties de rappel (recall), et indiquons si la recherche était complète.
Construire le document de recherche
Ce que la recherche peut trouver dépend de ce qui a atteint l’index : freedam combine donc les métadonnées disponibles et le texte dérivé de chaque média en un document de recherche stocké, et le reconstruit chaque fois que le média change.
Trois détails d’implémentation se sont révélés importants.
Nous implémentons les poids de champs par répétition. Un champ de poids n est ajouté n fois, car l’index BM25 n’a aucune notion de champs, mais cela augmente aussi la longueur du document, que la normalisation de longueur de BM25 (b = 0.75) pénalise. Nous avons vu onze vidéos de produits devancer les images de produits attendues pour une requête sur un nom de lieu, parce que leurs documents faisaient de 155 à 493 caractères alors que les documents des images en faisaient de 746 à 1 280 : le schéma de pondération avait aussi introduit un biais de longueur de document.
Nous aplatissons la hiérarchie du vocabulaire dans le document. Un média tagué avec le terme feuille espresso machine reçoit aussi small appliances (parent, poids 2) et kitchen (ancêtre, poids 3) dans son document. Cela rend une taxonomie cherchable et pas seulement filtrable : une requête sur la catégorie trouve la feuille sans jointure ni connaissance de l’arbre.
Le tsvector est une colonne générée stockée. La recherche hybride vérifie pour chaque candidat la couverture des termes après le parcours classé : nous stockons donc le tsvector sous forme de colonne GENERATED ALWAYS AS (…) STORED plutôt que de décompresser (detoast) et d’analyser le document avec to_tsvector(content) à chaque recherche. En contrepartie, son association langue-stemmer est figée dans le schéma : passer de 7 à 20 configurations a exigé une migration qui a reconstruit la colonne et les index BM25 concernés. Le tsvector stocké, l’index BM25 et le plainto_tsquery au moment de la requête doivent utiliser le même stemmer, sinon la vérification de couverture peut rejeter des correspondances valides.
Un piège de tokenisation
Un problème que nous avons rencontré concernait des termes de vocabulaire séparés par une barre oblique dans une photothèque de commerce : des libellés de compatibilité d’appareils comme Galaxy A52/A52S et iPhone 12/12 Pro étaient présents sur les médias et dans le document de recherche (un LIKE '%A52%' en trouvait 178), mais galaxy a52 case ne renvoyait que douze résultats.
L’analyseur english de PostgreSQL classe une valeur jointe par une barre oblique comme un seul token de type file : ts_debug('english', 'Galaxy A52/A52S') produit donc le lexème A52/A52S plutôt que a52 plus a52s. Le texte était indexé, mais le terme utilisé par la requête n’existait pas dans l’index BM25.
Le correctif a consisté à émettre A52 et A52S à côté de la valeur d’origine A52/A52S ; réindexer la même entrée aurait produit les mêmes tokens : le changement relevait donc du constructeur de document.
Trois enseignements utiles en sont ressortis :
- Appliquez ce type de normalisation à la frontière utile la plus étroite. Découper chaque barre oblique du document affectait aussi l’EXIF (
1/100,1:1.8/26), les tensions (50/60Hz), les URL, les références de directives européennes (2009/125/EC) et les conjonctions (and/or,und/oder) : nous ne découpons donc plus que les libellés de vocabulaire. - Vérifiez les termes par le chemin de recherche de production, car notre dictionnaire de fautes et de préfixes utilise un autre tokenizer et signalait déjà
a52dans 178 documents alors que BM25 n’en retrouvait que douze. - Gardez des requêtes témoins sans rapport dans la suite de pertinence, car le correctif ciblé a modifié des longueurs de documents et fait échouer un cas de test existant dont l’ensemble de candidats était déjà au plafond.
Deux chemins de recherche sur une même table
BM25 avec pg_textsearch
Nous exécutons BM25 via l’extension pg_textsearch, sur la même instance PostgreSQL que celle qui contient les médias : il n’y a donc ni cluster de recherche séparé ni tâche de synchronisation d’index.
Cela garde la recherche dans la même transaction que le reste de l’application : un média qui vient d’être importé est cherchable sans attendre un worker de synchronisation, tandis que les permissions, les embargos et les droits d’usage restent de simples prédicats SQL au lieu d’un second modèle d’autorisation. Pour un déploiement auto-hébergé, cela supprime aussi un service avec état.
Le principal coût est que les performances d’une requête dépendent fortement de la forme que voit le planificateur de PostgreSQL. Quelques détails ont compté dans nos requêtes :
- Les index sont par langue et partiels. Nous construisons un index BM25 par langue avec
WITH (text_config=…, k1=1.2, b=0.75) WHERE language_code = 'xx': se replier sur une autre langue exige donc de changer ensemble le nom de l’index, le prédicat de langue et la configuration de texte ; une version antérieure ne changeait que le nom de l’index et produisait un index partiel qui ne pouvait pas servir les lignes de la langue demandée. - Le parcours d’index ordonné ne reste rapide que s’il a une borne de lignes. Un parcours BM25 ordonné prend son chemin top-k quand l’exécuteur reçoit une borne, ce qui survit à une CTE référencée une seule fois, car le planificateur l’inline, ainsi qu’à une table dérivée avec son propre
LIMIT, mais pas à une sous-requête scalaire ni à une CTE référencée deux fois. Référencer une CTE une seconde fois a fait passer la requête de 6 ms à 7,3 s : nous transportons donc désormais des indicateurs d’appartenance à travers l’union avecBOOL_ORau lieu de refaire une jointure avec cette CTE. - Les prédicats non indexables passent après le parcours, pas dedans. La couverture des termes, c’est-à-dire « ce document contient-il réellement chaque terme de la requête ? », est vérifiée sur le
content_tsvstocké, sur l’ensemble borné de candidats renvoyé par le parcours. Mettez ce prédicat dans le parcours ordonné et le planificateur abandonne l’index au profit d’une jointure par fusion avec re-scoring ligne par ligne ; nous avons mesuré 7,4 s pour une telle forme de requête.
Le parcours brut récupère aussi 3 fois le nombre de candidats configuré, parce que le parcours ordonné de pg_textsearch émet environ trois lignes par document ; avec bm25_top_k = 1000, la limite brute est de 3 000 et le parcours prend environ 10 ms dans notre photothèque de test, mais la latence monte brutalement quand la limite brute approche 6 000.
Recherche vectorielle avec pgvector
Le texte et les images sont encodés dans le même espace de 768 dimensions avec nomic-embed-text-v1.5 et nomic-embed-vision-v1.5, tous deux servis par un sidecar local pour que le contenu des médias ne quitte pas le déploiement. Les modèles de Nomic utilisent des préfixes de tâche (nous encodons les documents avec search_document et les requêtes avec search_query), et un mauvais préfixe produit quand même des vecteurs, mais abaisse la pertinence sans produire d’erreur visible.
pgvector stocke ces vecteurs et fournit l’index HNSW, pour lequel nous utilisons la distance cosinus, m = 16 et ef_construction = 200, avec un index partiel sur les vecteurs non nuls. Nous mettons brièvement en cache les embeddings de requêtes, y compris des marqueurs d’échec : si le sidecar est indisponible, la recherche se replie sur BM25 au lieu de relancer l’appel au modèle en échec à chaque frappe.
Un seul seuil de distance ne convenait pas à toutes les requêtes : nous le faisons donc varier selon la longueur de la requête, et les valeurs ne sont pas monotones :
| Forme de la requête | Distance cosinus max |
|---|---|
| 1 mot | 0,35 |
| 2 mots | 0,42 |
| 3 à 5 mots | 0,45 |
| 6 à 10 mots (ou > 30 caractères) | 0,48 |
| > 10 mots (ou > 60 caractères) | 0,40 |
Nous utilisons des seuils plus stricts aux deux extrémités, pour des raisons différentes : un mot isolé comme chair a un large voisinage sémantique et peut correspondre à la plupart des photographies d’intérieur, tandis qu’un long titre de produit collé tend à produire un embedding moins spécifique qui se situe faiblement près de nombreux médias. Les requêtes intermédiaires sont assez précises pour décrire quelque chose sans faire la moyenne de trop de texte : nous y autorisons donc un seuil plus lâche.
Nous appliquons une règle supplémentaire aux requêtes d’un seul mot, elles exigent une correspondance lexicale, de sorte qu’une ligne trouvée uniquement par similarité vectorielle est écartée, au lieu de laisser un terme de niche sans vraie correspondance renvoyer une page entière d’images sémantiquement voisines.
Fusionner les deux listes de résultats
Comme un score BM25 et une distance cosinus mesurent des choses différentes, les normaliser dans un score commun ferait dépendre le résultat du corpus, de la langue et des seuils du moment : nous combinons donc leurs rangs avec la Reciprocal Rank Fusion (RRF) :
rrf_score(d) = w_bm25 / (k + rank_bm25(d)) + w_vector / (k + rank_vector(d))
k = 60, w = 1.0
Trois détails d’implémentation ont causé des bugs ou des comportements surprenants.
Gardez l’arithmétique de fusion en virgule flottante. Si la base de données évalue la division en arithmétique entière, les scores RRF fractionnaires tombent à zéro : la requête renvoie toujours des lignes mais ne les classe plus de façon significative. Nous convertissons donc explicitement les opérandes.
Le seuil adaptatif a des effets structurels. Nous écartons les lignes à faible score avec un score RRF minimal fondé sur le nombre de mots : 0.008 pour un mot, 0.015 pour deux, 0.025 pour trois à cinq, 0.028 pour six à dix, et 0.012 au-delà. Chaque valeur est mise à l’échelle par (w_bm25 + w_vector)/2 × 61/(k+1), de sorte que changer k ou les poids ne l’invalide pas. Cela a deux conséquences directes :
- Une requête de deux mots ne peut jamais renvoyer zéro résultat, car une ligne uniquement vectorielle obtient
1/(60 + rank)et le seuil de0.015pour deux mots admet exactement les rangs 1 à 6 : c’est pourquoi une requête absurde de deux mots renvoie exactement six éléments. - À trois à cinq mots, le seuil de
0.025dépasse le meilleur score possible d’une ligne uniquement vectorielle,1/61 ≈ 0.0164, ce qui rend une correspondance purement sémantique structurellement impossible dans cette tranche : chaque résultat d’une requête de quatre mots porte donc un rang lexical.
Les correspondances lexicales vérifiées contournent le seuil. Pour les requêtes de dix mots ou moins, nous vérifions si chaque candidat BM25 contient tous les termes de la requête : ces lignes n’ont donc pas besoin du score RRF minimal, et le seuil ne s’applique qu’aux lignes sans correspondance lexicale vérifiée.
Les plafonds de candidats peuvent masquer des correspondances
Tout système de recherche limite le nombre de candidats qu’il va scorer ; nos valeurs par défaut sont de 1 000 candidats BM25 issus d’un parcours brut de 3 000 lignes, 200 candidats vectoriels et 1 000 lignes dans l’ensemble fusionné.
Dès qu’un média tombe en dehors d’un plafond de candidats, les étapes de classement suivantes ne le voient plus, mais la première page peut paraître raisonnable, ce qui rend le problème difficile à remarquer. Nous appelons cela la saturation du plafond (cap crowding) : dans un cas, 1 953 documents contenaient tous les termes de la requête mais se disputaient 1 000 places BM25, laissant des médias à égalité exclus par le départage alphabétique final.
Pour les recherches où nous avons besoin d’un meilleur rappel, nous ajoutons une bande garantie à l’ensemble de candidats : ses lignes reçoivent des positions après le parcours normal, et chaque plafond suivant les laisse explicitement passer, ce qui les rend atteignables sans les placer devant les résultats classés normalement.
Rechercher à l’intérieur d’une collection
Nous avons vu la saturation du plafond le plus souvent lors de recherches à l’intérieur d’une collection : si 30 des 400 membres d’une collection contiennent un terme, une recherche sur tout le tenant peut n’en renvoyer que quatre. Cela vient de ce que des médias extérieurs à la collection occupent les autres positions des 1 000 premières avant l’application du filtre de collection : les résultats manquants sont donc un problème de recherche et non de classement.
Pour chaque recherche avec un terme textuel, freedam résout le périmètre (scope) courant et crée une bande de recherche pour lui. L’implémentation dépend du nombre de médias dans ce périmètre :
Les recherches sans périmètre n’exécutent pas ce SQL supplémentaire ; nous ne matérialisons la bande que lorsqu’il existe un périmètre dont la taille correspond à l’une des stratégies utiles ci-dessus.
Indiquer si les résultats sont complets
Avec les résultats, freedam renvoie une information de plus : si la recherche a trouvé toutes les correspondances dont elle peut prouver l’existence.
Cela compte quand un utilisateur applique une action à l’ensemble des résultats, car « sélectionne les 1 240 résultats et applique cette politique de droits » n’est sûr que si 1 240 est l’ensemble complet ; si la recherche s’était arrêtée à 1 000 candidats, la même opération ne mettrait à jour qu’une partie de la photothèque visée.
Nous calculons un verdict d’exhaustivité avec des sondes (probes) : des colonnes COUNT supplémentaires qui enregistrent combien de lignes chaque source de candidats a produites et si cette source a atteint son plafond.
Le verdict est prudent : une source ne déclare Complete que lorsqu’elle peut rendre compte de chaque correspondance. Scorer chaque membre d’une petite collection est donc complet par construction, alors qu’un scan profond n’est complet que lorsque la population du périmètre reste sous le plafond de la bande et que le scan n’atteint pas sa limite de lignes. Les nouvelles sources de candidats valent Unknown par défaut, et les replis comme la correction de fautes ou l’expansion de préfixe reçoivent leur propre verdict au lieu d’en hériter de la requête d’origine.
Ce travail nous a aussi donné une règle utile pour la supervision : si le comportement surveillé se casse, le contrôle doit produire un résultat différent. Nous avions un monitor qui restait silencieux après la mort de sa tâche, un test de départage qui restait vert après la disparition du départage de trois couches SQL, et des avertissements de qualité qui ne pouvaient pas modifier le code de sortie : nous nous posons donc désormais une question simple en ajoutant un contrôle : quelle sortie change quand cela se casse ?
Les autres façons de chercher
Le chemin ci-dessus part d’une requête textuelle, mais nous prenons aussi en charge les replis sur fautes et préfixes, l’entrée par image, les visages confirmés et les filtres en langage naturel.
Fautes de frappe et préfixes, dans cet ordre
Nous indexons aussi le document avec un index GIN trigramme pour les suggestions « vouliez-vous dire » et le repli sur fautes, avec des corrections appliquées token par token sur le vocabulaire propre à la photothèque et un seuil de similarité plus strict pour les tokens courts. L’expansion de préfixe a son propre budget de candidats et s’exécute en premier, car sinon la correction de fautes pourrait réécrire un préfixe valide avant qu’il ait une chance de correspondre.
Les index, dictionnaires et règles de repli propres à chaque langue sont traités dans Chercher dans une photothèque en vingt langues.
Recherche par image
La recherche d’images inversée (« avons-nous déjà vu ceci ? », « retrouve le reste de ce shooting », « est-ce un quasi-doublon de quelque chose dont nous avons déjà acquis la licence ? ») utilise un index différent : nous générons des hashs perceptuels pour six régions de chaque image, récupérons des candidats par multi-index hashing, et les vérifions avec un vecteur de vignette et un embedding visuel, car la recherche par hash est peu coûteuse et large alors que la vérification vectorielle est plus lente et plus précise. Le pipeline complet de candidats et de vérification est traité dans Retrouver deux fois la même image.
Visages
Les visages utilisent un détecteur et un espace d’embedding de dimension 512 distincts, issus du buffalo_l d’InsightFace, avec HNSW et la distance cosinus, tandis que la détection s’exécute dans un sidecar local. Le modèle regroupe les visages similaires, mais une personne doit confirmer l’identité avant que nous ajoutions le nom au document de recherche : photos of <person> at the launch event devient ainsi une requête textuelle ordinaire. Les choix de détection, d’identité et de seuil sont traités dans Un visage n’est pas un nom.
Langage naturel, compilé en arbre de filtres
Pour la recherche conversationnelle, l’assistant de freedam ne classe pas les médias : il transforme la phrase en arbre de filtres, la même structure que produit l’interface de filtres avancés. La requête porte aussi un intent (replace, refine, add, remove ou reset) qui détermine comment elle se combine avec les filtres actifs, avant que le compilateur de requêtes existant exécute cet arbre avec les mêmes permissions et les mêmes contrôles d’exhaustivité que toute autre recherche.
Les utilisateurs peuvent examiner et modifier les filtres générés ; une fois compilée, la recherche est déterministe, nécessite un appel au modèle plutôt qu’un appel par candidat, et ne peut renvoyer que des médias qui existent. Les agents externes utilisent le même chemin via MCP, avec les permissions attachées à leur token, tandis que le compilateur de filtres et le modèle d’état conversationnel sont traités dans Le chat qui ne voit jamais votre photothèque.
Comment nous testons la recherche
Les seuils et les limites de candidats ci-dessus sont des valeurs réglées et non des constantes générales : nous les évaluons donc avec un manifeste de requêtes réalistes et une vérité terrain convenue. La suite s’exécute par le chemin de recherche de production sur une réplique d’une vraie photothèque et rapporte la réussite ou l’échec net, le recall@10, le recall@50 et le rang réciproque moyen par catégorie, tout en restant en lecture seule : elle peut chercher et sélectionner les lignes de vérité terrain, mais ne peut ni modifier de réglages ni reconstruire un index.
Les premières exécutions ont changé notre façon de travailler sur la recherche :
- La première exécution complète a trouvé quinze échecs nets sur soixante cas. Les personnes qui utilisaient cette recherche l’avaient décrite comme « plutôt bonne », parce que les premiers résultats semblaient généralement plausibles.
- Les échecs se regroupaient par mécanisme. Les quinze échecs venaient de quelques causes (troncature du périmètre, tokenisation, saturation du plafond et effets de longueur de document) : corriger une cause a donc amélioré plusieurs cas à la fois.
- Notre diagnostic initial était souvent faux. Nous avions attribué cinq régressions de rappel à un changement de tokenisation, mais les mesures ont montré que quatre n’avaient pas bougé du tout et que la longueur des documents était la vraie cause.
- Un bon correctif peut avoir un coût. Limiter le découpage des barres obliques aux libellés de vocabulaire a corrigé le bug mais fait perdre un cas de test qui profitait de documents concurrents plus longs, faisant passer la suite de 48 réussites à 47 : nous avons livré le correctif et consigné le compromis.
Ce que nous referions
Les choix que nous reprendrions dans un autre système de recherche sont simples :
- Faites le travail d’indexation en amont. La qualité de la recherche est bornée par le document et les vecteurs que vous avez stockés, car le classement ne peut pas retrouver un terme que le tokenizer n’a jamais émis.
- Fusionnez des rangs, pas des scores. La reciprocal rank fusion ne demande aucune calibration des scores et reste utile quand le corpus, la langue ou les seuils changent.
k = 60est un point de départ raisonnable et a rarement été le réglage qui valait la peine d’être ajusté chez nous. - Faites varier les seuils selon la forme de la requête. Les requêtes d’un mot et de dix mots se comportent différemment, et pour les mots isolés, exiger une correspondance lexicale supprime beaucoup de bruit sémantique.
- Traitez les plafonds de candidats comme une contrainte de correction. Ajoutez un chemin capable d’atteindre des lignes importantes au-delà du plafond, signalez la troncature, ou faites les deux.
- Renvoyez un état inconnu. Complete, truncated et unknown sont plus utiles qu’un total qui a l’air exact sans l’être.
- Construisez les sondes à partir de la requête de production. Une version séparée écrite à la main finira par dériver.
- Testez aussi l’instrumentation. Notre dictionnaire de termes signalait
a52dans 178 documents alors que BM25 n’en retrouvait que douze. - Gardez des requêtes témoins stables dans la suite de pertinence. Elles montrent ce qui a changé en dehors des cas qu’un correctif devait améliorer.
Implémentation et démo
Ce chemin de recherche est partagé par la galerie, l’API, le SDK TypeScript, l’assistant et les agents connectés via MCP, avec BM25 via pg_textsearch et la recherche vectorielle HNSW via pgvector, sur la même instance PostgreSQL que les médias. Le sidecar d’embedding s’exécute en local : aucun contenu de média ne quitte donc le déploiement, et la même pile est utilisée pour les installations auto-hébergées.
Pour une présentation du produit, consultez recherche et découverte, ou essayez la démo pour lancer la recherche sur une bibliothèque préremplie ; un bon test consiste à décrire visuellement quelque chose qui n’apparaîtrait normalement pas dans des tags saisis à la main.



