Импорт данных через LOAD CSV

Самый частый способ наполнить граф реальными данными — построчная загрузка из CSV.

LOAD CSV — конструкция Cypher, которая читает CSV-файл построчно и позволяет на каждой строке выполнять CREATE/MERGE, превращая таблицу в граф.

Граф редко начинают с пустого места: данные почти всегда уже где-то лежат — в реляционной базе, в выгрузке из CRM, в Excel-таблице. Самый прямой мост между этим табличным миром и графом — формат CSV и конструкция LOAD CSV. Идея проста: вы выгружаете данные в плоские CSV-файлы (отдельно сущности, отдельно связи), а Cypher читает их построчно и на каждой строке решает, какой узел создать или какое ребро провести. Так таблица превращается в граф. Этот урок — про то, как сделать такой импорт быстрым, повторяемым и не убивающим сервер.

Базовая загрузка узлов

LOAD CSV отдаёт каждую строку как переменную, по которой можно строить узлы. С заголовками доступ идёт по имени колонки:

LOAD CSV WITH HEADERS FROM 'file:///people.csv' AS row
MERGE (p:Person {id: row.id})
SET p.name = row.name, p.born = toInteger(row.born)

MERGE (а не CREATE) делает загрузку идемпотентной — повторный импорт не наплодит дублей. toInteger приводит строку CSV к числу: из файла всё приходит текстом.

Слово «идемпотентность» здесь ключевое. Реальный импорт — это не «запустил один раз и забыл»: файл могут перезалить с исправлениями, процесс может упасть на середине и повторно стартовать, данные приходят инкрементально каждую ночь. С CREATE каждый повторный прогон плодил бы дубли одного и того же человека. С MERGE по ключу id повторный запуск либо находит уже существующий узел, либо создаёт новый — результат всегда один и тот же, сколько ни запускай. Поэтому SET внутри идёт после MERGE: он обновляет свойства и у новых, и у уже существовавших узлов, аккуратно «доводя» данные до актуального состояния.

Загрузка связей

Рёбра грузят из отдельного файла, находя оба конца по ключу и MERGE-я связь:

LOAD CSV WITH HEADERS FROM 'file:///knows.csv' AS row
MATCH (a:Person {id: row.from})
MATCH (b:Person {id: row.to})
MERGE (a)-[r:ЗНАЕТ]->(b)
SET r.since = toInteger(row.since)

Перед этим на :Person(id) обязательно должно стоять ограничение уникальности — иначе MATCH будет медленным, а MERGE рискует дублями.

Почему ограничение так важно именно при загрузке связей? Импорт миллиона рёбер делает два MATCH на каждую строку — это два миллиона поисков узла по ключу. Без индекса каждый такой поиск — полное сканирование всех людей, и импорт растягивается на часы. Ограничение уникальности CREATE CONSTRAINT FOR (p:Person) REQUIRE p.id IS UNIQUE попутно создаёт индекс, превращая каждый поиск из сканирования в мгновенный lookup. Заодно оно физически запрещает завести двух людей с одним id — страховка от грязных данных в источнике. Обратите внимание и на форму MERGE для связи: MERGE (a)-[r:ЗНАЕТ]->(b) ищет ребро именно между уже найденными a и b, поэтому повторный импорт не задвоит дружбу.

Батчи для больших файлов

Грузить миллион строк одной транзакцией опасно — память переполнится. Современный синтаксис разбивает импорт на батчи:

LOAD CSV WITH HEADERS FROM 'file:///big.csv' AS row
CALL {
  WITH row
  MERGE (p:Person {id: row.id})
  SET p.name = row.name
} IN TRANSACTIONS OF 1000 ROWS

IN TRANSACTIONS OF 1000 ROWS коммитит каждые 1000 строк, удерживая память под контролем.

Логика батчей такая же, как в уроке про транзакции: всё, что транзакция изменила, копится в памяти до commit. Одна транзакция на миллион строк — это миллион непримененных изменений в RAM, и сервер рискует упасть с нехваткой памяти. Подзапрос CALL { ... } IN TRANSACTIONS OF 1000 ROWS разбивает работу на множество маленьких транзакций по тысяче строк: каждая коммитится и освобождает память, прежде чем начнётся следующая. Размер батча подбирают опытно: слишком мелкий — лишние накладные на коммиты, слишком крупный — снова давление на память; 1000–10000 строк обычно хороший старт.

Порядок имеет значение

Сначала грузят все узлы, потом все связи. Если попытаться создать связь раньше, чем существуют её узлы, MATCH не найдёт концы и ребро не появится. Поэтому канонический порядок: constraints → узлы → связи.

Этот порядок — частая причина «молчаливых» багов импорта: ошибки нет, но половина связей не создалась. Разберём почему. Связь грузится через два MATCH, которые ищут уже существующие узлы. Если соответствующий человек ещё не загружен (его строка идёт в другом файле или позже), MATCH вернёт пусто, а MERGE для связи просто не выполнится — без всякой ошибки, ведь искать-то между чем. Поэтому канон железный: сначала CREATE CONSTRAINT (чтобы индекс был готов к моменту массовых поисков), затем загрузка всех узлов всех типов, и только в самом конце — все связи. Если перепутать, граф окажется «дырявым», и понять это удастся лишь по недобору рёбер.

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

LOAD CSV — это итератор по строкам файла, встроенный в выполнение Cypher. Каждая строка проходит через тело запроса как отдельная единица потока. Для очень больших импортов (десятки миллионов узлов) у Neo4j есть отдельный офлайн-инструмент neo4j-admin import, который строит хранилище напрямую, минуя транзакции, — он на порядки быстрее, но требует пустой базы. LOAD CSV же удобен для инкрементальных и средних загрузок в живую базу.

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

  • Грузить без ограничения на ключ. MATCH/MERGE по неиндексированному ключу на больших данных черепашьи медленный.
  • Забыть приведение типов. Из CSV всё строки; числа и даты нужно toInteger/toFloat/date().
  • Связи раньше узлов. Канон: узлы, затем рёбра.
  • Один гигантский импорт. Используйте IN TRANSACTIONS OF N ROWS для батчей.

Итоги

  • LOAD CSV WITH HEADERS читает файл построчно; на каждой строке строим узлы/связи.
  • MERGE делает импорт идемпотентным; типы приводим (toInteger и др.).
  • Порядок: constraints → узлы → связи; ключ обязательно проиндексирован.
  • Большие файлы грузим батчами IN TRANSACTIONS OF N ROWS; для гигантских — neo4j-admin import.
Проверьте себя
1. Почему в LOAD CSV для уникальных сущностей берут MERGE, а не CREATE?
AMERGE быстрее
BЧтобы повторный импорт был идемпотентным и не создавал дубли
CCREATE не работает в LOAD CSV
DMERGE не требует заголовков
2. Почему row.born нужно оборачивать в toInteger?
AДля красоты
BИз CSV все значения приходят строками, и число надо явно привести
CtoInteger удаляет пробелы
DИначе MERGE упадёт
3. Зачем нужен синтаксис IN TRANSACTIONS OF 1000 ROWS?
AДля сортировки строк
BЧтобы коммитить импорт батчами и не переполнить память на больших файлах
CЧтобы ускорить чтение файла вдвое
DЭто требование заголовков