Aller au contenu principal

Module 12 — Cypher avancé : chemins, agrégations et recommandation

Le graphe News est en place : 200 853 articles, 41 catégories, 23 082 auteurs, chargés en vingt-cinq secondes au module 11. Karim veut maintenant ajouter à la plateforme Veille une fonctionnalité que ses clients réclament : « pour cet auteur, propose-moi trois auteurs proches à surveiller ». Inès pose la contrainte : la recommandation doit tenir en une requête Cypher lisible, s'appuyer sur le graphe et pas sur un modèle statistique lourd. Ce module écrit huit requêtes progressives qui répondent à des questions de plus en plus riches, jusqu'à la recommandation par voisinage, en s'appuyant sur les paramètres, PROFILE, shortestPath et les motifs de longueur variable.

Se donner un paramètre : :param et $nom

Toutes les requêtes qui suivent parlent d'un auteur de référence. Plutôt que d'écrire « Lee Moran » vingt fois, on le pose une bonne fois dans Neo4j Browser comme paramètre de session.

:param nom => 'Lee Moran'

:param est une commande du Browser (comme :auto au module 11), pas du langage. Elle enregistre $nom pour toute la session : chaque requête pourra utiliser $nom sans plus se soucier de la valeur. Dans ./lab.sh cypher, on passe plutôt les paramètres depuis Python ou la ligne de commande de cypher-shell. Le point clé : les paramètres ne sont pas de la concaténation de chaîne, ce sont des vraies variables — le plan d'exécution est mis en cache et réutilisé, et l'injection Cypher est impossible.

Toujours des paramètres, jamais de la concaténation

Dans un vrai code applicatif (module 13), on écrit session.run("MATCH (au:Auteur {nom: $nom}) ...", nom="Lee Moran"). Concaténer la valeur dans la chaîne fait exploser le cache de plans et ouvre une injection Cypher. La règle vaut pour Elasticsearch et pour toute base moderne.

Requête 1 — les catégories favorites d'un auteur

Question métier : « dans quelles catégories Lee Moran écrit-il le plus, et combien d'articles chaque catégorie représente-t-elle ? ». C'est un MATCH sur un motif à trois nœuds, suivi d'un count.

MATCH (au:Auteur {nom: $nom})<-[:ECRIT_PAR]-(a:Article)-[:PUBLIE_DANS]->(c:Categorie)
RETURN c.nom AS categorie, count(a) AS articles
ORDER BY articles DESC
LIMIT 10;

Lecture du motif : on part de l'auteur, on remonte les articles qu'il a écrits (flèche entrante <-[:ECRIT_PAR]-), et on descend vers leur catégorie (flèche sortante -[:PUBLIE_DANS]->). Deux traversées, aucune jointure explicite.

Résultat attendu pour $nom = 'Lee Moran' :

categorie      | articles
---------------+---------
COMEDY | 779
WEIRD NEWS | 391
ENTERTAINMENT | 387
POLITICS | 264
SPORTS | 101
...

Total sur toutes les catégories : 2 433 articles, le compte prolifique de Lee Moran retrouvé au module 4.

Requête 2 — les auteurs proches par voisinage de catégories

Le cœur de la recommandation : on cherche les auteurs qui écrivent dans les mêmes catégories que Lee, et on les classe par le nombre de catégories partagées.

MATCH (au:Auteur {nom: $nom})<-[:ECRIT_PAR]-(:Article)-[:PUBLIE_DANS]->(c:Categorie)
MATCH (c)<-[:PUBLIE_DANS]-(:Article)-[:ECRIT_PAR]->(autre:Auteur)
WHERE autre <> au
RETURN autre.nom AS auteur, count(DISTINCT c) AS categories_communes, count(*) AS articles_partages
ORDER BY categories_communes DESC, articles_partages DESC
LIMIT 10;

