К property graph нужен язык запросов. В Neo4j это Cypher: если SQL описывает таблицы и JOIN, то Cypher описывает шаблон — какие узлы соединены какими рёбрами. Запрос выглядит как рисунок связи в тексте.
Индекс по метке нужен один раз — он находит стартовый узел. Дальше Cypher идёт по рёбрам от узла к соседям, и звёздочка *1..2 — это ровно два таких шага. Поэтому цена запроса зависит от числа пройденных рёбер, а не от размера базы.
Шаблон связи: рисунок в скобках
Узел — в круглых скобках, ребро — в квадратных, направление — стрелкой:
(p:Person) -[:КУПИЛ]-> (t:Product)
Читается буквально: «узел p с меткой :Person купил узел t с меткой :Product». Буквы p и t — переменные, чтобы дальше на них сослаться.
MATCH и RETURN: найти и вернуть
Чтение начинается с MATCH (найти шаблон) и заканчивается RETURN (что вернуть). «Какие товары купила Anna» — запустите и поменяйте имя:
живой пример
MATCH (p:Person {firstName: 'Anna'}) -[:КУПИЛ]-> (t:Product)
RETURN t.title
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Фильтр пишут прямо в узле ({firstName: 'Anna'}) или отдельным условием через WHERE — как в SQL; ниже оба способа сразу. «Товары, которые покупают вместе с этим» — обход на два шага: от товара к его покупателям, от них — к другим их покупкам:
живой пример
MATCH (t:Product {title: 'Механическая клавиатура'}) <-[:КУПИЛ]- (:Person) -[:КУПИЛ]-> (other:Product)
WHERE other.title <> 'Механическая клавиатура'
RETURN other.title, count(*) AS вместе
ORDER BY вместе DESC LIMIT 5
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Это и есть «с этим товаром берут» из карточки магазина — два ребра и одна группировка, без единой таблицы связей.
Стрелка <-[:КУПИЛ]- развёрнута: от товара к покупателю связь идёт в обратную сторону. Направление можно и вовсе опустить (-[:КУПИЛ]-) — тогда обход идёт в обе стороны.
Путь переменной длины: обход на неизвестную глубину
Главная сила Cypher — обход, когда число шагов заранее неизвестно. Его задают звёздочкой, как в регулярных выражениях. «Знакомые Anna и знакомые её знакомых»:
живой пример
MATCH (a:Person {firstName: 'Anna'}) -[:ЗНАКОМ_С*1..2]-> (кто:Person)
RETURN кто.firstName, кто.lastName
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
* означает «одно или больше рёбер ЗНАКОМ_С» — то же самое, что *1.., просто короче. Глубину ограничивают: *1..2 — один-два шага (как здесь), *1..4 — до четырёх, *0.. — ноль или больше (ноль шагов означает, что в ответ попадёт и сам стартовый узел). Поменяйте *1..2 на *1..1 и запустите снова: в ответе останутся только прямые знакомые. Тот же обход без базы, в десяти строках:
живой пример
import java.util.*;
public class VarLengthPath {
public static void main(String[] args) {
Map<String, List<String>> edges = Map.of("Anna", List.of("Boris", "Darya"),
"Boris", List.of("Vera"), "Darya", List.of("Egor"));
List<String> layer = List.of("Anna");
for (int hop = 1; hop <= 2; hop++) {
List<String> next = new ArrayList<>();
for (String node : layer) next.addAll(edges.getOrDefault(node, List.of()));
System.out.println("шаг " + hop + ": " + next);
layer = next;
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import (
"fmt"
"strings"
)
func main() {
edges := map[string][]string{"Anna": {"Boris", "Darya"}, "Boris": {"Vera"}, "Darya": {"Egor"}}
layer := []string{"Anna"}
for hop := 1; hop <= 2; hop++ {
var next []string
for _, node := range layer {
next = append(next, edges[node]...)
}
fmt.Printf("шаг %d: [%s]\n", hop, strings.Join(next, ", "))
layer = next
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const edges = new Map([['Anna', ['Boris', 'Darya']], ['Boris', ['Vera']], ['Darya', ['Egor']]]);
let layer = ['Anna'];
for (let hop = 1; hop <= 2; hop++) {
const next = layer.flatMap((node) => edges.get(node) ?? []);
console.log(`шаг ${hop}: [${next.join(', ')}]`);
layer = next;
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
edges = {"Anna": ["Boris", "Darya"], "Boris": ["Vera"], "Darya": ["Egor"]}
layer = ["Anna"]
for hop in (1, 2):
nxt = [n for node in layer for n in edges.get(node, [])]
print(f"шаг {hop}: [{', '.join(nxt)}]")
layer = nxt
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
В реляционной базе тот же вопрос — громоздкий рекурсивный CTE, в Cypher — одна строка. Отдельно есть поиск кратчайшего пути, классическая «цепочка рукопожатий»:
живой пример
MATCH путь = shortestPath(
(a:Person {firstName: 'Anna'}) -[:ЗНАКОМ_С*]- (b:Person {firstName: 'Grigory'})
)
RETURN [узел IN nodes(путь) | узел.firstName] AS цепочка
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
nodes(путь) разворачивает найденный путь в список узлов, а квадратные скобки — это выражение над списком, как map в обычном коде: из каждого узла берём имя. Ответ читается прямо: ["Anna", "Boris", "Vera", "Grigory"] — три рукопожатия.
CREATE и MERGE: создать и обновить
Ночная загрузка отработала дважды, и в графе теперь два узла одного и того же человека, а его связи разошлись между ними. Так бывает с CREATE: он всегда создаёт. MERGE сначала ищет и создаёт только если не нашёл, поэтому повторный запуск ничего не дублирует:
CREATE (p:Person {firstName: 'Maria', status: 'ACTIVE'})
MATCH (m:Person {firstName: 'Maria'}), (t:Product {title: 'Йога-коврик'})
MERGE (m) -[r:КУПИЛ]-> (t)
ON CREATE SET r.дата = '2026-09-17'
Кнопки «Запустить» под этими двумя примерами нет, и это не недосмотр: учебная песочница открыта только на чтение. Иначе первый же CREATE менял бы граф всем, кто читает статью следом.
MERGE не создаст дубликат, если связь уже есть — поэтому им грузят данные из внешних источников, где сущность приходит многократно. Но обратите внимание, как написан пример: свойство дата вынесено из шаблона в ON CREATE SET. Так и надо. MERGE ищет совпадение по всему, что перечислено внутри скобок, свойства включительно; напиши мы MERGE (m) -[:КУПИЛ {дата: '2026-09-17'}]-> (t), и назавтра, с другой датой, совпадения бы не нашлось — появилась бы вторая связь «купил». В шаблоне MERGE оставляют только то, что делает запись уникальной, а всё изменчивое дописывают через ON CREATE SET (при создании) и ON MATCH SET (при обновлении найденного).
WITH: конвейер из шагов
Один MATCH и один RETURN закрывают простые вопросы. Как только нужно посчитать промежуточный результат и продолжить от него, появляется WITH — он передаёт данные следующему шагу, как труба:
MATCH (c:Customer)-[:PLACED]->(o:Order)
WITH c, count(o) AS orders, sum(o.total) AS revenue
WHERE orders >= 3
MATCH (c)-[:PLACED]->(:Order)-[:CONTAINS]->(p:Product)
RETURN c.name, orders, revenue, collect(DISTINCT p.title)[0..5] AS examples
ORDER BY revenue DESC
LIMIT 20;
Читается сверху вниз: нашли покупателей с заказами, посчитали по каждому, отфильтровали по счёту, от оставшихся пошли дальше за товарами, собрали результат. Роль WITH здесь тройная — он группирует (всё, что не агрегат, становится ключом группировки), пробрасывает дальше только перечисленные переменные (остальные становятся недоступны) и позволяет WHERE после агрегата — это тот же HAVING из SQL.
Запрос идёт сверху вниз, и WITH закрывает предыдущий шаг, поэтому дальше видно только перечисленное в нём, а WHERE фильтрует уже по посчитанному.
Три вещи, о которые спотыкаются. Переменную, которая нужна дальше, надо перечислить в WITH явно — иначе она «исчезнет». WITH с DISTINCT и LIMIT работает как отдельный шаг: WITH c ORDER BY c.created DESC LIMIT 100 сначала обрежет до ста, и дальше работа пойдёт только с ними — так ограничивают дорогие обходы. И WITH — единственное место, где можно переименовать выражение, чтобы потом им пользоваться.
Обновление и удаление: SET, REMOVE, DELETE
CREATE и MERGE создают, а менять и удалять — отдельные слова.
// изменить свойства
MATCH (c:Customer {email: $email})
SET c.status = 'ACTIVE', c.updatedAt = datetime();
// добавить метку и убрать свойство
MATCH (c:Customer {email: $email})
SET c:Verified
REMOVE c.verificationCode;
// сразу набором из параметра-словаря
MATCH (c:Customer {email: $email})
SET c += $props; // += дописывает, = заменяет все свойства целиком
// удалить связь
MATCH (:Customer {email: $email})-[r:VIEWED]->(:Product {sku: $sku})
DELETE r;
// удалить узел вместе со всеми его связями
MATCH (p:Product {sku: $sku})
DETACH DELETE p;
Две ловушки. SET c = $props заменяет все свойства узла: то, чего нет в словаре, исчезнет; дописывает только SET c += $props. И DELETE на узле со связями падает с ошибкой «ещё есть связи» — узел удаляют либо DETACH DELETE (и тогда связи уходят молча, что стоит осознать), либо сначала удаляя связи явно.
Массовое обновление в одной транзакции упирается в память, поэтому для больших правок есть порционная форма:
MATCH (o:Order) WHERE o.total IS NULL
CALL { WITH o SET o.total = 0 } IN TRANSACTIONS OF 10000 ROWS;
OPTIONAL MATCH: сохранить тех, у кого связи нет
Обычный MATCH работает как внутреннее соединение: покупатель без заказов из выдачи исчезнет. OPTIONAL MATCH оставляет его, подставив null:
MATCH (c:Customer)
OPTIONAL MATCH (c)-[:PLACED]->(o:Order)
RETURN c.name, count(o) AS orders
ORDER BY orders;
Это и есть аналог левого соединения, и та же ловушка, что в SQL: условие на необязательную часть пишут внутри самого OPTIONAL MATCH (или в WITH … WHERE после него), а не в общем WHERE — иначе он превратится в обычный MATCH и отсечёт тех, кого должен был сохранить.
Где спотыкаются
- Забывают про
MERGEи плодят дубли.CREATEна повторной загрузке создаёт вторую копию узла или ребра. Для «создать, если ещё нет» — толькоMERGEпо уникальному ключу с индексом или ограничением уникальности. MATCHбез якоря обходит весь граф. Нет узла с меткой и индексируемым свойством — Neo4j начинает с полного сканирования. Заземляйте запрос на конкретный узел, от которого пойдёт обход.- Пишут
*без верхней границы. На плотном графе такой путь обойдёт пол-базы — ограничивайте глубину (*1..4), если это не полный обход по замыслу. - Ждут, что
MERGEнайдёт «хоть что-нибудь». Он работает по принципу «всё или ничего»: если хоть одна часть шаблона не нашлась, Neo4j создаёт шаблон целиком.MERGE (a:Person {name:'X'}) -[:ЗНАКОМ_С]-> (b:Person {name:'Y'})при отсутствии одной только связи создаст ещё и вторых «X» и «Y» рядом с настоящими. Лечится разбиением: сначалаMERGEна каждый узел отдельно, потомMERGEна связь между уже найденными. - Подставляют значения прямо в текст запроса.
{firstName: 'Anna'}годится, пока вы играете в песочнице. В коде имя приходит снаружи, и его передают параметром —{firstName: $name}, а значение отдают драйверу отдельно. Так закрывается подстановка чужого кода в запрос, и заодно база перестаёт заново продумывать план для каждого нового имени: с параметрами текст запроса один и тот же, план берётся из кэша.
Глубже: кратчайший путь и граница обходарасширенное
Про кратчайший путь стоит добавить, чем он отличается от обычного обхода. Путь переменной длины (*1..5) находит все пути в этих границах — их может быть очень много, и именно поэтому такой запрос бывает дорогим. shortestPath((a)-[:KNOWS*]-(b)) ищет один кратчайший путь и останавливается, как только нашёл: он идёт поиском в ширину с двух концов и потому обходит на порядки меньше связей. Есть и allShortestPaths — все пути минимальной длины (их бывает несколько, если до цели есть равные маршруты), и это уже дороже.
Практическое правило: вопрос «связаны ли и как коротко» — это shortestPath, вопрос «какими вообще путями связаны» — путь переменной длины с обязательной верхней границей.
И про память: путь без верхней границы (* или *..) на связном графе — самый простой способ получить запрос, который выполняется минутами и падает по памяти. Cypher не останавливает такой обход сам: он честно собирает промежуточные результаты, пока не кончится куча, а сообщение будет про нехватку памяти, а не про запрос. Поэтому верхнюю границу ставят всегда, а для «дальних» вопросов берут shortestPath или алгоритмы из отдельной библиотеки графовых алгоритмов.
Глубже: EXPLAIN и PROFILE: посмотреть, как выполняетсярасширенное
Совет «заземляйте запрос на индекс» проверяется прямо в браузере или в оболочке:
PROFILE
MATCH (c:Customer {email: $email})-[:PLACED]->(o:Order)
RETURN o.id, o.total;
EXPLAIN показывает план, не выполняя запрос; PROFILE выполняет и показывает факт — сколько строк прошло через каждый шаг и сколько обращений к хранилищу (db hits) он сделал.
Читают план по двум признакам. Первый — как начался запрос: NodeIndexSeek означает, что индекс сработал; NodeByLabelScan — что база перебрала все узлы с меткой, и это чаще всего ответ на вопрос «почему медленно». Второй — где вырос объём: шаг, на котором строк стало в тысячи раз больше, и есть тот самый дорогой обход (обычно проход через узел с огромным числом связей или путь переменной длины без границы).
Практический порядок такой: сначала PROFILE на медленном запросе, потом взгляд на первую строку плана, потом — на шаг с максимальным db hits.
Коротко
- Cypher описывает шаблон: узел в круглых скобках, ребро в квадратных, направление стрелкой.
- Читают через
MATCH…RETURN, фильтруют прямо в узле или черезWHERE. - Стрелку разворачивают (
<-[:КУПИЛ]-) или опускают — тогда обход идёт в обе стороны. - Звёздочка — путь переменной длины (
*1..3): находит все пути в границах, и без верхней границы падает по памяти;shortestPathищет один кратчайший и потому дёшев. MERGEберёт существующее или создаёт; загрузка черезCREATEплодит дубли. В шаблонMERGEкладут только то, что делает запись уникальной, изменчивое — вON CREATE SET/ON MATCH SET; не нашлась часть шаблона — создастся шаблон целиком.- В коде значения передают параметрами (
$name), а не вставляют в текст запроса: и безопаснее, и план запроса переиспользуется. - Запрос заземляют на узел с меткой и индексом: индекс работает один раз, дальше идут рёбра.
WITH— конвейер: группирует, пробрасывает переменные дальше и даётWHEREпосле агрегата; без него запрос длиннее двух шагов не написать.- Менять и удалять — это
SET(с+=, иначе свойства заменяются целиком),REMOVE,DELETEиDETACH DELETE; массовые правки идут порциями черезCALL { … } IN TRANSACTIONS. OPTIONAL MATCH— левое соединение с той же ловушкой про условие в общемWHERE;EXPLAINиPROFILEпоказывают, начался ли запрос сNodeIndexSeekвместоNodeByLabelScan.
Что почитать дальше
- Как устроен Neo4j: property graph, узлы, рёбра и обход без индексов — почему шаг по ребру дешевле
JOIN. - Neo4j: моделирование графа, индексы и эксплуатация — какие индексы нужны
MATCH. - Neo4j: где применяют графовую СУБД — от рекомендаций до GraphRAG — где граф выигрывает у таблиц.
- Графы — обход в ширину и в глубину: то, что звёздочка и
shortestPathделают за вас.