Перейти до вмісту

Графові БД

Продакт-менеджер «Крамниці» просить дві речі. Перша: показувати покупцям «людей, яких ви можете знати». Друга: за запитом будувати «ланцюжок знайомств» між двома покупцями, до шести ребер. Таблиця customer_friends є, на розмірі small (див. Датасет) у ній 7 452 пари.

Друзів друзів дають два JOIN, і це мілісекунди. З ланцюжком гірше. Рекурсивний CTE за зразком із модуля 3 у графі друзів не завершується: дружба має цикли, і запит ходить по колу. Із захистом від циклів він завершується, але з покупця, в якого 18 друзів, іде 1.8 с. З індексом по friend_id те саме займає 70 мс, а SHORTEST у Neo4j відповідає за одиниці мілісекунд.

Скільки з цієї різниці дає модель, скільки індекс, скільки алгоритм, а скільки маркетинг, розбираємо нижче. Числа виміряно на «Крамниці» в PostgreSQL 18.6 і Neo4j 2026.08.1 Community в Docker.

Передумови. Рекурсивні CTE на дереві категорій і CYCLE: модуль 3. Індекси: модуль 8. Плани виконання: модуль 9.

Граф: вузли, ребра, властивості

Section titled “Граф: вузли, ребра, властивості”

Дружбу легко намалювати: покупці — кружечки, друзі — лінії між ними. Граф властивостей (property graph) складається з вузлів (nodes) і ребер (relationships). Вузол має мітки (labels), як Customer чи Product. Ребро має тип, як FRIEND чи BOUGHT, і напрямок.

Властивості, пари «ключ — значення», можна повісити і на вузол, і на ребро. since на FRIEND каже, відколи дружать, status на BOUGHT — який був статус замовлення. Таку модель мають Neo4j і Memgraph, а GQL і SQL/PGQ описують саме її.

Фрагмент графа: Аліна Руденко і Марина Білоус повʼязані через двох спільних друзів; обидві купували товари категорії «Шапки, шарфи, рукавиці»; Аліна підписана на продавцяFRIENDFRIENDFRIENDFRIENDFOLLOWSBOUGHTBOUGHTIN_CATEGORYIN_CATEGORYCustomer · 4279Аліна РуденкоCustomer · 3462Олександр ШевченкоCustomer · 4709Вікторія ДмитрівськаCustomer · 2965Марина БілоусSeller · 41ТОВ «Непосидаплюс»Product · 1680Шарф Halychyna Basic 25Product · 1187Рукавиці Nitka Family 23Category · 44Шапки, шарфи, рукавиці
Фрагмент графа «Крамниці» small. Аліна Руденко і Марина Білоус мають двох спільних друзів і купували в категорії «Шапки, шарфи, рукавиці». Ребра FRIEND зображено без стрілок: у Cypher їх читають у будь-який бік.

Профіль neo4j піднімає Neo4j Community. Завантажувач dataset/loaders/neo4j/ читає CSV датасету через LOAD CSV і створює вузли Customer, Product, Seller, Category та ребра FRIEND, FOLLOWS, BOUGHT, IN_CATEGORY, SOLD_BY. FRIEND зберігається один раз для пари, як у customer_friends.

Terminal window
docker compose --profile postgres --profile neo4j up -d --wait
./dataset/loaders/neo4j/load.sh small
docker compose exec neo4j sh -c 'cypher-shell -u neo4j -p "${NEO4J_AUTH#neo4j/}"'

Cypher малює шаблон текстом: вузли в круглих дужках, ребра в квадратних. (a)-[:FRIEND]-(b) означає «між a і b є ребро FRIEND у будь-який бік». Далі small.

Друзі друзів покупця 4279 (Аліна Руденко): два кроки, не вона сама і не її прямі друзі.

