انتقل إلى المحتوى الرئيسي

الوحدة 11 — نمذجة رسم بيانيّ وتحميله: قيود، وفهارس، وLOAD CSV

الرسم البيانيّ المصغَّر لفريق Veille يقتصر على إحدى عشرة عقدة: مناسب لفهم Cypher، غير كافٍ للإنتاج. تطلب إيناس من سامي أن يُحمِّل المجموعة الحقيقيّة، وهي 200 853 مقالًا من مجموعة News، إلى Neo4j حتّى يستطيع كريم بعد ذلك كتابة نظام التوصية. تُعلّمه هذه الوحدة كيف ينتقل من جدول مسطَّح (news.csv) إلى رسم بيانيّ ذي دلالة، وكيف يضع القيود والفهارس بالترتيب الصحيح، وكيف يُحمِّل كلّ ذلك في خمس وعشرين ثانية بواسطة LOAD CSV WITH HEADERS وCALL { } IN TRANSACTIONS.

من الجدول إلى الرسم: ثلاثة أسئلة

يسرد ملفّ CSV مقالات بأعمدتها؛ ويربط الرسم البيانيّ كيانات بعضها ببعض. يتمّ العبور بثلاثة أسئلة نطرحها على كلّ عمود.

  • هل سنُصفِّي أم نعبر بهذا العمود؟ إن كانت الإجابة نعم، فهو يستحقّ أن يصير عقدة. الفئة التي سنُدرجها ونُصفّيها ونتبعها عقدة؛ أمّا معرّف لا ننظر إليه أبدًا فيبقى خاصّيّة.
  • هل يربط كيانَين؟ إذن هو علاقة. «المقال X نُشر في الفئة Y» ليست خاصّيّة، بل علاقة PUBLIE_DANS.
  • هل نريد فقط عرضه؟ يبقى خاصّيّةً للعقدة التي ينتمي إليها. عنوان المقال وتاريخه ورابطه خصائص: لا نبحث عن «كلّ المقالات ذات هذا العنوان بالضبط»، بل نعرضها بعد إيجاد المقال.

قاعدتان في التسمية تجعلان الرسم البيانيّ مقروءًا.

  • التصنيفات (Labels): كلمة واحدة بالمفرد بصيغة PascalCase — Article، Categorie، Auteur. لا تكتب Articles، ولا article.
  • العلاقات: بأحرف كبيرة، غالبًا فعل مسنَد إلى الغائب — PUBLIE_DANS، ECRIT_PAR، HABITE. اتّجاه السهم من الفاعل إلى المفعول: «المقال منشور في الفئة»، إذن (:Article)-[:PUBLIE_DANS]->(:Categorie).

هذه القواعد ليست زخرفيّة: فهي تجعل نمط Cypher يُقرأ كجملة. MATCH (a:Article)-[:ECRIT_PAR]->(au:Auteur) تُقرأ بلا عناء: «المقال a كتبه المؤلّف au». هذا أوّل مكسب على SQL.

نموذج Veille

ثلاثة كيانات، وعلاقتان، بلا لبس.

(:Article {id, titre, date, lien})-[:PUBLIE_DANS]->(:Categorie {nom})
(:Article)-[:ECRIT_PAR]->(:Auteur {nom})

لكلّ مقال فئة (علاقة إلزاميّة) وصفر أو مؤلّف أو أكثر (علاقة اختياريّة ومتعدّدة). خمس خصائص إجمالًا: id وnom مفتاحا فرادة، وtitre وdate وlien للعرض والترتيب. لا شيء غير ذلك. لن نُكرِّر العنوان على المؤلّف، ولا الفئة على المقال: يفعل الرسم البيانيّ ذلك عنّا. الفكرة الحاكمة: تبقى الخصائص قليلة ومحدَّدة، بينما تحمل العلاقات كامل غنى النموذج.

لماذا نفصل Categorie عن حقل category في المقال؟

