L’Autocomplétion à Grande Échelle : Comment Promed HIS Délivre une Recherche Médicamenteuse et Terminologique en Moins d’une Milliseconde sur 14 Millions d’Enregistrements
Dans les logiciels cliniques, une autocomplétion lente n’est pas un simple désagrément. C’est un risque pour la sécurité du patient. Lorsqu’un pharmacien saisit les trois premières lettres d’un nom de médicament et attend — même deux secondes — le fil de la pensée se rompt. Il peut accepter la première suggestion plutôt que la bonne. Il peut ressaisir. Dans les environnements de dispensation à fort volume, cette friction se cumule sur des centaines d’interactions par vacation.
Cet article décrit l’architecture du module d’interactions médicamenteuses et de vocabulaire clinique de Promed HIS, le Système d’Information Hospitalier de Yoctobe. Le module de vocabulaire est le plus complet de sa catégorie disponible dans un HIS commercialement déployé — couvrant la nomenclature médicamenteuse, les constatations cliniques, les observations de laboratoire, les codes diagnostiques et les synonymes multilingues sur plus de 14 millions de chaînes de concepts médicaux. Cet article explique précisément comment nous avons conçu la couche de recherche terminologique pour servir des complétions de préfixes sur l’ensemble de ce corpus en moins d’une milliseconde pour la majorité des requêtes.
L’Espace du Problème : La Terminologie Médicale N’est Pas une Donnée Ordinaire
La plupart des tutoriels sur l’autocomplétion supposent au maximum quelques centaines de milliers d’enregistrements — noms de produits, noms de villes, noms d’utilisateurs. La terminologie médicale est d’un ordre de grandeur entièrement différent.
Un corpus terminologique clinique complet consolide de multiples vocabulaires sources internationaux en une table de concepts unifiée. Un déploiement en production contient des chaînes issues de :
- RxNorm — noms de médicaments, concepts d’ingrédients, formes pharmaceutiques, noms de marque
- SNOMED CT — constatations cliniques, procédures, structures anatomiques, organismes
- LOINC — observations de laboratoire et cliniques
- CIM-10 / CIM-11 — codes diagnostiques et leurs termes préférés
- MeSH, Thésaurus NCI, DrugBank — et des dizaines d’autres
À pleine échelle, la table de chaînes de concepts dépasse 14 millions de lignes. Chaque ligne représente une chaîne — un nom, un synonyme, une abréviation ou une traduction — associée à un identifiant de concept, un vocabulaire source et un code de langue. Un seul concept médicamenteux comme le paracétamol peut apparaître sous des dizaines de lignes : son DCI, ses noms de marque, sa forme d’ingrédient RxNorm, son terme préféré SNOMED, sa traduction française, espagnole, et ainsi de suite.
Pour qu’un module d’interactions médicamenteuses et de vocabulaire clinique soit véritablement utile au point de soins, il doit interroger ce corpus en temps réel, pendant que le clinicien saisit, sans latence perceptible.
Pourquoi l’Approche Naïve Échoue
La première implémentation instinctive de l’autocomplétion terminologique est une requête SQL LIKE :
SELECT str FROM terminology
WHERE str LIKE 'para%'
AND suppress = 'N'
AND lang = 'ENG'
LIMIT 25
Sur une table de 14 millions de lignes, cette requête force le moteur InnoDB de MySQL à effectuer un balayage sur l’index full-text, qui est optimisé pour le scoring de pertinence, et non pour la correspondance de préfixes. Dans nos benchmarks, les requêtes à froid dépassaient systématiquement 30 secondes. Même avec un buffer pool InnoDB chaud, les temps d’exécution restaient dans la plage de 4 à 8 secondes — totalement inadaptés à une autocomplétion en temps réel.
Le problème fondamental est architectural : l’index FULLTEXT de MySQL est conçu pour les requêtes en langage naturel scorées par pertinence. Les index B-tree supportent efficacement les balayages de préfixes, mais la table terminologique source ne possède aucun index B-tree sur la colonne de chaînes sous une forme bénéfique aux recherches par préfixe. Ajouter un tel index directement sur la table source est déconseillé — la colonne est de type VARCHAR(3000), bien au-delà des limites de préfixe d’index de MySQL, et toute modification structurelle des tables terminologiques importées compromet les pipelines de mise à jour reproductibles des vocabulaires.
L’Architecture en Trois Couches
La solution implémentée par Yoctobe pour Promed HIS sépare les préoccupations en trois couches distinctes, chacune optimisée pour un pattern d’accès spécifique.
Couche 1 : La Table Miroir
Plutôt que de modifier la table terminologique source, nous introduisons une table miroir dédiée : autocomplete_index. Elle est alimentée depuis les données source et maintenue de manière incrémentale, mais elle existe indépendamment et peut être reconstruite sans toucher à la terminologie source.
CREATE TABLE autocomplete_index (
str_lower VARCHAR(500) NOT NULL,
str VARCHAR(3000) NOT NULL,
sab VARCHAR(40) NOT NULL,
lat CHAR(3) NOT NULL,
PRIMARY KEY (str_lower, sab, lat),
INDEX idx_ac_lat_str (lat, str_lower),
INDEX idx_ac_sab_str (sab, str_lower),
INDEX idx_ac_all (lat, sab, str_lower)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
Plusieurs décisions de conception sont intégrées ici. str_lower stocke la forme en minuscules et tronquée (500 caractères) de la chaîne originale. Cette normalisation signifie que les requêtes par préfixe sont insensibles à la casse par construction, sans appels LOWER() à l’exécution qui empêcheraient l’utilisation de l’index. La clé primaire composite sur (str_lower, sab, lat) élimine automatiquement les doublons entre vocabulaires sources via INSERT IGNORE. Les trois index composites couvrent les trois formes de requêtes les plus courantes : filtre sur la langue uniquement, filtre sur le vocabulaire, et les deux combinés.
Un balayage par préfixe sur cette table est désormais une opération de plage B-tree. Pour la requête str_lower LIKE 'para%', MySQL effectue une recherche B-tree unique vers la première entrée correspondant à para, puis lit séquentiellement jusqu’à ce que le préfixe ne corresponde plus. Sur 14 millions de chaînes normalisées, cela s’exécute en moins de 10 millisecondes — une amélioration de 300 fois par rapport à l’approche naïve.
Couche 2 : Les Ensembles Triés Redis Fragmentés
Pour le pattern de requête le plus courant — langue anglaise, sans filtre de vocabulaire, longueur de requête supérieure ou égale à deux caractères — nous pouvons faire encore mieux.
Les ensembles triés Redis supportent une commande de plage lexicographique, ZRANGEBYLEX, qui récupère tous les membres entre deux bornes de chaîne. Un ensemble trié unique contenant les 4 millions de chaînes anglaises distinctes peut répondre à une requête par préfixe en microsecondes. Cependant, il existe une subtilité critique : Redis est mono-thread. Une opération ZRANGEBYLEX sur un ensemble trié de 4 millions de membres pour un préfixe court comme "a" doit potentiellement analyser des centaines de milliers d’entrées avant d’atteindre sa limite. Pendant ce balayage, toutes les autres commandes Redis sont bloquées.
La solution est la fragmentation par les deux premiers caractères du terme. Au lieu d’une clé monolithique, nous maintenons 676 clés de la forme ac:ENG:{xy} où xy est le préfixe en minuscules de deux caractères — aa, ab, …, zz. Chaque fragment contient en moyenne environ 6 000 membres. Un ZRANGEBYLEX sur 6 000 membres est O(log 6000 + limite) — un temps effectivement constant, s’exécutant en moins d’une milliseconde en incluant l’aller-retour réseau.
shard_key = f"ac:ENG:{query.lower()[:2]}"
suggestions = await redis.zrangebylex(
shard_key,
f"[{query.lower()}",
f"[{query.lower()}\xff",
start=0, num=limit
)
Les requêtes à un seul caractère — qui nécessiteraient d’analyser tous les fragments commençant par ce caractère — sont délibérément redirigées vers la table miroir. En pratique clinique, l’autocomplétion à un seul caractère n’est pas utile ; un clinicien saisissant "p" pour paracétamol n’est pas encore à un stade où les suggestions ont de la valeur.
Couche 3 : Le Routeur de Requêtes
La couche applicative achemine chaque requête d’autocomplétion à travers un arbre de décision :
- Chemin rapide Redis — si la requête est en anglais, sans filtre, avec deux caractères ou plus, et que la clé de statut Redis indique que les fragments sont entièrement peuplés : servir depuis Redis. Latence : moins d’une milliseconde.
- Chemin intermédiaire via la table miroir — si Redis est indisponible, encore en cours de chargement, ou si la requête comporte des filtres de vocabulaire ou de langue : servir depuis
autocomplete_index. Latence : 5 à 15 millisecondes. - Cache de résultats — toutes les réponses, quel que soit le chemin emprunté, sont mises en cache dans Redis avec un TTL configurable sous une clé dérivée par SHA-256. Les requêtes répétées pour des préfixes courants —
"met","amox","ibu"— sont servies depuis le cache avec une latence inférieure à la milliseconde dès la deuxième requête.
L’implication clinique est significative. Même pendant les minutes qui suivent immédiatement le démarrage de l’application, avant que les fragments Redis soient entièrement peuplés, l’autocomplétion continue de fonctionner correctement via la table miroir. Il n’existe aucun mode dégradé qui retournerait des résultats vides ou des erreurs — seulement une différence de latence imperceptible pour l’utilisateur final.
Stratégie de Peuplement : Pagination par Curseur sur 14 Millions de Lignes
La construction de la table miroir et des fragments Redis à partir de 14 millions de lignes source nécessite une ingénierie soignée pour éviter de verrouiller la base de données ou de provoquer des timeouts sur les connexions applicatives.
L’approche instinctive — INSERT par lots avec LIMIT et OFFSET — échoue à grande échelle. OFFSET 7500000 demande à MySQL d’analyser et de discarter 7,5 millions de lignes avant de retourner le lot. Le temps de peuplement croît de manière quadratique ; le processus échoue systématiquement avant la fin sur des serveurs aux ressources contraintes.
L’approche correcte est la pagination par curseur (keyset) utilisant la clé primaire :
INSERT IGNORE INTO autocomplete_index (str_lower, str, sab, lat)
SELECT LOWER(SUBSTRING(str, 1, 500)), str, sab, lat
FROM terminology
WHERE suppress = 'N'
AND id > :last_id
ORDER BY id
LIMIT :batch
Chaque lot accède directement à la prochaine ligne non traitée via le B-tree de la clé primaire — O(log n) quelle que soit la position. Une table de 14,9 millions de lignes avec des lots de 50 000 lignes nécessite environ 298 lots, chacun s’exécutant en 1 à 3 secondes, pour un temps de peuplement total de 5 à 15 minutes, fonctionnant proprement en arrière-plan pendant que l’application sert le trafic.
La valeur du curseur — l’id maximum traité — est persistée dans une table index_meta au sein de la même transaction que chaque insertion par lot. Si le processus est interrompu par un crash, un redémarrage ou un événement de mémoire insuffisante, l’invocation suivante lit le dernier curseur validé et reprend exactement depuis ce point. Aucune ligne n’est omise. Aucune ligne n’est dupliquée. La combinaison INSERT IGNORE et la contrainte de clé primaire rendent chaque lot idempotent.
Synchronisation Incrémentale : Maintenir la Terminologie à Jour
Les vocabulaires médicaux sont mis à jour en continu — nouvelles approbations de médicaments, termes préférés révisés, traductions supplémentaires, données d’interaction mises à jour. Dans un HIS déployé, de nouvelles lignes de terminologie peuvent être ajoutées à la table source à tout moment.
Le mécanisme de synchronisation repose sur un invariant simple : le curseur représente l’id source le plus élevé ayant été indexé. À chaque déclenchement manuel de synchronisation, le système compare le MAX(id) actuel de la source contre le curseur stocké. S’ils sont égaux, rien n’a changé et l’opération retourne immédiatement. Si la table source a évolué, seul le delta — les lignes avec un id supérieur au curseur — est traité.
source_max = await get_source_max_id()
ac_cursor = await get_ac_cursor()
if source_max <= ac_cursor:
return {"status": "up_to_date"}
# traiter uniquement les lignes avec id > ac_cursor
Cela rend les opérations de synchronisation sûres à déclencher fréquemment. Un opérateur qui lance le endpoint de synchronisation après chaque mise à jour de vocabulaire n’importe que les nouvelles lignes, quel que soit le nombre de fois où le endpoint a été appelé précédemment. L’opération est idempotente, repérable et proportionnelle en coût au volume de changement réel plutôt qu’à la taille totale de la table.
Si une synchronisation est interrompue en cours d’exécution, le prochain déclenchement reprend depuis le dernier lot de curseur validé — et non depuis zéro. La combinaison de la pagination par curseur, des validations transactionnelles du curseur et de l’idempotence INSERT IGNORE garantit que l’interruption est toujours sûre.
Efficacité Mémoire sur Infrastructure Contrainte
Les établissements de santé dans les marchés émergents — la principale base de clients de Promed HIS — fonctionnent fréquemment avec une infrastructure serveur contrainte. L’architecture est explicitement conçue pour fonctionner sur un serveur de 8 Go de RAM et 2 cœurs CPU hébergeant simultanément MySQL, Redis et l’application.
Le budget mémoire se décompose comme suit :
- Buffer pool InnoDB MySQL : 4 Go — suffisant pour mettre en cache le jeu de travail de l’autocomplete_index et réchauffer les index terminologiques source
- Fragments Redis AC : environ 640 Mo — 4 millions de termes à ~129 octets par membre incluant la surcharge du skiplist, répartis sur 676 clés de fragments
- Processus applicatifs : 256 Mo
- OS et noyau : 512 Mo
- Marge libre : environ 2,5 Go
Redis est configuré avec maxmemory 1800mb et la politique d’éviction volatile-lru. Les clés de fragments AC n’ont aucun TTL et ne sont donc jamais évincées sous pression mémoire. Les clés de cache de résultats de recherche ont des TTL et sont les premières à être évincées si la mémoire se resserre. Cela garantit que l’index d’autocomplétion principal — l’actif le plus coûteux à reconstruire — reste toujours en mémoire.
La persistance est configurée en mode snapshot RDB uniquement, avec l’AOF désactivé. Sur une infrastructure VPS avec stockage partagé, la latence d’écriture AOF introduit un blocage Redis imprévisible. Les snapshots toutes les 15 minutes offrent une durabilité suffisante étant donné que le contenu Redis est entièrement reconstructible depuis MySQL en moins de 20 minutes.
Pertinence Clinique : Interactions Médicamenteuses au Point de Prescription
L’architecture décrite ci-dessus n’est pas une infrastructure accessoire — elle active directement des fonctionnalités de sécurité clinique dans Promed HIS qui seraient autrement impraticables.
Le module d’interactions médicamenteuses fonctionne comme suit. Pendant qu’un clinicien saisit un nom de médicament lors de la saisie d’une ordonnance, les suggestions d’autocomplétion sont extraites des concepts d’ingrédients normalisés — la couche de vocabulaire la plus cliniquement pertinente pour les décisions de prescription. Lorsqu’un concept est sélectionné, son identifiant canonique est résolu et la base de données d’interactions est interrogée en temps réel par rapport à la liste de médicaments actuelle du patient.
Ce flux de travail n’est viable que si l’autocomplétion est véritablement instantanée. Une réponse de 500 millisecondes est acceptable dans un contexte de recherche de produit. En prescription, cela rompt le fil de pensée clinique. Le clinicien doit maintenir en mémoire de travail le médicament qu’il entend prescrire, les médicaments actuels du patient, l’indication clinique et la posologie appropriée. Introduire de la latence à l’étape de sélection du médicament ajoute une charge cognitive exactement au moment où la précision est la plus importante.
L’autocomplétion sous la milliseconde n’est pas une optimisation de performance. C’est un prérequis pour que la fonctionnalité de vérification des interactions fonctionne comme prévu.
Endpoints Opérationnels
Promed HIS expose trois endpoints d’administration pour la gestion de l’index terminologique :
GET /api/v1/complete/status retourne l’état actuel de la table miroir et des fragments Redis, incluant le nombre de lignes, les positions des curseurs et le statut de synchronisation. Cela permet aux opérateurs de vérifier que la terminologie est à jour après une mise à jour de vocabulaire.
POST /api/v1/complete/status/populate déclenche une synchronisation incrémentale — traitant uniquement les lignes ajoutées depuis le dernier curseur. C’est l’opération de maintenance de routine, sûre à appeler immédiatement après toute mise à jour terminologique.
POST /api/v1/complete/status/resync déclenche une reconstruction complète — tronquant la table miroir et les fragments Redis puis les repeuplant depuis zéro. Cela est réservé aux mises à niveau complètes de version de vocabulaire où l’ensemble du corpus terminologique est remplacé plutôt qu’étendu.
Lors de toute opération de reconstruction, l’autocomplétion se dégrade gracieusement à travers la hiérarchie des couches : les fragments Redis sont indisponibles, les requêtes transitent donc vers la table miroir, qui continue de servir les résultats de la version terminologique précédente jusqu’à ce que la reconstruction soit terminée.
Conclusion
Le pattern de conception décrit ici — table miroir avec index de préfixe B-tree, ensembles triés Redis fragmentés, peuplement par curseur et routeur de requêtes en trois couches — n’est pas lié à un standard terminologique ou à une source de vocabulaire particulière. C’est une solution générale au problème de la recherche par préfixe en temps réel sur tout ensemble de données de référence volumineux et périodiquement mis à jour dans un contexte de santé. C’est également le fondement qui fait du module de vocabulaire de Promed HIS le plus étendu et le plus performant du marché : 14 millions de concepts, une récupération sous la milliseconde, et une architecture de synchronisation qui maintient l’index à jour sans jamais interrompre le système.
Ce qui le rend particulièrement adapté à l’informatique clinique, c’est la combinaison de propriétés qu’il délivre simultanément : latence sous la milliseconde pour le cas courant, dégradation gracieuse lorsque la couche rapide est indisponible, synchronisation incrémentale résistante aux crashs pour la maintenance continue et efficacité mémoire qui le rend viable sur une infrastructure représentative des environnements où les logiciels cliniques sont réellement déployés.
Dans la recherche terminologique médicale, le coût d’une mauvaise conception n’est pas une mauvaise expérience utilisateur. C’est un clinicien qui choisit le mauvais médicament parce que le bon n’est apparu trop lentement.







