Freedam
IngénieriePartie 4 sur 7 · 29 min de lectureMis à jour le

Rechercher dans une bibliothèque en vingt langues

Activer une deuxième langue dans un gestionnaire de médias change l’interface et les formulaires de métadonnées, alors que la recherche peut sembler continuer à fonctionner. Dans notre cas, cette apparence cachait un repli qui sélectionnait un autre corpus que celui demandé, sans produire d’erreur ni d’avertissement.

Cet article est le troisième d’une série sur la recherche dans freedam. Le premier traitait de l’architecture de récupération : construire un document interrogeable, fusionner BM25 et pgvector, gérer les plafonds de candidats. Le deuxième étudiait le hash perceptuel. Celui-ci revient au texte et demande quelle langue utilise chaque index.

La réponse dépend de plusieurs valeurs réparties en trois endroits, dont deux valeurs figées dans le DDL. Quand elles divergent, les requêtes peuvent renvoyer des résultats incomplets ou vides tout en restant syntaxiquement valides : c’est la défaillance que cet article examine.

Un index de recherche textuelle a une langue

La recherche plein texte n’est pas une simple comparaison de chaînes. Une configuration de recherche textuelle (text search configuration) fournit l’analyseur, la liste de mots vides (stopwords) et le stemmer utilisés entre le texte stocké et la requête : en choisir une change donc les termes physiquement stockés dans l’index.

Avec la configuration english, running shoes devient les lexèmes run et shoe, si bien qu’une recherche de run peut le trouver. Avec simple, qui ne fait aucun stemming (racinisation), la même expression reste running et shoes, et run ne trouve rien. Le texte et la requête sont les mêmes, mais la configuration utilisée à l’indexation change la réponse.

freedam prend en charge trente langues d’interface, tandis que PostgreSQL 18 fournit des configurations Snowball adaptées à vingt d’entre elles ; les dix autres utilisent simple.

Trente langues d’application associées à vingt stemmers PostgreSQL TRENTE LANGUES D’INTERFACE ar da de el en es et fi fr hu id it lt nb nl pt ro ru sv tr bg cs ja ko lv pl sk sl uk zh trait plein : un stemmer Snowball existe pointillé : repli sur la configuration `simple` POSTGRESQL 18 FOURNIT 29 CONFIGS AVEC STEMMING 20 sont accessibles depuis une langue d’interface et ont un vrai index. 9 existent et ne servent jamais : armenian, basque, catalan, hindi, irish, nepali, serbian, tamil, yiddish. Aucune langue d’interface n’y mène. Aucun des 10 codes en pointillé n’en a un. LES DIX NE SONT PAS UN OUBLI Slaves hors russe, baltes hors lituanien, et toutes les écritures CJK. PostgreSQL n’a pas de stemmer intégré pour elles, donc rien à associer. La section sur le stockage en donne le coût. L’association vit dans une fonction PHP et se projette en trois endroits. Les garder d’accord est le sujet de cet article.
Code de langue vers configuration de recherche textuelle. Vingt langues obtiennent une correspondance sensible à la flexion ; dix obtiennent une correspondance sur la forme exacte et doivent être secourues, ou non, par d’autres parties du pipeline.

Le mappage a commencé à sept stemmers et est passé à vingt. Il touche le DDL d’une colonne générée, chaque index BM25 et un réglage lu au moment de la requête : l’étendre exige donc aussi de reconstruire les parties adossées au DDL. Un tsvector stocké construit avec english et une requête compilée avec simple restent valides chacun de leur côté, mais ne contiennent plus de termes qui se correspondent.

Traduire le document plutôt que la requête

Il y a deux façons de rendre un index de recherche multilingue, et le choix détermine tout ce qui suit.

La première option est de traduire la requête. Une recherche française de chaussures devient shoes et s’exécute sur un seul corpus anglais. Cela économise du stockage, mais place la traduction dans le chemin critique, peut mal gérer le vocabulaire propre à une marque et introduit de l’ambiguïté avant le classement.

La seconde option est de construire un document interrogeable par média et par langue, à partir de traductions déjà stockées dans le système, puis de chercher dans le document qui correspond à la locale de l’utilisateur. C’est ce que fait freedam. Le stockage et le travail de reconstruction croissent linéairement avec les langues actives, mais les requêtes ne sont pas traduites et les termes contrôlés viennent de traductions validées par des humains.