في Elasticsearch، يبقى category سلسلة keyword على المستند: هو الشيء الذي نبحث عنه. في Neo4j، نودّ سرد الفئات، وعدّ مقالاتها، والقفز من مؤلّف إلى فئاته المفضّلة — فنجعلها عقدة. لا يُنمذج المحرّكان الشيء نفسه لأنّهما لا يجيبان عن العائلة نفسها من الأسئلة.

ملفّ news.csv، شرط مسبق للتحميل

لا يُنزِّل سكربت Cypher شيئًا: يقرأ ملفّ CSV موجودًا مسبقًا على قرص حاوية Neo4j. يكتب هذا الملفّ مُستوردُ Elasticsearch (الوحدة 3) عند فهرسة 200 853 مستندًا.

  • المجلَّد على المضيف: kits/42-elasticsearch-neo4j/neo4j/import/news.csv.
  • المجلَّد داخل الحاوية: /import/news.csv، والمسار file:///news.csv في LOAD CSV.
  • كاتبه: ./lab.sh import-news، بالتوازي مع إرسال المستندات إلى فهرس news.
  • أعمدته (بهذا الترتيب، مع رأس): id، headline، category، authors، date، link.
يجب تشغيل import-news أوّلًا

بدون ملفّ news.csv، يفشل LOAD CSV برسالة « Couldn't load the external resource ». التتابع الصحيح دومًا: ./lab.sh up، ثمّ ./lab.sh import-news (الذي يكتب CSV)، ثمّ فقط ./lab.sh cypher 11-charger-news.cypher. إن غاب CSV، يُشير الخطأ إلى مجلَّد /import المضاف في الحاوية: ليس Cypher هو المكسور، بل ترتيب الخطوات.

السكربت 11-charger-news.cypher، كتلةً كتلةً

تُسلّم الحقيبة neo4j/cypher/11-charger-news.cypher. ننفّذه بأمر واحد.

./lab.sh cypher 11-charger-news.cypher

على جهاز مُبعَّد صحيحًا، ينتهي السكربت في خمس وعشرين ثانية تقريبًا. لنمشِّطه سطرًا سطرًا.

الكتلة 1 — قيود وفهارس قبل البيانات

CREATE CONSTRAINT article_id IF NOT EXISTS FOR (a:Article) REQUIRE a.id IS UNIQUE;
CREATE CONSTRAINT categorie_nom IF NOT EXISTS FOR (c:Categorie) REQUIRE c.nom IS UNIQUE;
CREATE CONSTRAINT auteur_nom IF NOT EXISTS FOR (au:Auteur) REQUIRE au.nom IS UNIQUE;
CREATE INDEX article_date IF NOT EXISTS FOR (a:Article) ON (a.date);

ثلاث قيود فرادة، واحدة لكلّ تصنيف، على الخاصّيّة التي تُميِّز الكيان (id للمقال، وnom للفئة والمؤلّف). قيد الفرادة يُنشئ فهرسًا ضمنيًّا على الخاصّيّة نفسها: يصبح MERGE (a:Article {id: 42}) بحثًا بمفتاح، لا مسحًا كاملًا. بدون هذه القيود، يتحقّق كلّ MERGE على 200 853 سطرًا من الوجود بمسح، ويستغرق التحميل ساعات بدل خمس وعشرين ثانية.

الفهرس المستقلّ على Article.date ليس مطلوبًا للتحميل: يُهيِّئ للوحدة 12 حيث سنُصفّي كثيرًا حسب فترة. IF NOT EXISTS تجعل المجموعة عديمة الأثر التكراريّ — إعادة تشغيل السكربت لا تُحدث شيئًا إن كان كلّ شيء في مكانه.

القيود تسبق البيانات

القاعدة مطلقة في التحميل الضخم: أوّلًا القيد، ثمّ MERGE. وضع القيد بعد التحميل يعمل، لكنّه يُلزم Neo4j بالتحقّق لاحقًا من ملايين الأسطر. القاعدة العكسيّة — «أوّلًا البيانات ثمّ الفهارس» — قادمة من عالم SQL ولا تنطبق على Neo4j.

الكتلة 2 — المقالات والفئات، بدفعات من 5000 سطر