MATCH (me:Customer {customer_id: 4279})-[:FRIEND]-(f:Customer)-[:FRIEND]-(c:Customer)
WHERE c <> me AND NOT EXISTS { (me)-[:FRIEND]-(c) }
RETURN c.customer_id AS customer_id, c.full_name AS name, count(DISTINCT f) AS mutual_friends
ORDER BY mutual_friends DESC, customer_id
LIMIT 5;
+--------------------------------------------------------+
| customer_id | name | mutual_friends |
+--------------------------------------------------------+
| 4583 | "Максим Яремчук" | 3 |
| 4634 | "Мирослава Савченко" | 3 |
| 2376 | "Олександр Вишневський" | 2 |
| 2965 | "Марина Білоус" | 2 |
| 4469 | "Галина Гордій" | 2 |
+--------------------------------------------------------+

c <> me прибирає саму Аліну: шлях «до друга й назад» повертає її як друга друга. NOT EXISTS прибирає прямих друзів, які теж можуть бути друзями друзів. Без них список правдоподібний, але хибний.

«Покупці, що купили цей товар, купували також». Товар 58, блендер Trembita Tech. Скасовані замовлення не рахуємо: статус лежить на ребрі BOUGHT.

MATCH (p:Product {product_id: 58})<-[b1:BOUGHT]-(buyer:Customer)-[b2:BOUGHT]->(other:Product)
WHERE other <> p AND b1.status <> 'cancelled' AND b2.status <> 'cancelled'
RETURN other.product_id AS product_id, other.title AS title, count(DISTINCT buyer) AS buyers
ORDER BY buyers DESC, product_id
LIMIT 3;
+----------------------------------------------------------------+
| product_id | title | buyers |
+----------------------------------------------------------------+
| 465 | "Плюшевий ведмедик Malyuk Basic 37, бежевий" | 419 |
| 2410 | "Настільна лампа Pryhoda Urban, синя" | 392 |
| 602 | "Настільна гра Veselka Basic" | 199 |
+----------------------------------------------------------------+

Перші два рядки є найпопулярнішими товарами магазину взагалі: ведмедика купили 1 186 покупців із 5 000. Лічильник показує популярність, а не схожість.

Продавці, на яких підписані друзі покупця 4279, але не вона сама:

MATCH (me:Customer {customer_id: 4279})-[:FRIEND]-(f:Customer)-[:FOLLOWS]->(s:Seller)
WHERE NOT EXISTS { (me)-[:FOLLOWS]->(s) }
RETURN s.seller_id AS seller_id, s.name AS seller, count(DISTINCT f) AS friends_following
ORDER BY friends_following DESC, seller_id
LIMIT 2;
+-------------------------------------------------------+
| seller_id | seller | friends_following |
+-------------------------------------------------------+
| 21 | "Ярослав Гаврилюк" | 1 |
| 60 | "ТОВ «Веселкасервіс»" | 1 |
+-------------------------------------------------------+

Пара в customer_friends збережена один раз, з customer_id < friend_id. Тож друзів шукають у двох напрямках: UNION ALL двох вибірок. Результат збігається з Cypher.

СпробуйPostgreSQLCtrl+Enter — виконати
customer_id | full_name | mutual_friends
-------------+-----------------------+----------------
4583 | Максим Яремчук | 3
4634 | Мирослава Савченко | 3
2376 | Олександр Вишневський | 2
2965 | Марина Білоус | 2
4469 | Галина Гордій | 2

Для фіксованої глибини 1–2 це звичайний JOIN, і графова база нічого не додає.

Змінна глибина: рекурсивний CTE і цикли

Section titled “Змінна глибина: рекурсивний CTE і цикли”

Фіксовану глибину пишуть через JOIN. Змінну без рекурсії не написати: невідомо, скільки разів з’єднувати customer_friends із собою. Рекурсивний CTE з модуля 3 ходив по дереву категорій, де в кожного вузла один батько й циклів немає. У графі друзів цикли є: з Аліни до Олександра, до Марини, до Вікторії й знову до Аліни.

З UNION ALL така рекурсія не зупиниться, бо кожен крок знаходить наступний. Документація PostgreSQL: рекурсивна частина має зрештою перестати повертати рядки, інакше запит не завершиться. Зупинити її можна чотирма способами.

  • Межа глибини (WHERE depth < 6). Скінченна, але рахує ходи, які вертаються в той самий вузол.
  • Масив відвіданих (n.id <> ALL (path)). Шлях не вертається у вузол, де вже був.
  • Клауза CYCLE (PostgreSQL 14 і новіші): СУБД сама веде масив і додає булеву колонку. Рядок, у якому вузол повторився, потрапляє у вивід з is_cycle = true, і з нього рекурсія далі не йде.
  • UNION замість UNION ALL з рядками без шляху, (id, depth). Дублі відкидаються, тож кожен вузол розглядається на кожній глибині один раз. Це пошук у ширину (BFS, breadth-first search): ціна пропорційна кількості ребер, а не шляхів.