Chaque média a donc un document de recherche stocké par langue active. Le premier article explique comment ce document est construit ; la question multilingue est de savoir quelles parties varient selon la langue et lesquelles non.

Un média produit un document par langue mais un seul embedding un média métadonnées, vocabulaire, OCR, légendes, pistes document · en indexé avec le stemmer english document · fr indexé avec le stemmer french document · ja indexé avec simple : sans stemming un vecteur de 768 dim. aucune colonne de langue VARIE SELON LA LANGUE libellés des termes de vocabulaire et tout leur chemin d’ancêtres, par traduction valeurs de métadonnées multilingues piste de sous-titres préférée et le stemmer appliqué à tout cela IDENTIQUE DANS CHAQUE DOCUMENT nom de fichier, titre, EXIF et IPTC texte OCR, légende IA, tags IA champs de métadonnées monolingues donc l’essentiel du texte d’un document français n’est pas en français. La section suivante mesure l’effet. Désactivez une langue : ses documents et son index sont supprimés ; le vecteur reste intact, il n’a jamais appartenu à une langue.
La moitié lexicale du pipeline se multiplie avec les langues. La moitié sémantique, non. Cette asymétrie est voulue, et c’est là que se trouvent les vraies limites de la conception.

Le vocabulaire contrôlé est la partie qui justifie le stockage. Un terme de taxonomie est stocké une fois avec un code et traduit dans chaque langue active : le document français contient donc le libellé français, son chemin de parents en français, et rien d’anglais. Un utilisateur qui cherche chaussures de course trouve un terme dont le code stocké pourrait ressembler à footwear.running, parce qu’un humain a validé cette traduction une fois, dans un éditeur de vocabulaire contrôlé, et que chaque média portant le terme l’a héritée.

Les autres textes ne sont pas traduits : les noms de fichiers restent des noms de fichiers, l’EXIF vient de l’appareil photo, l’OCR conserve le texte des emballages et les légendes IA sont générées une fois dans la langue par défaut. Ce contenu est copié dans le document de chaque langue : un document français peut donc contenir surtout du texte anglais traité par un stemmer français.

C’est moins nuisible qu’il n’y paraît, à condition que l’index et la requête utilisent la même configuration.

Ce qu’un stemmer fait à un texte d’une autre langue

Prenez une expression de vocabulaire produit anglais ordinaire et passez-la dans quatre configurations. Tout ce qui suit est la sortie de to_tsvector sur PostgreSQL 18, et non une description de celle-ci.

Avec english, running shoes bags images packaging devient run, shoe, bag, imag, packag. Avec french, la même entrée devient running, sho, bag, imag, packaging. Avec german, running, shos, bag, imag, packaging. Avec simple, rien ne change.

Le stemmer français produit sho à partir de shoes, ce qui n’est un mot dans aucune langue. Cela n’a pas d’importance. Ce qui compte, c’est que la requête passe par la même configuration : un utilisateur de locale française qui cherche shoes produit aussi sho, et les deux concordent. Sur neuf requêtes anglaises testées contre ce document, huit ont correspondu avec la configuration française exactement comme avec l’anglaise. Le seul échec était run : le français n’a aucune règle qui retire -ing, donc running reste entier et la requête plus courte ne peut pas l’atteindre.

La leçon pratique est que la cohérence entre l’index et la requête compte plus que le choix de la configuration idéale pour chaque token. Appliquer le même stemmer imparfait des deux côtés tend à se dégrader vers une correspondance sur la forme exacte, alors qu’appliquer des configurations différentes fait que des termes cessent de correspondre. Le bogue de repli ci-dessous en est un exemple.

Le mauvais stemmer n’est pourtant pas gratuit, et son coût apparaît là où on ne l’attend pas : la longueur des documents.