LOAD CSV WITH HEADERS FROM 'file:///news.csv' AS ligne
CALL {
WITH ligne
MERGE (a:Article {id: toInteger(ligne.id)})
SET a.titre = ligne.headline,
a.date = date(ligne.date),
a.lien = ligne.link
MERGE (c:Categorie {nom: ligne.category})
MERGE (a)-[:PUBLIE_DANS]->(c)
} IN TRANSACTIONS OF 5000 ROWS;

سبعة أسطر، وثلاث أفكار.

أوّلًا، يقرأ LOAD CSV WITH HEADERS الملفّ ويحوّل كلّ سطر إلى خريطة (map) مفاتيحها رؤوس الأعمدة. كلّ حقل سلسلة نصّيّة: لهذا نكتب toInteger(ligne.id) (تمرّ الفرادة عبر عدد صحيح، لا عبر السلسلة "42")، وdate(ligne.date) (الصيغة ISO yyyy-MM-dd تتعرّف عليها الدالّة date() وتُنشئ نوعًا زمنيًّا حقيقيًّا يمكن مقارنته بـ< وترتيبه وفهرسته).

ثانيًا، تعزل الاستعلام الفرعيّ CALL { ... } العمل الذي يُنفَّذ لكلّ سطر. يحوي ثلاث MERGE: عقدة Article (تُنشأ إن لم توجد، أو تُكمَّل بـSET)، وعقدة Categorie، ثمّ العلاقة بينهما. يضمن MERGE على العلاقة أنّ المقال نفسه لن يُربَط بفئته مرّتَين حتّى إن أُعيد تشغيل السكربت.

أخيرًا، تطلب IN TRANSACTIONS OF 5000 ROWS من Neo4j أن يُحقِّق (commit) كلّ 5000 سطر بدل معاملة عملاقة واحدة. هذه الدفعة هي حلٌّ وسط: كلّ صغيرة، يهيمن كلفة المعاملة؛ وكلّ كبيرة، تنفجر الذاكرة. 5000 قيمة تصلح في كلّ مكان على جهاز بذاكرة heap 2 جيجابايت. إن قلّت الذاكرة، انزل إلى 2000 أو 1000؛ وإن توفّرت heap ضخمة وسريعة، ارفعها إلى 10 000 لتقليل التكرار.

الكتلة 3 — المؤلّفون، مع تقطيع التواقيع المشتركة

تخلط مقالات مجموعة News أحيانًا عدّة مؤلّفين في الحقل نفسه، بفاصلَين: الفاصلة ("Lee Moran, Ron Dicker") وكلمة « and » ("Lee Moran and Ron Dicker"). لا بدّ من التقطيع، وإلّا حصلنا على مؤلّف واحد بالاسم « Lee Moran, Ron Dicker »، فضاع الرابط بين الاثنَين وضاعت أساسًا فكرة التوصية بالتجاور التي سنبنيها في الوحدة القادمة.

LOAD CSV WITH HEADERS FROM 'file:///news.csv' AS ligne
WITH ligne WHERE ligne.authors <> ''
CALL {
WITH ligne
MATCH (a:Article {id: toInteger(ligne.id)})
UNWIND [x IN split(replace(ligne.authors, ' and ', ', '), ', ') WHERE trim(x) <> ''] AS nom
MERGE (au:Auteur {nom: trim(nom)})
MERGE (a)-[:ECRIT_PAR]->(au)
} IN TRANSACTIONS OF 5000 ROWS;

جديدان مقارنةً بالكتلة السابقة.

يتجاهل الفلتر WHERE ligne.authors <> '' المقالات بلا مؤلّف (نحو 8 % من المجموعة): لا نُنشئ مؤلّفًا فارغًا. يجب أن يكون خارج CALL { ... }، قبل IN TRANSACTIONS، وإلّا رفض Neo4j الصياغة.

