Jour 54 Day 54 · mardi 27 octobre 2026 Tuesday 27 October 2026 Data Avancé

Recherche full-text & Elasticsearch Full-text search & Elasticsearch

Index inversé, analyse, BM25, synchro avec la base : comprendre comment fonctionne un moteur de recherche — et savoir dire en entretien quand Postgres suffit. Inverted index, analysis, BM25, syncing with the database: understand how a search engine works — and know when to say in an interview that Postgres is enough.

L’essentiel

Le réflexe naïf pour chercher « chaussures de running » dans une table de produits, c’est WHERE description LIKE '%running%'. Ça marche en démo, ça meurt en prod : un LIKE '%…%' ne peut pas utiliser d’index B-tree (le motif commence par un joker), donc la base scanne toute la table à chaque requête — O(n) sur des millions de lignes. Et même en acceptant la lenteur, le résultat est mauvais : pas de « chaussure » au singulier, pas de tolérance aux fautes, pas de tri par pertinence.

La recherche full-text inverse le problème avec l’index inversé : au lieu de parcourir les documents pour y trouver des termes, on précalcule, pour chaque terme, la liste des documents qui le contiennent. Chercher devient une lecture directe : « running » → documents 2, 7, 42. C’est la structure au cœur de Lucene, donc d’Elasticsearch et d’OpenSearch (son fork Apache 2.0), mais aussi du full-text search de Postgres.

En entretien, on attend les deux : la mécanique (index inversé, analyse, pertinence) et le discernement — Elasticsearch est puissant mais c’est un second système à opérer et à synchroniser ; Postgres full-text couvre déjà énormément de besoins.

Comment ça marche

1. L’index inversé. À l’indexation, chaque document est découpé en termes ; l’index stocke terme → liste de documents (postings), avec les positions pour les recherches de phrases :

Documents
  d1: "Chaussures de running légères"
  d2: "Running : programme débutant"
  d3: "Chaussures de ville en cuir"

Index inversé (après analyse)
  chaussur  → [d1, d3]
  running   → [d1, d2]
  leger     → [d1]
  programm  → [d2]
  debutant  → [d2]
  ville     → [d3]
  cuir      → [d3]

Requête "chaussures running"
  chaussur ∩ running = [d1]   (AND)
  chaussur ∪ running = [d1, d2, d3]  (OR, trié par score)

2. L’analyse (analysis). Le texte ne rentre jamais brut dans l’index : il passe par un analyzer — tokenization (découpage en mots), lowercase, suppression des stop words (« de », « the »…), stemming (réduction à la racine : « chaussures » → « chaussur », « running » → « run » selon l’analyzer). Point crucial : l’analyse est par langue (le stemmer français ne sait rien de l’anglais) et la requête subit la même analyse que les documents — c’est ce qui fait que « Chaussure » matche « chaussures ».

3. La pertinence. Tous les documents qui matchent ne se valent pas. L’intuition TF-IDF : un terme compte davantage s’il est fréquent dans ce document (TF) et rare dans l’ensemble du corpus (IDF) — « running » discrimine, « de » ne discrimine rien. BM25, le défaut d’Elasticsearch et de Lucene moderne, raffine cette idée : saturation du TF (le 50ᵉ « running » n’apporte presque rien de plus que le 5ᵉ) et normalisation par la longueur du document (un match dans un titre court pèse plus que dans un pavé).

4. Elasticsearch en pratique. Des documents JSON dans des index, un mapping qui déclare le type de chaque champ — distinction clé : text (analysé, pour la recherche) vs keyword (brut, pour filtres exacts, tris et agrégations) — et un query DSL en JSON :

// GET /products/_search — requête + filtres + score
{
  "query": {
    "bool": {
      "must": [                       // participe au score BM25
        { "match": {                  // "match" analyse le texte cherché
          "name": {
            "query": "chaussure running",
            "fuzziness": "AUTO"       // tolère les fautes de frappe
          }
        }}
      ],
      "filter": [                     // filtre binaire, sans score, cacheable
        { "term":  { "category": "shoes" } },
        { "range": { "price": { "lte": 150 } } }
      ]
    }
  }
}

must note les documents (pertinence), filter élimine sans noter (exact, plus rapide, mis en cache). C’est la requête type d’une recherche e-commerce.