Le même texte anglais sous quatre configurations de recherche textuelle running shoes bags images packaging english run shoe bag imag packag french running sho bag imag packaging german running shos bag imag packaging simple running shoes bags images packaging la langue du texte 8 requêtes sur 9 correspondent encore 8 requêtes sur 9 correspondent encore forme exacte seulement LEXÈMES DISTINCTS EN MOYENNE PAR DOCUMENT, 2 000 DOCUMENTS RÉELS english 192,0 french 207,9 +8,3 % simple 220,6 +14,9 % Mêmes octets. Les mots vides anglais n’en sont pas en français : the, of, and, with restent des lexèmes, et les flexions non racinisées ne fusionnent plus. BM25 normalise par la longueur du document, donc les scores bougent.
Mesuré sur 2 000 documents d’une réplique de production de 22 944 médias. Choisir une configuration ne change pas seulement les termes qui correspondent ; cela change la longueur de chaque document, donc son classement.

Il y a aussi un effet sur la longueur des documents. Sur 2 000 documents, to_tsvector('english', …) a produit en moyenne 192,0 lexèmes distincts, contre 207,9 avec french pour le même contenu. Avec la normalisation de longueur de BM25 à b = 0.75, le rendu français est donc noté différemment même quand le texte sous-jacent est identique.

Cette différence est constante au sein d’un corpus français, mais elle compte quand on combine des résultats de plusieurs corpus de langues.

Deux autres comportements à connaître avant de croire qu’un stemmer règle une langue :

L’allemand ne décompose pas les mots composés. Produktfotografie est réduit au lexème unique produktfotografi. Produkt Fotografie, écrit avec une espace, en donne deux. Un utilisateur allemand qui cherche Produkt ne trouvera pas le composé, et aucune configuration allemande n’y change rien, parce que Snowball retire des suffixes et ne coupe pas les mots. Les langues riches en composés ont besoin d’un dictionnaire de décomposition (decompounding), que PostgreSQL prend en charge via les dictionnaires ispell et que nous ne livrons pas actuellement.

Le turc et le russe s’en sortent très bien. fotoğraflarımızda, composé de cinq morphèmes, se réduit proprement à fotoğraf. Les mots russes фотографии et фотография se réduisent tous deux à фотограф, exactement le regroupement qu’on attend d’une langue très fléchie. L’agglutination et la flexion sont ce que Snowball sait faire. La composition est ce qu’il ne sait pas faire.

Un index partiel par langue

freedam ne construit pas un seul index plein texte sur asset_search_documents. Il en construit un par langue active, chacun étant un index partiel restreint aux lignes de cette langue.

L’instruction, avec les vrais paramètres du tenant interpolés, ressemble à ceci : CREATE INDEX assets_bm25_fr_idx ON asset_search_documents USING bm25(content) WITH (text_config='french', k1=1.2, b=0.75) WHERE language_code = 'fr'.

L’instruction lie trois valeurs, alors qu’une seule est visible dans le nom de l’index.

Le nom est dérivé du code de langue. La configuration de texte est dérivée du code de langue via la table de réglages. Le prédicat est le code de langue lui-même. Pour utiliser cet index, une requête doit fournir un WHERE language_code = 'fr' que PostgreSQL peut prouver impliqué par le prédicat de l’index, et doit compiler son tsquery avec french, faute de quoi les termes recherchés ne seront pas ceux qui ont été stockés.

Deux index voisins utilisent des clés différentes, pour des raisons pratiques.

Quatre structures d’index, indexées de trois façons différentes Index de contenu BM25 · un par LANGUE assets_bm25_<code>_idx partiel : WHERE language_code = <code> porte text_config, k1 = 1,2 ; b = 0,75 8,1 Mo pour 22 944 documents anglais tsvector stocké · une COLONNE, toutes les langues content_tsv GENERATED ALWAYS AS … un CASE sur language_code choisit la config, figée dans le DDL à la migration index GIN de 9,3 Mo sur la colonne index de titre · un par CONFIG DE TEXTE assets_title_tsv_<config>_idx sur assets, pas sur la table de documents les 10 langues sans stemming partagent un index 520 Ko · un index fonctionnel doit correspondre exactement index vectoriel · UN SEUL, aucune langue assets_ai_vector_hnsw_idx HNSW, cosinus, m = 16, ef_construction = 200 sur assets.ai_vector, 768 dimensions ne grossit pas quand une langue est activée Trois clés pour un concept. Une mauvaise clé construit un index valide, maintenu, inutilisé, dont le seul symptôme est la latence.
Tailles mesurées sur un tenant monolingue contenant 22 944 médias et 30 Mo de texte de documents. Tout ce qui figure dans les deux cadres bleus se multiplie à chaque langue activée.