Deux MATCH chaînés (les deux vues du même chemin), un filtre WHERE autre <> au qui exclut Lee de son propre voisinage (sans cette ligne, l'auteur le plus proche de Lee serait toujours Lee lui-même), et deux compteurs — le nombre de catégories distinctes partagées et le volume brut d'articles concernés. Le second départage les auteurs à égalité sur le premier.

C'est une recommandation par similarité de contenu : on ne cherche pas à imiter un algorithme d'apprentissage, on exploite la topologie du graphe. Les auteurs les mieux classés sont ceux qui écrivent dans les cinq catégories favorites de Lee ; ils feront de bons candidats pour la page « à lire aussi ». (Vos noms peuvent différer légèrement selon le nettoyage des auteurs multiples.)

Requête 3 — les articles récents des co-auteurs d'un article donné

Question métier : « pour l'article numéro 100, qui l'a co-signé, et qu'ont-ils publié de plus récent ailleurs ? ». C'est un chemin en deux temps qui utilise WITH pour porter les auteurs vers la deuxième étape.

MATCH (:Article {id: 100})-[:ECRIT_PAR]->(au:Auteur)
WITH au
MATCH (au)<-[:ECRIT_PAR]-(autre:Article)
RETURN au.nom AS auteur, autre.titre AS titre, autre.date AS date
ORDER BY autre.date DESC
LIMIT 5;

WITH joue ici le rôle d'un pipe : on prend les auteurs trouvés à l'étape 1, on les injecte dans l'étape 2. Sans WITH, chaque MATCH recommence à zéro et le lien logique est perdu. Le tri ORDER BY autre.date DESC bénéficie de l'index article_date posé au module 11 : on ne trie pas 200 000 lignes, on lit l'index par ordre décroissant.

Requête 4 — le chemin le plus court entre deux auteurs

Une question purement graphique : « à quelle distance Lee Moran et Ed Mazza se croisent-ils dans le graphe ? ». Réponse avec shortestPath et un motif de longueur variable.

MATCH (a:Auteur {nom: 'Lee Moran'}), (b:Auteur {nom: 'Ed Mazza'})
MATCH chemin = shortestPath((a)-[*..6]-(b))
RETURN [n IN nodes(chemin) | coalesce(n.nom, n.titre)] AS etapes,
length(chemin) AS longueur;

shortestPath((a)-[*..6]-(b)) demande le plus court chemin, toutes relations confondues, jusqu'à six sauts. La borne haute est essentielle : sans elle, Neo4j pourrait explorer l'ensemble du graphe.

Le chemin passe typiquement par un article commun (Auteur → Article → Auteur, longueur 2) ou par une catégorie partagée (Auteur → Article → Categorie → Article → Auteur, longueur 4). La compréhension [n IN nodes(chemin) | coalesce(n.nom, n.titre)] construit la liste lisible des étapes, en prenant nom si c'est un auteur ou une catégorie et titre si c'est un article. (La longueur précise dépend des co-signatures dans votre chargement.)

Toujours borner la longueur des chemins

shortestPath((a)-[*]-(b)) sans borne explore par largeur toute la composante connexe. Sur un graphe de 200 000 nœuds, c'est catastrophique. La convention Veille : jamais plus de six sauts pour une recherche de proximité.

Requête 5 — les chemins de longueur variable *1..3

shortestPath renvoie un chemin. Pour récupérer tous les chemins jusqu'à une certaine longueur, on utilise directement le quantificateur *min..max sur la relation.

MATCH chemin = (a:Auteur {nom: 'Lee Moran'})-[*1..3]-(voisin:Auteur)
WHERE voisin <> a
RETURN voisin.nom AS voisin, length(chemin) AS distance
ORDER BY distance ASC, voisin.nom
LIMIT 10;

*1..3 autorise 1, 2 ou 3 relations dans n'importe quel sens (le motif n'a plus de flèche > orientée). Le filtre WHERE voisin <> a élimine l'auteur de départ. Cette requête coûte plus cher que la précédente (elle explore tout, pas seulement le plus court) et se prête bien à un LIMIT strict.

En pratique, pour la recommandation, la requête 2 est préférable — plus rapide, plus lisible et plus interprétable. Les chemins de longueur variable brillent surtout pour détecter une connexion (« existe-t-il un lien à trois sauts entre X et Y ? ») plus que pour la classer.

