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

الوحدة 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 مقابل PROFILE

EXPLAIN مجّانيّ: لا ينفّذ شيئًا، يعدّ تقديرات. أمّا PROFILE فينفّذ الاستعلام فعلًا — تجنّبه على استعلام هدّام، وفكِّر مرّتَين قبل إطلاق PROFILE MATCH (n) DETACH DELETE n.

الفخّ الذي يُتفادى: الجداء الكارتيزيّ

النمط التالي يُترجَم لكنّه يُنتج كارثة:

MATCH (a:Auteur), (b:Auteur)
WHERE a <> b
RETURN a.nom, b.nom
LIMIT 10;

لا علاقة بين a وb: يحسب Neo4j الجداء الكارتيزيّ بين التصنيفَين، أي 23 082 × 23 082 ≈ 533 مليون زوج، قبل أن يبدأ التصفية والاحتفاظ بعشرة أسطر. يعرض Neo4j تحذيرًا صريحًا (« This query builds a cartesian product… »).

الصيغة السليمة تربط العقد دومًا بعلاقة، ولو طويلة:

MATCH (a:Auteur)-[:ECRIT_PAR|PUBLIE_DANS*1..4]-(b:Auteur)
WHERE a <> b
RETURN a.nom, b.nom
LIMIT 10;

كلّما كتبت MATCH‍َين (أو عقدتَين مفصولتَين بفاصلة) دون نمط يربطهما، أعد القراءة: إمّا أنّك أردت WITH، وإمّا أنّك أردت مسارًا. هذه الحيلة الصغيرة تحمي من قضاء دقائق في التحديق باستعلام يبدو صحيحًا بينما هو، تقنيًّا، يطلب أن يُحسب كلّ زوج مرتَّب من التصنيف نفسه.

التحديثات الضخمة بـapoc.periodic.iterate

حالة متكرِّرة: إضافة خاصّيّة محسوبة إلى مئات آلاف العقد. مكتوبةً في معاملة واحدة، يُفجِّر الأمر الـheap. مكتوبةً بـapoc.periodic.iterate، تجري المعالجة على دفعات.

CALL apoc.periodic.iterate(
'MATCH (a:Article) RETURN a',
'SET a.annee = a.date.year',
{batchSize: 10000, parallel: false}
) YIELD batches, total
RETURN batches AS lots, total AS articles_mis_a_jour;

استعلامان، يفصل بينهما فاصلة منقوطة في السلسلة: المُنتِج (MATCH ... RETURN) والمُستهلك (SET ...). يوصل APOC أحدهما بالآخر بدفعات من 10 000، ويُحقِّق بين كلٍّ، ويُرجع المجموع في النهاية. هذه هي نسخة « الكتابة » من نمط LOAD CSV ... IN TRANSACTIONS. الفارق مهمّ في الإنتاج: بدون هذا التقطيع، قد يقفز استهلاك الذاكرة إلى عدّة جيجابايت خلال ثوانٍ وتُلغى المعاملة بأكملها، فتخسر ما أُنجز. مع APOC، تبقى الذاكرة مستقرّة، ويسهل إعادة تشغيل الجزء الفاشل وحده لو حصلت مشكلة عابرة.

جرّب 1 — النسبة المئويّة للفئات المفضّلة

اكتب الاستعلام الذي يُعطي، لمؤلّف مُعطًى وسيطًا، أعلى خمس فئات نشرًا عنده، والنسبة المئويّة من مقالاته التي تُمثِّلها هذه الفئات مقارنةً بمجموعه.

الحلّ
MATCH (au:Auteur {nom: $nom})<-[:ECRIT_PAR]-(a:Article)
WITH au, count(a) AS total
MATCH (au)<-[:ECRIT_PAR]-(a:Article)-[:PUBLIE_DANS]->(c:Categorie)
WITH c.nom AS categorie, count(a) AS articles, total
RETURN categorie, articles, round(100.0 * articles / total, 1) AS pourcentage
ORDER BY articles DESC
LIMIT 5;

لـLee Moran، تخرج COMEDY عند نحو 32 %، وWEIRD NEWS عند 16 %، وENTERTAINMENT عند 16 %، وPOLITICS عند 11 %، وSPORTS عند 4 %.

جرّب 2 — التوصية مع تصفية الضجيج

جِد أقرب ثلاثة مؤلّفين إلى Lee Moran بالفئات المشتركة، مع الاكتفاء بمن كتبوا 20 مقالًا على الأقلّ (لتصفية الضجيج).