L’index de titre est indexé par configuration, et non par langue, parce qu’un index fonctionnel ne sert qu’un prédicat dont l’expression, configuration comprise, est identique. Les vingt langues avec stemmer correspondent à des configurations distinctes, tandis que les dix langues sans stemmer utilisent toutes simple : indexer par configuration évite de construire dix index équivalents sur la même colonne.

Cela signifie aussi que la création d’un index ne peut pas dépendre d’un réglage de tenant indisponible pendant les migrations. Dans ce contexte, la configuration se résolvait en simple, alors que les requêtes de production utilisaient une expression english : on obtenait un index valide et maintenu que les requêtes ne pouvaient pas utiliser. Le symptôme était un parcours séquentiel de la table des médias, mesuré à environ 26 ms sur le corpus de 22 944 médias. Nous avons modifié le point d’entrée de construction des index pour exiger une configuration explicite dans les contextes où la table du tenant ne peut pas être résolue.

Il existe un troisième index, et il crée les conditions du bogue de repli ci-dessous : aucun de ces index n’est atteignable si la requête ne nomme pas la bonne langue à trois endroits à la fois.

Un repli qui sélectionnait le mauvais corpus

Le bogue a commencé quand un utilisateur a cherché avec la locale fr avant qu’un index français existe. La recherche d’index s’est repliée sur le nom d’index anglais, mais le prédicat de langue et la configuration de texte ne se sont pas repliés avec lui.

Chaque repli pris isolément semblait raisonnable, mais les trois valeurs dérivées ne décrivaient plus le même corpus.

Replier le seul nom d’index laisse derrière lui le prédicat et la config de texte AVANT · UNE SEULE VALEUR REPLIÉE langue demandée fr nom d’index assets_bm25_en_idx WHERE language_code = 'fr' config de tsquery french un index anglais interrogé pour des lignes françaises APRÈS · RÉSOUDRE UNE FOIS, DÉRIVER TROIS langue demandée fr langue effective en nom d’index assets_bm25_en_idx WHERE language_code = 'en' config de tsquery english DEUX FORMES D’ÉCHEC, TOUTES DEUX SILENCIEUSES 1 · l’index et le prédicat divergent Un index partiel sur des lignes anglaises ne peut pas servir un prédicat qui demande des lignes françaises. Le parcours ne renvoie rien du tout. Résultat : zéro ligne. Ni erreur, ni avertissement. 2 · config stockée et config de requête divergent Le corpus a été racinisé d’une façon, la requête compilée d’une autre. La couverture des termes rejette alors des documents qui contiennent le terme. Résultat : rappel partiel, qui ressemble à du classement. Résoudre d’abord la langue effective, puis dériver l’index, le prédicat et la configuration de texte de la même valeur.
La correction est structurelle plutôt qu’astucieuse : résoudre la langue effective une fois, en haut, et dériver le nom d’index, le prédicat et la configuration de texte de cette unique valeur résolue.

Quand le prédicat et la configuration sont tous deux faux, la requête ne renvoie rien. Corriger seulement le prédicat atteint le corpus anglais mais compile quand même la requête avec la mauvaise configuration, ce qui produit une défaillance partielle que de simples contrôles ponctuels peuvent manquer.

Nous pouvons mesurer ce résidu. Prenez le corpus anglais, 22 944 documents indexés avec la configuration english, et interrogez-le avec une requête compilée en simple (le tsquery), ce que produit une langue non résolue qui n’a pas d’entrée dans la table de configuration. Comptez les correspondances dans les deux cas :

  • images correspond à 19 029 documents correctement, et à 0 avec la configuration non concordante.
  • bags correspond à 1 236, et à 0.
  • packaging correspond à 371, et à 0.
  • shoes correspond à 4, et à 0.
  • leather correspond à 6 171, et à 6 171.
  • campaign correspond à 8, et à 8.

Les deux derniers termes montrent pourquoi le bogue a survécu aux tests : une discordance est invisible quand la racine est égale à la forme de surface. Un bon contrôle ponctuel demande donc un terme fléchi comme images, et pas seulement un nom dont la forme ne change pas.

