Aller au contenu principal

Module 11 — Modéliser un graphe et le charger : contraintes, index, LOAD CSV

Le mini-graphe de l'équipe Veille tient sur onze nœuds : parfait pour comprendre Cypher, insuffisant pour la production. Inès demande à Sami de charger le vrai corpus, les 200 853 articles du jeu News, dans Neo4j pour que Karim puisse ensuite écrire la recommandation. Ce module lui apprend à passer d'une table plate (news.csv) à un graphe pertinent, à poser les contraintes et les index dans le bon ordre, et à charger le tout en vingt-cinq secondes avec LOAD CSV WITH HEADERS et CALL { } IN TRANSACTIONS.

De la table au graphe : trois questions

Un fichier CSV liste des articles avec leurs colonnes ; un graphe met en relation des entités. Le passage se fait avec trois questions qu'on pose à chaque colonne.

  • Est-ce qu'on va la filtrer ou la traverser ? Si oui, elle mérite de devenir un nœud. Une catégorie qu'on va lister, filtrer et suivre est un nœud ; un identifiant qu'on ne regarde jamais reste une propriété.
  • Est-ce qu'elle relie deux entités ? Alors c'est une relation. « L'article X est publié dans la catégorie Y » n'est pas une propriété, c'est une relation PUBLIE_DANS.
  • Est-ce qu'on veut simplement l'afficher ? Elle reste une propriété du nœud auquel elle appartient. Le titre, la date et le lien de l'article sont des propriétés : on ne recherche pas « tous les articles avec exactement ce titre », on les affiche une fois l'article trouvé.

Deux conventions de nommage rendent le graphe lisible.

  • Étiquettes : un mot, au singulier, en PascalCase — Article, Categorie, Auteur. Jamais Articles, jamais article.
  • Relations : en majuscules, souvent un verbe conjugué à la troisième personne — PUBLIE_DANS, ECRIT_PAR, HABITE. Le sens de la flèche va du sujet vers le complément : « l'article est publié dans la catégorie », donc (:Article)-[:PUBLIE_DANS]->(:Categorie).

Ces conventions ne sont pas décoratives : elles font que le motif Cypher se lit comme une phrase. MATCH (a:Article)-[:ECRIT_PAR]->(au:Auteur) se lit sans effort « l'article a est écrit par l'auteur au ». C'est le premier gain sur SQL.

Le modèle Veille

Trois entités, deux relations, aucune ambiguïté.

(:Article {id, titre, date, lien})-[:PUBLIE_DANS]->(:Categorie {nom})
(:Article)-[:ECRIT_PAR]->(:Auteur {nom})

Chaque article a une catégorie (relation obligatoire) et zéro, un ou plusieurs auteurs (relation optionnelle et multiple). Cinq propriétés au total : id et nom servent de clés d'unicité, titre, date et lien sont là pour l'affichage et le tri. Rien de plus. On ne dupliquera pas le titre sur l'auteur, ni la catégorie sur l'article : le graphe le fait pour nous.

Pourquoi séparer Categorie du champ category de l'article ?

Dans Elasticsearch, category reste une chaîne keyword sur le document : c'est l'objet qu'on cherche. Dans Neo4j, on veut lister les catégories, en compter les articles, sauter d'un auteur à ses catégories favorites — donc en faire un nœud. Les deux moteurs ne modélisent pas la même chose parce qu'ils ne répondent pas à la même famille de questions.

Le fichier news.csv, prérequis du chargement

Le script Cypher ne télécharge rien : il lit un fichier CSV déjà présent sur le disque du conteneur Neo4j. Ce fichier est écrit par l'importateur Elasticsearch (module 3) au moment où il indexe les 200 853 documents.

  • Volume hôte : kits/42-elasticsearch-neo4j/neo4j/import/news.csv.
  • Volume conteneur : /import/news.csv, chemin file:///news.csv dans LOAD CSV.
  • Écrit par : ./lab.sh import-news, en même temps qu'il envoie les documents dans l'index news.
  • Colonnes (dans cet ordre, avec en-tête) : id, headline, category, authors, date, link.