Що робить CYCLE на друзях покупця 4279 (small): rows включає й рядки, що вернулися у відвіданий вузол.

СпробуйPostgreSQLCtrl+Enter — виконати
depth | rows | cycle_rows | paths | distinct_friends
-------+-------+------------+-------+------------------
0 | 1 | 0 | 1 | 1
1 | 12 | 0 | 12 | 12
2 | 82 | 12 | 70 | 51
3 | 437 | 90 | 347 | 207
4 | 2135 | 437 | 1698 | 790
5 | 10437 | 2008 | 8429 | 2121

На глибині 2 дванадцять рядків вернулися до Аліни: це кроки «до друга й назад». Шляхів на глибині 5 вісім тисяч чотириста, а різних друзів дві тисячі, бо до одного друга ведуть різні шляхи. З UNION, тобто пошуком у ширину, рядків на кожній глибині не більше за кількість вузлів. Робота росте з розміром графа, а не з кількістю шляхів.

Тепер задача з початку: ланцюжок між покупцями 152 і 8. У Cypher SHORTEST 1 повертає один найкоротший шлях, {1,6} обмежує його шістьма ребрами.

MATCH p = SHORTEST 1 (a:Customer {customer_id: 152})-[:FRIEND]-{1,6}(b:Customer {customer_id: 8})
RETURN [n IN nodes(p) | n.customer_id] AS chain, length(p) AS hops;
+-----------------------------------------------+
| chain | hops |
+-----------------------------------------------+
| [152, 1982, 2074, 4997, 2655, 3550, 8] | 6 |
+-----------------------------------------------+

У SQL це записують двома способами. Перший сортує: ORDER BY cardinality(path) LIMIT 1 змушує перебрати всі шляхи до глибини 6, навіть коли відповідь знайдено на третьому кроці.

WITH RECURSIVE walk(id, path) AS (
SELECT 152::bigint, ARRAY[152::bigint]
UNION ALL
SELECT n.id, w.path || n.id
FROM walk AS w
CROSS JOIN LATERAL (
SELECT friend_id AS id FROM customer_friends WHERE customer_id = w.id
UNION ALL
SELECT customer_id FROM customer_friends WHERE friend_id = w.id
) AS n
WHERE cardinality(w.path) <= 6 AND n.id <> ALL (w.path)
)
SELECT path FROM walk WHERE id = 8 ORDER BY cardinality(path) LIMIT 1;

Цей запит лише для Docker: у пісочниці він не завершився за 6 хвилин, PostgreSQL виконує його за 1.8 с.

Другий спосіб прибирає ORDER BY. Рекурсія в PostgreSQL йде по рівнях, тож перший знайдений рядок лежить на найменшій глибині, а зовнішній запит із LIMIT 1 не просить решти. Документація описує цей прийом, але застерігає: порядок виводу рекурсії є деталлю реалізації, і в промисловому коді на нього спиратися не варто. Виміряно на small:

Запит Пара Без індексу по friend_id З індексом
ORDER BY … LIMIT 1 152 → 8, 6 ребер 1.8 с 70 мс
ORDER BY … LIMIT 1 4279 → 3333, 4 ребра 1.5 с 60 мс
LIMIT 1 без сортування 4279 → 3333 14 мс не мірили
LIMIT 1 без сортування 152 → 102, шляху немає 1.8 с не мірили
SHORTEST 1 у Neo4j будь-яка з цих пар до 2 мс (перший виклик 40 мс)

Передостанній рядок показує межу прийому: коли шляху немає, перебирається все. За вимірами, SHORTEST не перебирає всіх шляхів до глибини 6, а в SQL такого раннього виходу не досягти без спирання на деталь реалізації. Тут граф справді виграє: перевага в готовому алгоритмі пошуку шляху, а не у виразності мови.

