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_headlinedans 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
tsvectorgé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 compte | Où 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 racine | Configuration linguistique (français ≠ anglais) |
| Syntaxe markdown dans le corps | **gras**, URL, blocs de code polluent l’index | Pré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 :
- Extension BM25 pour PostgreSQL :
pg_searchde ParadeDB (bâti sur Tantivy, bibliothèque Rust analogue à Lucene) oupg_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 - Reclasser côté Node les 50 premiers résultats avec un scoring maison ou MiniSearch (section 7.1).
- 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 BYcroissant 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
unicode61retire les diacritiques (valeur par défautremove_diacritics 1) ; la valeur2corrige un défaut de la valeur 1 sur certains caractères latins rares. - Racinisation : le tokenizer
porterest 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éfixeserveur*, ce que je fais dans le benchmark, ou un tokenizer externe). - Sous-chaînes : le tokenizer
trigrampermet les recherches par sous-chaîne, mais les termes de moins de 3 caractères ne correspondent à rien. - Extraits :
snippet()ethighlight()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
langde l’élémenthtml, 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àh6sont pondérées par défaut de 7,0 à 2,0 (le reste vaut 1,0), et l’attributdata-pagefind-weightaccepte 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ère | Serveur (PostgreSQL / FTS5) | Client, index monolithique (MiniSearch) | Client, index fragmenté (Pagefind) |
|---|---|---|---|
| Latence de frappe | Aller-retour réseau + ~1 ms de recherche | Instantanée (~0,04 ms ici) | Instantanée après le premier fragment |
| Premier usage | Immédiat | Télécharger 0,5–0,9 Mo (compressé) puis charger en mémoire | Quelques fragments à la demande (~100 ko annoncés) |
| Mémoire navigateur | Nulle | Index entier en RAM | Seulement les fragments chargés |
| Confidentialité | Le contrôle d’accès s’applique à chaque requête | L’index est le contenu : quiconque le télécharge peut tout lire | Idem : fragments statiques |
| Droits par utilisateur | Filtrage en SQL | Impossible sans un index par profil | Impossible sans un index par profil |
| Fraîcheur | Immédiate après indexation | Au prochain téléchargement ; cache à invalider | Idem |
| Classement | ts_rank_cd (sans IDF) ou BM25 selon l’option | BM25, fuzzy, préfixe, boost | Pondération HTML, racinisation par langue |
| Fautes de frappe | Non (natif) ; pg_trgm en complément | Oui (fuzzy) | À vérifier dans la documentation |
| Accents | unaccent dans la configuration | À faire soi-même (processTerm) | À tester |
| Empreinte serveur | Aucune de plus (Postgres) | Un fichier statique | Un dossier de fichiers statiques |
| Dépendance au framework front | Aucune (appel HTTP) | Aucune | Intégré à Astro/Starlight |
| Hors-ligne | Non | Oui, une fois l’index en cache | Partiel (fragments en cache) |
Deux points méritent d’être soulignés :
- 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.
- 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 avecgray-matter4.0.3). Lestagsetheadingsde 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,pg8.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éthode | Recherche (médiane) | 95ᵉ centile | Construction de l’index | Taille de l’index |
|---|---|---|---|---|
Force brute JS (includes, en RAM) | 4,97 ms | 7,23 ms | n/a | n/a |
| MiniSearch (Node, préfixe + AND + boost) | 0,04 ms | 0,07 ms | 1 340 ms | 3,87 Mo JSON / 0,90 Mo gzip / 0,54 Mo Brotli |
SQLite FTS5, requête seule (bm25 pondéré) | 0,21 ms | 0,35 ms | 329 ms + optimize | 27,9 Mo (fichier avec contenu) |
SQLite FTS5, avec snippet() | 6,24 ms | 9,60 ms | — | — |
SQLite FTS5, filtre title : kubernetes | 0,06 ms | 0,07 ms | — | — |
PostgreSQL, ts_rank_cd + LIMIT 10 | 0,48 ms | 1,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ésultats | 36,80 ms | 50,99 ms | — | — |
PostgreSQL, filtre poids A (kubernetes:A) | 1,24 ms | 1,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 situation | Choix | Pourquoi |
|---|---|---|
| Postgres déjà là, contenu interne | PostgreSQL FTS | Empreinte nulle, accès contrôlé, indépendant du front |
| Besoin de BM25 / extraits rapides, sans toucher à la base | SQLite FTS5 | Un fichier, bm25 et snippet natifs |
| Contenu public, front Astro | Pagefind | Défaut de Starlight, index fragmenté |
| Contenu public, index petit, fuzzy voulu | MiniSearch | Contrôle fin, recherche instantanée |
| Fautes de frappe sur titres/tags uniquement | pg_trgm en complément | Extension fournie avec PostgreSQL |
| Millions de documents, facettes, vectoriel | Moteur dédié ou extension BM25 | Hors du périmètre de cet article |
Ce que je vérifierais avant de m’engager
- 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.
- 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. - Le traitement des accents et du pluriel sur une dizaine de requêtes réelles de vos utilisateurs, dans l’option retenue.
- 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).
- 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.