الوحدة 12 — Cypher متقدّم: المسارات، والتجميعات، والتوصية
رسم News البياني في مكانه: 200 853 مقالًا، و41 فئة، و23 082 مؤلّفًا، حُمِّلوا في خمس وعشرين ثانية بالوحدة 11. يريد كريم الآن أن يُضيف إلى منصّة Veille وظيفةً يطلبها عملاؤه: «لهذا المؤلّف، اقترح عليّ ثلاثة مؤلّفين قريبين لأتابعهم». تضع إيناس القيد: يجب أن تسع التوصية استعلامَ Cypher واحدًا مقروءًا، يعتمد على الرسم البيانيّ لا على نموذج إحصائيّ ثقيل. تكتب هذه الوحدة ثمانية استعلامات تصاعديّة تُجيب عن أسئلة أغنى فأغنى، وصولًا إلى التوصية بالتجاور، عبر الاستفادة من الوسائط، وPROFILE، وshortestPath، وأنماط الطول المتغيّر.
إعطاء وسيط: :param و$nom
كلّ الاستعلامات التي تلي تتحدّث عن مؤلّف مرجعيّ. بدل كتابة « Lee Moran » عشرين مرّة، نضبطه مرّةً واحدة في Neo4j Browser وسيطًا للجلسة.
:param nom => 'Lee Moran'
:param أمر Browser (مثل :auto في الوحدة 11)، لا أمرٌ في اللغة. يُسجِّل $nom طوال الجلسة: كلّ استعلام يستخدم $nom دون الالتفات إلى القيمة. في ./lab.sh cypher، نمرِّر الوسائط من Python أو من سطر أوامر cypher-shell. النقطة المهمّة: الوسائط ليست تسلسلًا للسلاسل، بل متغيّرات حقيقيّة — تُحفَظ خطّة التنفيذ في المخبأ وتُعاد، ويستحيل حقن Cypher.
في شيفرة تطبيق حقيقيّة (الوحدة 13)، نكتب session.run("MATCH (au:Auteur {nom: $nom}) ...", nom="Lee Moran"). تسلسل القيمة داخل السلسلة يُفجِّر مخبأ الخطط ويفتح باب حقن Cypher. القاعدة نفسها تصلح لـElasticsearch ولكلّ قاعدة حديثة.
الاستعلام 1 — الفئات المفضّلة عند مؤلّف
سؤال الأعمال: «في أيّ الفئات يكتب Lee Moran أكثر، وكم مقالًا تُمثِّل كلّ فئة؟». هذا MATCH على نمط بثلاث عقد، متبوعًا بـcount.
MATCH (au:Auteur {nom: $nom})<-[:ECRIT_PAR]-(a:Article)-[:PUBLIE_DANS]->(c:Categorie)
RETURN c.nom AS categorie, count(a) AS articles
ORDER BY articles DESC
LIMIT 10;
قراءة النمط: ننطلق من المؤلّف، نصعد إلى المقالات التي كتبها (السهم الداخل <-[:ECRIT_PAR]-)، وننزل إلى فئتها (السهم الخارج -[:PUBLIE_DANS]->). عبوران، ودون أيّ jointure صريحة.
النتيجة المنتظرة لـ$nom = 'Lee Moran':
categorie | articles
---------------+---------
COMEDY | 779
WEIRD NEWS | 391
ENTERTAINMENT | 387
POLITICS | 264
SPORTS | 101
...
المجموع على كلّ الفئات: 2 433 مقالًا، رقم Lee Moran الوفير المستعاد من الوحدة 4.
الاستعلام 2 — المؤلّفون القريبون بتجاور الفئات
قلب التوصية: نبحث عن المؤلّفين الذين يكتبون ف ي الفئات نفسها التي يكتب فيها Lee، ونُصنِّفهم بعدد الفئات المشتركة.
MATCH (au:Auteur {nom: $nom})<-[:ECRIT_PAR]-(:Article)-[:PUBLIE_DANS]->(c:Categorie)
MATCH (c)<-[:PUBLIE_DANS]-(:Article)-[:ECRIT_PAR]->(autre:Auteur)
WHERE autre <> au
RETURN autre.nom AS auteur, count(DISTINCT c) AS categories_communes, count(*) AS articles_partages
ORDER BY categories_communes DESC, articles_partages DESC
LIMIT 10;
اثنان من MATCH متسلسلان (رؤي تان للمسار نفسه)، وفلتر WHERE autre <> au يستبعد Lee من جواره (بدون هذا السطر، سيكون أقرب مؤلّف إلى Lee هو Lee نفسه)، وعدّادان — عدد الفئات المشتركة المتمايزة، والحجم الخام للمقالات المعنيّة. الثاني يفصل بين المؤلّفين المتعادلين في الأوّل.
هي توصية بتشابه المحتوى: لا نحاول محاكاة خوارزميّة تعلُّم آليّ، بل نستثمر طوبولوجيا الرسم البيانيّ. المؤلّفون الأعلى تصنيفًا هم الذين يكتبون في الفئات الخمس المفضّلة عند Lee؛ سيصنعون مرشَّحين جيّدين لصفحة « اقرأ أيضًا ». (قد تختلف أسماؤك قليلًا بحسب تنظيف المؤلّفين المتعدّدين.) قوّة هذه الطريقة أنّها تشرح نفسها: يمكن أن نُقدِّم لكلّ توصية عبارةً واضحة من نوع «مقترَح لأنّ المؤلّف يشارك خمس فئات مشتركة مع Lee»، وهذا شرط لا يمنحه أيّ نموذج علبةً سوداء.
الاستعلام 3 — مقالات حديثة لمشاركي مقال معيّن
سؤال الأعمال: «للمقال رقم 100، من شاركه، وماذا نشر هؤلاء من مقالات أحدث في مكان آخر؟». م سار بمرحلتَين يستعمل WITH لحمل المؤلّفين إلى المرحلة الثانية.
MATCH (:Article {id: 100})-[:ECRIT_PAR]->(au:Auteur)
WITH au
MATCH (au)<-[:ECRIT_PAR]-(autre:Article)
RETURN au.nom AS auteur, autre.titre AS titre, autre.date AS date
ORDER BY autre.date DESC
LIMIT 5;
يلعب WITH هنا دور الأنبوب: نأخذ المؤلّفين الذين وجدناهم في المرحلة 1 ونحقنهم في المرحلة 2. بدون WITH، ينطلق ك لّ MATCH من الصفر ويضيع الرابط المنطقيّ. يستفيد الفرز ORDER BY autre.date DESC من فهرس article_date الموضوع في الوحدة 11: لا نُفرِز 200 000 سطر، بل نقرأ الفهرس بترتيب تنازليّ.
الاستعلام 4 — أقصر مسار بين مؤلّفَين
سؤال بيانيّ صرف: «على أيّ مسافة يلتقي Lee Moran وEd Mazza في الرسم البيانيّ؟». الجواب بواسطة shortestPath ونمط طول متغيّر.
MATCH (a:Auteur {nom: 'Lee Moran'}), (b:Auteur {nom: 'Ed Mazza'})
MATCH chemin = shortestPath((a)-[*..6]-(b))
RETURN [n IN nodes(chemin) | coalesce(n.nom, n.titre)] AS etapes,
length(chemin) AS longueur;
يطلب shortestPath((a)-[*..6]-(b)) أقصر مسار، بكلّ العلاقات، حتّى ستّ قفزات. الحدّ الأعلى ضروريّ: بدونه، قد يستكشف Neo4j الرسم البيانيّ بأكمله.
يمرّ المسار عادةً بمقال مشترك (Auteur → Article → Auteur، طول 2)، أو بفئة مشتركة (Auteur → Article → Categorie → Article → Auteur، طول 4). يبني فهم القائمة [n IN nodes(chemin) | coalesce(n.nom, n.titre)] قائمة المراحل المقروءة، آخذًا nom إن كانت العقدة مؤلّفًا أو فئة، وtitre إن كانت مقالًا. (يعتمد الطول الدقيق على تواقيع مشتركك.) هذا العرض «سردًا» للطريق أحد أجمل أوجه Cypher: تُرى بأمّ عينك لماذا اعتبر المحرّك مؤلّفَين قريبَين، وتفهم إن كانت القرابة عبر مقال حقيقيّ أم عبر فئة بعيدة.
shortestPath((a)-[*]-(b)) بلا حدّ يستكشف بالعرض كامل المكوّن المتّصل. على رسم بيانيّ من 200 000 عقدة، هذه كارثة. اصطلاح Veille: لا تتجاوز ستّ قفزات لبحث تجاور.
الاستعلام 5 — مسارات ذات طول متغيّر *1..3
يُرجع shortestPath مسارًا واحدًا. لاسترجاع كلّ المسارات حتّى طول محدَّد، نستخدم مباشرةً المحدِّد *min..max على العلاقة.
MATCH chemin = (a:Auteur {nom: 'Lee Moran'})-[*1..3]-(voisin:Auteur)
WHERE voisin <> a
RETURN voisin.nom AS voisin, length(chemin) AS distance
ORDER BY distance ASC, voisin.nom
LIMIT 10;
يُتيح *1..3 علاقةً واحدة أو اثنتَين أو ثلاثًا بأيّ اتّجاه (لم يعد للنمط سهم > موجَّه). يستبعد الفلتر WHERE voisin <> a مؤلّف الانطلاق. يكلِّف هذا الاستعلام أكثر من سابقه (يستكشف كلّ شيء، لا الأقصر فقط) ويلائم LIMIT صارمًا.
في الممارسة، للتوصية، الاستعلام 2 مُفضَّل — أسرع، وأوضح، وأكثر قابليّة للتفسير. تتألّق مسارات الطول المتغيّر خاصّةً في الكشف عن اتّصال («هل يوجد رابط على ثلاث قفزات بين X وY؟») أكثر من ترتيبه. حين تُوصَف حالة الاستخدام «كشف علاقة موجودة»، اختر النمط *min..max؛ وحين تُوصَف بـ«رتِّب المرشَّحين»، فضِّل مسارًا مضبوط الشكل مع تجميع محدَّد.
الاستعلام 6 — OPTIONAL MATCH، وWITH، وcollect
سؤال الأعمال: «لكلّ مؤلّف من أعلى 5 بالحجم، ما مقالاته الثلاثة الأحدث، وما فئته الرئيسة؟». لبنتان جديدتان: OPTIONAL MATCH الذي لا يفشل إن كان النمط فارغًا، وcollect الذي يجمع الأسطر في قائمة.
MATCH (au:Auteur)<-[:ECRIT_PAR]-(a:Article)
WITH au, count(a) AS total
ORDER BY total DESC
LIMIT 5
OPTIONAL MATCH (au)<-[:ECRIT_PAR]-(recent:Article)
WITH au, total, recent
ORDER BY recent.date DESC
WITH au, total, collect(recent.titre)[..3] AS derniers_titres
RETURN au.nom AS auteur, total, derniers_titres;
ثلاثة WITH متتابعة. الأوّل يحسب المجموع ويحتفظ بالأعلى خمسة. والثاني يُرتِّب المقالات الحديثة. والثالث يجمعها في قائمة بواسطة collect، ثمّ يحتفظ بأوائل الثلاثة بالقطّاعة [..3]. يُغطّي OPTIONAL MATCH الحالة النادرة (لكن ممكنة) حين لا يعود لمؤلّف مقالٌ بعد تنظيف.
النتيجة المنتظرة (الأعلى 5 ثابت): Reuters، وLee Moran، وRon Dicker، وEd Mazza، وCole Delbyck، ولكلٍّ منهم عناوينه الثلاثة الأحدث. (تختلف العناوين الدقيقة حسب الاختيار، لا عددها لكلّ مؤلّف.)
الاستعلام 7 — UNWIND وCASE: تصنيف المؤلّفين
يفعل UNWIND عكس collect: يحوِّل قائمة إلى سطر لكلّ عنصر. ويصلح CASE لتصنيف قيمة عدديّة في تسمية.
MATCH (au:Auteur)<-[:ECRIT_PAR]-(a:Article)
WITH au, count(a) AS total
WITH
CASE
WHEN total >= 500 THEN 'prolifique'
WHEN total >= 50 THEN 'régulier'
ELSE 'occasionnel'
END AS niveau,
au, total
RETURN niveau, count(au) AS auteurs, avg(total) AS moyenne
ORDER BY
CASE niveau WHEN 'prolifique' THEN 1 WHEN 'régulier' THEN 2 ELSE 3 END;
ثلاثة حدود، وثلاث فئات. CASE على total يُنتج تسمية niveau، وCASE ثانٍ يخدم مفتاح الفرز (الترتيب الأبجديّ يُعطي « occasionnel »، ثمّ « prolifique »، ثمّ « régulier »). النتيجة المنتظرة: المؤلّفون « prolifiques » حفنة (Reuters، وLee Moran، وRon Dicker، وEd Mazza، وCole Delbyck، وAndy McDonald، وDavid Moye، وMary Papenfuss…)، و« réguliers » بضع مئات، و« occasionnels » الأغلبيّة الساحقة — الذيل الطويل. (الأرقام الدقيقة متغيّرة بحسب تقطيع المؤلّفين المشتركين.)
مثال محض لـUNWIND على هذا الرسم البيانيّ، لنشر قائمة الفئات المفضّلة عند Lee في جدول واحد:
MATCH (au:Auteur {nom: $nom})<-[:ECRIT_PAR]-(a:Article)-[:PUBLIE_DANS]->(c:Categorie)
WITH au, collect(DISTINCT c.nom) AS categories
UNWIND categories AS categorie
RETURN au.nom AS auteur, categorie
ORDER BY categorie;
الاستعلام 8 — PROFILE قبل وبعد WHERE مفهرَس
يعرض EXPLAIN خطّة التنفيذ دون تشغيل الاستعلام؛ ويُشغِّل PROFILE الاستعلام ويُبلِّغ عن الكلفة الحقيقيّة بـdb hits (وصول قرص منطقيّ). ه و الأداة لفهم لِمَ استعلام بطيء، وللتحقّق من أنّ فهرسًا يخدم فعلًا.
النسخة 1 — بلا فلتر مفهرَس:
PROFILE
MATCH (a:Article)-[:PUBLIE_DANS]->(:Categorie {nom: 'POLITICS'})
WHERE a.titre CONTAINS 'trump'
RETURN count(a);
تبدأ الخطّة بـNodeIndexSeek على القيد categorie_nom (بحث بالاسم، فوريّ)، ثمّ تنزل إلى المقالات بـExpand(All)، ثمّ تطبّق الفلتر CONTAINS 'trump' مقالًا مقالًا — سطر Filter بعدّاد db hits مرتفع، متناسب مع عدد مقالات POLITICS (32 739).
النسخة 2 — بفلتر مفهرَس:
PROFILE
MATCH (a:Article)-[:PUBLIE_DANS]->(:Categorie {nom: 'POLITICS'})
WHERE a.date >= date('2018-01-01')
RETURN count(a);
هذه المرّة، يستفيد القيد على التاريخ من فهرس article_date الذي أنشأناه في الوحدة 11. تعرض الخطّة NodeIndexSeekByRange على Article(date)، ويهبط عدد db hits بمرتبة قدر: نبدأ مباشرةً من المقالات الحديثة بدل مسح POLITICS.
ما يجب قراءته في PROFILE: عدد db hits لكلّ مرحلة (كلّما قلّ كان أفضل)، وNodeByLabelScan (تُتفادى على التصنيفات الكبيرة)، وExpand(All) (عبور، طبيعيّ)، وFilter (الفلاتر المكلِفة بعد العبور إشارة إلى إضافة فهرس أو إعادة صياغة). القاعدة العمليّة: قارِن دائمًا رقمًا برقمَين، لا رقمًا بشعور — إن انتقل db hits من 200 000 إلى 20 000 بمجرّد إضافة فلتر، فقد أنقذت الاستعلام بلا شكّ.
EXPLAIN مقابل PROFILEEXPLAIN مجّانيّ: لا ينفّذ شيئًا، يعدّ تقديرات. أمّا PROFILE فينفّذ الاستعلام فعلًا — تجنّبه على استعلام هدّام، وفكِّر مرّتَين قبل إطلاق PROFILE MATCH (n) DETACH DELETE n.