Pour savoir quelle part du corpus cela couvre, nous avons pris les 500 termes anglais les plus fréquents du dictionnaire de recherche du tenant, d’au moins trois lettres, et comparé la racine english de chaque terme à sa forme simple. 281 des 500 diffèrent, soit 56,2 %. Pondéré par le nombre de documents où chaque terme apparaît, 693 029 références de documents sur 1 240 305, soit 55,9 %. Un peu plus de la moitié du trafic de requêtes réel ne renvoie donc rien, un peu moins de la moitié se comporte parfaitement, et aucune ligne de journal ne distingue les deux.

Le même défaut est apparu dans la recherche hybride et l’autocomplétion, car toutes deux repliaient un nom d’index sans résoudre d’abord la langue effective. Nous avons remplacé le petit utilitaire de nom d’index par un autre qui résout la langue comme une unité, puis dérive le nom d’index, le prédicat et la configuration de texte de ce résultat. Il devient ainsi plus difficile pour un appelant de ne mettre à jour qu’une des trois valeurs.

La règle réutilisable est de replier la valeur source partagée, puis de recalculer tout ce qui en dérive.

Fusionner les rangs de deux corpus de langues

Résoudre la langue règle la justesse. Cela ne règle pas le vrai problème produit : un utilisateur français, dans une bibliothèque dont les métadonnées sont surtout en anglais, doit quand même trouver des choses.

La récupération exécute donc deux branches quand la langue effective de l’utilisateur diffère de la langue par défaut du tenant : une sur le corpus de l’utilisateur, une sur le corpus par défaut. Chaque branche est un parcours BM25 complet et indépendant sur son propre index partiel, avec sa propre configuration de texte et sa propre vérification de couverture des termes. Les deux sont ensuite fusionnées.

La fusion se fait au niveau des rangs, pas des scores, et cette décision mérite la même défense que celle faite pour la fusion de rangs réciproques dans le premier article. Deux scores BM25 issus de deux index ne sont comparables d’aucune façon rigoureuse : ils ont été calculés sur des corpus différents, avec des longueurs moyennes de document différentes, à partir de distributions de lexèmes différentes, avec un terme de fréquence inverse de document calibré sur des vocabulaires différents. Les additionner, les moyenner ou leur appliquer un seuil commun reviendrait à faire de l’arithmétique sur des unités incompatibles. Les rangs sont ordinaux, comparables par construction, et ce sont de toute façon ce que consomme l’étape de fusion en aval.

Deux branches de langue fusionnées par meilleure position de parcours, puis replafonnées branche A · langue de l’utilisateur index assets_bm25_fr_idx config french, prédicat 'fr' dédupliqué au top-k par position branche B · défaut du tenant index assets_bm25_en_idx config english, prédicat 'en' dédupliqué au top-k par position fusion UNION ALL GROUP BY asset_id MIN(best_pos) un média garde son meilleur rang replafonner au top-k deux branches plafonnées dépenseraient sinon le double du budget de candidats en aval Si la langue effective EST la langue par défaut, la seconde branche disparaît au lieu de la dupliquer : la fusion est conditionnée à des langues différentes APRÈS résolution : une locale non indexée se réduit donc seule à une branche.
Les positions de parcours ne sont comparables entre corpus dans aucun sens absolu, mais les scores bruts non plus. Prendre le minimum signifie qu’un média trouvable dans l’une ou l’autre langue est classé selon la langue qui le trouve le mieux.

Trois détails gardent les deux branches cohérentes avec le reste du pipeline de récupération.

Les deux branches doivent être dédupliquées avant l’union. Un parcours d’index ordonné BM25 renvoie environ trois lignes par document correspondant, un artefact de la façon dont les positions sortent de l’index. Dédupliquer après l’union fonctionne, mais le plafond s’applique alors à un ensemble redondant aux trois quarts : chaque branche se déduplique donc d’abord à son propre top-k.

L’ensemble fusionné est replafonné. Sinon, deux branches peuvent produire le double du budget de candidats, alors que la fusion en aval, les seuils de score et le décompte d’exhaustivité attendent un seul budget. Réappliquer le plafond préserve ce contrat.