Concepts clés à maîtriser

  • Le pattern DB + index de recherche : la base relationnelle reste la source de vérité (transactions, contraintes) ; l’index de recherche en est une projection dénormalisée, optimisée pour la lecture. Toute écriture doit être propagée : c’est le problème de la synchro. Options : double écriture applicative (simple mais fragile — que se passe-t-il si l’indexation échoue après le commit ?), file de messages, ou CDC (Change Data Capture : Debezium lit le WAL et rejoue les changements). L’index est en cohérence à terme : quelques secondes de décalage, à assumer dans l’UX.
  • Reindexation : changer un analyzer ou un mapping impose souvent de reconstruire l’index. Pattern : indexer vers products_v2, puis basculer un alias — zéro downtime.
  • Postgres full-text : tsvector (le document analysé) + tsquery (la requête), opérateur @@, index GIN, dictionnaires par langue ('french'), ts_rank pour trier. Dans la même base que les données : pas de synchro, transactions incluses. Souvent suffisant.
  • Facettes & agrégations : les compteurs par catégorie/marque/prix des sites e-commerce = agrégations Elasticsearch (terms, range) calculées sur les résultats filtrés.
  • Autocomplete : préfixes via edge_ngram à l’indexation (« chau » → « chaussure ») ou champ search_as_you_type ; la fuzziness gère les fautes.
Postgres FTSElasticsearch / OpenSearch
InfraDéjà là (votre DB)Cluster séparé à opérer
SynchroAucune (même base, transactionnel)Obligatoire (double écriture, CDC)
Pertinencets_rank correctBM25, tuning fin, suggesteurs
Fuzzy / fautesLimité (pg_trgm en complément)Natif (fuzziness)
Facettes/aggsGROUP BY (correct)Agrégations natives, très rapides
ÉchelleTrès loin sur une instanceDistribué : sharding, réplicas
Bon choix quand…Recherche « feature » d’une appLa recherche EST le produit

💡 Commence par Postgres FTS — si vos données sont déjà dans Postgres, tsvector + index GIN donnent une vraie recherche full-text (stemming, ranking, multi-langue) sans nouveau système, sans synchro, sans cluster à opérer. Migrez vers Elasticsearch quand vous butez sur ses limites réelles (fuzzy avancé, facettes massives, volumétrie) — pas avant. C’est la réponse qui fait mouche en entretien.

En entretien

« Pourquoi LIKE '%mot%' ne scale pas ? » — Le joker en tête empêche l’usage d’un index B-tree (qui range par préfixe) : scan complet de la table à chaque requête, O(n). Et fonctionnellement : pas de stemming, pas de pertinence, pas de tolérance aux fautes. La réponse structurelle est l’index inversé — terme → documents — où la recherche devient une lecture directe.

« C’est quoi un index inversé ? » — La structure qui inverse la relation document→termes en terme→documents. À l’indexation, chaque document est analysé (tokenization, lowercase, stemming) et chaque terme pointe vers sa liste de documents (postings, avec positions). Une requête est analysée pareil, puis on intersecte (AND) ou unit (OR) les listes et on trie par score BM25.

« Comment un moteur trie-t-il par pertinence ? » — Intuition TF-IDF : fréquent dans le document (TF) × rare dans le corpus (IDF). BM25 raffine : saturation du TF et normalisation par longueur du document. Bonus : mentionner qu’on peut booster des champs (titre > description) et que filter ne participe pas au score.

« Comment gardes-tu Elasticsearch synchronisé avec ta base ? » — La DB reste source de vérité, l’index est une projection. Double écriture applicative pour commencer (en gérant l’échec d’indexation : retry, file), CDC avec Debezium pour du robuste (lecture du WAL). Dans tous les cas, cohérence à terme — et prévoir une réindexation complète pour rattraper les dérives.

« Elasticsearch ou Postgres full-text ? » — Postgres FTS d’abord si les données y sont : zéro synchro, transactionnel, tsvector/GIN couvrent stemming et ranking. Elasticsearch quand la recherche est centrale au produit : fuzzy natif, facettes massives, autocomplete avancé, échelle horizontale. Le coût caché d’Elasticsearch n’est pas la recherche, c’est l’opération du cluster et la synchro.

Pièges & idées reçues

⚠️ Elasticsearch n’est pas une base de données primaire — pas de transactions, durabilité pensée pour un index reconstructible. Si l’index brûle, on le reconstruit depuis la DB ; si la DB brûle et que vos données n’étaient que dans Elasticsearch, elles sont perdues. Source de vérité : toujours ailleurs.

  • Oublier que la requête est analysée aussi — chercher Running en term (non analysé) sur un champ text (analysé, donc « running » en minuscule dans l’index) ne matche rien. Le grand classique du débutant : match pour le texte analysé, term pour les champs keyword.
  • text vs keyword mal choisis — trier ou agréger sur un champ analysé n’a pas de sens (on trierait sur des racines stemmées) ; chercher du plein texte sur un keyword exige l’égalité exacte. Le mapping se réfléchit avant l’indexation.
  • Sous-estimer la synchro — la double écriture « fire and forget » perd des documents en silence (crash entre le commit DB et l’indexation). Il faut un mécanisme de rattrapage : file avec retry, CDC, ou réindexation périodique.
  • Résultats « en retard » — l’index est en cohérence à terme (refresh ~1s par défaut, plus le délai de synchro) : un produit créé peut ne pas apparaître immédiatement dans la recherche. À expliquer au product owner avant qu’il n’ouvre un bug.

