Паттерны Cypher: запрос как рисунок графа

Почему Cypher называют «ASCII-артом для графов» и как читается строка из скобок и стрелок.

Cypher — декларативный язык запросов Neo4j, в котором паттерн графа рисуется текстом: узлы — круглыми скобками (), связи — стрелками со скобками -[]->.

Главная интуиция

Если в SQL вы описываете таблицы и условия соединения, то в Cypher вы рисуете форму подграфа, который хотите найти или создать. Это и есть его суперсила: запрос визуально похож на то, что вы ищете. Сравните рисунок на доске и строку Cypher:

Рисунок:   (Алиса) ──ЗНАЕТ──> (Боб)
Cypher:    (a:Person {name:'Алиса'})-[:ЗНАЕТ]->(b:Person {name:'Боб'})

Почему это важно для обучения? Когда вы проектируете запрос к реляционной базе, вам приходится держать в голове две разные модели: бизнес-смысл («друзья Алисы») и техническую реализацию («таблица users, таблица friendships, два JOIN по внешним ключам»). В Cypher эти две модели совпадают: вы пишете прямо то, что нарисовали бы на салфетке. Чем меньше зазор между мыслью и кодом, тем меньше ошибок и тем быстрее вы рассуждаете о сложных обходах вроде «друзья друзей, которые лайкнули тот же пост».

Сразу зафиксируем словарь, чтобы дальше не путаться. Узел (node) — это сущность: человек, фильм, компания. Связь (relationship) — именованное ребро между двумя узлами: ЗНАЕТ, ACTED_IN, РАБОТАЕТ_В. Метка (label) — это «тип» узла, например :Person или :Movie; у одного узла может быть несколько меток. Свойство (property) — пара «ключ-значение» на узле или на связи: name:'Алиса', since:2020. Паттерн — это просто комбинация этих четырёх вещей, выписанная скобками и стрелками.

Анатомия паттерна

Разберём по частям паттерн (a:Person {name:'Алиса'})-[r:ЗНАЕТ]->(b:Person):

  • (a:Person {name:'Алиса'})узел. a — переменная (чтобы сослаться позже), :Person — метка, {name:'Алиса'} — фильтр по свойству.
  • -[r:ЗНАЕТ]->связь. r — переменная связи, :ЗНАЕТ — тип, -> — направление слева направо.
  • (b:Person) — второй узел, тоже Person, без фильтра по свойствам.

Направление и его отсутствие

Стрелка задаёт направление обхода. Три варианта:

ПаттернСмысл
(a)-[:ЗНАЕТ]->(b)связь идёт от a к b
(a)<-[:ЗНАЕТ]-(b)связь идёт от b к a
(a)-[:ЗНАЕТ]-(b)любое направление (ненаправленный обход)

Обратите внимание: данные всегда хранятся направленными, но искать можно без учёта направления, убрав стрелку. Это частая потребность: «кто как-либо связан с Алисой». Представьте соцсеть с типом связи :FOLLOWS (подписка). Направление здесь несёт смысл: «Алиса подписана на Боба» — это не то же самое, что «Боб подписан на Алису». А вот для связи :ДРУЖИТ, которая по своей природе взаимна, направление в данных есть (его не выкинуть), но при чтении мы почти всегда убираем стрелку, потому что дружба симметрична.

Когда направление меняет ответ

Маленький мысленный эксперимент. У нас есть факты: Алиса подписана на Боба, Боб подписан на Веру. Сравните два запроса-паттерна. (:Person {name:'Алиса'})-[:FOLLOWS]->(x) найдёт только Боба — тех, на кого подписана сама Алиса. А (:Person {name:'Алиса'})<-[:FOLLOWS]-(x) найдёт её подписчиков — тех, кто подписан на неё. Если же стрелку убрать совсем, (:Person {name:'Алиса'})-[:FOLLOWS]-(x) вернёт обе стороны разом. Одна стрелочка — три разных вопроса.

Цепочки и ветвления

Паттерны соединяются в цепочки и деревья — так выражаются многошаговые обходы:

(a:Person)-[:ЗНАЕТ]->(b:Person)-[:ЗНАЕТ]->(c:Person)

Это «a знает b, b знает c» — наши друзья друзей. Заметьте, как естественно растёт длина обхода: добавили ещё один -[:ЗНАЕТ]->(d:Person) — и получили друзей друзей друзей. В реляционной базе каждый такой шаг — это ещё один JOIN, и на третьем-четвёртом запрос становится нечитаемым. В Cypher вы просто продолжаете рисовать линию. Именно поэтому графовые базы так хороши в задачах «найди путь» и «на сколько рукопожатий мы знакомы».

