<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mady Cissé</title>
    <description>The latest articles on DEV Community by Mady Cissé (@mady_zenin).</description>
    <link>https://dev.to/mady_zenin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4063169%2Fa1c65572-9320-441a-bb61-9a5624a6e965.png</url>
      <title>DEV Community: Mady Cissé</title>
      <link>https://dev.to/mady_zenin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mady_zenin"/>
    <language>en</language>
    <item>
      <title>Autocomplétion sur plus de 100 millions de documents : du piège des agrégations à l'index dédié</title>
      <dc:creator>Mady Cissé</dc:creator>
      <pubDate>Wed, 05 Aug 2026 10:24:17 +0000</pubDate>
      <link>https://dev.to/mady_zenin/autocompletion-sur-plus-de-100-millions-de-documents-du-piege-des-agregations-a-lindex-dedie-5fmi</link>
      <guid>https://dev.to/mady_zenin/autocompletion-sur-plus-de-100-millions-de-documents-du-piege-des-agregations-a-lindex-dedie-5fmi</guid>
      <description>&lt;p&gt;Le problème n'était pas Elasticsearch mais le choix du pattern utilisé pour l'autocomplétion.&lt;/p&gt;

&lt;p&gt;L'autocomplétion semblait répondre vite au début mais en faisant plus de tests j'ai constaté que le premier appel était toujours très long, &lt;strong&gt;en moyenne 5 secondes&lt;/strong&gt;, ce qui est inacceptable à la saisie. Les appels suivants passent par le cache, ce qui rend les autres appels plus rapides, de l'ordre de quelques millisecondes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pourquoi était-ce aussi lent à froid ?
&lt;/h2&gt;

&lt;p&gt;Certes la base contient des centaines de millions de documents, mais les données sont pourtant stockées dans Elasticsearch, qui promet des temps de réponse plus performants qu'un SGBD.&lt;/p&gt;

&lt;p&gt;La toute première requête a un coût fixe d'initialisation, découpé de la façon suivante :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;la compilation JIT du code C#&lt;/li&gt;
&lt;li&gt;la création du pool de connexions HTTP/TLS avec Elasticsearch&lt;/li&gt;
&lt;li&gt;le chargement initial des structures de données de l'index (segments Lucene) depuis le disque vers le cache mémoire de l'OS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Une fois ce pipeline « chauffé », les requêtes suivantes bénéficient d'un accès direct à la RAM et d'une réutilisation des connexions, faisant chuter le temps de réponse de plusieurs centaines de millisecondes à quelques millisecondes seulement.&lt;/p&gt;

&lt;p&gt;Pour prendre la bonne décision il fallait absolument avoir des éléments chiffrés, et la seule façon de trouver le meilleur pattern était donc de faire un benchmark sur notre volumétrie.&lt;/p&gt;

&lt;h2&gt;
  
  
  Les quatre approches
&lt;/h2&gt;

&lt;p&gt;Nous utilisions &lt;strong&gt;Prefix match&lt;/strong&gt;, qui concentre tout son travail au moment de la recherche (query-time).&lt;/p&gt;

&lt;p&gt;Les autres patterns tels que &lt;strong&gt;Search As You Type&lt;/strong&gt; et &lt;strong&gt;Edge N-gram&lt;/strong&gt; pré-mâchent le travail lors de la création de l'index, donc le temps de recherche est réduit. C'est ce que j'ai commencé à tester.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Completion Suggester&lt;/strong&gt; est un autre type de pattern : il utilise une structure de données appelée &lt;em&gt;Finite State Transducer&lt;/em&gt; (FST). Le FST est écrit sur le disque à l'indexation et chargé en mémoire au premier &lt;code&gt;suggest&lt;/code&gt;. Le travail est donc fait côté index-time.&lt;/p&gt;

&lt;p&gt;Pour évaluer la meilleure solution, nous avons besoin de plusieurs métriques :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;les temps de réponse côté Elastic (&lt;code&gt;took&lt;/code&gt;, en millisecondes)&lt;/li&gt;
&lt;li&gt;le temps d'appel via .NET&lt;/li&gt;
&lt;li&gt;l'enrichissement des données&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Erreur n°1 : benchmarker en local
&lt;/h2&gt;

&lt;p&gt;Les premiers tests que j'ai faits étaient sur un index local, car je ne pouvais pas tester sur l'environnement de test à ce moment-là. C'était une erreur : il ne contenait pas autant de données qu'en prod.&lt;/p&gt;

&lt;p&gt;À cette échelle, le coût fixe d'initialisation à froid est très visible. Surtout, ces résultats locaux montraient que les appels à la base de données (pour enrichir les résultats) prenaient énormément de temps et écrasaient totalement les métriques d'Elasticsearch.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stratégie&lt;/th&gt;
&lt;th&gt;Took&lt;/th&gt;
&lt;th&gt;Call .NET&lt;/th&gt;
&lt;th&gt;Enrichissement&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prefix&lt;/td&gt;
&lt;td&gt;189 ms&lt;/td&gt;
&lt;td&gt;504 ms&lt;/td&gt;
&lt;td&gt;1563 ms (76 %)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2067 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge N-gram&lt;/td&gt;
&lt;td&gt;153 ms&lt;/td&gt;
&lt;td&gt;174 ms&lt;/td&gt;
&lt;td&gt;392 ms (69 %)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;566 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search As You Type&lt;/td&gt;
&lt;td&gt;154 ms&lt;/td&gt;
&lt;td&gt;194 ms&lt;/td&gt;
&lt;td&gt;384 ms (66 %)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;578 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;En local, l'enrichissement représentait &lt;strong&gt;66 à 76 %&lt;/strong&gt; du temps total. Sur un véritable environnement avec plus de 100 millions de documents, c'est l'inverse, le &lt;code&gt;took&lt;/code&gt; seul pèse 3,6 à 5,2 secondes, et l'enrichissement devient négligeable.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Le benchmark local ne m'a pas donné de mauvais chiffres, il m'a donné les bons chiffres du mauvais problème.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Erreur n°2 : accuser la base de données
&lt;/h2&gt;

