Personne ne conteste qu'un moteur de recherche se place à côté de la base de données, et non à sa place. C'est la bonne réponse dans quatre-vingt-dix pour cent des cas, et c'est pourquoi presque personne ne regarde les dix pour cent restants, où cette seconde copie ne fait que créer du travail. Vous verrez ici où passe exactement cette ligne, ce que vous y gagnez et ce que vous abandonnez. Les exemples sont en Laravel, l'une de nos spécialités chez AndorraDev, mais le raisonnement vaut pour n'importe quel framework et n'importe quel moteur de recherche.
Un index de recherche est une copie, et les copies se désynchronisent
Le montage habituel est celui-ci : les données vivent dans la base de données relationnelle et un moteur de recherche en maintient une copie optimisée pour la consultation.
Cela fonctionne, et cela a un coût que l'on comptabilise presque jamais au moment de décider. Il faut écrire une commande de synchronisation, maintenir un worker de file d'attente pour les mises à jour, surveiller la dérive qui apparaît quand un job échoue et garder prêt un script de réindexation complète pour le jour où le modèle d'embeddings changera.
Tout cela est de l'infrastructure qui n'apporte aucune fonctionnalité. Elle existe uniquement pour que deux copies disent la même chose.
Pour la plupart des données c'est un prix juste, parce que la base de données apporte des transactions, des relations et une intégrité que le moteur de recherche n'a pas. Mais il existe une catégorie de données où elle n'apporte rien.
Les fragments de texte n'ont pas de vie propre dans votre schéma relationnel
Pensez à ce qui se passe quand vous découpez un long document pour pouvoir le chercher par le sens.
Vous prenez une loi, vous la découpez en articles, chaque article en passages de quelques centaines de mots, vous calculez l'embedding de chaque passage et vous le stockez. Pareil pour de la documentation, pour des transcriptions de réunions ou pour un historique de tickets de support.
Ces fragments ne sont pas des entités de votre métier. Ils n'ont pas de relations à respecter, personne ne les édite un par un, ils ne participent à aucune transaction et leur clé primaire est un identifiant que vous avez inventé au moment du découpage. Ils existent pour une seule raison : être trouvés.
Les mettre dans une table relationnelle puis les copier vers le moteur de recherche revient à payer le prix complet de la synchronisation en échange de garanties dont cette donnée n'a pas besoin.
Ce que vous y gagnez, c'est de n'avoir plus rien à synchroniser
Si l'index est la source de vérité, la seconde copie disparaît et avec elle tout ce qui avait été écrit pour la maintenir.
Plus de commande de synchronisation, plus de file d'attente, plus de dérive possible. Réindexer cesse d'être "vider et recopier depuis SQL" et devient simplement regénérer les fragments.
Et il y a une économie à l'exécution qui se remarque plus qu'on ne le croit. Un moteur qui reflète une table renvoie des identifiants puis doit aller chercher en base de données de quoi réhydrater les résultats, avec une requête par recherche. Si le document de l'index porte déjà tous les champs, cette requête n'existe pas : zéro requête SQL par recherche.
Quelles données supportent ce traitement et lesquelles non
C'est la partie décisive, et il vaut mieux l'avoir sous les yeux avant de déplacer quoi que ce soit. Un moteur de recherche est un moteur de recherche : il donne de la récupération par pertinence, pas les garanties d'un moteur relationnel.
Les transactions restent dans votre base de données. Enregistrer un fragment et mettre à jour un compteur dans la même opération atomique, c'est l'affaire du SQL.
Les relations et les clés étrangères aussi. Rien n'empêche un fragment de pointer vers un document qui n'existe plus, l'intégrité est donc maintenue par votre processus de découpage.
Et les contraintes de même : unicité, non nuls et vérifications vivent là où vit la donnée canonique.
Et surtout : si vous perdez l'index, vous perdez les données. Avec un index miroir, le reconstruire tient en une commande. Ici c'est une restauration de sauvegarde comme pour n'importe quelle autre base de données, ce qui fait que l'index demande désormais la même politique de sauvegarde que votre SQL.
Pour des fragments de texte, aucune de ces pertes ne fait mal. Pour des commandes et des factures, elles feraient toutes mal. La limite est là, et elle est assez nette.
Avant de déplacer un type de donnée vers l'index, posez-vous une question : si je perdais ceci demain, pourrais-je le regénérer ? Les fragments d'un document, oui, parce que le document d'origine est toujours là. Une commande, non. Cette question sépare mieux les deux cas que n'importe quel débat sur la performance, et elle vous dit en plus si vous avez besoin de sauvegardes de l'index ou s'il vous suffit de pouvoir le reconstruire.
L'API dont vous avez besoin pour traiter un index comme une table
Si vous vous lancez, ce que vous voulez c'est écrire du code qui ressemble à celui que vous écrivez déjà, pas des appels bruts au SDK dispersés dans toute l'application.
La configuration de l'index se déclare dans la classe elle-même, là où elle peut se lire et se versionner :
class LawChunk extends Meili
{
protected static string $index = 'law_chunks';
protected static string $primaryKey = 'external_id';
protected static array $searchable = ['content', 'title_path'];
protected static array $filterable = ['law_id', 'country', 'date'];
protected static array $sortable = ['position'];
}
Ensuite une commande pousse ces réglages vers le serveur, et la configuration du moteur cesse d'être quelque chose que quelqu'un a touché une fois dans un panneau pour rejoindre le dépôt comme le reste du code.
Les requêtes se compilent vers les paramètres que le moteur comprend, et la recherche hybride est de première classe :
LawChunk::query()
->where('country', 'AD')
->semantic(0.7)
->limit(20)
->get();
Il faut accepter que le langage de requête soit plus pauvre que celui de SQL, non pas à cause d'une limite de l'enveloppe mais du moteur : les filtres s'enchaînent avec un ET logique et pour une disjonction il faut descendre à l'expression brute. Pas d'agrégats. Pas de jointures. C'est un moteur de recherche, pas un moteur relationnel.
Ce que l'on peut bien résoudre, en revanche, c'est la jonction avec votre vraie base de données. Un fragment peut avoir une relation avec un modèle Eloquent, et au chargement d'une liste de résultats cette relation se résout avec une seule requête pour tous, pas une par fragment.
La trappe de sortie compte autant que l'abstraction
Toute couche commode posée sur un service externe couvre quatre-vingts pour cent des cas et masque le reste. Ce qui décide si elle vous sert encore au bout de deux ans, ce n'est pas la qualité de son emballage de l'ordinaire, c'est la facilité avec laquelle on la contourne le jour où il faut quelque chose qu'elle n'avait pas prévu.
Un moteur de recherche sérieux offre des fonctionnalités qu'aucune couche fluide ne couvrira entièrement. Quand vous voulez demander le score de pertinence de chaque résultat pour les réordonner vous-même, ou lui passer un vecteur que vous avez déjà calculé de votre côté au lieu de le laisser le calculer, il vous faut le client brut.
La bonne façon de résoudre cela n'est pas d'élargir l'abstraction jusqu'à tout couvrir, c'est de laisser le client accessible :
LawChunk::client()
->index(LawChunk::indexName())
->search($query, $options)
->getHits();
Là, vous gardez le nom de l'index et la configuration en un seul endroit, tout en ayant le SDK entier disponible pour le cas rare. Sur Crowd Legal, le moteur de recherche de législation emprunte exactement cette voie, parce qu'il calcule ses propres embeddings et réordonne les résultats en agrégeant le score par document avant de les renvoyer.
Une abstraction qui vous oblige à choisir entre l'utiliser entièrement ou ne pas l'utiliser est une abstraction que vous finirez par retirer.
Scout et cette approche ne sont pas concurrents
Dès qu'on entend Meilisearch et Laravel dans la même phrase, la réponse automatique est Scout. Il vaut la peine de s'y arrêter, car les deux semblent régler la même chose alors qu'elles n'ont pas la même source de vérité, et c'est là que se joue la différence.
Laravel Scout fait autre chose : il maintient un index miroir d'une table Eloquent. Sa source de vérité est la table, et l'index est un dérivé que l'on peut reconstruire quand on veut. Pour le catalogue de produits, pour les clients ou pour tout ce qui vit aussi dans votre schéma, c'est le bon outil et il n'y a aucune raison d'en changer : il vous donne les observers, la file d'attente et l'import massif sans rien écrire.
Ce que résout le fait de traiter l'index comme stockage primaire, c'est l'autre cas, celui de la donnée qui n'existe que pour être trouvée. Et les deux cohabitent sans problème dans le même projet : ils partagent la même connexion au serveur et ne se gênent pas.
Autrement dit : si vous vous demandez "où vit vraiment cette donnée ?", et que la réponse est "dans ma base de données", utilisez Scout. Si la réponse est "nulle part, je la génère en découpant un document", alors la table intermédiaire ne fait que vous donner du travail.
Et si vous ne travaillez pas avec Laravel
Le critère relève de la conception, pas du framework :
- Demandez-vous si la donnée est regénérable. Si elle l'est et qu'elle ne sert qu'à la recherche, la copie relationnelle est probablement superflue.
- Déclarez la configuration de l'index dans le code, pas dans un panneau. Les réglages d'un moteur de recherche sont de la configuration applicative et méritent d'être sous contrôle de version.
- Stockez dans le document tout ce que vous allez afficher. Si vous devez aller en base de données pour compléter les résultats, vous êtes revenu au point de départ.
- Et exigez une trappe de sortie. Toute couche posée sur un service externe doit vous laisser atteindre le client brut, parce que le jour où vous aurez besoin d'un paramètre qu'elle ne couvre pas, vous en aurez vraiment besoin.
Où se trouve le code
Le paquet est sur Packagist, le code sur GitHub et la documentation du paquet reprend l'API complète. Cela fait environ mille lignes, sans migrations et sans tables, et il réutilise la même configuration de connexion que celle que vous avez déjà en place pour Scout.
Il est en production sur Crowd Legal, où les fragments de législation et de jurisprudence vivent dans l'index et se récupèrent par le sens.
Si vous montez de la recherche sémantique sur vos propres documents, cette décision d'architecture est la première et conditionne tout le reste. Nous la travaillons en intelligence artificielle et en logiciel juridique, et si vous nous racontez ce que vous allez indexer, nous vous dirons où placer la limite.