fr

Indexer des collections de fichiers markdown : recherche full-text à faible empreinte, côté serveur et côté client

Guide sourcé de l'indexation de quelques centaines de documents markdown pour une recherche full-text (corps et frontmatter) en moins de 50 ms : sans index, PostgreSQL (tsvector, setweight, unaccent), SQLite FTS5, moteurs dédiés, recherche côté client (MiniSearch, Pagefind), mesures sur un corpus réel, compromis explicités et recommandation pour une stack Postgres + Fastify + Astro.

Conversation ayant produit cet article

Le besoin : quelques centaines de documents .md, une recherche full-text qui porte sur le corps mais aussi sur les en-têtes (le frontmatter YAML : titre, description, tags… et, selon le cas, les titres de section), une latence de l’ordre de 50 ms, une empreinte la plus faible possible, et une indexation rare (1 à 5 fois par jour) pour laquelle on a le droit de prendre son temps. L’infra existante est une base PostgreSQL, un système de fichiers Ubuntu, un proxy Apache, une API Node.js/Fastify et un front React/Next.js, appelé à migrer vers Astro.

La réponse courte, après lecture des documentations et mesures sur un corpus réel (section 8) :

  • à cette échelle, la latence n’est pas le problème. Une recherche indexée tourne sous la milliseconde, et même une recherche « bête » en mémoire passe sous les 10 ms ;
  • ce qui consomme le budget de 50 ms, ce n’est pas la recherche, c’est la génération des extraits surlignés (ts_headline dans PostgreSQL : ~37 ms de médiane dans mes mesures) ;
  • avec PostgreSQL déjà en place, la solution à plus faible empreinte est de ne rien ajouter : une colonne tsvector générée et un index GIN, soit 2,4 Mo d’index pour 352 documents de 13 Mo ;
  • côté client, c’est faisable et très rapide, mais le coût se déplace : poids du téléchargement, mémoire du navigateur, et surtout publication de l’index, donc du contenu.

Cet article prolonge Systèmes documentaires d’entreprise : markdown, schémas, coûts. Celui-là comparait des outils pour écrire et stocker de la documentation en markdown ; celui-ci traite de la brique qui permet de la retrouver.

Les sigles sont définis à leur première occurrence. Les chiffres que j’ai mesurés moi-même sont signalés comme tels, avec leurs limites ; les autres viennent de documentations officielles ou, quand ce n’est pas le cas, sont étiquetés comme chiffres d’éditeur ou de source secondaire.


1. Ce que « indexer du markdown » veut dire

Un fichier markdown n’est pas un document plat. Il a deux parties à traiter différemment :

---
title: "Déployer Kubernetes à petite échelle" # ← en-tête (frontmatter YAML)
tags: [kubernetes, ops]
---

## Pourquoi pas Docker Compose ? # ← titres de section