Requête 6 — OPTIONAL MATCH, WITH et collect

Question métier : « pour chaque auteur du top 5 par volume, quels sont ses trois articles les plus récents, et sa catégorie principale ? ». Deux briques nouvelles : OPTIONAL MATCH, qui n'échoue pas si le motif est vide, et collect, qui regroupe des lignes en liste.

MATCH (au:Auteur)<-[:ECRIT_PAR]-(a:Article)
WITH au, count(a) AS total
ORDER BY total DESC
LIMIT 5
OPTIONAL MATCH (au)<-[:ECRIT_PAR]-(recent:Article)
WITH au, total, recent
ORDER BY recent.date DESC
WITH au, total, collect(recent.titre)[..3] AS derniers_titres
RETURN au.nom AS auteur, total, derniers_titres;

Trois WITH en cascade. Le premier calcule le total et garde les cinq premiers auteurs. Le deuxième trie les articles récents. Le troisième les regroupe en liste avec collect, puis en garde les trois premiers avec la slice [..3]. OPTIONAL MATCH couvre le cas rare (mais possible) où un auteur n'aurait plus d'article après un nettoyage.

Résultat attendu (le top 5 est stable) : Reuters, Lee Moran, Ron Dicker, Ed Mazza, Cole Delbyck, avec pour chacun leurs trois titres les plus récents. (Les titres exacts varient selon la sélection ; leur nombre par auteur, non.)

Requête 7 — UNWIND et CASE : classer les auteurs

UNWIND fait l'inverse de collect : il transforme une liste en une ligne par élément. CASE sert à catégoriser une valeur numérique en libellé.

MATCH (au:Auteur)<-[:ECRIT_PAR]-(a:Article)
WITH au, count(a) AS total
WITH
CASE
WHEN total >= 500 THEN 'prolifique'
WHEN total >= 50 THEN 'régulier'
ELSE 'occasionnel'
END AS niveau,
au, total
RETURN niveau, count(au) AS auteurs, avg(total) AS moyenne
ORDER BY
CASE niveau WHEN 'prolifique' THEN 1 WHEN 'régulier' THEN 2 ELSE 3 END;

Trois seuils, trois catégories. Un CASE sur total produit le libellé niveau, un second CASE sert de clé de tri (l'ordre alphabétique donnerait « occasionnel », « prolifique », « régulier »). Résultat attendu : les auteurs « prolifiques » sont une poignée (Reuters, Lee Moran, Ron Dicker, Ed Mazza, Cole Delbyck, Andy McDonald, David Moye, Mary Papenfuss…), les « réguliers » sont quelques centaines, les « occasionnels » sont la vaste majorité — l'écrasant reste de la longue traîne. (Chiffres exacts variables selon la découpe des co-auteurs.)

Un exemple pur d'UNWIND sur ce graphe, pour dérouler la liste des catégories favorites de Lee dans une seule table :

MATCH (au:Auteur {nom: $nom})<-[:ECRIT_PAR]-(a:Article)-[:PUBLIE_DANS]->(c:Categorie)
WITH au, collect(DISTINCT c.nom) AS categories
UNWIND categories AS categorie
RETURN au.nom AS auteur, categorie
ORDER BY categorie;

Requête 8 — PROFILE avant et après un WHERE indexé

EXPLAIN affiche le plan d'exécution sans lancer la requête ; PROFILE la lance et rapporte le coût réel en db hits (accès disque logique). C'est l'outil pour comprendre pourquoi une requête est lente et vérifier qu'un index sert vraiment.

Version 1 — sans filtre indexé :

PROFILE
MATCH (a:Article)-[:PUBLIE_DANS]->(:Categorie {nom: 'POLITICS'})
WHERE a.titre CONTAINS 'trump'
RETURN count(a);

Le plan commence par un NodeIndexSeek sur la contrainte categorie_nom (recherche par nom, instantanée), puis descend vers les articles avec Expand(All), puis applique le filtre CONTAINS 'trump' article par article — ligne Filter avec un compteur db hits élevé, proportionnel au nombre d'articles POLITICS (32 739).

Version 2 — avec filtre indexé :

PROFILE
MATCH (a:Article)-[:PUBLIE_DANS]->(:Categorie {nom: 'POLITICS'})
WHERE a.date >= date('2018-01-01')
RETURN count(a);

Cette fois, la contrainte sur la date bénéficie de l'index article_date créé au module 11. Le plan montre NodeIndexSeekByRange sur Article(date), et le nombre de db hits chute d'un ordre de grandeur : on part directement des articles récents plutôt que de balayer POLITICS.

Ce qu'il faut lire dans un PROFILE : le nombre de db hits par étape (plus bas c'est mieux), les NodeByLabelScan (à éviter sur les grandes étiquettes), les Expand(All) (traversées, normales) et les Filter (les filtres coûteux après traversée sont un signal pour ajouter un index ou reformuler).

