Freedam
IngénieriePartie 2 sur 7 · 27 min de lectureMis à jour le

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 :

Quatre ponts entre une requête texte et les pixels d’une image requête « basket rouge » les pixels aucun terme à l’intérieur Nom de fichier et titresaisi une fois, par une personne, souvent pressée Métadonnées structurées et vocabulairesprécis et filtrable, mais coûteux : donc toujours partiel Texte dérivé : OCR, légende, transcriptionécrit par machine : abondant, bruité, aveugle à votre métier Embedding : 768 nombres à virgule flottantedu sens sans mots ; ne sait pas épeler un identifiant
Chaque représentation traite un type de requête différent. Une recherche en production en demande plus d’une.

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.

Comment le document de recherche et l’embedding d’un média sont compilés SOURCES CONSTRUCTEURS INDEX titre, nom de fichier, description créateur, copyright, lieu mots-clés + tags IA, légende texte OCR, texte extrait valeurs de métadonnées personnalisées terme de vocabulaire + ancêtres noms de collections personnes confirmées (visages) libellés de points focaux sous-titres / transcription document de recherche tout, pondéré poids = répétition littérale ancêtre 3 / parent 2 / feuille 1 une ligne par langue texte d’embedding un sous-ensemble budgété : titre 300 / desc. 1 500 méta 2 000 / OCR 2 000 8 000 caractères au total lexical content_tsv, généré et stocké un index bm25(content) par langue k1 = 1,2 ; b = 0,75, partiel par langue index GIN trigramme pour la tolérance aux fautes 20 configurations de stemmer sémantique nomic-embed-text-v1.5, 768 dimensions aligné avec nomic-embed-vision-v1.5 HNSW, cosinus, m = 16 ef_construction = 200 partiel : vecteurs non nuls seulement Chaque source alimente le document de recherche ; le texte d’embedding en prend une sélection limitée en caractères.
Les mêmes données sources produisent un document lexical et un embedding. Les deux sont stockés dans la même ligne PostgreSQL et mis à jour dans la même transaction.

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.

Les libellés joints par une barre oblique s’indexent comme un seul lexème tant qu’ils ne sont pas découpés AVANT Galaxy A52/A52S libellé de vocabulaire, tel quel analyseur galaxi asciiword A52/A52S un seul token, de type « file » requête « a52 » : aucune correspondance 12 médias sur 178 trouvables APRÈS : FORMES DÉCOUPÉES ÉMISES AVEC L’ORIGINAL Galaxy A52/A52S constructeur A52/A52S · A52 · A52S original conservé : la recherche de la forme exacte fonctionne toujours requête « a52 » : correspond 178 médias ; rappel 0,06 → 1,00
Cinquante-cinq libellés de vocabulaire joints par une barre oblique couvraient 2 811 médias dans une photothèque, mais aucun n’était trouvable par ses parties alors que les données paraissaient correctes.

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à a52 dans 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 avec BOOL_OR au 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_tsv stocké, 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
Fusion de rangs réciproques d’une liste BM25 et d’une liste vectorielle RANGS BM25 1 000 premiers conservés (scan brut 3 000) 1 média 4471 2 média 9310 8 média 1180 40 média 7702 … pas de correspondance vectorielle RANGS VECTORIELS 200 premiers, filtrés par distance 1 média 6025 3 média 7702 11 média 4471 62 média 1180 … pas de correspondance lexicale FULL OUTER JOIN une ligne peut être dans l’une des listes, ou dans les deux 1/(60+r₁) + 1/(60+r₂) rang absent ⇒ 0,0, jamais une pénalité FUSIONNÉ 4471 0,0305 7702 0,0259 1180 0,0229 6025 0,0164 9310 0,0161 7702 : rang 40 en lexical, rang 3 en sémantique : il gagne. puis, dans l’ordre : bonus de phrase (titre contenant la requête littérale = 2, document la contenant = 1) ▸ rrf_score ▸ id média seuil adaptatif au nombre de mots ▸ une ligne à un mot doit avoir un rang lexical ▸ bandes garanties toujours admises
La RRF n’a besoin d’aucune calibration des scores : un document absent d’une liste contribue simplement zéro pour cette voie, et la jointure externe complète permet à une correspondance purement sémantique de concourir.

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 de 0.015 pour 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.025 dé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.

Les bandes garanties survivent au plafond de candidats CANDIDATS CLASSÉS pos. 1 … 998 pos. 999 pos. 1000 plafond pos. 1001, écarté pos. 1002, écarté … 953 autres, en silence BANDE GARANTIE Lignes qui doivent être trouvables quel que soit leur rang : • membres de la collection dans laquelle vous avez cherché • (en option) tout média dont le titre contient le terme Elles entrent aux positions rawLimit + n, au-delà de toutes les positions réelles du scan, et MIN(best_pos) conserve le vrai rang de tout ce qui est déjà dans la coupe. Pourquoi l’ordre n’est pas modifié Les lignes de bande viennent après toutes les lignes classées : la première page est identique octet pour octet à celle sans bande. Chaque porte en aval, le seuil RRF et le LIMIT final, porte une clause explicite « …OU cette ligne est dans une bande ». L’indicateur remonte dans l’union avec BOOL_OR plutôt que par une seconde jointure, pour que le scan garde sa borne de lignes.
La bande change les lignes atteignables sans modifier l’ordre des lignes classées.

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 :

Stratégie de bande selon la taille du périmètre 1 32 2 000 50 000 ∞ TAILLE DU PÉRIMÈTRE : MEMBRES DE LA COLLECTION DANS LAQUELLE VOUS CHERCHEZ scorer chaque membre aucun scan : classer directement le document de chaque membre complet par construction ≈ 0,6 ms par membre + scan vectoriel exact cosinus en force brute sur les membres, aucune perte de rappel ANN dans le périmètre < 40 ms à 2 000 scan profond du classement lire la liste classée jusqu’à couvrir le périmètre, dans la limite de 45 000 lignes ≈ 2,7 µs/ligne → ≈ 120 ms aucune bande un périmètre aussi grand recoupe déjà fortement le pool de candidats du tenant le travail en plus ne serait pas rentable
La stratégie varie avec la taille du périmètre, et pour un petit périmètre nous scorons chaque membre, car c’est la seule variante qui reste prouvablement complète quelle que soit la taille du corpus.

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.

Exécution de recherche en deux phases avec une sonde d’exhaustivité dans la transaction PHASE 1 avant l’ouverture de toute transaction figer tous les réglages dans un instantané immuable récupérer chaque embedding dont l’arbre de filtres pourrait avoir besoin après cette ligne, aucun appel externe PHASE 2 une transaction en lecture seule, isolation REPEATABLE READ compiler l’arbre de filtres → SQL requête de comptage · requête de page requête sonde, une par feuille chaque comptage décrit les mêmes données que les résultats VERDICT Complete Hit limit Unknown une preuve manquante aboutit ici, jamais sur Complete Deux règles qui gardent les comptages honnêtes 1. Le SQL des sondes est assemblé à partir des mêmes fragments que la requête de résultats, jamais à la main : pas de dérive. 2. Les colonnes sonde sont des tables dérivées d’une ligne en jointure croisée dans le FROM principal : seule forme où chaque scan intégré garde sa borne de lignes. En sous-requêtes scalaires, elles seraient correctes et environ mille fois plus lentes.
Les résultats et leurs sondes d’exhaustivité s’exécutent sur le même instantané de la base de données, dans une seule transaction.

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 :

  1. 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.
  2. 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.
  3. 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.
  4. 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 = 60 est 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 a52 dans 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.

Pour les développeurs

À lire aussi