Графові БД
Навіщо це
Section titled “Навіщо це”Продакт-менеджер «Крамниці» просить дві речі. Перша: показувати покупцям «людей, яких ви можете знати». Друга: за
запитом будувати «ланцюжок знайомств» між двома покупцями, до шести ребер. Таблиця 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 описують саме її.
Cypher: шаблон замість JOIN
Section titled “Cypher: шаблон замість JOIN”Профіль neo4j піднімає Neo4j Community. Завантажувач dataset/loaders/neo4j/ читає CSV датасету через LOAD CSV і
створює вузли Customer, Product, Seller, Category та ребра FRIEND, FOLLOWS, BOUGHT, IN_CATEGORY,
SOLD_BY. FRIEND зберігається один раз для пари, як у customer_friends.
docker compose --profile postgres --profile neo4j up -d --wait./dataset/loaders/neo4j/load.sh smalldocker 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_friendsORDER BY mutual_friends DESC, customer_idLIMIT 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 buyersORDER BY buyers DESC, product_idLIMIT 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_followingORDER BY friends_following DESC, seller_idLIMIT 2;+-------------------------------------------------------+| seller_id | seller | friends_following |+-------------------------------------------------------+| 21 | "Ярослав Гаврилюк" | 1 || 60 | "ТОВ «Веселкасервіс»" | 1 |+-------------------------------------------------------+Та сама задача в SQL
Section titled “Та сама задача в SQL”Пара в customer_friends збережена один раз, з customer_id < friend_id. Тож друзів шукають у двох напрямках:
UNION ALL двох вибірок. Результат збігається з Cypher.
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 включає й рядки, що вернулися у відвіданий вузол.
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, тобто пошуком у ширину, рядків
на кожній глибині не більше за кількість вузлів. Робота росте з розміром графа, а не з кількістю шляхів.
Найкоротший ланцюжок
Section titled “Найкоротший ланцюжок”Тепер задача з початку: ланцюжок між покупцями 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 такого раннього виходу не досягти без спирання на деталь реалізації. Тут граф справді
виграє: перевага в готовому алгоритмі пошуку шляху, а не у виразності мови.
Вибух шляхів
Section titled “Вибух шляхів”На рисунку середнє по 18 покупцях 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: інша модель
Section titled “RDF: інша модель”RDF (Resource Description Framework) влаштований інакше. Усе в ньому триплет «суб’єкт — предикат — об’єкт»: суб’єкти й предикати зазвичай IRI, об’єкт — IRI або літерал. У базовій моделі ребро властивостей не має: «Аліна дружить з Олександром з 2023 року» розкладають на кілька триплетів або беруть розширення моделі.
Запитує RDF мова SPARQL, рекомендація W3C 1.1 від 21 березня 2013 року. Природна ніша RDF: інтеграція даних з різних джерел за спільними IRI, онтології, виведення фактів із правил. Для застосунку з власними даними зручніший граф властивостей.
Стандарти: GQL і SQL/PGQ
Section titled “Стандарти: GQL і SQL/PGQ”У квітні 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–2 у PostgreSQL з індексом теж мілісекунди: на
smallпри k = 2 це 0.4 мс. - Такі задачі становлять основне навантаження, а не епізод, бо інакше їх дешевше виконувати в PostgreSQL.
- Команда готова вести ще одну систему. Дані про друзів і покупки живуть у 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]-> втратить приблизно половину друзів.
Перевір себе
Лабораторна
Section titled “Лабораторна”L10. Граф проти рекурсивного CTE: рекомендації з друзів друзів і найкоротший ланцюжок до шести ребер
у Cypher й у PostgreSQL, з перевіркою обох систем на small.
Джерела
Section titled “Джерела”- Neo4j Cypher Manual, Shortest paths, Variable-length paths, Supported optional GQL features.
- Neo4j Operations Manual, Introduction: Community і Enterprise.
- Neo4j, Get started with Neo4j: graph database.
- Neo4j, Native vs. non-native graph technology, 2023.
- Neo4j, Creating the GQL database language standard, 2024.
- ISO/IEC 39075:2024, Information technology — Database languages — GQL; ISO/IEC 9075-16:2023, Database languages SQL — Part 16: Property Graph Queries (SQL/PGQ).
- PostgreSQL 18, документація: WITH Queries (рекурсивні запити,
CYCLE), Release Notes 18. - PostgreSQL, PostgreSQL 19 Beta 4 Released, 24 вересня 2026.
- W3C, SPARQL 1.1 Query Language, Recommendation, 21 березня 2013.