Il faut avoir lancé import-news avant

Sans le fichier news.csv, LOAD CSV échoue avec « Couldn't load the external resource ». La séquence correcte est toujours ./lab.sh up, puis ./lab.sh import-news (qui écrit le CSV), puis seulement ./lab.sh cypher 11-charger-news.cypher. Si le CSV manque, l'erreur pointe vers le répertoire /import monté dans le conteneur : ce n'est pas Cypher qui est cassé, c'est l'ordre des étapes.

Le script 11-charger-news.cypher, bloc par bloc

Le kit livre neo4j/cypher/11-charger-news.cypher. On l'exécute en une commande.

./lab.sh cypher 11-charger-news.cypher

Sur une machine correctement dimensionnée, le script termine en vingt-cinq secondes environ. Passons-le au peigne fin.

Bloc 1 — contraintes et index, avant les données

CREATE CONSTRAINT article_id IF NOT EXISTS FOR (a:Article) REQUIRE a.id IS UNIQUE;
CREATE CONSTRAINT categorie_nom IF NOT EXISTS FOR (c:Categorie) REQUIRE c.nom IS UNIQUE;
CREATE CONSTRAINT auteur_nom IF NOT EXISTS FOR (au:Auteur) REQUIRE au.nom IS UNIQUE;
CREATE INDEX article_date IF NOT EXISTS FOR (a:Article) ON (a.date);

Trois contraintes d'unicité, une par étiquette, sur la propriété qui identifie l'entité (id pour un article, nom pour une catégorie et un auteur). Une contrainte d'unicité crée implicitement un index sur la même propriété : le futur MERGE (a:Article {id: 42}) deviendra une recherche par clé, pas un scan complet. Sans ces contraintes, chaque MERGE sur les 200 853 lignes vérifierait l'existence par un balayage, et le chargement prendrait plusieurs heures au lieu de vingt-cinq secondes.

L'index séparé sur Article.date n'est pas exigé par le chargement : il prépare le module 12, où on filtrera fréquemment par période. IF NOT EXISTS rend l'ensemble idempotent — relancer le script est sans effet si tout est déjà en place.

Les contraintes viennent avant les données

La règle est absolue pour un chargement massif : d'abord la contrainte, ensuite le MERGE. Poser la contrainte après coup fonctionne mais oblige Neo4j à valider a posteriori sur des millions de lignes. La règle inverse — « d'abord les données, ensuite les index » — vient du monde SQL et ne s'applique pas à Neo4j.

Bloc 2 — articles et catégories, par lots de 5 000 lignes

LOAD CSV WITH HEADERS FROM 'file:///news.csv' AS ligne
CALL {
WITH ligne
MERGE (a:Article {id: toInteger(ligne.id)})
SET a.titre = ligne.headline,
a.date = date(ligne.date),
a.lien = ligne.link
MERGE (c:Categorie {nom: ligne.category})
MERGE (a)-[:PUBLIE_DANS]->(c)
} IN TRANSACTIONS OF 5000 ROWS;

Sept lignes, trois idées.