На рисунку середнє по 18 покупцях small, у яких є друзі.

Кількість ходів, простих шляхів і різних друзів залежно від кількості кроків k від 1 до 8: ходи й шляхи зростають приблизно в 5–6 разів на крок, а кількість різних друзів впирається в розмір компоненти 319811010^210^310^410^510^610^712345678кількість кроків kна одного покупця, лог. шкаларозмір компоненти: 31981 064 374250 2143 191ходи (вузли повторюються)прості шляхирізні друзі в межах k кроків
На кожному кроці кількість ходів і простих шляхів зростає приблизно в 5–6 разів, а кількість різних друзів впирається в розмір зв'язної частини графа (3 198 покупців із 5 000, решта без друзів або в малих компонентах). Шкала логарифмічна, PostgreSQL 18.6, small.

Граф друзів у small не масштабно-вільний: у покупця не більше 18 друзів, у середньому 4.65. Все одно на восьмому кроці з одного покупця виходить 250 тисяч простих шляхів і понад мільйон ходів, а різних людей, до яких вони ведуть, лише 3 191. Запит, що перебирає шляхи, робить у сотні разів більше роботи, ніж той, що перебирає вузли. Це властивість задачі, а не системи.

У Cypher RETURN count(*) над [:FRIEND*1..6] перебирає всі реброво-унікальні шляхи (VarLengthExpand(All)). RETURN count(DISTINCT b) Neo4j виконує пошуком у ширину (VarLengthExpand(Pruning,BFS,All)): для 20 стартових покупців small при k = 6 це в середньому 16 716 шляхів проти 2 630 різних друзів. Якщо потрібні вузли або найкоротший шлях, пишіть пошук у ширину (UNION, SHORTEST, count(DISTINCT)). Якщо потрібні самі шляхи, їх кількість обмежена лише глибиною, і жодна база цього не прибере.

Пастка: ребро в шаблоні береться один раз

Section titled “Пастка: ребро в шаблоні береться один раз”

В одному шаблоні MATCH кожне ребро беруть не більше одного разу. Для друзів це доречно: обхід не ходить туди й назад.

Але шлях «покупець — товар — категорія — товар — інший покупець» ламається, коли обидва купили той самий товар: ребро IN_CATEGORY довелося б пройти двічі, і категорія випадає. Для покупця 152 і кандидата 848 такий запит знайшов 4 спільні категорії (small), а їх 5. Виправляє це розбиття на два MATCH: унікальність діє в кожному окремо. У SQL такої пастки немає, бо JOIN не знає, що ребро вже було. У L10 ви на неї натрапите.

Як це насправді: index-free adjacency і маркетинг

Section titled “Як це насправді: index-free adjacency і маркетинг”

Index-free adjacency означає, що кожен вузол зберігає прямі посилання на сусідні. Блог Neo4j про рідне й нерідне графове сховище описує це так: перехід по ребру є розіменуванням вказівника, а не пошуком в індексі. Звідси обіцянка: швидкість обходу залежить від кількості торкнутих вузлів, а не від розміру всього графа. Вступна сторінка документації додає, що графова база обходиться без JOIN.

Перевіримо. Завдання: скільки різних друзів у покупця на відстані до k ребер. У Cypher це MATCH (a {customer_id: $id})-[:FRIEND*1..k]-(b) RETURN count(DISTINCT b), у PostgreSQL рекурсивний CTE з UNION, який відкидає повторно відвідані пари (вузол, глибина). Двадцять стартових покупців, по 3 прогони, медіана в мілісекундах (клієнт усередині контейнера; cypher-shell показує час із точністю до мілісекунди):

Розмір k Друзів у середньому PostgreSQL без індексу по friend_id PostgreSQL з індексом Neo4j
small 2 24.8 0.8 0.4 менше 1
small 3 113 3.2 0.6 менше 1
small 4 476 14.1 1.0 1
small 6 2 630 248 6.9 2
medium 2 32.9 9.9 0.4 менше 1
medium 3 171 46.5 0.7 менше 1
medium 4 845 215 1.3 1
medium 6 13 184 не мірили 15.9 8