يُعالَج المؤلّفون المشتركون بسطر واحد: replace(ligne.authors, ' and ', ', ') يُوحِّد الفواصل، وsplit(..., ', ') يُنتج قائمة، وفهم القائمة [x IN ... WHERE trim(x) <> ''] يُصفِّي المُدخلات الفارغة، وUNWIND يُعيد نشر القائمة سطرًا لكلّ مؤلّف. لكلّ اسم، MERGE (au:Auteur {nom: trim(nom)}) يُنشئ المؤلّف إن لم يوجد، وMERGE (a)-[:ECRIT_PAR]->(au) العلاقة بالمقال. trim(nom) يُزيل الفراغات الطفيليّة (« Lee Moran » و« Lee Moran » المؤلّف نفسه، لا اثنان).

يعمل MATCH (a:Article {id: toInteger(ligne.id)}) لأنّ الكتلة السابقة أنشأت كلّ المقالات مسبقًا. لو انقلب ترتيب الكتلتَين، لفشل MATCH بصمت ولم تُنشأ أيّ علاقة. هذا هو الفرق العميق مع SQL: صحّة الرسم البيانيّ تعتمد على ترتيب البناء، وليس على وجود مفاتيح أجنبيّة تفرضها القاعدة.

الكتلة 4 — الحصيلة

MATCH (a:Article)   WITH count(a) AS articles
MATCH (c:Categorie) WITH articles, count(c) AS categories
MATCH (au:Auteur) RETURN articles, categories, count(au) AS auteurs;

تعدّ سلسلة MATCH ... WITH ... MATCH ... كلّ تصنيف مع عزل النطاقات: WITH articles يحمل العدّاد السابق إلى النطاق الجديد. النتيجة:

articles | categories | auteurs
---------+------------+--------
200853 | 41 | 23082

اخْتُبرت الحقيبة من البداية إلى النهاية: يجب أن تظهر لك هذه الأرقام الثلاثة نفسها في وحدة التحكّم. إن اختلفت، فهناك سبب واحد: أعيد توليد news.csv بقيمة NEWS_LIMIT غير صفريّة، أو انقطع التحميل في منتصفه. ./lab.sh cypher 99-reset.cypher ثمّ إعادة التشغيل.

Neo4j Browser: البادئة :auto إلزاميّة