D'abord, LOAD CSV WITH HEADERS lit le fichier et transforme chaque ligne en une carte (map) dont les clés sont les en-têtes. Chaque champ est une chaîne : c'est pour cela qu'on écrit toInteger(ligne.id) (l'unicité passe par un entier, pas par la chaîne "42"), et date(ligne.date) (le format ISO yyyy-MM-dd est reconnu par la fonction date(), qui crée un vrai type temporel qu'on pourra comparer avec <, ordonner et indexer).

Ensuite, la sous-requête CALL { ... } isole le travail à exécuter pour chaque ligne. Elle contient trois MERGE : le nœud Article (créé s'il n'existe pas, sinon complété avec SET), le nœud Categorie, et enfin la relation entre les deux. MERGE sur la relation garantit qu'un même article ne sera pas rattaché deux fois à sa catégorie même si on relance le script.

Enfin, IN TRANSACTIONS OF 5000 ROWS demande à Neo4j de commiter tous les 5 000 lignes plutôt que de faire une seule transaction géante. Ce lot est un compromis : trop petit, le surcoût de transaction domine ; trop grand, la mémoire explose. 5 000 est une valeur qui passe partout sur un poste avec 2 Go de heap.

Bloc 3 — auteurs, avec découpage des co-signatures

Les articles du jeu News mélangent parfois plusieurs auteurs dans un même champ, avec deux séparateurs : la virgule ("Lee Moran, Ron Dicker") et le mot « and » ("Lee Moran and Ron Dicker"). Il faut découper.

LOAD CSV WITH HEADERS FROM 'file:///news.csv' AS ligne
WITH ligne WHERE ligne.authors <> ''
CALL {
WITH ligne
MATCH (a:Article {id: toInteger(ligne.id)})
UNWIND [x IN split(replace(ligne.authors, ' and ', ', '), ', ') WHERE trim(x) <> ''] AS nom
MERGE (au:Auteur {nom: trim(nom)})
MERGE (a)-[:ECRIT_PAR]->(au)
} IN TRANSACTIONS OF 5000 ROWS;

Deux nouveautés par rapport au bloc précédent.

Le filtre WHERE ligne.authors <> '' ignore les articles sans auteur (environ 8 % du corpus) : on ne crée pas d'auteur vide. Il est important qu'il soit à l'extérieur du CALL { ... }, avant IN TRANSACTIONS, sinon Neo4j refuse la syntaxe.

Le traitement des co-auteurs tient en une ligne : replace(ligne.authors, ' and ', ', ') uniformise les séparateurs, split(..., ', ') produit une liste, la compréhension [x IN ... WHERE trim(x) <> ''] filtre les entrées vides, et UNWIND re-déroule la liste en une ligne par auteur. Pour chaque nom, MERGE (au:Auteur {nom: trim(nom)}) crée l'auteur s'il n'existe pas, et MERGE (a)-[:ECRIT_PAR]->(au) la relation avec l'article. trim(nom) élimine les espaces parasites (« Lee Moran » et « Lee Moran » sont le même auteur, pas deux).

Le MATCH (a:Article {id: toInteger(ligne.id)}) fonctionne parce que le bloc précédent a déjà créé tous les articles. Si l'ordre des deux blocs était inversé, le MATCH échouerait silencieusement et aucune relation ne serait créée.

Bloc 4 — le bilan

MATCH (a:Article)   WITH count(a) AS articles
MATCH (c:Categorie) WITH articles, count(c) AS categories
MATCH (au:Auteur) RETURN articles, categories, count(au) AS auteurs;

La chaîne MATCH ... WITH ... MATCH ... compte chaque étiquette en isolant les scopes : WITH articles porte le compteur précédent dans le nouveau scope. À l'arrivée :

articles | categories | auteurs
---------+------------+--------
200853 | 41 | 23082

Le kit a été testé de bout en bout : ces trois chiffres doivent apparaître à l'identique dans votre console. S'ils diffèrent, une seule cause : news.csv a été régénéré avec NEWS_LIMIT non nul, ou le chargement s'est interrompu en cours. ./lab.sh cypher 99-reset.cypher puis relance.

Neo4j Browser : le préfixe :auto obligatoire

Le script marche parfaitement quand on le lance depuis le terminal via ./lab.sh cypher 11-charger-news.cypher. Mais si on colle le bloc 2 directement dans Neo4j Browser (http://localhost:7474) et qu'on lance, Neo4j répond :

A query with 'CALL { ... } IN TRANSACTIONS' can only be executed
in an implicit transaction, but tried to execute in an explicit transaction.

La raison : par défaut, Neo4j Browser enveloppe chaque requête dans une transaction explicite (le begin / commit invisible). Or CALL { } IN TRANSACTIONS gère ses propres commits par lots ; les deux régimes sont incompatibles. Il faut donc préfixer la requête par :auto pour dire à Browser de laisser la sous-requête gérer.

:auto LOAD CSV WITH HEADERS FROM 'file:///news.csv' AS ligne
CALL {
WITH ligne
MERGE (a:Article {id: toInteger(ligne.id)})
SET a.titre = ligne.headline
MERGE (c:Categorie {nom: ligne.category})
MERGE (a)-[:PUBLIE_DANS]->(c)
} IN TRANSACTIONS OF 5000 ROWS;

./lab.sh cypher passe par cypher-shell avec l'option -f, qui ouvre déjà une transaction implicite : pas besoin de :auto là-bas. C'est un des cas où le kit rend service silencieusement.

:auto uniquement dans Browser

:auto est une commande du client Browser, pas du langage Cypher. Elle disparaît dans cypher-shell et dans les drivers (Python, Java, JS) parce que ces clients contrôlent déjà le mode de transaction.

Vérifier le graphe

Trois requêtes utiles pour valider le chargement et repérer les anomalies avant de passer au module 12.

Statistiques globales avec APOC — nombre de nœuds et de relations par étiquette :

CALL apoc.meta.stats() YIELD labels, relTypes, nodeCount, relCount
RETURN nodeCount, relCount, labels, relTypes;

labels renvoie une carte {Article: 200853, Categorie: 41, Auteur: 23082} et relTypes un décompte par type de relation. C'est le contrôle rapide qui remplace un SHOW STATS : deux secondes, tout le graphe.

Le schéma sous forme visuelle — Neo4j Browser dessine les nœuds et les relations avec :

CALL db.schema.visualization();

Attendu : trois cercles (Article, Categorie, Auteur) reliés par deux flèches (PUBLIE_DANS, ECRIT_PAR). Si vous voyez des étiquettes en trop (par exemple Personne d'un ancien script), c'est que la base n'a pas été remise à zéro.

Contraintes et index — la liste exhaustive :

SHOW CONSTRAINTS;
SHOW INDEXES;

Vous devez trouver article_id, categorie_nom, auteur_nom (contraintes d'unicité) et article_date (index). Chaque contrainte apparaît aussi dans SHOW INDEXES puisqu'elle en crée un implicitement.

Remettre à zéro : 99-reset.cypher

Une manipulation ratée arrive à tout le monde. Le script de reset est court et utilise APOC pour éviter les pièges.

./lab.sh cypher 99-reset.cypher

Son contenu :

CALL apoc.periodic.iterate(
'MATCH (n) RETURN n',
'DETACH DELETE n',
{batchSize: 10000, parallel: false}
) YIELD batches, total
RETURN batches AS lots, total AS noeuds_supprimes;

CALL apoc.schema.assert({}, {}, true) YIELD label, key, action
RETURN label, key, action;

apoc.periodic.iterate prend une requête productrice (MATCH (n) RETURN n) et une requête consommatrice (DETACH DELETE n), qu'elle applique par lots de 10 000 : chaque lot est commité, la mémoire ne monte jamais. parallel: false garde l'ordre et évite les verrouillages sur les mêmes relations. Sans APOC, il faudrait écrire soi-même une boucle avec CALL { } IN TRANSACTIONS.

La seconde procédure, apoc.schema.assert({}, {}, true) avec le troisième argument à true, supprime toutes les contraintes et index existants. À la sortie, la base est vide de données et de schéma : parfait pour repartir. Sans elle, relancer 11-charger-news.cypher réutiliserait les contraintes déjà là — ce qui est correct mais empêche de tester le chemin « installation neuve ».

À vous

Exercice 1 — Écrivez la requête Cypher qui liste les cinq catégories comptant le plus d'articles, avec leur nombre.

Solution
MATCH (a:Article)-[:PUBLIE_DANS]->(c:Categorie)
RETURN c.nom AS categorie, count(a) AS articles
ORDER BY articles DESC
LIMIT 5;

Attendu : POLITICS 32 739, WELLNESS 17 827, ENTERTAINMENT 16 058, TRAVEL 9 887, STYLE & BEAUTY 9 649. Les mêmes chiffres qu'Elasticsearch en terms, obtenus par une traversée directe des relations.

Exercice 2 — Trouvez les articles publiés en 2018 dans la catégorie POLITICS, triés du plus récent au plus ancien, limités à cinq.

Solution
MATCH (a:Article)-[:PUBLIE_DANS]->(:Categorie {nom: 'POLITICS'})
WHERE a.date >= date('2018-01-01') AND a.date <= date('2018-12-31')
RETURN a.titre, a.date
ORDER BY a.date DESC
LIMIT 5;

Le filtre a.date >= date('2018-01-01') bénéficie de l'index article_date posé au bloc 1 : la requête part directement sur la plage attendue au lieu de scanner les 200 853 articles.

Exercice 3 — Comptez les auteurs qui ont co-signé au moins un article avec Lee Moran (auteurs distincts, en excluant Lee Moran lui-même).

Solution
MATCH (lee:Auteur {nom: 'Lee Moran'})<-[:ECRIT_PAR]-(a:Article)-[:ECRIT_PAR]->(autre:Auteur)
WHERE autre <> lee
RETURN count(DISTINCT autre) AS co_auteurs;

Le motif (lee)<-[:ECRIT_PAR]-(a)-[:ECRIT_PAR]->(autre) remonte les articles signés par Lee, puis redescend vers leurs autres auteurs. count(DISTINCT ...) évite les doublons quand deux auteurs partagent plusieurs articles. (Votre chiffre peut différer légèrement selon le nettoyage des noms.)

Points à retenir

  • Une entité qu'on filtre ou qu'on traverse devient un nœud ; une entité qu'on affiche reste une propriété.
  • Étiquettes au singulier en PascalCase (Article), relations en MAJUSCULES_VERBE (PUBLIE_DANS).
  • Les contraintes d'unicité viennent avant les données : elles créent un index gratuit et rendent un MERGE massif viable (25 s au lieu de plusieurs heures).
  • LOAD CSV WITH HEADERS FROM 'file:///...' lit depuis le volume /import monté sur neo4j/import/ — donc ./lab.sh import-news est un prérequis strict.
  • CALL { ... } IN TRANSACTIONS OF 5000 ROWS commite par lots ; dans Neo4j Browser, il faut préfixer par :auto, pas dans ./lab.sh cypher.
  • Le graphe Veille compte 200 853 articles, 41 catégories, 23 082 auteurs — trois chiffres à retenir pour la suite.
  • apoc.periodic.iterate + apoc.schema.assert = reset propre en une commande (99-reset.cypher).

Si ça ne marche pas

  • LOAD CSV renvoie « Couldn't load the external resource » → le fichier news.csv n'est pas dans neo4j/import/ → lancer ./lab.sh import-news puis relancer ./lab.sh cypher 11-charger-news.cypher.
  • Erreur « A query with CALL { ... } IN TRANSACTIONS can only be executed in an implicit transaction » → vous êtes dans Neo4j Browser → préfixer la requête par :auto, ou passer par ./lab.sh cypher 11-charger-news.cypher.
  • Le chargement prend plusieurs minutes et ne finit pas → les contraintes n'ont pas été créées avant les MERGE./lab.sh cypher 99-reset.cypher puis relancer 11-charger-news.cypher dans l'ordre.
  • apoc.periodic.iterate renvoie « Unknown procedure » → le conteneur neo4j a démarré sans APOC → ./lab.sh logs neo4j (chercher « Loaded apoc »), sinon ./lab.sh reset puis ./lab.sh up.

Pour aller plus loin

Module suivant : exploiter le graphe News avec du Cypher avancé — chemins de longueur variable, agrégations, WITH/UNWIND, PROFILE et une vraie logique de recommandation par voisinage.