&lt;p&gt;Après quelques optimisations côté BDD, et en me branchant sur un environnement de test qui contenait des centaines de millions de documents, j'ai constaté qu'en réalité le mur n'était pas la base de données mais l'agrégation.&lt;/p&gt;

&lt;p&gt;Que ce soit Prefix match, Edge N-gram ou Search As You Type, nous devions faire une &lt;code&gt;terms aggregation&lt;/code&gt; pour ne pas retourner de doublons et celle-ci était coûteuse sur plus de 100 millions de documents.&lt;/p&gt;

&lt;p&gt;Optimisation du mapping des données (passage à un dictionnaire, accès O(1)) :&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requête&lt;/th&gt;
&lt;th&gt;Avant&lt;/th&gt;
&lt;th&gt;Après&lt;/th&gt;
&lt;th&gt;Gain&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Query 1&lt;/td&gt;
&lt;td&gt;391 ms&lt;/td&gt;
&lt;td&gt;135 ms&lt;/td&gt;
&lt;td&gt;-65 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query 2&lt;/td&gt;
&lt;td&gt;246 ms&lt;/td&gt;
&lt;td&gt;51 ms&lt;/td&gt;
&lt;td&gt;-79 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query 3&lt;/td&gt;
&lt;td&gt;253 ms&lt;/td&gt;
&lt;td&gt;45 ms&lt;/td&gt;
&lt;td&gt;-82 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query 4&lt;/td&gt;
&lt;td&gt;285 ms&lt;/td&gt;
&lt;td&gt;47 ms&lt;/td&gt;
&lt;td&gt;-83 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Et l'enrichissement, qui déclenchait un scan en base à froid (~1500 ms), est passé à zéro accès à la base. La donnée est désormais lue directement depuis l'agrégation.&lt;/p&gt;

&lt;p&gt;Ces gains sont réels. Mais à eux seuls, ils n'auraient jamais suffi, c'est exactement ce qui m'a fait perdre du temps.&lt;/p&gt;

&lt;h2&gt;
  
  
  L'OOM du Completion Suggester
&lt;/h2&gt;

&lt;p&gt;La dernière option était donc &lt;strong&gt;Completion Suggester&lt;/strong&gt;, qui stocke un ensemble de suggestions dans le FST sur l'index.&lt;/p&gt;

&lt;p&gt;Le feed s'était bien déroulé, mais dans notre version, la FST du Completion Suggester sur plus de 10 millions de documents ne tenait pas dans le heap et la première requête &lt;code&gt;suggest&lt;/code&gt; provoquait une erreur &lt;code&gt;java.lang.OutOfMemoryError&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  La solution : un index dédié
&lt;/h2&gt;

&lt;p&gt;L'idéal était donc de faire un index dédié, plus petit, contenant uniquement les données destinées à l'autocomplétion.&lt;/p&gt;

&lt;p&gt;C'était &lt;strong&gt;LA&lt;/strong&gt; solution, le &lt;code&gt;took&lt;/code&gt; était d'à peine 1 ms à froid (parfois 0), et les optimisations côté base de données avec la mise en cache avaient amélioré les perfs globales.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stratégie&lt;/th&gt;
&lt;th&gt;Took (ms)&lt;/th&gt;
&lt;th&gt;Call .NET (ms)&lt;/th&gt;
&lt;th&gt;Enrichissement (ms)&lt;/th&gt;
&lt;th&gt;Total (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prefix match + agg (existant)&lt;/td&gt;
&lt;td&gt;3 710&lt;/td&gt;
&lt;td&gt;3 812&lt;/td&gt;
&lt;td&gt;391&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4 203&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Completion suggester sur l'index principal&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OOM&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Completion suggester + index dédié&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;85&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Edge N-gram et Search As You Type n'ont pas été rejoués sur l'environnement de test : les trois approches partagent la même &lt;code&gt;terms aggregation&lt;/code&gt;, déjà identifiée comme le goulot.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tous les chemins empruntés et toutes les embûches ont permis d'avoir une autocomplétion qui répond instantanément.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que je retiens
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tester sur un environnement qui ne reflète pas la PROD peut masquer le réel goulot d'étranglement&lt;/strong&gt;, pourtant de taille O(N) comme les agrégations sur une centaine de millions de docs. Cela a quand même permis de faire des optimisations côté BDD et .NET.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le réel mur était la &lt;code&gt;terms aggregation&lt;/code&gt;.&lt;/strong&gt; Agréger des données sur ce volume durant le query-time n'était pas tenable. La vraie question n'était pas « quel match » mais « agrégation ou pas ». Au final j'ai changé de stratégie et le dédoublonnage s'opère au moment de l'index-time, via un index dédié.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La puissance du FST demande une mémoire maîtrisée.&lt;/strong&gt; Un index d'autocomplétion dédié et léger a été la meilleure solution pour isoler la RAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.elastic.co/search-labs/blog/elasticsearch-autocomplete-search" rel="noopener noreferrer"&gt;https://www.elastic.co/search-labs/blog/elasticsearch-autocomplete-search&lt;/a&gt;&lt;br&gt;
&lt;a href="https://www.elastic.co/blog/you-complete-me" rel="noopener noreferrer"&gt;https://www.elastic.co/blog/you-complete-me&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>elasticsearch</category>
      <category>performance</category>
      <category>opensearch</category>
    </item>
  </channel>
</rss>