La même fusion sert la recherche hybride et la recherche lexicale seule. La fusion à deux branches n’existait au départ que sur le chemin hybride : un service d’embeddings indisponible faisait donc aussi perdre la branche lexicale de la langue par défaut aux utilisateurs d’une autre langue. Déplacer la fusion dans un constructeur partagé garde les deux chemins sur les mêmes corpus.

La langue effective et la configuration qui en dérive sont résolues une fois par exécution de recherche et transmises à la compilation des règles, à la récupération, à la génération des préfixes, aux suggestions et aux nouvelles tentatives de correction de frappe. Cela empêche qu’un changement de réglage en cours de requête permette à des étapes différentes de construire la même requête à partir d’états de langue différents.

La branche vectorielle indépendante de la langue

Chaque média a un vecteur de 768 dimensions stocké sans colonne de langue. C’est un choix de stockage délibéré, qui rend la branche sémantique asymétrique par rapport aux index lexicaux par langue.

Le texte d’embedding est compilé une fois, dans la langue par défaut du tenant, et envoyé à un modèle nomic-embed-text-v1.5 hébergé localement, avec le préfixe de tâche search_document:. Les requêtes passent par le même modèle avec search_query:. Les deux restent dans le déploiement, ce qui explique en grande partie pourquoi l’ensemble du système reste praticable en auto-hébergement : aucun contenu de média ne quitte l’infrastructure pour être vectorisé.

Pour un utilisateur français, la recherche qui s’exécute est donc réellement asymétrique. La branche lexicale cherche dans un corpus français et dans un corpus anglais, tous deux racinisés de façon appropriée, et les fusionne. La branche sémantique vectorise une requête française avec un modèle dont l’entraînement est très majoritairement anglais, et la compare à un vecteur dérivé d’un texte de document anglais.

Ce que cela apporte est réel mais borné. Les espaces d’embedding entraînés surtout sur l’anglais portent une certaine structure translinguistique : une requête française n’est donc pas du bruit, et la branche sémantique fait encore remonter des médias dont la description anglaise est conceptuellement proche. Ce que cela n’apporte pas, c’est la parité. Une requête française sur un corpus français obtient une excellente récupération lexicale et une récupération sémantique médiocre, alors qu’une requête anglaise dans le même tenant obtient les deux. Le réglage pour construire des embeddings par langue existe et est désactivé par défaut, car l’activer multiplie le coût d’embedding et le stockage par le nombre de langues actives pour améliorer la moitié la plus faible d’un pipeline dont la moitié la plus forte gère déjà correctement la langue.

En pratique, la recherche sémantique de freedam accepte des requêtes multilingues mais n’offre pas la même qualité dans toutes les langues. Une vraie parité demande un modèle d’embedding multilingue, même si la colonne vectorielle, l’index HNSW, la fusion et les seuils environnants resteraient les mêmes.

Les langues que l’analyseur par défaut indexe mal

Dix des trente langues d’interface obtiennent simple. Pour le tchèque, le polonais, le slovaque, le slovène, le bulgare, l’ukrainien et le letton, cela signifie une correspondance sur la forme exacte dans des langues à systèmes de cas riches : l’utilisateur doit donc taper la même forme fléchie que celle des métadonnées. C’est une vraie dégradation, mais modérée : un nom au nominatif est en général ce que quelqu’un saisit.

Pour le chinois, le japonais et le coréen, la limite est plus sérieuse, car elle concerne les limites de tokens et non le stemming.

L’analyseur par défaut de PostgreSQL trouve les limites de tokens avec les espaces et la ponctuation. Le chinois et le japonais s’écrivent sans espaces entre les mots. L’analyseur ne produit donc pas des tokens mal racinisés : il produit un seul token pour une phrase entière.

