Схема в Neo4j необязательна: в прошлом уроке мы добавили лишь пару правил. Но «необязательна» не значит «не нужна». Форма графа решает, какие вопросы к нему будут короткими, а какие превратятся в мучение. В реляционном мире для этого есть нормальные формы и ER-диаграммы. В графовом мире правила проще и ближе к обычной речи.
Начни с вопросов
Таблицы часто проектируют от данных: какие поля есть, туда их и положим. Граф удобнее проектировать от вопросов. Выпиши, что приложение будет спрашивать, например:
- какие книги читали друзья Ани;
- какие авторы популярнее всего в Перми;
- кто приходил на встречи клуба, где обсуждали Стругацких.
Каждый вопрос это будущий паттерн. Если паттерн для вопроса получается коротким и читается как сам вопрос, модель удачная.
Существительные, глаголы, прилагательные
Работающее правило для первого наброска:
- существительные становятся узлами: участник, книга, город, автор;
- глаголы становятся связями: знает, читал, живёт в, написал;
- прилагательные и числа становятся свойствами: имя, год, оценка.
«Аня прочитала книгу и поставила ей пятёрку» даёт два узла, связь READ и свойство связи rating: 5. Оценка принадлежит не книге и не Ане, а именно факту чтения, поэтому она живёт на связи.
Свойство или узел
Самый частый вопрос моделирования: оставить значение свойством или вынести в отдельный узел. В графе клуба автор книги сейчас свойство:
MATCH (b:Book)
RETURN b.author, count(*) AS books, collect(b.title) AS titles
ORDER BY books, b.author;
+-------------------------------------------------------------------------------------+
| b.author | books | titles |
+-------------------------------------------------------------------------------------+
| "Булгаков" | 1 | ["Мастер и Маргарита"] |
| "Гончаров" | 1 | ["Обломов"] |
| "Ильф и Петров" | 1 | ["Двенадцать стульев"] |
| "Стругацкие" | 2 | ["Пикник на обочине", "Понедельник начинается в субботу"] |
+-------------------------------------------------------------------------------------+
4 rows
Пока вопросы только «кто автор этой книги», свойства хватает. Но «каких авторов читают в Перми» уже требует сравнивать строки, а опечатка «Стругацкий» вместо «Стругацкие» тихо разобьёт автора на двоих. Правило такое: если по значению ищут, через него ходят или у него появляются свои данные, это узел.
Рефакторинг запросом
Граф легко перестраивать на ходу, это одно из его сильных мест. Вынесем авторов в узлы одним запросом:
MATCH (b:Book)
MERGE (a:Author {name: b.author})
MERGE (a)-[:WROTE]->(b)
REMOVE b.author;
0 rows
Created 4 nodes, created 5 relationships, set 9 properties, added 4 labels
Четыре автора, пять связей WROTE, свойство author стёрто у всех книг. MERGE для автора не дал Стругацким раздвоиться: вторая книга нашла уже созданный узел. Теперь вопрос про популярность авторов это обход:
MATCH (a:Author)-[:WROTE]->(:Book)<-[:READ]-(p:Person)
RETURN a.name, count(DISTINCT p) AS readers
ORDER BY readers DESC, a.name;
+---------------------------+
| a.name | readers |
+---------------------------+
| "Стругацкие" | 6 |
| "Булгаков" | 4 |
| "Гончаров" | 2 |
| "Ильф и Петров" | 2 |
+---------------------------+
4 rows
Конкретные типы связей
Можно было бы завести одну связь INTERACTED {kind: 'read'} на все случаи жизни. Так делать не стоит: тип связи это самый дешёвый фильтр при обходе, база отбирает связи по типу, не читая свойства. Пусть типы будут конкретными глаголами: READ, WROTE, LIVES_IN. Отдельный тип для каждого оттенка смысла тоже перебор: «читал в 2024» лучше записать свойством связи, а не типом READ_2024.
Промежуточный узел
Иногда связь соединяет больше двух участников. Встреча клуба: она проходит в городе, обсуждает книгу, на неё приходят несколько человек. Связь бывает только между двумя узлами, поэтому само событие становится узлом, к которому сходятся связи:
MATCH (b:Book {title: 'Пикник на обочине'}), (c:City {name: 'Пермь'})
CREATE (m:Meeting {date: date('2026-11-14')})-[:DISCUSSES]->(b),
(m)-[:HELD_IN]->(c)
WITH m
MATCH (p:Person)-[:LIVES_IN]->(:City {name: 'Пермь'})
CREATE (p)-[:ATTENDS]->(m);
0 rows
Created 1 node, created 5 relationships, set 1 property, added 1 label
MATCH (p:Person)-[:ATTENDS]->(m:Meeting)-[:DISCUSSES]->(b:Book)
WITH m, b, p
ORDER BY p.name
RETURN m.date, b.title, collect(p.name) AS guests;
+-------------------------------------------------------------+
| m.date | b.title | guests |
+-------------------------------------------------------------+
| 2026-11-14 | "Пикник на обочине" | ["Егор", "Жанна", "Зоя"] |
+-------------------------------------------------------------+
1 row
Такой узел называют промежуточным: он превращает событие с несколькими участниками в точку, к которой можно прицепить и дату, и место, и протокол.
Чего избегать
- Узел-свалка. Узел, к которому сходятся миллионы связей, например «пол: женский» или «страна: Россия». Обход через него разворачивает весь граф. Такие значения чаще оставляют свойствами или метками.
- Связи вместо списков и наоборот. Если в свойстве узла хранится список чужих имён
friends: ['Борис', 'Глеб'], это скрытые связи, которые база не сможет обойти. - Дубли связей в обе стороны. Мы обсуждали это в уроке про направление: одна связь и чтение без стрелки.
Попробуй сам
Песочница начинает с исходного графа. Запрос выносит авторов в узлы и возвращает пути «автор, книга, читатель», чтобы на картинке было видно новую часть модели.
Найди, каких авторов читают жители Москвы. Потом вынеси в узлы год издания книги и подумай, стоило ли: какие вопросы это упростило, а какие нет?
Проверь себя
Задание
Дальше
Модель готова, пора решать на ней типовые задачи. В следующем уроке разберём три классики графов: рекомендации, кратчайший путь и поиск сообществ.
домашка