🎤 En entretien — la question « comment ajouterais-tu une recherche à cette app ? » teste votre discernement, pas votre connaissance du query DSL. Réponse gagnante : « d’abord Postgres FTS puisque les données y sont — tsvector, index GIN, ts_rank ; si les besoins dépassent (fuzzy, facettes, volumétrie), Elasticsearch avec la DB comme source de vérité et une synchro par CDC ». Vous venez de montrer l’architecture et le pragmatisme.

Pour aller plus loin

The essentials

The naive reflex for finding “running shoes” in a products table is WHERE description LIKE '%running%'. It works in a demo, it dies in production: a LIKE '%…%' cannot use a B-tree index (the pattern starts with a wildcard), so the database scans the whole table on every query — O(n) over millions of rows. And even if you accept the slowness, the results are poor: no singular/plural matching, no typo tolerance, no relevance ranking.

Full-text search flips the problem with the inverted index: instead of walking through documents looking for terms, you precompute, for each term, the list of documents containing it. Searching becomes a direct lookup: “running” → documents 2, 7, 42. It’s the structure at the heart of Lucene, hence of Elasticsearch and OpenSearch (its Apache 2.0 fork), but also of Postgres full-text search.

In an interview, both are expected: the mechanics (inverted index, analysis, relevance) and the judgment — Elasticsearch is powerful but it’s a second system to operate and keep in sync; Postgres full-text already covers a huge share of real needs.

How it works

1. The inverted index. At indexing time, each document is split into terms; the index stores term → list of documents (postings), with positions for phrase queries:

Documents
  d1: "Lightweight running shoes"
  d2: "Running: beginner program"
  d3: "Leather dress shoes"

Inverted index (after analysis)
  shoe     → [d1, d3]
  run      → [d1, d2]
  light    → [d1]
  program  → [d2]
  beginn   → [d2]
  dress    → [d3]
  leather  → [d3]

Query "running shoes"
  run ∩ shoe = [d1]        (AND)
  run ∪ shoe = [d1, d2, d3]  (OR, sorted by score)

2. Analysis. Text never enters the index raw: it goes through an analyzer — tokenization (splitting into words), lowercasing, stop word removal (“the”, “de”…), stemming (reducing to the root: “running” → “run”, “shoes” → “shoe” depending on the analyzer). Crucial point: analysis is per language (the French stemmer knows nothing about English) and the query goes through the same analysis as the documents — that’s what makes “Shoe” match “shoes”.

3. Relevance. Not all matching documents are equal. The TF-IDF intuition: a term counts more if it’s frequent in this document (TF) and rare across the whole corpus (IDF) — “running” discriminates, “the” discriminates nothing. BM25, the default in Elasticsearch and modern Lucene, refines the idea: TF saturation (the 50th “running” adds almost nothing over the 5th) and document-length normalization (a match in a short title weighs more than in a wall of text).

4. Elasticsearch in practice. JSON documents in indices, a mapping declaring each field’s type — key distinction: text (analyzed, for search) vs keyword (raw, for exact filters, sorting and aggregations) — and a JSON query DSL:

// GET /products/_search — query + filters + scoring
{
  "query": {
    "bool": {
      "must": [                       // contributes to the BM25 score
        { "match": {                  // "match" analyzes the searched text
          "name": {
            "query": "running shoe",
            "fuzziness": "AUTO"       // tolerates typos
          }
        }}
      ],
      "filter": [                     // binary filter, no scoring, cacheable
        { "term":  { "category": "shoes" } },
        { "range": { "price": { "lte": 150 } } }
      ]
    }
  }
}

must scores documents (relevance), filter eliminates without scoring (exact, faster, cached). This is the archetypal e-commerce search query.