Ce qui survit par famille d’écriture, et jusqu’où vont les replis CJK CORPUS TERME EXACT FLÉCHI SOUS-CHAÎNE VECTEUR Écriture latine, stemmer disponible ✓✓✓✓ Cyrillique, stemmer russe ✓✓✓~ Polonais, tchèque : simple, sans stemmer ✓✗✓~ Chinois, japonais : pas de limites de mots phrase seulement✗~~ MESURÉ · 16 CARACTÈRES D’UN TEXTE PRODUIT CHINOIS to_tsvector('simple', …) produit exactement 1 lexème pour toute la phrase de 16 caractères. Une requête avec le mot de 3 caractères pour « baskets » ne correspond pas : lexèmes différents. Similarité trigramme entre les deux : 0,050. Le repli sur typos exige 0,5 à 0,7 selon la longueur du terme. « Les trigrammes et vecteurs couvriront » est un espoir, pas un repli. ILIKE trouve ; aucun index ne trouve.
La dernière ligne est celle qui compte. Pour le CJK, l’échec n’est pas un classement plus faible : le terme saisi n’est tout simplement pas un token de l’index.

Une phrase chinoise de seize caractères décrivant une campagne de baskets d’automne en cuir produit un seul lexème avec simple. Chercher le mot de trois caractères pour baskets ne trouve rien, parce que tsquery compare des lexèmes entiers. La similarité trigramme est de 0,050 pour un seuil de faute de frappe compris entre 0,5 et 0,7 : ce chemin n’aide donc pas non plus, alors qu’une requête de sous-chaîne simple trouve bien le texte. La donnée est présente, mais les index configurés ne peuvent pas récupérer la sous-phrase.

Nous n’avons pas livré de correction. Les trois approches viables, par ordre approximatif de coût opérationnel, sont :

Une extension de tokenisation dédiée. pg_bigm indexe des bigrammes de caractères et fonctionne sur le CJK là où la similarité trigramme échoue. C’est une vraie extension, avec de vraies conséquences de maintenance pour un déploiement auto-hébergé, et elle change ce que signifie « un token » pour toutes les langues de la base, pas seulement le CJK.

Segmenter à l’indexation. Passer le texte chinois et japonais dans un segmenteur avant qu’il atteigne le constructeur de documents, et insérer des espaces. Cela garde PostgreSQL entièrement standard, ce qui compte beaucoup pour un produit que l’on exécute sur sa propre infrastructure, et déplace le problème vers un endroit où on peut le tester : la sortie d’un segmenteur est inspectable, et ses échecs sont visibles dans le document plutôt que dans un plan de requête. Il doit aussi s’exécuter sur la requête, et les deux doivent rester d’accord, ce qui est la même classe de contrainte que celle de tout cet article.

Utiliser un modèle d’embedding multilingue. Cela permet aux requêtes CJK d’atteindre des médias par la branche sémantique quand la récupération lexicale les manque. C’est le plus petit changement de code mais la garantie la plus faible, car la récupération sémantique est approximative et les identifiants exacts ne devraient pas dépendre de la distance cosinus.

Tant que l’une de ces approches n’est pas mise en œuvre, le produit doit indiquer que la recherche lexicale CJK est limitée, plutôt que de laisser entendre une parité avec des langues dotées d’une tokenisation appropriée.

Coût de stockage et activation d’une langue

Tout ce qui précède se paie en stockage et en temps de reconstruction, et les deux croissent linéairement avec le nombre de langues actives.

Sur le tenant utilisé pour ces mesures, 22 944 documents anglais occupent 30 Mo de texte, avec un index BM25 de 8,1 Mo, un index GIN de 9,3 Mo sur le tsvector stocké, un index de titre de 520 Ko et 21 692 termes dans le dictionnaire de fautes de frappe. Ces structures sont par langue : activer une autre langue les duplique, même quand les métadonnées traduites sont identiques, parce que la configuration de texte peut quand même différer. C’est une projection à partir du schéma, et non une mesure faite sur un tenant multilingue.

L’index vectoriel, la table des médias, le stockage des fichiers et les déclinaisons ne croissent pas avec le nombre de langues. Le coût supplémentaire se trouve dans les index et documents textuels, qui est faible par rapport aux fichiers médias pour ce corpus.

L’activation d’une langue s’exécute comme un workflow de file ordonné plutôt que comme une migration.

Activer une langue met à jour la table langue-vers-configuration, puis lance un backfill (recalcul sur les données existantes) pour cette langue seule. Le backfill parcourt la table des médias par segments bornés, en enchaînant une continuation à partir de l’identifiant de média suivant plutôt qu’en exécutant une longue passe : un gros tenant est ainsi couvert par une chaîne de courtes tâches qui peuvent être relancées individuellement. Seul le segment qui termine la plage lance la construction de l’index, qui supprime et recrée plutôt que de créer, parce que l’extension BM25 peut garder un état interne corrompu quand un index est créé sur une table vide puis alimenté.