EXPLAIN vs PROFILE

EXPLAIN est gratuit : il n'exécute rien, il compte des estimations. PROFILE exécute la requête pour de vrai — évitez-le sur une requête destructive et regardez-y à deux fois avant de lancer PROFILE MATCH (n) DETACH DELETE n.

Le piège à éviter : le produit cartésien

Le motif suivant compile mais produit un désastre :

MATCH (a:Auteur), (b:Auteur)
WHERE a <> b
RETURN a.nom, b.nom
LIMIT 10;

Aucune relation entre a et b : Neo4j calcule le produit cartésien des deux étiquettes, soit 23 082 × 23 082 ≈ 533 millions de paires, avant de commencer à filtrer et à retenir dix lignes. Neo4j affiche un avertissement explicite (« This query builds a cartesian product… »).

La bonne forme lie toujours les nœuds par une relation, même longue :

MATCH (a:Auteur)-[:ECRIT_PAR|PUBLIE_DANS*1..4]-(b:Auteur)
WHERE a <> b
RETURN a.nom, b.nom
LIMIT 10;

Chaque fois que vous écrivez deux MATCH (ou deux nœuds séparés par une virgule) sans motif qui les relie, relisez : soit vous vouliez un WITH, soit vous vouliez un chemin.

Mises à jour massives avec apoc.periodic.iterate

Un cas récurrent : ajouter une propriété calculée à des centaines de milliers de nœuds. Écrit en une transaction, la commande fait exploser la heap. Écrit avec apoc.periodic.iterate, le traitement se fait par lots.

CALL apoc.periodic.iterate(
'MATCH (a:Article) RETURN a',
'SET a.annee = a.date.year',
{batchSize: 10000, parallel: false}
) YIELD batches, total
RETURN batches AS lots, total AS articles_mis_a_jour;

Deux requêtes, séparées par un point-virgule dans la chaîne : la productrice (MATCH ... RETURN) et la consommatrice (SET ...). APOC pipe l'une dans l'autre par lots de 10 000, commite entre chaque, et renvoie le total à la fin. C'est la version « écriture » du motif LOAD CSV ... IN TRANSACTIONS.

À vous

Exercice 1 — Écrivez la requête qui donne, pour un auteur donné en paramètre, ses cinq catégories les plus fréquentes et le pourcentage d'articles qu'elles représentent par rapport à son total.

Solution
MATCH (au:Auteur {nom: $nom})<-[:ECRIT_PAR]-(a:Article)
WITH au, count(a) AS total
MATCH (au)<-[:ECRIT_PAR]-(a:Article)-[:PUBLIE_DANS]->(c:Categorie)
WITH c.nom AS categorie, count(a) AS articles, total
RETURN categorie, articles, round(100.0 * articles / total, 1) AS pourcentage
ORDER BY articles DESC
LIMIT 5;

Pour Lee Moran, COMEDY sort à environ 32 %, WEIRD NEWS à 16 %, ENTERTAINMENT à 16 %, POLITICS à 11 %, SPORTS à 4 %.