Key concepts to master

  • The DB + search index pattern: the relational database stays the source of truth (transactions, constraints); the search index is a denormalized projection of it, optimized for reads. Every write must be propagated: that’s the sync problem. Options: application-level double write (simple but fragile — what happens if indexing fails after the commit?), a message queue, or CDC (Change Data Capture: Debezium reads the WAL and replays changes). The index is eventually consistent: a few seconds of lag, to be owned in the UX.
  • Reindexing: changing an analyzer or a mapping often requires rebuilding the index. Pattern: index into products_v2, then flip an alias — zero downtime.
  • Postgres full-text: tsvector (the analyzed document) + tsquery (the query), the @@ operator, a GIN index, per-language dictionaries ('french'), ts_rank for sorting. In the same database as the data: no sync, transactions included. Often enough.
  • Facets & aggregations: the per-category/brand/price counters on e-commerce sites = Elasticsearch aggregations (terms, range) computed over the filtered results.
  • Autocomplete: prefixes via edge_ngram at indexing time (“sho” → “shoe”) or a search_as_you_type field; fuzziness handles typos.
Postgres FTSElasticsearch / OpenSearch
InfraAlready there (your DB)Separate cluster to operate
SyncNone (same database, transactional)Mandatory (double write, CDC)
RelevanceDecent ts_rankBM25, fine tuning, suggesters
Fuzzy / typosLimited (pg_trgm as a complement)Native (fuzziness)
Facets/aggsGROUP BY (decent)Native aggregations, very fast
ScaleVery far on one instanceDistributed: sharding, replicas
Right choice when…Search is an app “feature”Search IS the product

💡 Start with Postgres FTS — if your data already lives in Postgres, tsvector + a GIN index give you real full-text search (stemming, ranking, multi-language) with no new system, no sync, no cluster to operate. Migrate to Elasticsearch when you hit its actual limits (advanced fuzzy, massive facets, volume) — not before. That’s the answer that lands in an interview.

In an interview

“Why doesn’t LIKE '%word%' scale?” — The leading wildcard prevents any B-tree index use (B-trees order by prefix): full table scan on every query, O(n). And functionally: no stemming, no relevance, no typo tolerance. The structural answer is the inverted index — term → documents — where searching becomes a direct lookup.

“What is an inverted index?” — The structure that inverts the document→terms relation into term→documents. At indexing time, each document is analyzed (tokenization, lowercasing, stemming) and each term points to its list of documents (postings, with positions). A query is analyzed the same way, then you intersect (AND) or union (OR) the lists and sort by BM25 score.

“How does an engine rank by relevance?” — TF-IDF intuition: frequent in the document (TF) × rare in the corpus (IDF). BM25 refines it: TF saturation and document-length normalization. Bonus: mention field boosting (title > description) and that filter clauses don’t contribute to the score.

“How do you keep Elasticsearch in sync with your database?” — The DB stays the source of truth, the index is a projection. Application-level double write to start (handling indexing failure: retry, queue), CDC with Debezium for robustness (reading the WAL). Either way, eventual consistency — and plan a full reindex to catch up on drift.

“Elasticsearch or Postgres full-text?” — Postgres FTS first if the data is already there: zero sync, transactional, tsvector/GIN cover stemming and ranking. Elasticsearch when search is central to the product: native fuzzy, massive facets, advanced autocomplete, horizontal scale. Elasticsearch’s hidden cost isn’t the search, it’s operating the cluster and the sync.

Pitfalls & misconceptions

⚠️ Elasticsearch is not a primary database — no transactions, durability designed for a rebuildable index. If the index burns down, you rebuild it from the DB; if the DB burns down and your data lived only in Elasticsearch, it’s gone. Source of truth: always elsewhere.

  • Forgetting the query is analyzed too — searching Running with a term query (not analyzed) against a text field (analyzed, so “running” is lowercased in the index) matches nothing. The classic beginner trap: match for analyzed text, term for keyword fields.
  • Wrong text vs keyword choices — sorting or aggregating on an analyzed field makes no sense (you’d sort on stemmed roots); full-text searching a keyword field requires exact equality. Think the mapping through before indexing.
  • Underestimating the sync — “fire and forget” double writes silently lose documents (crash between the DB commit and the indexing call). You need a catch-up mechanism: queue with retries, CDC, or periodic reindexing.
  • “Late” results — the index is eventually consistent (default refresh ~1s, plus sync lag): a freshly created product may not appear in search immediately. Explain that to the product owner before they file a bug.

🎤 In an interview — the question “how would you add search to this app?” tests your judgment, not your query DSL knowledge. Winning answer: “Postgres FTS first since the data is already there — tsvector, GIN index, ts_rank; if the needs outgrow it (fuzzy, facets, volume), Elasticsearch with the DB as source of truth and CDC-based sync”. You’ve just shown architecture and pragmatism.

Going further

S'entraîner sur ce sujet → Practice this topic →