يعمل السكربت بلا مشكلة عند تشغيله من الطرفيّة عبر ./lab.sh cypher 11-charger-news.cypher. لكن إن ألصقت الكتلة 2 مباشرةً في Neo4j Browser (http://localhost:7474) وأطلقت التنفيذ، ردّ Neo4j:

A query with 'CALL { ... } IN TRANSACTIONS' can only be executed
in an implicit transaction, but tried to execute in an explicit transaction.

السبب: يُغلّف Browser افتراضيًّا كلّ استعلام بمعاملة صريحة (الـbegin / commit الخفيّ). أمّا CALL { } IN TRANSACTIONS فيُدير حوالاته على دفعات؛ والنظامان متعارضان. يجب إذن تسبيق الاستعلام بـ:auto لإخبار Browser أن يترك الاستعلام الفرعيّ يُدير بنفسه.

:auto LOAD CSV WITH HEADERS FROM 'file:///news.csv' AS ligne
CALL {
WITH ligne
MERGE (a:Article {id: toInteger(ligne.id)})
SET a.titre = ligne.headline
MERGE (c:Categorie {nom: ligne.category})
MERGE (a)-[:PUBLIE_DANS]->(c)
} IN TRANSACTIONS OF 5000 ROWS;

يمرّ ./lab.sh cypher عبر cypher-shell بالخيار -f الذي يفتح معاملة ضمنيّة أصلًا: لا حاجة إلى :auto هناك. هذه حالة تخدمك فيها الحقيبة بصمت.

:auto في Browser فقط

:auto أمر من عميل Browser، لا من لغة Cypher. يختفي في cypher-shell وفي عملاء اللغات (Python وJava وJS) لأنّ هذه العملاء تتحكّم أصلًا بوضع المعاملة.

التحقّق من الرسم البيانيّ

ثلاث استعلامات نافعة للتحقّق من التحميل ورصد الشذوذ قبل الانتقال إلى الوحدة 12. الغرض بسيط: يجب أن نتمكّن من الإجابة عن ثلاثة أسئلة قبل أيّ استعلام أعقد. كم عقدة توجد فعلًا؟ هل يطابق المخطّط ما نمذجناه؟ وهل قيود الفرادة نافذة؟

إحصاءات إجماليّة بواسطة APOC — عدد العقد والعلاقات لكلّ تصنيف:

CALL apoc.meta.stats() YIELD labels, relTypes, nodeCount, relCount
RETURN nodeCount, relCount, labels, relTypes;

يُرجع labels خريطة {Article: 200853, Categorie: 41, Auteur: 23082} ويُرجع relTypes عدّادًا حسب نوع العلاقة. هو التحقّق السريع الذي يعوض SHOW STATS: ثانيتان، وكامل الرسم البيانيّ.

المخطّط بشكل بصريّ — يرسم Neo4j Browser العقد والعلاقات بواسطة:

CALL db.schema.visualization();

المنتظر: ثلاث دوائر (Article، Categorie، Auteur) موصولة بسهمَين (PUBLIE_DANS، ECRIT_PAR). إن رأيت تصنيفات زائدة (مثل Personne من سكربت قديم)، فالقاعدة لم تُصفَّر.

القيود والفهارس — القائمة الكاملة:

SHOW CONSTRAINTS;
SHOW INDEXES;

يجب أن تجد article_id، وcategorie_nom، وauteur_nom (قيود فرادة)، وarticle_date (فهرس). يظهر كلّ قيد أيضًا في SHOW INDEXES لأنّه يُنشئ فهرسًا ضمنيًّا.

التصفير: 99-reset.cypher

يقع لكلّ منّا تصرّف خاطئ. سكربت التصفير قصير ويستخدم APOC لتفادي الشراك.

./lab.sh cypher 99-reset.cypher

محتواه:

CALL apoc.periodic.iterate(
'MATCH (n) RETURN n',
'DETACH DELETE n',
{batchSize: 10000, parallel: false}
) YIELD batches, total
RETURN batches AS lots, total AS noeuds_supprimes;

CALL apoc.schema.assert({}, {}, true) YIELD label, key, action
RETURN label, key, action;

يأخذ apoc.periodic.iterate استعلامًا مُنتِجًا (MATCH (n) RETURN n) واستعلامًا مُستهلكًا (DETACH DELETE n)، ويطبّقه على دفعات من 10 000: يُحقَّق كلّ دفعة، ولا ترتفع الذاكرة أبدًا. parallel: false يحفظ الترتيب ويمنع الإقفال المتبادل على العلاقات نفسها. بدون APOC، سيتعيّن كتابة حلقة بأنفسنا باستخدام CALL { } IN TRANSACTIONS.

أمّا الإجراء الثاني، apoc.schema.assert({}, {}, true) مع الوسيط الثالث true، فيحذف كلّ القيود والفهارس القائمة. في النهاية، تكون القاعدة فارغة من البيانات ومن المخطّط: مثاليّ لبداية جديدة. بدونه، تُعيد إعادة تشغيل 11-charger-news.cypher استخدام القيود القائمة — وهو صحيح لكنّه يمنع اختبار مسار «التثبيت الجديد». في التدريب، تشغيل 99-reset.cypher قبل كلّ نمذجة تجريبيّة عادة صحّيّة: تضمن أنّ الاختبار يعكس ما يعيشه زميلٌ يستنسخ الحقيبة لأوّل مرّة.

جرّب 1 — أعلى الفئات نشرًا

اكتب استعلام Cypher الذي يسرد أعلى خمس فئات نشرًا للمقالات، مع عددها.

الحلّ
MATCH (a:Article)-[:PUBLIE_DANS]->(c:Categorie)
RETURN c.nom AS categorie, count(a) AS articles
ORDER BY articles DESC
LIMIT 5;

المنتظر: POLITICS 32 739، وWELLNESS 17 827، وENTERTAINMENT 16 058، وTRAVEL 9 887، وSTYLE & BEAUTY 9 649. الأرقام نفسها التي أعطاها Elasticsearch بـterms، لكن هنا بعبور مباشر للعلاقات.

جرّب 2 — مقالات POLITICS في 2018

جِد المقالات المنشورة في 2018 ضمن الفئة POLITICS، مرتَّبةً من الأحدث إلى الأقدم، بحدّ أقصى خمسة.

الحلّ
MATCH (a:Article)-[:PUBLIE_DANS]->(:Categorie {nom: 'POLITICS'})
WHERE a.date >= date('2018-01-01') AND a.date <= date('2018-12-31')
RETURN a.titre, a.date
ORDER BY a.date DESC
LIMIT 5;

يستفيد الفلتر a.date >= date('2018-01-01') من فهرس article_date الموضوع في الكتلة 1: ينطلق الاستعلام مباشرةً إلى النطاق المطلوب بدل مسح 200 853 مقالًا.

جرّب 3 — المؤلّفون المشتركون مع Lee Moran

عُدّ المؤلّفين الذين شاركوا Lee Moran توقيع مقال واحد على الأقلّ (مؤلّفون مختلفون، مع استبعاد Lee Moran نفسه).

الحلّ
MATCH (lee:Auteur {nom: 'Lee Moran'})<-[:ECRIT_PAR]-(a:Article)-[:ECRIT_PAR]->(autre:Auteur)
WHERE autre <> lee
RETURN count(DISTINCT autre) AS co_auteurs;

يصعد النمط (lee)<-[:ECRIT_PAR]-(a)-[:ECRIT_PAR]->(autre) من Lee إلى مقالاته ثمّ ينزل إلى مؤلّفيها الآخرين. count(DISTINCT ...) يتفادى المكرَّرات حين يقتسم مؤلّفان عدّة مقالات. (قد يختلف رقمك قليلًا بحسب تنظيف الأسماء.)

النقاط الأساسيّة

  • كيان نُصفّيه أو نعبر عبره يصبح عقدة؛ وكيان نعرضه فقط يبقى خاصّيّة.
  • التصنيفات بالمفرد وبصيغة PascalCase (Article)، والعلاقات بصيغة أحرف_كبيرة_فعل (PUBLIE_DANS).
  • قيود الفرادة تسبق البيانات: تمنحك فهرسًا مجّانيًّا وتجعل MERGE الضخم قابلًا للتنفيذ (25 ثانية بدل ساعات).
  • LOAD CSV WITH HEADERS FROM 'file:///...' يقرأ من مجلَّد /import المضاف على neo4j/import/ — إذن ./lab.sh import-news شرط مُسبق صارم.
  • CALL { ... } IN TRANSACTIONS OF 5000 ROWS يُحقِّق على دفعات؛ في Neo4j Browser يجب تسبيقه بـ:auto، لا في ./lab.sh cypher.
  • يحوي رسم Veille البيانيّ 200 853 مقالًا، و41 فئة، و23 082 مؤلّفًا — ثلاثة أرقام تُحفَظ لما يلي.
  • apoc.periodic.iterate + apoc.schema.assert = تصفير نظيف بأمر واحد (99-reset.cypher).

استكشاف الأخطاء

  • LOAD CSV يُرجع « Couldn't load the external resource » → ملفّ news.csv ليس في neo4j/import/ → شغّل ./lab.sh import-news ثمّ أعِد ./lab.sh cypher 11-charger-news.cypher.
  • خطأ « A query with CALL { ... } IN TRANSACTIONS can only be executed in an implicit transaction » → أنت داخل Neo4j Browser → سبّق الاستعلام بـ:auto، أو مرّ عبر ./lab.sh cypher 11-charger-news.cypher.
  • يستغرق التحميل دقائق ولا ينتهي → لم تُنشأ القيود قبل MERGE./lab.sh cypher 99-reset.cypher ثمّ إعادة 11-charger-news.cypher بالترتيب.
  • apoc.periodic.iterate يُرجع « Unknown procedure » → الحاوية neo4j أُقلعت بلا APOC → ./lab.sh logs neo4j (ابحث عن « Loaded apoc »)، وإلّا ./lab.sh reset ثمّ ./lab.sh up.

للاستزادة

الوحدة التالية: استثمار رسم News بـCypher متقدّم — مسارات ذات طول متغيّر، وتجميعات، وWITH/UNWIND، وPROFILE، ومنطق توصية حقيقيّ بالتجاور.