Exercice 2 — Trouvez les trois auteurs les plus proches de Lee Moran par catégories communes, en n'incluant que les auteurs qui ont écrit au moins 20 articles au total (pour filtrer le bruit).

Solution
MATCH (au:Auteur {nom: 'Lee Moran'})<-[:ECRIT_PAR]-(:Article)-[:PUBLIE_DANS]->(c:Categorie)
MATCH (c)<-[:PUBLIE_DANS]-(:Article)-[:ECRIT_PAR]->(autre:Auteur)
WHERE autre <> au
WITH autre, count(DISTINCT c) AS categories_communes
MATCH (autre)<-[:ECRIT_PAR]-(a:Article)
WITH autre, categories_communes, count(a) AS total_autre
WHERE total_autre >= 20
RETURN autre.nom AS auteur, categories_communes, total_autre
ORDER BY categories_communes DESC, total_autre DESC
LIMIT 3;

Le second MATCH avec count(a) permet de filtrer les auteurs marginaux. (Votre chiffre peut différer légèrement.)

Exercice 3 — Comparez PROFILE de la même requête avec et sans un WHERE a.date >= date('2018-01-01'), et notez le rapport approximatif de db hits.

Solution
PROFILE
MATCH (a:Article)-[:PUBLIE_DANS]->(:Categorie {nom: 'POLITICS'})
RETURN count(a);

puis

PROFILE
MATCH (a:Article)-[:PUBLIE_DANS]->(:Categorie {nom: 'POLITICS'})
WHERE a.date >= date('2018-01-01')
RETURN count(a);

La première version parcourt les 32 739 articles POLITICS ; la seconde restreint la traversée grâce à l'index article_date. Le rapport de db hits est typiquement d'un ordre de grandeur (facteur 5 à 15 selon la répartition annuelle des articles).

Points à retenir

  • Poser un paramètre avec :param nom => 'Lee Moran' puis utiliser $nom : cache de plans respecté, injection Cypher impossible.
  • La recommandation par voisinage tient en un motif double : « articles → catégories partagées → autres auteurs », avec WHERE autre <> au et count(DISTINCT c) trié.
  • shortestPath((a)-[*..N]-(b)) avec une borne trouve la proximité entre deux nœuds ; sans borne, la requête explose.
  • WITH chaîne les étapes, collect regroupe en liste, UNWIND la déroule, CASE catégorise une valeur numérique en libellé.
  • PROFILE révèle le coût réel en db hits : un NodeIndexSeekByRange remplace un Filter coûteux dès qu'on filtre sur une propriété indexée.
  • Deux MATCH sans relation entre eux = produit cartésien : Neo4j prévient, il faut relire.
  • Les mises à jour massives passent par apoc.periodic.iterate avec une requête productrice et une consommatrice, jamais en une seule transaction.

Si ça ne marche pas

  • La requête tourne indéfiniment (spinner qui ne s'arrête pas) → chemin sans borne (*.. ou *) ou produit cartésien → interrompre dans le Browser, ajouter une borne *..6 ou une relation entre les deux nœuds, puis PROFILE la nouvelle version.
  • $nom renvoie « Expected parameter(s): nom » → la valeur n'a pas été posée dans la session → :param nom => 'Lee Moran' dans Neo4j Browser, ou passer le paramètre depuis Python.
  • PROFILE affiche NodeByLabelScan sur Article → aucune contrainte n'a été rencontrée avant le filtre → vérifier que le motif commence par la partie la plus sélective (Categorie avec nom, par exemple), sinon poser un index avec CREATE INDEX ... sur la propriété filtrée.
  • apoc.periodic.iterate renvoie « Unknown function » → APOC n'est pas chargé dans le conteneur → ./lab.sh logs neo4j (chercher « Loaded apoc »), sinon ./lab.sh reset puis ./lab.sh up.

Pour aller plus loin

Module suivant : piloter Elasticsearch et Neo4j depuis Python, avec les clients officiels installés dans le conteneur veille-python, et écrire le script qui combine recherche et recommandation pour la démonstration finale.