Перший висновок: різниця понад 150 разів між PostgreSQL без індексу і з індексом на medium при k = 4 більша за різницю між PostgreSQL з індексом і Neo4j. Пара збережена один раз, з меншим ідентифікатором першим, тож пошук у другий бік (WHERE friend_id = …) без індексу читає всю таблицю на кожному кроці. Причина у відсутньому індексі (модуль 8).

Другий: з індексом PostgreSQL повільніший за Neo4j у 2–3 рази, на глибині 6 це 15.9 проти 8 мс на medium, а не на порядки. Від small до medium, десять разів більше даних, час PostgreSQL з індексом при k = 4 змінився з 1.0 до 1.3 мс, хоча друзів на цій глибині стало більше (476 і 845). Вимога «час залежить від торкнутих даних, а не від розміру графа» виконується й у PostgreSQL.

Де ж маркетинг? Старт обходу теж шукається по індексу. План запиту count(DISTINCT b) при k = 4 (PROFILE, скорочено):

+VarLengthExpand(Pruning,BFS,All) (a)-[:FRIEND*..4]-(b) rows 677 db hits 2067
+NodeUniqueIndexSeek UNIQUE a:Customer(customer_id) WHERE ... = $id rows 1 db hits 2

Без обмеження унікальності довелося б сканувати всі вузли Customer. Вказівник замінює індекс лише на кроках обходу. Вигода реальна, але вимірюється разами і лише тоді, коли індекс у PostgreSQL на місці. Скільки це дає на full (300 тисяч покупців), ми не вимірювали.

RDF (Resource Description Framework) влаштований інакше. Усе в ньому триплет «суб’єкт — предикат — об’єкт»: суб’єкти й предикати зазвичай IRI, об’єкт — IRI або літерал. У базовій моделі ребро властивостей не має: «Аліна дружить з Олександром з 2023 року» розкладають на кілька триплетів або беруть розширення моделі.

Запитує RDF мова SPARQL, рекомендація W3C 1.1 від 21 березня 2013 року. Природна ніша RDF: інтеграція даних з різних джерел за спільними IRI, онтології, виведення фактів із правил. Для застосунку з власними даними зручніший граф властивостей.

У квітні 2024 року ISO опублікувала ISO/IEC 39075:2024, GQL (Graph Query Language). Це не розширення SQL, а окрема мова для графів властивостей. Її розробляв той самий комітет SC32/WG3, і вона увібрала досвід openCypher, PGQL і G-CORE.

Neo4j наближає Cypher до GQL у версії, яку називає Cypher 25: це типова мова сервера в нашому контейнері (db.query.default_language = CYPHER_25). Запит із SHORTEST 1 і -[:FRIEND]-{1,6} вище написано за правилами GQL: квантифікований шаблон шляху й ключове слово SHORTEST. Старі shortestPath() і allShortestPaths() працюють, але, за документацією Neo4j, GQL не відповідають.

SQL/PGQ (Property Graph Queries) — частина 16 стандарту SQL:2023, ISO/IEC 9075-16:2023, червень 2023 року. Вона дозволяє описати таблиці як вузли й ребра (CREATE PROPERTY GRAPH) і запитувати їх шаблонами в стилі GQL усередині SQL (GRAPH_TABLE). Нова база не потрібна: граф є представленням над таблицями.

-- форма запиту за SQL/PGQ; у PostgreSQL 18 не виконується
CREATE PROPERTY GRAPH shop_graph
VERTEX TABLES (customers KEY (customer_id))
EDGE TABLES (customer_friends KEY (customer_id, friend_id)
SOURCE KEY (customer_id) REFERENCES customers (customer_id)
DESTINATION KEY (friend_id) REFERENCES customers (customer_id));
SELECT * FROM GRAPH_TABLE (shop_graph
MATCH (a IS customers WHERE a.customer_id = 4279)-[IS customer_friends]->(b IS customers)
COLUMNS (b.customer_id));

Синтаксис наведено за стандартом; так само його реалізує, наприклад, Oracle Database 23ai.

У PostgreSQL 18 SQL/PGQ немає: у нотатках до 18.0 (25 вересня 2025 року) про GRAPH_TABLE і CREATE PROPERTY GRAPH не йдеться, а в нашому контейнері 18.6 обидві команди завершуються синтаксичною помилкою:

ERROR: syntax error at or near "PROPERTY"
LINE 1: CREATE PROPERTY GRAPH shop_graph VERTEX TABLES (customers KE...

У розробницькій гілці 19 підтримку додали, але перед випуском відкотили: оголошення PostgreSQL 19 Beta 4 від 24 вересня 2026 року серед змін називає «Revert SQL/PGQ (property graph query) support», а в нотатках до 19 її немає. Станом на цей текст SQL/PGQ немає в жодній випущеній версії PostgreSQL.

Коли граф варто окремої системи

Section titled “Коли граф варто окремої системи”

Окрема графова база виправдана, коли виконуються всі три умови.

  1. Запити мають змінну або велику глибину. «Усі, кого можна досягти», «найкоротший шлях», зв’язки третього порядку й далі. Фіксована глибина 1–2 у PostgreSQL з індексом теж мілісекунди: на small при k = 2 це 0.4 мс.
  2. Такі задачі становлять основне навантаження, а не епізод, бо інакше їх дешевше виконувати в PostgreSQL.
  3. Команда готова вести ще одну систему. Дані про друзів і покупки живуть у PostgreSQL, тож у Neo4j їх треба перевантажувати (CDC, тобто потік змін із бази, або періодичний LOAD CSV, як у нашому завантажувачі), стежити за розбіжністю й мати друге резервне копіювання. Neo4j Community працює одним екземпляром без кластера й з єдиною базою (CREATE DATABASE повертає «not supported in community edition»), а ACID у ньому є, як і в PostgreSQL.

Для змінної глибини на помірних даних вистачає рекурсивного CTE з індексом, UNION чи CYCLE. Окремий граф виграє там, де алгоритми складні, як PageRank чи пошук спільнот, або ребер сотні мільйонів. Нашого датасету (full: 477 тисяч ребер) на це не вистачає, і таких випадків ми не вимірювали.

Типові помилки розуміння

Section titled “Типові помилки розуміння”

«Графова база швидша, бо в ній немає JOIN». На medium при k = 4 PostgreSQL без індексу по friend_id витрачає 215 мс, з індексом 1.3 мс, Neo4j близько 1 мс. Основну різницю дає індекс, решту, в межах кількох разів, вказівники замість B-дерева.

«Рекурсивний CTE на графі завжди зациклюється». Зациклюється UNION ALL без захисту. Межа глибини, масив відвіданих, CYCLE і UNION роблять запит скінченним, а пошук у ширину ще й дешевим.

«Cypher сам знаходить найкоротший шлях». MATCH (a)-[:FRIEND*..6]-(b) повертає всі шляхи довжиною до шести ребер, і їх тисячі. Найкоротший дає SHORTEST 1 або shortestPath().

«Дружба взаємна, тож потрібні два ребра». FRIEND зберігає пару один раз, а читають його без напрямку. -[:FRIEND]-> втратить приблизно половину друзів.

Перевір себе

1. Потрібні «друзі друзів» покупця: рівно два кроки, не він сам і не його прямі друзі. Що доречніше?
2. Рекурсивний CTE з `UNION ALL` шукає ланцюжок між двома покупцями, дружба має цикли. Що гарантує завершення?
3. У PostgreSQL 18.6 виконали `CREATE PROPERTY GRAPH shop_graph …`. Що буде?
4. На `medium` при k = 4 PostgreSQL без індексу по `friend_id` дає 215 мс, з індексом 1.3 мс, Neo4j близько 1 мс. Чим пояснюється різниця між 215 і 1.3?
5. Cypher-запит шукає категорії, у яких купували і Аліна, і кандидат, одним шаблоном `(me)-[:BOUGHT]->(:Product)-[:IN_CATEGORY]->(cat)<-[:IN_CATEGORY]-(:Product)<-[:BOUGHT]-(c)`. Чому для частини кандидатів категорій менше, ніж є?

L10. Граф проти рекурсивного CTE: рекомендації з друзів друзів і найкоротший ланцюжок до шести ребер у Cypher й у PostgreSQL, з перевіркою обох систем на small.