Le corps, avec du **gras**, des [liens](https://…) et des blocs de code.

Indexer, c’est donc d’abord extraire des champs : title, description, tags, éventuellement headings (les #/## du corps) et body (le texte restant). Ensuite, on donne un poids différent à chaque champ : un mot trouvé dans le titre compte plus qu’un mot trouvé au milieu d’un paragraphe. C’est la réponse à l’exigence « chercher aussi dans les en-têtes » : ce n’est pas une option du moteur, c’est un choix de modélisation (un champ par en-tête, un poids par champ), que tous les moteurs ci-dessous savent exprimer.

Quatre difficultés reviennent quel que soit l’outil :

DifficultéPourquoi elle compteOù elle se règle
Accents« decouvrabilite » doit trouver « découvrabilité »Normalisation à l’indexation et à la requête
Langue / racinisation (stemming)« serveurs » doit trouver « serveur » ; la racinisation ramène un mot à sa racineConfiguration linguistique (français ≠ anglais)
Syntaxe markdown dans le corps**gras**, URL, blocs de code polluent l’indexPrétraitement avant indexation
Classement (ranking)Parmi 40 résultats, lequel afficher en premier ?Fonction de score (voir BM25 plus bas)

Le BM25 (Best Matching 25) est la fonction de score standard des moteurs de recherche textuels : elle combine la fréquence du terme dans le document, sa rareté dans la collection (IDF, Inverse Document Frequency) et la longueur du document. On y revient parce que PostgreSQL natif ne l’implémente pas (section 4.4).


2. Le budget de 50 ms : où il part vraiment

Une recherche web interactive se décompose en : trajet réseau (variable, hors de notre contrôle), traitement Apache + Fastify (quelques ms), recherche (la partie qu’on indexe), puis mise en forme du résultat (extraits, surlignage). Sur un corpus de quelques centaines de documents, la deuxième partie de la phrase est la bonne : ce n’est pas la recherche qui coûte, c’est ce qu’on fait des résultats.

 navigateur ──▶ Apache ──▶ Fastify ──▶ [ recherche indexée : < 1 ms ]
                                        [ extraits / highlight : 5 à 40 ms ]  ◀── le vrai poste
                                        [ sérialisation JSON : < 1 ms ]

Conséquence pratique : on choisit l’outil sur ses autres critères (empreinte, qualité du classement, accents, simplicité d’exploitation), et on dimensionne séparément la génération des extraits.


3. Option 0 : pas d’index du tout

Avant d’ajouter une brique, il faut regarder ce que coûte l’absence de brique. Sur 352 documents (13,4 Mo de texte), un simple includes JavaScript sur des chaînes normalisées (minuscules, sans accents) tenues en mémoire répond en ~5 ms de médiane (mesure personnelle, section 8). Pour un outil en ligne de commande, ripgrep revendique 0,082 s pour parcourir l’arbre source du noyau Linux, soit plusieurs centaines de Mo, avec la réserve de ses auteurs : « a single benchmark is never enough ». Source : README de ripgrep.

Quand c’est suffisant : corpus qui tient en RAM, recherche par mots exacts, pas de besoin de classement fin.

Pourquoi on s’en éloigne quand même :

  • aucune racinisation (« serveurs » ne trouve pas « serveur ») ;
  • aucun classement : on obtient une liste de documents qui contiennent les mots, pas les plus pertinents ;
  • la latence croît linéairement avec le volume, alors que les index restent quasi plats ;
  • il faut recharger ou surveiller les fichiers pour rester à jour.

C’est le bon point de départ d’un prototype, pas un état final.


4. Option A : PostgreSQL, déjà là

PostgreSQL embarque un moteur de recherche full-text : le type tsvector (le texte découpé en lexèmes normalisés avec leurs positions), le type tsquery (la requête), l’opérateur @@ (correspondance) et l’index inversé GIN (Generalized Inverted Index). Aucune extension à installer, aucun service de plus.

4.1 Schéma : une colonne générée, un index

La documentation officielle recommande de stocker le tsvector dans une colonne générée, calculée automatiquement à l’INSERT et à l’UPDATE, puis d’indexer cette colonne en GIN. Sans index, la recherche reste possible mais « most applications will find this approach too slow, except perhaps for occasional ad-hoc searches ». Source : PostgreSQL, Tables and Indexes

Pour pondérer les en-têtes, on assemble plusieurs tsvector avec setweight (étiquettes A à D) :

-- Extension fournissant le dictionnaire de suppression des accents
CREATE EXTENSION IF NOT EXISTS unaccent;

-- Configuration linguistique « fr » : unaccent AVANT le stemmer français
CREATE TEXT SEARCH CONFIGURATION fr ( COPY = french );
ALTER TEXT SEARCH CONFIGURATION fr
  ALTER MAPPING FOR hword, hword_part, word
  WITH unaccent, french_stem;

CREATE TABLE docs (
  id          serial PRIMARY KEY,
  path        text UNIQUE NOT NULL,   -- chemin relatif du .md
  sha256      text NOT NULL,          -- pour l'indexation incrémentale (section 4.5)
  title       text,
  description text,
  tags        text NOT NULL DEFAULT '', -- tags séparés par des espaces (voir le piège ci-dessous)
  headings    text,                   -- titres de section extraits du corps
  body        text NOT NULL,          -- texte sans syntaxe markdown
  tsv tsvector GENERATED ALWAYS AS (
      setweight(to_tsvector('fr', coalesce(title, '')),                 'A')
   || setweight(to_tsvector('fr', tags),                                'A')
   || setweight(to_tsvector('fr', coalesce(description, '')),           'B')
   || setweight(to_tsvector('fr', coalesce(headings, '')),              'B')
   || setweight(to_tsvector('fr', body),                                'C')
  ) STORED
);

CREATE INDEX docs_tsv_idx ON docs USING GIN (tsv);

Le mécanisme de configuration unaccent → french_stem est celui que documente PostgreSQL : unaccent est un dictionnaire « filtrant », dont la sortie est transmise au dictionnaire suivant. Source : PostgreSQL, unaccent

Piège : une colonne générée n’accepte que des fonctions immuables. Ma première version stockait les tags dans un text[] et appelait array_to_string(tags, ' ') dans l’expression : PostgreSQL 15.14 la refuse avec generation expression is not immutable, car array_to_string est déclarée STABLE (provolatile = 's' dans pg_proc). D’où la colonne tags en simple texte, rempli par l’indexeur (section 4.7). En revanche, to_tsvector('fr', …) avec la configuration passée explicitement est acceptée : c’est la raison pour laquelle la documentation impose cette forme à deux arguments pour les index d’expression (« the index contents must be unaffected by default_text_search_config »). Si vous tenez à un text[], il faut une fonction enveloppe IMMUTABLE, avec la réserve de la section 4.6.

4.2 Requête : syntaxe « moteur de recherche » et classement

websearch_to_tsquery accepte la saisie brute d’un utilisateur sans jamais lever d’erreur de syntaxe : les mots sont joints par &, "entre guillemets" devient une phrase (<->), or devient |, -mot exclut. Source : PostgreSQL, Controlling Text Search

SELECT path, title,
       ts_rank_cd(tsv, query) AS score
FROM   docs, websearch_to_tsquery('fr', $1) AS query
WHERE  tsv @@ query
ORDER  BY score DESC
LIMIT  10;

Le poids des étiquettes {D, C, B, A} vaut par défaut {0.1, 0.2, 0.4, 1.0} : un mot du titre (A) compte donc 5 fois un mot du corps (C), et 2,5 fois un mot de la description (B). On peut passer un tableau de poids à ts_rank_cd pour ajuster. La même documentation précise que ts_rank_cd est un classement par « densité de couverture » qui tient compte de la proximité des mots entre eux.

Chercher uniquement dans le titre (l’équivalent d’un filtre de champ) se fait avec l’étiquette de poids dans la requête : to_tsquery('fr', 'kubernetes:A'). Attention, websearch_to_tsquery ne reconnaît ni les étiquettes de poids ni les préfixes : il faut passer par to_tsquery pour ces cas, donc construire la requête soi-même côté API.

4.3 Extraits surlignés : le piège de latence

ts_headline produit l’extrait avec les mots surlignés. La documentation prévient : il « uses the original document, not a tsvector summary, so it can be slow and should be used with care ». Il relit et re-découpe le texte original à chaque appel. Elle indique aussi que sa sortie n’est pas garantie sûre contre le XSS (Cross-Site Scripting) : il faut assainir le HTML si le texte source n’est pas de confiance. Source : PostgreSQL, Controlling Text Search

Mes mesures illustrent l’avertissement : la requête classée seule prend ~0,5 ms, la même requête avec ts_headline sur les 10 premiers résultats (documents d’environ 39 Ko) monte à ~37 ms de médiane, 51 ms au 95ᵉ centile. Le budget de 50 ms est consommé par l’extrait, pas par la recherche.

Problème :

-- ts_headline sur toutes les lignes avant le LIMIT : désastre
SELECT ts_headline('fr', body, query) ...
FROM docs, websearch_to_tsquery('fr', $1) query
WHERE tsv @@ query ORDER BY ts_rank_cd(tsv, query) DESC LIMIT 10;

Solution : classer et limiter d’abord, surligner ensuite, et sur peu de lignes (sous-requête), avec des options bornées :

SELECT path, title,
       ts_headline('fr', body, query,
                   'MaxFragments=1, MinWords=10, MaxWords=25') AS extrait
FROM (
  SELECT path, title, body, query
  FROM   docs, websearch_to_tsquery('fr', $1) AS query
  WHERE  tsv @@ query
  ORDER  BY ts_rank_cd(tsv, query) DESC
  LIMIT  5                          -- 5 plutôt que 10 : le coût est linéaire
) top;

Autres leviers, par ordre d’efficacité : ne surligner que les premiers résultats et charger le reste à la demande ; générer l’extrait côté Node à partir du texte stocké (une recherche de position du premier terme suffit pour un extrait honnête) ; ou stocker un résumé et ne surligner que lui.

4.4 Limites du classement natif et alternatives

Le classement natif de PostgreSQL n’utilise aucune information globale : la documentation écrit que les fonctions de classement n’utilisent pas d’information sur l’ensemble des documents, ce qui rend « impossible » une normalisation à 1 % ou 100 % ; l’option de normalisation 32 n’est qu’un changement cosmétique qui « will not affect the ordering ». Source : PostgreSQL, Controlling Text Search Autrement dit : un mot rare et un mot courant pèsent pareil (pas d’IDF). Des éditeurs d’extensions le disent plus crûment (Tiger Data : « no inverse document frequency, so common words carry the same weight as rare ones », source), mais leur intérêt commercial est évident.

Sur quelques centaines de documents de même genre, l’effet est limité : on corrige l’essentiel avec la pondération par champ. Si le classement déçoit, trois voies, par ordre croissant d’empreinte :

  1. Extension BM25 pour PostgreSQL : pg_search de ParadeDB (bâti sur Tantivy, bibliothèque Rust analogue à Lucene) ou pg_textsearch (Tiger Data, implémentation C native). Les deux publient des benchmarks flatteurs sur des millions de lignes, non vérifiés ici et hors d’échelle pour notre besoin. Le coût, lui, est certain : une extension à installer, à mettre à jour avec PostgreSQL, et à faire accepter par l’hébergeur de la base. Source : ParadeDB, Source : Tiger Data
  2. Reclasser côté Node les 50 premiers résultats avec un scoring maison ou MiniSearch (section 7.1).
  3. Basculer sur SQLite FTS5, qui fournit bm25() en natif (section 5).

4.5 Tolérance aux fautes de frappe : pg_trgm

Le full-text natif cherche des lexèmes exacts : « kubernets » ne trouve rien. L’extension pg_trgm (distribuée avec PostgreSQL, « trusted » donc installable sans superutilisateur) découpe le texte en trigrammes (suites de 3 caractères) et mesure la similarité ; ses index GIN/GiST accélèrent aussi LIKE '%foo%', ILIKE et les expressions régulières. Source : PostgreSQL, pg_trgm

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX docs_title_trgm ON docs USING GIN (title gin_trgm_ops);

SELECT title FROM docs WHERE title % 'kubernets' ORDER BY similarity(title, 'kubernets') DESC LIMIT 5;

Je le réserve aux titres et tags (courts) : un index trigramme sur 40 Ko de corps par document est cher pour un gain faible. La documentation ne détaille pas le comportement sur les caractères accentués : à tester sur vos données avec show_trgm() avant de s’y fier.

4.6 Le piège unaccent dans les index et colonnes générées

La configuration fr ci-dessus règle les accents dans le full-text. Si vous voulez en plus un unaccent(title) dans un index d’expression ou une colonne générée (par exemple pour un LIKE insensible aux accents), la fonction à un argument n’est pas immuable et PostgreSQL la refuse. Le contournement courant est une fonction enveloppe déclarée IMMUTABLE :

CREATE FUNCTION f_unaccent(text) RETURNS text
  LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
  AS $$ SELECT public.unaccent('public.unaccent', $1) $$;

C’est une promesse faite à PostgreSQL : unaccent est STABLE (je l’ai vérifié dans pg_proc) parce que son résultat dépend d’un dictionnaire modifiable avec ALTER TEXT SEARCH DICTIONARY ; le contrat « immuable » n’est donc tenu que tant que personne ne touche aux règles, et il faut reconstruire les index concernés si on les modifie. Les discussions du projet sur le sujet sont réputées nuancées (voir ce fil sur postgrespro, que j’ai seulement repéré, pas lu en entier). Mises en œuvre : Neon, unaccent, Cybertec. À noter : la configuration fr de la section 4.1 n’a pas besoin de cette fonction enveloppe, elle fonctionne dans la colonne générée telle quelle (testé sur PostgreSQL 15.14) ; la fonction enveloppe ne sert que pour un unaccent() explicite.

4.7 Indexation incrémentale

L’indexation est rare et peut être lente : un script Node suffit, lancé par cron, systemd timer ou un hook de déploiement. Le principe : parcourir les .md, calculer un hash, n’écrire que ce qui a changé, supprimer ce qui a disparu.

// scripts/index-markdown.mjs — Node ≥ 20, dépendances : gray-matter, pg
import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import { join, relative } from "node:path";
import matter from "gray-matter";
import pg from "pg";

const ROOT = process.argv[2];
const db = new pg.Client({ connectionString: process.env.DATABASE_URL });
await db.connect();

async function* walk(dir) {
  for (const e of await readdir(dir, { withFileTypes: true })) {
    const p = join(dir, e.name);
    if (e.isDirectory()) yield* walk(p);
    else if (e.name.endsWith(".md")) yield p;
  }
}

// Titres de section : on ignore les lignes dans les blocs de code
function extract(markdown) {
  const headings = [];
  let inFence = false;
  const text = markdown
    .split("\n")
    .filter((l) => {
      if (l.startsWith("```")) {
        inFence = !inFence;
        return false;
      }
      if (inFence) return false; // le code n'entre pas dans l'index
      const m = /^#{1,6}\s+(.*)/.exec(l);
      if (m) headings.push(m[1]);
      return true;
    })
    .join("\n")
    .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") // [texte](url) → texte
    .replace(/[*_`>#~]/g, ""); // emphase et marqueurs
  return { headings: headings.join("\n"), body: text };
}

const seen = [];
for await (const file of walk(ROOT)) {
  const raw = await readFile(file, "utf8");
  const path = relative(ROOT, file);
  const sha256 = createHash("sha256").update(raw).digest("hex");
  const { data, content } = matter(raw);
  const { headings, body } = extract(content);
  seen.push(path);
  await db.query(
    `INSERT INTO docs (path, sha256, title, description, tags, headings, body)
     VALUES ($1,$2,$3,$4,$5,$6,$7)
     ON CONFLICT (path) DO UPDATE
       SET sha256 = EXCLUDED.sha256, title = EXCLUDED.title,
           description = EXCLUDED.description, tags = EXCLUDED.tags,
           headings = EXCLUDED.headings, body = EXCLUDED.body
     WHERE docs.sha256 IS DISTINCT FROM EXCLUDED.sha256`, // ne réécrit que les fichiers modifiés
    [
      path,
      sha256,
      data.title ?? null,
      data.description ?? null,
      (data.tags ?? []).map(String).join(" "),
      headings,
      body,
    ],
  );
}
await db.query("DELETE FROM docs WHERE NOT (path = ANY($1))", [seen]);
await db.end();

La colonne tsv étant générée, aucune étape d’indexation à part : l’UPSERT suffit. Le WHERE docs.sha256 IS DISTINCT FROM … évite de recalculer le tsvector des fichiers inchangés. Mes mesures : insertion + calcul des tsvector de 352 documents en ~1,5 s, création de l’index GIN en ~0,12 s, soit bien en dessous de tout seuil gênant pour une opération 1 à 5 fois par jour.


5. Option B : SQLite FTS5, la bibliothèque dans un fichier

SQLite fournit le module FTS5 (Full-Text Search version 5) : une table virtuelle qui indexe plusieurs colonnes, avec classement BM25 et pondération par colonne. L’ensemble tient dans un fichier, sans service. C’est l’option à privilégier si l’on ne veut pas toucher à la base PostgreSQL (par exemple pour isoler le contenu documentaire) ou si l’on veut un classement BM25 natif.

CREATE VIRTUAL TABLE docs USING fts5(
  title, description, tags, headings, body,
  path UNINDEXED,                                -- stocké mais non indexé
  tokenize = 'unicode61 remove_diacritics 2',    -- accents ignorés
  prefix   = '2 3'                               -- index de préfixes pour « kube* »
);

-- Poids : titre ×10, description ×5, tags ×10, titres de section ×4, corps ×1
SELECT path, title,
       snippet(docs, 4, '<mark>', '</mark>', '…', 20) AS extrait
FROM   docs
WHERE  docs MATCH 'serveur* mcp'
ORDER  BY bm25(docs, 10.0, 5.0, 10.0, 4.0, 1.0)
LIMIT  10;

Points confirmés par la documentation officielle :

  • Pondération par colonne : bm25(table, poids_col0, poids_col1, …), les colonnes sans poids valent 1,0 ; la fonction renvoie un score négatif, donc l’ORDER BY croissant par défaut renvoie les meilleurs d’abord. Les constantes k1 = 1,2 et b = 0,75 sont codées en dur.
  • Filtre par colonne : MATCH 'title : sqlite' limite la recherche à une colonne, ce qui répond directement à « chercher dans un en-tête précis » ; '{title body} : texte' en cible plusieurs.
  • Accents : le tokenizer unicode61 retire les diacritiques (valeur par défaut remove_diacritics 1) ; la valeur 2 corrige un défaut de la valeur 1 sur certains caractères latins rares.
  • Racinisation : le tokenizer porter est conçu pour l’anglais. Il n’existe pas de stemmer français intégré : sans lui, « serveurs » ne trouve pas « serveur » (on contourne avec des requêtes à préfixe serveur*, ce que je fais dans le benchmark, ou un tokenizer externe).
  • Sous-chaînes : le tokenizer trigram permet les recherches par sous-chaîne, mais les termes de moins de 3 caractères ne correspondent à rien.
  • Extraits : snippet() et highlight() sont natifs.
  • Maintenance : la commande INSERT INTO docs(docs) VALUES('optimize') fusionne les arbres d’index pour minimiser la taille et accélérer les requêtes, au prix d’un temps potentiellement long : sans importance ici.

Source : SQLite, FTS5

Taille : dans mon essai, le fichier fait 27,9 Mo pour 13,4 Mo de texte, parce que FTS5 stocke aussi le contenu des colonnes. Pour réduire l’empreinte, la documentation propose les tables « external content » (l’index ne contient pas de copie, il relit une table source, à synchroniser par triggers) et « contentless » (aucune copie, mais plus de snippet() possible).

Côté Node : le module node:sqlite est intégré à Node et FTS5 y était disponible dans ma version (Node 24.21). Des paquets comme better-sqlite3 jouent le même rôle sur d’autres versions.

Son compromis par rapport à PostgreSQL : une technologie de plus à sauvegarder et à déployer (le fichier .db), mais aucune dépendance à la base partagée, un bm25 natif, un snippet() plus rapide (~6 ms contre ~37 ms pour ts_headline dans mes mesures). L’index est un artefact jetable : on peut le reconstruire en entier à chaque indexation (moins d’une seconde ici) et remplacer le fichier de façon atomique (mv), ce qui élimine toute question de synchronisation.


6. Option C : un moteur de recherche dédié

Meilisearch, Typesense, Elasticsearch/OpenSearch… sont des services à part entière. Pour quelques centaines de documents, ils apportent la tolérance aux fautes de frappe, les facettes et un classement soigné, au prix d’un processus supplémentaire à exploiter. L’empreinte annoncée par Meilisearch pour son moteur est révélatrice : sa FAQ recommande « at least ten times the disk space of your raw dataset » comme point de départ, et indique que la vitesse de recherche dépend de la RAM disponible relativement à la taille de la base. Source : FAQ Meilisearch (édition communautaire sous licence MIT.) Je n’ai pas mesuré ces moteurs, et je n’avance donc aucun chiffre de latence pour eux.

Le critère d’arbitrage : ce qu’ils ajoutent n’est pas ce qui manque à notre besoin. Les besoins non couverts par PostgreSQL ou FTS5 (fautes de frappe, facettes complexes, recherche vectorielle) ne sont pas dans le cahier des charges. Je ne les retiendrais que si l’un d’eux apparaît.


7. Côté client : l’index voyage jusqu’au navigateur

Le principe : on construit l’index à l’indexation (rare, lente, peu importe), on le publie comme fichier statique, et le navigateur fait la recherche. Plus de requête réseau par frappe, plus de serveur de recherche, donc une latence perçue de quelques millisecondes. Le coût se déplace : l’index doit être téléchargé, chargé en mémoire, et il est lisible par quiconque peut le télécharger.

 INDEXATION (1 à 5×/jour)                      NAVIGATION
┌───────────┐   ┌────────────┐   ┌──────────┐   ┌─────────────────────────┐
│ *.md      │──▶│ script     │──▶│ index    │──▶│ navigateur télécharge   │
│ (fichiers)│   │ (build)    │   │ statique │   │ l'index, cherche en JS  │
└───────────┘   └────────────┘   └──────────┘   └─────────────────────────┘
                                  servi par Apache (fichier statique)

7.1 MiniSearch : l’index monolithique

MiniSearch est une bibliothèque JavaScript d’index en mémoire, sans dépendance, qui tourne dans Node comme dans le navigateur. Elle fournit recherche exacte, par préfixe, floue (distance d’édition), pondération des champs (boost) et auto-suggestion ; par défaut elle découpe sur les espaces et la ponctuation Unicode, met en minuscules, sans racinisation ni liste de mots vides (modifiable via processTerm). Source : MiniSearch · documentation

import MiniSearch from "minisearch";

// Mêmes options à la construction (serveur) et au chargement (navigateur) !
const options = {
  fields: ["title", "tags", "description", "headings", "body"],
  storeFields: ["path", "title"],
  // Normalisation des accents : MiniSearch ne le fait pas par défaut
  processTerm: (t) =>
    t
      .normalize("NFD")
      .replace(/\p{Diacritic}/gu, "")
      .toLowerCase(),
  searchOptions: {
    boost: { title: 4, tags: 4, description: 2, headings: 2 },
    prefix: true,
    fuzzy: 0.2,
    combineWith: "AND",
  },
};

// Indexation (Node)
const ms = new MiniSearch(options);
ms.addAll(docs); // docs : [{ id, path, title, … }]
await writeFile("public/search-index.json", JSON.stringify(ms));

// Navigateur
const json = await (await fetch("/search-index.json")).text();
const index = MiniSearch.loadJSON(json, options); // mêmes options obligatoires
index.search("serveurs mcp");

La documentation exige de repasser « the same options used to create the original instance » à loadJSON, et propose loadJSONAsync qui charge par lots pour ne pas bloquer le fil principal, mais « tends to be slower ». Source : MiniSearch, API

Mesures (corpus de 352 documents, 13,4 Mo de texte, ma machine) : index sérialisé de 3,9 Mo, soit 0,90 Mo en gzip et 0,54 Mo en Brotli, chargement en ~61 ms, recherche en ~0,04 ms. La recherche est donc instantanée ; ce qui compte est le téléchargement initial (0,5 à 0,9 Mo compressé) et le chargement en mémoire (le JSON est entièrement reconstruit en structures JavaScript). Sur un téléphone d’entrée de gamme, la phase de chargement sera plus longue que sur ma machine : mesurer sur la cible avant de s’engager.

7.2 Pagefind : l’index découpé en morceaux

Pagefind part du constat inverse : plutôt que de tout télécharger, il découpe l’index en fragments que le navigateur charge à la demande, selon les mots cherchés. Le site du projet annonce, pour 10 000 pages, une charge réseau totale sous 300 ko (bibliothèque comprise), et autour de 100 ko pour la plupart des sites ; c’est un chiffre d’éditeur, non mesuré ici. Source : Pagefind

Ce qui le rend pertinent pour ce cas :

  • Pas de serveur : après indexation, le bundle est un dossier de fichiers statiques. Source : Pagefind, docs
  • Français racinisé : le français fait partie des langues avec racinisation ; la langue est lue sur l’attribut lang de l’élément html, et un index séparé est construit par langue. Source : Pagefind, multilingue La page ne dit pas comment les accents sont traités : à tester.
  • Pondération native des titres : les balises h1 à h6 sont pondérées par défaut de 7,0 à 2,0 (le reste vaut 1,0), et l’attribut data-pagefind-weight accepte une valeur de 0,0 à 10,0 sur n’importe quel élément, sur une échelle quadratique (un poids de 2,0 compte environ 4 fois plus, 10,0 environ 100 fois). Source : Pagefind, pondération
  • Intégré à Astro : c’est le moteur de recherche par défaut de Starlight, le thème de documentation d’Astro, sans configuration. Source : Starlight, recherche Avec la migration vers Astro prévue, c’est une raison forte d’y regarder.

Limite à connaître : Pagefind indexe du HTML (« works with any set of static HTML files »). Pour des fichiers markdown bruts, deux chemins documentés dans son API Node : rendre le markdown en HTML puis index.addHTMLFile(), ou index.addCustomRecord({ url, content, language, meta, filters, sort }) pour du texte brut. Mon analyse (non vérifiée par essai) : avec addCustomRecord, il n’y a pas de balises h1/h2, donc pas de pondération par titre ; pour garder la pondération des en-têtes, rendre en HTML d’abord.

import * as pagefind from "pagefind";
import { marked } from "marked";

const { index } = await pagefind.createIndex();
for (const doc of docs) {
  // Le titre en <h1> reçoit le poids par défaut de 7.0
  const html = `<html lang="fr"><body data-pagefind-body>
    <h1>${doc.title}</h1>
    <p data-pagefind-weight="3">${doc.description}</p>
    ${marked.parse(doc.body)}
  </body></html>`;
  await index.addHTMLFile({ url: `/docs/${doc.path}`, content: html });
}
await index.writeFiles({ outputPath: "public/pagefind" });
await pagefind.close();

Source des fonctions : Pagefind, API Node. Pensez à échapper le HTML de doc.title et doc.description si leur origine n’est pas de confiance.

7.3 Autres bibliothèques

Orama, FlexSearch, Fuse.js et Lunr remplissent des rôles voisins. Un comparatif de presse spécialisée (source secondaire, sans méthodologie publiée) donne des ordres de grandeur de poids du code : Orama ~22 Ko, FlexSearch ~6 Ko, Fuse.js ~4 Ko. Source : PkgPulse Je n’ai pas évalué ces bibliothèques. Pour la taille du code de recherche, ces écarts sont secondaires devant la taille de l’index (0,5 à 0,9 Mo compressé pour MiniSearch dans mes mesures).

7.4 Compromis du côté client, explicités

CritèreServeur (PostgreSQL / FTS5)Client, index monolithique (MiniSearch)Client, index fragmenté (Pagefind)
Latence de frappeAller-retour réseau + ~1 ms de rechercheInstantanée (~0,04 ms ici)Instantanée après le premier fragment
Premier usageImmédiatTélécharger 0,5–0,9 Mo (compressé) puis charger en mémoireQuelques fragments à la demande (~100 ko annoncés)
Mémoire navigateurNulleIndex entier en RAMSeulement les fragments chargés
ConfidentialitéLe contrôle d’accès s’applique à chaque requêteL’index est le contenu : quiconque le télécharge peut tout lireIdem : fragments statiques
Droits par utilisateurFiltrage en SQLImpossible sans un index par profilImpossible sans un index par profil
FraîcheurImmédiate après indexationAu prochain téléchargement ; cache à invaliderIdem
Classementts_rank_cd (sans IDF) ou BM25 selon l’optionBM25, fuzzy, préfixe, boostPondération HTML, racinisation par langue
Fautes de frappeNon (natif) ; pg_trgm en complémentOui (fuzzy)À vérifier dans la documentation
Accentsunaccent dans la configurationÀ faire soi-même (processTerm)À tester
Empreinte serveurAucune de plus (Postgres)Un fichier statiqueUn dossier de fichiers statiques
Dépendance au framework frontAucune (appel HTTP)AucuneIntégré à Astro/Starlight
Hors-ligneNonOui, une fois l’index en cachePartiel (fragments en cache)

Deux points méritent d’être soulignés :

  1. La confidentialité est le critère éliminatoire. Si les documents sont accessibles à tous (comme les articles d’un blog), publier l’index ne divulgue rien de plus. S’ils sont restreints, un index côté client n’a de sens que derrière la même protection d’accès que les documents (par exemple une authentification Apache sur le dossier de l’index), et seulement si tous les utilisateurs ont les mêmes droits. Dès qu’il y a des droits par utilisateur, la recherche doit passer côté serveur.
  2. Le poids du premier chargement se paie à chaque nouvelle version de l’index. Avec un index monolithique, la moindre indexation (1 à 5 par jour) invalide le cache : chaque visiteur retélécharge 0,5 à 0,9 Mo. Pagefind limite la casse par ses fragments, dont seuls ceux qui changent sont à retélécharger en pratique si leurs noms dépendent du contenu : je n’ai pas vérifié ce point.

8. Mesures sur un corpus réel

Protocole

  • Corpus : les 44 articles de ce blog (src/content/posts/*.md), répétés 8 fois avec des identifiants distincts, soit 352 documents, 13,4 Mo de corps. Les articles sont longs (environ 39 Ko en moyenne) : c’est volontairement plus lourd qu’un document documentaire moyen.
  • Champs : title, description, body (frontmatter extrait avec gray-matter 4.0.3). Les tags et headings de la section 4 ne sont pas dans le benchmark.
  • Machine : Apple M2, macOS, Node 24.21.0. PostgreSQL 15.14 (Homebrew) local, shared_buffers = 32 MB, connexion par socket local. MiniSearch 7.2.0, pg 8.23.1.
  • Requêtes : 5 requêtes (kubernetes, cartes maplibre, orchestration paiement, decouvrabilite serveurs, markdown schemas) × 100 répétitions, après chargement. Latence mesurée dans le processus Node, aller-retour compris pour PostgreSQL.

Résultats

MéthodeRecherche (médiane)95ᵉ centileConstruction de l’indexTaille de l’index
Force brute JS (includes, en RAM)4,97 ms7,23 msn/an/a
MiniSearch (Node, préfixe + AND + boost)0,04 ms0,07 ms1 340 ms3,87 Mo JSON / 0,90 Mo gzip / 0,54 Mo Brotli
SQLite FTS5, requête seule (bm25 pondéré)0,21 ms0,35 ms329 ms + optimize27,9 Mo (fichier avec contenu)
SQLite FTS5, avec snippet()6,24 ms9,60 ms——
SQLite FTS5, filtre title : kubernetes0,06 ms0,07 ms——
PostgreSQL, ts_rank_cd + LIMIT 100,48 ms1,67 ms~1,5 s (insertion + tsvector), 120 ms (GIN)GIN 2,4 Mo ; table 15 Mo ; total 18 Mo
PostgreSQL, avec ts_headline sur 10 résultats36,80 ms50,99 ms——
PostgreSQL, filtre poids A (kubernetes:A)1,24 ms1,46 ms——

Ce que ces chiffres disent, et ce qu’ils ne disent pas

  • Lecture solide : toutes les recherches indexées sont sous 2 ms ; l’écart entre les moteurs est sans importance pour un budget de 50 ms. L’unique poste qui s’en approche est ts_headline.
  • Biais du corpus répété : dupliquer 8 fois les mêmes textes n’augmente pas le vocabulaire. Avec 352 textes réellement distincts, les index (GIN, MiniSearch) seraient plus gros et les recherches un peu plus lentes. Les tailles ci-dessus sont donc des bornes basses.
  • Biais de l’environnement : poste de développement, base chaude en cache, socket local sans latence réseau, un seul client. Une base de production partagée avec d’autres charges donnera des valeurs plus dispersées.
  • Non mesuré : navigateur (téléchargement, parsing, mémoire sur mobile), Pagefind, ripgrep, Meilisearch, extensions BM25, requêtes concurrentes, la mémoire du processus pour chaque option.
  • Comparer l’empreinte avec prudence : les 27,9 Mo de FTS5 contiennent une copie du texte, alors que l’index GIN de PostgreSQL (2,4 Mo) ne la contient pas (le texte est dans la table, déjà là de toute façon). Les deux ne mesurent pas la même chose.

Le script complet est en annexe.


9. Pièges et limitations connues

Le tsvector plafonne

Un tsvector (lexèmes + positions) doit rester sous 1 Mo, un lexème sous 2 Ko, les positions sont plafonnées à 16 383 (au-delà, elles ne sont plus distinguées) et chaque lexème garde au plus 256 positions. Source : PostgreSQL, Limitations Pour des documents ordinaires c’est sans objet (mes articles de ~39 Ko passent sans difficulté) ; cela concerne les fichiers volumineux collés dans un .md (journaux, exports) : to_tsvector échouerait à l’insertion et bloquerait l’UPSERT du script. Prévoir une troncature du corps ou un try/catch par fichier dans l’indexeur.

Les accents ne se règlent pas partout pareil

PostgreSQL : par la configuration (unaccent avant french_stem). SQLite : par le tokenizer (remove_diacritics 2). MiniSearch : par processTerm, à répliquer à l’identique à la requête et à l’indexation. Pagefind : non documenté sur les pages consultées. Un oubli donne l’effet le plus déroutant : « ça marche pour tout sauf mes mots accentués ».

Le français n’est pas l’anglais

porter (SQLite) est un stemmer anglais ; MiniSearch ne racinise pas par défaut ; PostgreSQL et Pagefind proposent le français. Les moteurs sans stemmer français exigent de compenser par des recherches par préfixe (serveur*), ce qui trouve « serveurs » mais pas toutes les formes verbales.

Le markdown pollue l’index s’il n’est pas nettoyé

Sans prétraitement, les URL, les blocs de code et les marqueurs d’emphase sont indexés comme du texte. Le script de la section 4.7 retire les blocs de code et les marqueurs ; c’est volontaire pour un corpus de prose, à inverser si les extraits de code doivent être cherchables.

ts_headline et le XSS

La sortie de ts_headline n’est pas garantie sûre contre le XSS. Si les documents ne sont pas écrits par vous, assainir (ou échapper puis réinjecter les seules balises <b>).

Index client périmé

Un index monolithique mis en cache par le navigateur ou par un proxy peut servir des résultats qui pointent vers des documents supprimés. Nommer le fichier avec son hash (search-index.3f9a.json) et le référencer depuis une petite page de métadonnées non cachée.

Les benchmarks d’éditeurs

Les chiffres de ParadeDB (« 20× plus rapide que tsquery/ts_rank sur 1 M de lignes »), de Tiger Data (« 2,4× à 6,5× plus rapide que ParadeDB à 138 M ») ou de Pagefind (« 300 ko pour 10 000 pages ») sont publiés par leurs auteurs, à des échelles sans rapport avec la nôtre, et je ne les ai pas reproduits.


10. Recommandation pour cette infrastructure

Hypothèses : quelques centaines de documents, indexation 1 à 5 fois par jour, PostgreSQL et Fastify en place, migration prévue de Next.js vers Astro. Opinion, justifiée par les mesures ci-dessus.

1. Par défaut : PostgreSQL FTS côté serveur (section 4). C’est la seule option à empreinte nulle en services : pas de processus de plus, pas de fichier à synchroniser, un index de quelques mégaoctets, une sauvegarde déjà couverte par celle de la base. Le frontmatter devient des colonnes, les poids A/B/C répondent à l’exigence « chercher aussi dans les en-têtes », et l’indexation est un UPSERT incrémental. Le front (Next.js aujourd’hui, Astro demain) n’en dépend pas : il appelle une route HTTP, ce qui fait de cette solution la plus indépendante de la migration.

// Fastify — route de recherche (extrait)
app.get(
  "/api/search",
  {
    schema: {
      querystring: {
        type: "object",
        required: ["q"],
        properties: { q: { type: "string", minLength: 2, maxLength: 200 } },
      },
    },
  },
  async (req) => {
    const { rows } = await app.pg.query(
      `SELECT path, title,
              ts_headline('fr', body, query, 'MaxFragments=1,MinWords=10,MaxWords=25') AS extrait
       FROM (SELECT path, title, body, query
             FROM docs, websearch_to_tsquery('fr', $1) AS query
             WHERE tsv @@ query
             ORDER BY ts_rank_cd(tsv, query) DESC
             LIMIT 5) top`,
      [req.query.q],
    );
    return rows;
  },
);

Côté Apache, rien de spécifique : un ProxyPass vers Fastify suffit. Les extraits sont à assainir avant affichage (section 9).

2. Si le classement ou les extraits déçoivent : SQLite FTS5 (section 5), reconstruit entièrement à chaque indexation, plutôt qu’une extension PostgreSQL. L’index est jetable et se remplace atomiquement ; bm25() et snippet() sont natifs et rapides.

3. Côté client : seulement si le contenu est public. Dans ce cas, et si la migration vers Astro se confirme, Pagefind (section 7.2) est le choix naturel : intégré à l’écosystème (défaut de Starlight), index fragmenté (donc premier chargement léger), pondération des titres native, français racinisé. MiniSearch (section 7.1) est préférable si l’on veut le contrôle fin (fuzzy, boosts, requêtes composées) sur un index qui reste petit (< 1 Mo compressé). Pour du contenu restreint ou à droits variables, rester côté serveur.

4. À ne pas faire pour ce besoin : déployer Meilisearch/Elasticsearch (empreinte disproportionnée, fonctionnalités inutilisées), ou chercher par LIKE '%mot%' sans index (aucune pertinence, aucune racinisation).

Tableau de décision

Votre situationChoixPourquoi
Postgres déjà là, contenu internePostgreSQL FTSEmpreinte nulle, accès contrôlé, indépendant du front
Besoin de BM25 / extraits rapides, sans toucher à la baseSQLite FTS5Un fichier, bm25 et snippet natifs
Contenu public, front AstroPagefindDéfaut de Starlight, index fragmenté
Contenu public, index petit, fuzzy vouluMiniSearchContrôle fin, recherche instantanée
Fautes de frappe sur titres/tags uniquementpg_trgm en complémentExtension fournie avec PostgreSQL
Millions de documents, facettes, vectorielMoteur dédié ou extension BM25Hors du périmètre de cet article

Ce que je vérifierais avant de m’engager

  1. La nature du contenu : public ou restreint ? C’est ce qui tranche entre côté client et côté serveur, avant toute considération de performance.
  2. Un benchmark sur vos vrais documents, pas sur les miens : leur taille moyenne détermine le coût de ts_headline, et leur vocabulaire la taille réelle de l’index.
  3. Le traitement des accents et du pluriel sur une dizaine de requêtes réelles de vos utilisateurs, dans l’option retenue.
  4. Pour Pagefind : le traitement des accents et le comportement du cache des fragments quand l’index change (deux points que je n’ai pas pu confirmer dans la documentation).
  5. Sur mobile : le temps de chargement d’un index MiniSearch de la taille de votre corpus.

Annexe : reproduire les mesures

Le script (bench.mjs) lit un dossier de .md, duplique le corpus, puis mesure force brute, MiniSearch, SQLite FTS5 (node:sqlite) et PostgreSQL, avec 100 répétitions par requête. Dépendances : minisearch, gray-matter, pg ; un PostgreSQL local avec l’extension unaccent.

# Hors du repo, pour ne pas y laisser node_modules
mkdir -p ~/dev/benchmarks-work/indexation-markdown && cd ~/dev/benchmarks-work/indexation-markdown
npm init -y && npm i minisearch gray-matter pg

# PostgreSQL jetable (macOS/Homebrew). Une locale valide est nécessaire au démarrage :
export LC_ALL=en_US.UTF-8
initdb -D pgdata -E UTF8 -A trust
pg_ctl -D pgdata -o "-p 54329 -c shared_buffers=32MB" -l pg.log start
createdb -p 54329 bench && psql -p 54329 bench -c "CREATE EXTENSION unaccent"

node --no-warnings bench.mjs
pg_ctl -D pgdata stop

Un piège rencontré en route : sans LC_ALL valide, le démarrage de PostgreSQL échoue avec « le postmaster est devenu multithreadé lors du démarrage » ; définir la variable d’environnement règle le problème.

Le script bench.mjs

import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
import { DatabaseSync } from "node:sqlite";
import matter from "gray-matter";
import MiniSearch from "minisearch";
import pg from "pg";

const DIR = "/Users/yco/dev/learn/src/content/posts";
const COPIES = 8;
const QUERIES = [
  "kubernetes",
  "cartes maplibre",
  "orchestration paiement",
  "decouvrabilite serveurs",
  "markdown schemas",
];
const N = 100;

const base = fs
  .readdirSync(DIR)
  .filter((f) => f.endsWith(".md"))
  .map((f) => {
    const { data, content } = matter(
      fs.readFileSync(path.join(DIR, f), "utf8"),
    );
    return {
      path: f,
      title: String(data.title ?? ""),
      description: String(data.description ?? ""),
      body: content,
    };
  });
const docs = [];
for (let c = 0; c < COPIES; c++)
  base.forEach((d, i) =>
    docs.push({ id: c * base.length + i + 1, ...d, path: `${c}/${d.path}` }),
  );
const bytes = docs.reduce((s, d) => s + Buffer.byteLength(d.body), 0);
console.log(
  `corpus: ${docs.length} docs, ${(bytes / 1e6).toFixed(1)} MB de corps`,
);

const strip = (s) =>
  s
    .normalize("NFD")
    .replace(/\p{Diacritic}/gu, "")
    .toLowerCase();
const median = (a) => a.sort((x, y) => x - y)[Math.floor(a.length / 2)];
const p95 = (a) => a.sort((x, y) => x - y)[Math.floor(a.length * 0.95)];
async function time(fn) {
  const t = [];
  for (let i = 0; i < N; i++)
    for (const q of QUERIES) {
      const s = performance.now();
      await fn(q);
      t.push(performance.now() - s);
    }
  return `médiane ${median(t).toFixed(2)} ms, p95 ${p95(t).toFixed(2)} ms`;
}

// 1. force brute en mémoire
const haystack = docs.map((d) =>
  strip(`${d.title} ${d.description} ${d.body}`),
);
console.log(
  "brute JS includes :",
  await time((q) => {
    const ts = strip(q).split(" ");
    return haystack.filter((h) => ts.every((t) => h.includes(t))).length;
  }),
);

// 2. MiniSearch
let s = performance.now();
const ms = new MiniSearch({
  fields: ["title", "description", "body"],
  storeFields: ["path", "title"],
  processTerm: (t) => strip(t),
  searchOptions: {
    boost: { title: 3, description: 2 },
    prefix: true,
    combineWith: "AND",
  },
});
ms.addAll(docs);
console.log(`MiniSearch build: ${(performance.now() - s).toFixed(0)} ms`);
const json = JSON.stringify(ms);
console.log(
  `MiniSearch index JSON: ${(json.length / 1e6).toFixed(2)} MB, gzip ${(zlib.gzipSync(json).length / 1e6).toFixed(2)} MB, brotli ${(zlib.brotliCompressSync(json).length / 1e6).toFixed(2)} MB`,
);
s = performance.now();
const ms2 = MiniSearch.loadJSON(json, {
  fields: ["title", "description", "body"],
  storeFields: ["path", "title"],
  processTerm: (t) => strip(t),
  searchOptions: {
    boost: { title: 3, description: 2 },
    prefix: true,
    combineWith: "AND",
  },
});
console.log(`MiniSearch loadJSON: ${(performance.now() - s).toFixed(0)} ms`);
console.log("MiniSearch query :", await time((q) => ms2.search(q)));
console.log(
  `MiniSearch RSS heap ~ ${(process.memoryUsage().heapUsed / 1e6).toFixed(0)} MB (process entier)`,
);

// 3. SQLite FTS5
const dbf = "/tmp/idxbench/fts.db";
for (const f of [dbf, dbf + "-wal", dbf + "-shm"])
  fs.rmSync(f, { force: true });
const db = new DatabaseSync(dbf);
db.exec(
  `CREATE VIRTUAL TABLE d USING fts5(title, description, body, path UNINDEXED, tokenize='unicode61 remove_diacritics 2', prefix='2 3')`,
);
s = performance.now();
db.exec("BEGIN");
const ins = db.prepare(
  "INSERT INTO d(rowid,title,description,body,path) VALUES (?,?,?,?,?)",
);
for (const d of docs) ins.run(d.id, d.title, d.description, d.body, d.path);
db.exec("COMMIT");
console.log(`FTS5 build: ${(performance.now() - s).toFixed(0)} ms`);
db.exec(`INSERT INTO d(d) VALUES('optimize')`);
console.log(
  `FTS5 fichier: ${(fs.statSync(dbf).size / 1e6).toFixed(1)} MB (contenu inclus)`,
);
const sel = db.prepare(
  `SELECT path, title, snippet(d, 2, '<b>', '</b>', '…', 20) AS s FROM d WHERE d MATCH ? ORDER BY bm25(d, 10.0, 5.0, 1.0) LIMIT 10`,
);
console.log(
  "FTS5 query + snippet :",
  await time((q) =>
    sel.all(
      q
        .split(" ")
        .map((t) => `${strip(t)}*`)
        .join(" "),
    ),
  ),
);
const selNo = db.prepare(
  `SELECT path FROM d WHERE d MATCH ? ORDER BY bm25(d, 10.0, 5.0, 1.0) LIMIT 10`,
);
console.log(
  "FTS5 query seule :",
  await time((q) =>
    selNo.all(
      q
        .split(" ")
        .map((t) => `${strip(t)}*`)
        .join(" "),
    ),
  ),
);
console.log(
  "FTS5 filtre titre :",
  await time(() => selNo.all("title : kubernetes")),
);

// 4. PostgreSQL
const c = new pg.Client({ port: 54329, database: "bench" });
await c.connect();
await c.query(`
DROP TABLE IF EXISTS docs;
CREATE OR REPLACE FUNCTION f_unaccent(text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT AS $$ SELECT public.unaccent('public.unaccent', $1) $$;
DROP TEXT SEARCH CONFIGURATION IF EXISTS fr;
CREATE TEXT SEARCH CONFIGURATION fr (COPY = french);
ALTER TEXT SEARCH CONFIGURATION fr ALTER MAPPING FOR hword, hword_part, word WITH unaccent, french_stem;
CREATE TABLE docs (id int PRIMARY KEY, path text, title text, description text, body text,
  tsv tsvector GENERATED ALWAYS AS (
    setweight(to_tsvector('fr', coalesce(title,'')), 'A') ||
    setweight(to_tsvector('fr', coalesce(description,'')), 'B') ||
    setweight(to_tsvector('fr', coalesce(body,'')), 'C')) STORED);
`);
s = performance.now();
for (const d of docs)
  await c.query(
    "INSERT INTO docs(id,path,title,description,body) VALUES ($1,$2,$3,$4,$5)",
    [d.id, d.path, d.title, d.description, d.body],
  );
console.log(`PG insert + tsvector: ${(performance.now() - s).toFixed(0)} ms`);
s = performance.now();
await c.query("CREATE INDEX docs_tsv ON docs USING GIN (tsv)");
console.log(`PG CREATE INDEX GIN: ${(performance.now() - s).toFixed(0)} ms`);
await c.query("VACUUM ANALYZE docs");
const sz = (
  await c.query(
    `SELECT pg_size_pretty(pg_relation_size('docs_tsv')) gin, pg_size_pretty(pg_table_size('docs')) tbl, pg_size_pretty(pg_total_relation_size('docs')) tot`,
  )
).rows[0];
console.log("PG tailles:", JSON.stringify(sz));
const pgq = (q) =>
  c.query(
    `SELECT path, title, ts_rank_cd(tsv, query) r FROM docs, websearch_to_tsquery('fr', $1) query WHERE tsv @@ query ORDER BY r DESC LIMIT 10`,
    [q],
  );
console.log("PG query (rank_cd) :", await time(pgq));
const pgh = (q) =>
  c.query(
    `SELECT path, ts_headline('fr', body, query, 'MaxFragments=1,MaxWords=25,MinWords=10') h FROM (SELECT * FROM docs, websearch_to_tsquery('fr', $1) query WHERE tsv @@ query ORDER BY ts_rank_cd(tsv, query) DESC LIMIT 10) x`,
    [q],
  );
console.log("PG query + ts_headline :", await time(pgh));
const pgt = (q) =>
  c.query(
    `SELECT path FROM docs WHERE tsv @@ to_tsquery('fr', 'kubernetes:A')`,
  );
console.log("PG filtre titre (poids A) :", await time(pgt));
await c.end();

Le script de la section 4.7 (index-markdown.mjs) a été testé de bout en bout sur ce même dossier : 45 fichiers écrits à la première passe, 0 à la seconde (indexation incrémentale), et la requête « decouvrabilite serveurs » (sans accents) retrouve bien l’article sur la découvrabilité des serveurs MCP.