Entre l’activation et la fin du dernier segment, les documents ne sont construits que partiellement et le nouvel index n’existe pas encore. Pendant cette fenêtre, la recherche se résout entièrement dans la langue par défaut, y compris son index, son prédicat et sa configuration. Une fois le backfill terminé, le nouvel index devient disponible et la langue effective peut basculer comme une unité.

La désactivation inverse ces étapes : les documents et les lignes de dictionnaire de la langue sont supprimés, l’index est abandonné, et les utilisateurs qui l’avaient choisie passent à la langue par défaut du système. Les médias eux-mêmes restent inchangés, car l’état propre à une langue vit dans les documents de recherche et les index.

Ce que nous dirions à quiconque construit cela

  • Décidez si vous traduisez la requête ou le document, et acceptez toute la facture. Traduire les documents coûte du stockage linéaire et une reconstruction à chaque changement de traduction. En échange, vous n’avez jamais à transformer une requête, et l’index est construit à partir de termes validés par un humain.
  • Un repli est un ensemble, pas une valeur. Si replier une valeur dérivée en invalide une autre, ne repliez pas la valeur dérivée. Résolvez ce dont elles dérivent toutes, une fois, en haut, et recalculez tout ce qui est en dessous.
  • Préférez la cohérence à la justesse dans les configurations de texte. Un mauvais stemmer des deux côtés se dégrade doucement vers la correspondance exacte. Un bon stemmer d’un seul côté est une perte silencieuse, partielle et invisible de la moitié de votre corpus.
  • Les termes qui révèlent une discordance de stemming ne sont jamais ceux avec lesquels vous faites vos contrôles ponctuels. La moitié de notre vocabulaire le plus fréquent s’est cassée et l’autre moitié se comportait parfaitement, avec une séparation exacte selon que le stemming change ou non le mot. Testez avec un pluriel fléchi, pas avec un nom.
  • Fusionnez les rangs entre corpus, jamais les scores. Les scores BM25 de deux index sont calculés sur des vocabulaires différents avec des normalisations de longueur différentes. Ce sont des unités incomparables qui ont par hasard le même type de données.
  • Une branche plafonnée plus une branche plafonnée font deux plafonds. Toute union d’ensembles de candidats limités indépendamment doit être replafonnée, sinon tout ce qui est en aval travaille avec un budget pour lequel il n’a pas été conçu.
  • Figez chaque décision de langue une fois par requête. La recherche touche la locale à cinq ou six étapes. Si chacune la relit, un changement de réglage survenu en cours de requête leur permet de diverger, et vous avez reconstruit le même bogue à partir de deux moments différents.
  • Sachez quelles langues votre base de données ne peut vraiment pas indexer, et dites-le. « Stemmer indisponible » et « l’analyseur ne trouve pas les limites de mots » sont des problèmes d’ordres différents, et l’un des deux n’est sauvé ni par les trigrammes, ni par les vecteurs, ni par l’optimisme.

Où cela se trouve dans le produit

Tout ce qui précède s’exécute à chaque recherche freedam, dans chaque langue active : la galerie, l’API REST, le SDK TypeScript et tout agent IA connecté via MCP. Les langues actives, et celle qui est par défaut, sont des réglages qu’un administrateur modifie ; les index, les backfills et les replis découlent de ce seul choix. La vue produit du même sujet se trouve sur la page recherche.

Prochain article de la série : comment nous mesurons si tout cela fonctionne vraiment. Un panel de soixante cas de pertinence, ce qui s’est passé la première fois que nous l’avons exécuté sur un vrai corpus client, et pourquoi le diagnostic évident était faux quatre fois sur cinq.

La démo exécute une bibliothèque monolingue : elle ne montre donc pas le comportement décrit dans cet article. Une évaluation multilingue utile demande un vocabulaire contrôlé traduit et un corpus alimenté dans chaque langue ; les questions pertinentes sont de savoir quelles langues reçoivent un stemmer, lesquelles utilisent simple, et comment le repli se résout pendant qu’un index est indisponible.

Pour les développeurs

À lire aussi