А вот ветвление от одного узла:

          ┌──[:ACTED_IN]──>(:Movie {title:'Матрица'})
(p:Person)│
          └──[:ACTED_IN]──>(:Movie {title:'Джон Уик'})

Записывается двумя паттернами через запятую: (p)-[:ACTED_IN]->(m1), (p)-[:ACTED_IN]->(m2). Переменная p повторяется в обоих кусках — и Cypher понимает, что речь об одном и том же человеке. Это ключевой приём: общая переменная «склеивает» отдельные паттерны в единый подграф. Так выражаются запросы вида «человек, снявшийся и в Матрице, и в Джоне Уике» — то есть Киану Ривз.

Якорь и ребро как фильтры

Полезно думать про каждую часть паттерна как про фильтр, который что-то отсекает. Метка :Movie отсекает всё, что не фильм. Свойство {released:1999} отсекает фильмы других лет. Тип связи :ACTED_IN отсекает связи других типов (скажем, :DIRECTED). Чем больше таких фильтров вы задали, тем меньше подграфов подойдёт под форму — и тем точнее ответ. Пустой паттерн (n) подходит вообще ко всему в базе; добавляя метки, типы и свойства, вы постепенно сужаете его до нужного.

Как работает под капотом

Получив паттерн, планировщик Cypher выбирает точку привязки (anchor) — узел, который дешевле всего найти (обычно по индексу: {name:'Алиса'} при наличии индекса на :Person(name)). От него он разворачивает обход по стрелкам, проверяя метки, типы и свойства как фильтры. Поэтому чем конкретнее заданы свойства якорного узла, тем меньше работы у движка.

Разберём это на конкретике. Пусть паттерн такой: (a:Person {name:'Алиса'})-[:ЗНАЕТ]->(b:Person). У планировщика два кандидата на якорь: узел a и узел b. Узел b — это «любой человек», его нельзя найти точечно, пришлось бы сканировать всех людей в базе. А узел a задан именем; если на :Person(name) есть индекс, движок прыгнет прямо к Алисе за один шаг. Поэтому планировщик выберет якорем a, найдёт Алису, а затем пройдётся только по её исходящим связям :ЗНАЕТ. Это и есть знаменитая «безындексная смежность» (index-free adjacency): чтобы узнать соседей узла, не нужно искать по всей базе — связи физически записаны рядом с узлом, как список указателей.

Отсюда практический вывод: стоимость графового обхода зависит не от размера всей базы, а от локальной плотности — сколько связей выходит из узлов на пути. Найти друзей конкретного человека одинаково быстро и в базе на тысячу человек, и в базе на сто миллионов, если у самого человека связей немного. В реляционной базе тот же запрос с JOIN-ами замедлялся бы по мере роста таблиц. Это фундаментальная причина, по которой графовые СУБД берут для соцсетей, рекомендаций и графов знаний.

Частые ошибки

  • Забыть направление, когда оно важно. (a)-[:FOLLOWS]-(b) найдёт подписку в любую сторону — иногда это не то, что нужно.
  • Перепутать скобки. Узлы — круглые (), связи — квадратные []. Это первое, что путают новички.
  • Лишние переменные. Если переменная r или b дальше не используется, её можно опустить — паттерн станет чище.
  • Считать, что метка — это таблица. Метка не ограничивает узел одним «типом»: у одного узла может быть и :Person, и :Director одновременно. Мыслить метками как жёсткими таблицами — реляционная привычка, мешающая в графе.

Итоги

  • Cypher описывает форму подграфа текстом: узлы — (), связи — -[]->.
  • Узел несёт переменную, метку и фильтр-свойства; связь — переменную, тип и направление.
  • Стрелка задаёт направление; убрав её, ищем связь в любую сторону.
  • Паттерны соединяются в цепочки и ветви; планировщик стартует с самого дешёвого якорного узла.
Проверьте себя
1. Какими скобками обозначают узел и связь в Cypher?
AУзел [], связь ()
BУзел (), связь []
CОба ()
DОба {}
2. Что означает паттерн (a)-[:FOLLOWS]-(b) без стрелки?
AСвязь обязательно от a к b
BСвязь обязательно от b к a
CСвязь FOLLOWS в любом направлении между a и b
DОшибка синтаксиса
3. Как Cypher-планировщик выбирает, с чего начать обход?
AВсегда с первого узла в паттерне
BБерёт самый дешёвый якорный узел, обычно находимый по индексу
CСлучайно
DС узла без меток