الحلّ
MATCH (au:Auteur {nom: 'Lee Moran'})<-[:ECRIT_PAR]-(:Article)-[:PUBLIE_DANS]->(c:Categorie)
MATCH (c)<-[:PUBLIE_DANS]-(:Article)-[:ECRIT_PAR]->(autre:Auteur)
WHERE autre <> au
WITH autre, count(DISTINCT c) AS categories_communes
MATCH (autre)<-[:ECRIT_PAR]-(a:Article)
WITH autre, categories_communes, count(a) AS total_autre
WHERE total_autre >= 20
RETURN autre.nom AS auteur, categories_communes, total_autre
ORDER BY categories_communes DESC, total_autre DESC
LIMIT 3;

يُتيح MATCH الثاني مع count(a) تصفية المؤلّفين الهامشيّين. (قد يختلف رقمك قليلًا.)

جرّب 3 — قارن PROFILE مع فلتر مفهرَس وبدونه

قارِن PROFILE للاستعلام نفسه مع WHERE a.date >= date('2018-01-01') وبدونه، ولاحِظ النسبة التقريبيّة لـdb hits.

الحلّ
PROFILE
MATCH (a:Article)-[:PUBLIE_DANS]->(:Categorie {nom: 'POLITICS'})
RETURN count(a);

ثمّ

PROFILE
MATCH (a:Article)-[:PUBLIE_DANS]->(:Categorie {nom: 'POLITICS'})
WHERE a.date >= date('2018-01-01')
RETURN count(a);

النسخة الأولى تعبر 32 739 مقال POLITICS؛ أمّا الثانية فتُقيّد العبور بفضل فهرس article_date. نسبة db hits عادةً بمرتبة قدر (عامل 5 إلى 15 حسب توزّع المقالات السنويّ).

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

  • ضبط وسيط بـ:param nom => 'Lee Moran' ثمّ استعمال $nom: مخبأ الخطط محفوظ، وحقن Cypher متعذّر.
  • التوصية بالتجاور تسع نمطًا مزدوجًا: «مقالات → فئات مشتركة → مؤلّفون آخرون»، مع WHERE autre <> au وcount(DISTINCT c) مُرتَّبًا.
  • shortestPath((a)-[*..N]-(b)) بحدّ يجد التجاور بين عقدتَين؛ وبلا حدّ ينفجر الاستعلام.
  • WITH يُسلسل المراحل، وcollect يجمع في قائمة، وUNWIND يفكّها، وCASE يُصنِّف قيمة عدديّة في تسمية.
  • PROFILE يكشف الكلفة الحقيقيّة بـdb hits: يستبدل NodeIndexSeekByRange Filterًا مكلِفًا بمجرّد تصفية على خاصّيّة مفهرَسة.
  • MATCH‍َان بلا علاقة بينهما = جداء كارتيزيّ: يُحذِّر Neo4j، ويجب إعادة القراءة.
  • تمرّ التحديثات الضخمة عبر apoc.periodic.iterate باستعلام مُنتِج ومُستهلك، لا في معاملة واحدة أبدًا.

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

  • الاستعلام يدور بلا نهاية (spinner لا يتوقّف) → مسار بلا حدّ (*.. أو *) أو جداء كارتيزيّ → قاطع في Browser، وأضف حدًّا *..6 أو علاقةً بين العقدتَين، ثمّ PROFILE النسخة الجديدة.
  • $nom يُرجع « Expected parameter(s): nom » → لم تُوضَع القيمة في الجلسة → :param nom => 'Lee Moran' في Neo4j Browser، أو مرّر الوسيط من Python.
  • PROFILE يُظهر NodeByLabelScan على Article → لم يُصادَف أيّ قيد قبل الفلتر → تحقّق أنّ النمط يبدأ من الجزء الأكثر انتقائيّة (Categorie بـnom مثلًا)، وإلّا ضع فهرسًا بـCREATE INDEX ... على الخاصّيّة المُصفّاة.
  • apoc.periodic.iterate يُرجع « Unknown function » → APOC غير مُحمَّل في الحاوية → ./lab.sh logs neo4j (ابحث عن « Loaded apoc »)، وإلّا ./lab.sh reset ثمّ ./lab.sh up.

للاستزادة

الوحدة التالية: قيادة Elasticsearch وNeo4j من Python، بالعملاء الرسميّين المثبَّتَين في حاوية veille-python، وكتابة السكربت الذي يجمع البحث والتوصية للعرض التوضيحيّ النهائيّ.