Skip to main content
تُعرف مشكلة العثور على أقرب N نقطة في فضاء متعدد الأبعاد (متجهي) إلى نقطة معيّنة باسم البحث عن أقرب جار، أو اختصارًا: البحث عن المتجهات. يوجد نهجان أساسيان لحل مسألة البحث عن المتجهات:
  • يحسب البحث الدقيق عن المتجهات المسافة بين النقطة المعطاة وجميع النقاط في الفضاء المتجهي. وهذا يضمن أعلى دقة ممكنة، أي إن النقاط المُعادة تكون مضمونة بوصفها أقرب الجيران الفعليين. ونظرًا إلى أن الفضاء المتجهي يُفحَص بالكامل، فقد يكون البحث الدقيق عن المتجهات بطيئًا جدًا للاستخدامات العملية.
  • يشير البحث التقريبي عن المتجهات إلى مجموعة من التقنيات (مثل بُنى بيانات خاصة كالرُّسوم البيانية والغابات العشوائية) التي تحسب النتائج بسرعة أكبر بكثير من البحث الدقيق عن المتجهات. وعادةً ما تكون دقة النتائج “جيدة بما يكفي” للاستخدام العملي. كما توفّر العديد من التقنيات التقريبية معلمات لضبط المفاضلة بين دقة النتائج ووقت البحث.
يمكن كتابة البحث عن المتجهات (الدقيق أو التقريبي) في SQL كما يلي:
تُخزَّن النقاط في فضاء المتجهات في عمود vectors من نوع Array، مثل Array(Float64) أو Array(Float32) أو Array(BFloat16). يكون المتجه المرجعي مصفوفةً ثابتة، ويُعرَّف كتعبير جدول شائع. تحسب <DistanceFunction> المسافة بين النقطة المرجعية وجميع النقاط المخزَّنة. ويمكن استخدام أي من دوال المسافة المتاحة لهذا الغرض. ويحدّد <N> عدد الجيران المطلوب إرجاعهم. يمكن إجراء بحث متجهي دقيق باستخدام استعلام SELECT المذكور أعلاه كما هو. ويكون زمن تنفيذ مثل هذه الاستعلامات متناسبًا عمومًا مع عدد المتجهات المخزّنة وأبعادها، أي عدد عناصر المصفوفة. ونظرًا لأن ClickHouse يجري فحصًا شاملًا لجميع المتجهات، فإن زمن التنفيذ يعتمد أيضًا على عدد الخيوط التي يستخدمها الاستعلام (راجع الإعداد max_threads).

مثال

القيمة المُعادة

فهارس تشابه المتجهات

يوفّر ClickHouse فهرسًا خاصًا لـ “تشابه المتجهات” لتنفيذ بحث تقريبي عن المتجهات.
تتوفّر فهارس تشابه المتجهات في ClickHouse الإصدار 25.8 وما بعده. إذا واجهت أي مشكلات، يُرجى فتح بلاغ في مستودع ClickHouse.

إنشاء فهرس لتشابه المتجهات

يمكن إنشاء فهرس لتشابه المتجهات على جدول جديد كما يلي:
بدلاً من ذلك، لإضافة فهرس تشابه المتجهات إلى جدول موجود:
فهارس تشابه المتجهات هي نوع خاص من فهارس تخطي البيانات (راجع هنا وهنا). وبناءً على ذلك، فإن عبارة ALTER TABLE أعلاه لا تؤدي إلا إلى إنشاء الفهرس للبيانات الجديدة التي ستُدرَج في الجدول لاحقًا. ولإنشاء الفهرس للبيانات الموجودة أيضًا، تحتاج إلى إضفاء الطابع المادي عليه:
يجب أن تكون الدالة <distance_function> إحدى القيم التالية:
  • L2Distance، وهي المسافة الإقليدية، وتمثل طول القطعة المستقيمة بين نقطتين في الفضاء الإقليدي،
  • cosineDistance، وهي مسافة جيب التمام، وتمثل الزاوية بين متجهين غير صفريين، أو
  • dotProduct، وهو الضرب النقطي (الضرب الداخلي)، ويمثل مجموع نواتج الضرب عنصرًا بعنصر لمتجهين. وهو مكافئ لـ cosineDistance في البيانات المُطبَّعة.
بالنسبة إلى البيانات المُطبَّعة، يكون L2Distance عادةً الخيار الأفضل، وإلا فيُوصى باستخدام cosineDistance للتعويض عن اختلاف المقياس.
بالنسبة إلى دالتي المسافة L2Distance وcosineDistance، فإن القيمة الأصغر تعني تشابهًا أعلى، بينما بالنسبة إلى dotProduct، فإن القيمة الأكبر تعني تشابهًا أعلى. ونتيجة لذلك، لا يمكن استخدام فهارس المتجهات مع L2Distance وcosineDistance إلا في استعلامات SELECT [...] ORDER BY [...] ASC (حيث إن ASC هي القيمة الافتراضية لـ ORDER BY)، بينما لا يمكن استخدام فهارس المتجهات المُنشأة لـ dotProduct إلا في استعلامات SELECT [...] ORDER BY [...] DESC.
يحدد <dimensions> عدد عناصر المصفوفة في العمود الأساسي. إذا عثر ClickHouse على مصفوفة بعدد عناصر مختلف أثناء إنشاء الفهرس، فسيتم تجاهل الفهرس وإرجاع خطأ. تشير المعلمة الاختيارية GRANULARITY <N> إلى حجم حبيبات الفهرس (انظر هنا). وعلى خلاف فهارس التخطي العادية، التي تستخدم قيمة افتراضية لمحببية الفهرس مقدارها 1، تستخدم فهارس تشابه المتجهات 100 مليون بوصفها محببية الفهرس الافتراضية. تضمن هذه القيمة ألّا يُنشأ داخليًا سوى عدد قليل من الفهارس حتى مع الأجزاء الكبيرة. نوصي بتغيير محببية الفهرس فقط للمستخدمين المتقدمين الذين يفهمون تبعات ما يفعلونه (انظر أدناه). تُعد فهارس تشابه المتجهات عامة، بمعنى أنها يمكن أن تدعم طرق بحث تقريبي مختلفة. ويُحدَّد النوع المستخدم فعليًا بواسطة المعلمة <type>. حتى الآن، الطريقة الوحيدة المتاحة هي HNSW (ورقة أكاديمية)، وهي تقنية شائعة ومتقدمة للبحث المتجهي التقريبي تعتمد على رسوم بيانية هرمية للتقارب. إذا استُخدم HNSW بوصفه النوع، فيمكن للمستخدمين اختياريًا تحديد معلمات إضافية خاصة بـ HNSW:
المعلمات الخاصة بـ HNSW المتاحة هي:
  • يتحكم <quantization> في تكميم المتجهات في رسم بياني القرب. القيم الممكنة هي f64 وf32 وf16 وbf16 وi8 أو b1. القيمة الافتراضية هي bf16. لاحظ أن هذه المَعلمة لا تؤثر في تمثيل المتجهات في العمود الأساسي.
  • يتحكم <hnsw_max_connections_per_layer> في عدد الجيران لكل عقدة في الرسم البياني، ويُعرف أيضًا باسم المَعلمة الفائقة M في HNSW. القيمة الافتراضية هي 32. وتعني القيمة 0 استخدام القيمة الافتراضية.
  • يتحكم <hnsw_candidate_list_size_for_construction> في حجم قائمة المرشحين الديناميكية أثناء إنشاء رسم HNSW البياني، ويُعرف أيضًا باسم المَعلمة الفائقة ef_construction في HNSW. القيمة الافتراضية هي 128. وتعني القيمة 0 استخدام القيمة الافتراضية.
تعمل القيم الافتراضية لجميع المَعلمات الخاصة بـ HNSW بشكل جيد إلى حد معقول في معظم حالات الاستخدام. لذلك، لا نوصي بتخصيص المَعلمات الخاصة بـ HNSW. تنطبق القيود الإضافية التالية:
  • لا يمكن إنشاء فهارس تشابه المتجهات إلا على أعمدة من النوع Array(Float32) أو Array(Float64) أو Array(BFloat16). ولا يُسمح بالمصفوفات ذات القيم العائمة القابلة لأن تكون NULL أو منخفضة الكاردينالية، مثل Array(Nullable(Float32)) وArray(LowCardinality(Float32)).
  • يجب إنشاء فهارس تشابه المتجهات على أعمدة مفردة.
  • يمكن إنشاء فهارس تشابه المتجهات على تعبيرات محسوبة (مثل INDEX index_name arraySort(vectors) TYPE vector_similarity([...]))، ولكن لا يمكن استخدام هذه الفهارس لاحقًا في البحث التقريبي عن الجيران.
  • تتطلب فهارس تشابه المتجهات أن تحتوي جميع المصفوفات في العمود الأساسي على <dimension> عنصرًا — ويُتحقق من ذلك أثناء إنشاء الفهرس. ولاكتشاف أي انتهاك لهذا الشرط في أقرب وقت ممكن، يمكن للمستخدمين إضافة قيد على عمود المتجهات، مثل CONSTRAINT same_length CHECK length(vectors) = 256.
  • وبالمثل، يجب ألا تكون قيم المصفوفة في العمود الأساسي فارغة ([]) أو ذات قيمة افتراضية (وهي أيضًا []).
تقدير استهلاك التخزين والذاكرة يتكون المتجه المُنشأ لاستخدامه مع نموذج ذكاء اصطناعي نموذجي (مثل نموذج لغوي كبير، LLMs) من مئات أو آلاف القيم ذات الفاصلة العائمة. لذلك، قد تستهلك قيمة متجه واحدة عدة كيلوبايت من الذاكرة. يمكن للمستخدمين الذين يرغبون في تقدير مساحة التخزين المطلوبة للعمود الأساسي للمتجهات في الجدول، وكذلك الذاكرة الرئيسية اللازمة لفهرس تشابه المتجهات، استخدام الصيغتين أدناه: استهلاك التخزين لعمود المتجهات في الجدول (غير مضغوط):
مثال على مجموعة بيانات dbpedia:
يجب تحميل فهرس تشابه المتجهات بالكامل من القرص إلى الذاكرة الرئيسية لإجراء عمليات البحث. وبالمثل، يُبنى فهرس المتجهات أيضًا بالكامل في الذاكرة ثم يُحفَظ على القرص. استهلاك الذاكرة المطلوب لتحميل فهرس المتجهات:
مثال على مجموعة بيانات dbpedia:
لا تأخذ الصيغة أعلاه في الحسبان الذاكرة الإضافية التي تتطلبها فهارس تشابه المتجهات لتخصيص بُنى بيانات وقت التشغيل، مثل المخازن المؤقتة المخصّصة مسبقًا وذاكرات التخزين المؤقت.

استخدام فهرس تشابه المتجهات

لاستخدام فهارس تشابه المتجهات، يجب أن تكون قيمة الإعداد compatibility هي '' (القيمة الافتراضية)، أو '25.1' أو إصدارًا أحدث.
تدعم فهارس تشابه المتجهات استعلامات SELECT من هذا الشكل:
يحاول مُحسِّن الاستعلامات في ClickHouse مطابقة قالب الاستعلام أعلاه والاستفادة من فهارس تشابه المتجهات المتاحة. لا يمكن للاستعلام استخدام فهرس تشابه المتجهات إلا إذا كانت دالة المسافة في استعلام SELECT مطابقةً لدالة المسافة المحددة في تعريف الفهرس. يمكن للمستخدمين المتقدمين تحديد قيمة مخصصة للإعداد hnsw_candidate_list_size_for_search (المعروف أيضاً بالمعامل الفائق لـ HNSW “ef_search”) للتحكم في حجم قائمة المرشحين أثناء البحث (مثلاً: SELECT [...] SETTINGS hnsw_candidate_list_size_for_search = <value>). القيمة الافتراضية للإعداد وهي 256 تُحقق أداءً جيداً في غالبية حالات الاستخدام. القيم الأعلى تعني دقةً أفضل على حساب أداء أبطأ. إذا كان بإمكان الاستعلام استخدام فهرس تشابه المتجهات، يتحقق ClickHouse من أن قيمة LIMIT <N> المحددة في استعلامات SELECT تقع ضمن حدود معقولة. وتحديدًا، يُعاد خطأ إذا كانت <N> أكبر من قيمة الإعداد max_limit_for_vector_search_queries التي قيمتها الافتراضية 100. قد تؤدي قيم LIMIT الكبيرة جدًا إلى إبطاء عمليات البحث، وغالبًا ما تشير إلى خطأ في الاستخدام. للتحقق مما إذا كان استعلام SELECT يستخدم فهرس تشابه المتجهات، يمكنك إضافة EXPLAIN indexes = 1 في بداية الاستعلام. كمثال، الاستعلام
قد يُعيد
في هذا المثال، يتم تخزين مليون متجه من مجموعة بيانات dbpedia، يبلغ بُعد كل منها 1536، في 575 granule، أي ما يعادل 1.7 ألف صف لكل granule. يطلب الاستعلام 10 جيران، ويعثر فهرس تشابه المتجهات على هؤلاء الـ10 جيران في 10 granules منفصلة. ستُقرأ هذه الـ10 granules أثناء تنفيذ الاستعلام. تُستخدم فهارس تشابه المتجهات إذا احتوى الناتج على Skip واسم فهرس المتجه ونوعه (في المثال، idx وvector_similarity). في هذه الحالة، تخطّى فهرس تشابه المتجهات اثنتين من أصل أربع حبيبات، أي ما يعادل 50% من البيانات. وكلما زاد عدد الحبيبات القابلة للتخطي، كان استخدام الفهرس أكثر فاعلية.
لفرض استخدام الفهرس، يمكنك تشغيل استعلام SELECT مع الإعداد force_data_skipping_indexes (حدِّد اسم الفهرس كقيمة للإعداد).
التصفية اللاحقة والتصفية المسبقة يمكن للمستخدمين اختياريًا تحديد جملة WHERE مع شروط تصفية إضافية لاستعلام SELECT. سيُقيِّم ClickHouse شروط التصفية هذه باستخدام إحدى استراتيجيتَي post-filtering أو pre-filtering. باختصار، تحدد كلتا الاستراتيجيتين الترتيبَ الذي تُقيَّم به عوامل التصفية:
  • تعني التصفية اللاحقة أنه يجري أولًا تقييم فهرس تشابه المتجهات، ثم يقيّم ClickHouse عوامل التصفية الإضافية المحددة في عبارة WHERE.
  • وتعني التصفية المسبقة أن ترتيب تقييم عوامل التصفية يكون بالعكس.
لكل استراتيجية مقايضاتها الخاصة:
  • تواجه التصفية اللاحقة مشكلة عامة، وهي أنها قد تُرجِع عددًا أقل من الصفوف المطلوبة في عبارة LIMIT <N>. ويحدث ذلك عندما لا يستوفي صفّ نتيجة واحد أو أكثر، أعاده فهرس تشابه المتجهات، المرشِّحات الإضافية.
  • تُعدّ التصفية المسبقة عمومًا مشكلة غير محلولة. توفّر بعض قواعد بيانات المتجهات المتخصصة خوارزميات للتصفية المسبقة، لكن معظم قواعد البيانات العلائقية (بما في ذلك ClickHouse) تعود إلى البحث الدقيق عن أقرب الجيران، أي فحص brute-force من دون فهرس.
تعتمد الاستراتيجية المستخدمة على شرط التصفية. المرشحات الإضافية جزء من مفتاح التقسيم إذا كان شرط التصفية الإضافي جزءًا من مفتاح التقسيم، فسيطبّق ClickHouse تشذيب الأقسام. على سبيل المثال، إذا كان الجدول مقسّمًا نطاقيًا حسب العمود year وشُغِّل الاستعلام التالي:
سيستبعد ClickHouse جميع التقسيمات باستثناء تقسيم 2025. لا يمكن تقييم عوامل التصفية الإضافية باستخدام الفهارس إذا تعذر تقييم شروط التصفية الإضافية باستخدام الفهارس (فهرس المفتاح الأساسي، فهرس تخطي البيانات)، فسيطبّق ClickHouse التصفية اللاحقة. يمكن تقييم عوامل التصفية الإضافية باستخدام فهرس المفتاح الأساسي إذا أمكن تقييم شروط التصفية الإضافية باستخدام المفتاح الأساسي (أي إنها تشكّل بادئة للمفتاح الأساسي) و
  • إذا كان شرط التصفية يستبعد صفًا واحدًا على الأقل داخل جزء، فسيعود ClickHouse إلى التصفية المسبقة للنطاقات “المتبقية” داخل الجزء،
  • إذا كان شرط التصفية لا يستبعد أي صفوف داخل جزء، فسينفّذ ClickHouse التصفية اللاحقة لهذا الجزء.
في حالات الاستخدام العملية، تُعدّ الحالة الأخيرة غير مرجّحة إلى حدّ كبير. يمكن تقييم عوامل التصفية الإضافية باستخدام فهرس تخطي البيانات إذا أمكن تقييم شروط التصفية الإضافية باستخدام فهارس تخطي البيانات (فهرس minmax، وفهرس set، وما إلى ذلك)، فإن ClickHouse ينفّذ التصفية اللاحقة. في مثل هذه الحالات، يُقيَّم فهرس تشابه المتجهات أولًا لأنه يُتوقع أن يستبعد أكبر عدد من الصفوف مقارنةً بفهارس تخطي البيانات الأخرى. للحصول على تحكم أدق في التصفية اللاحقة مقابل التصفية المسبقة، يمكن استخدام إعدادين: يمكن ضبط الإعداد vector_search_filter_strategy (القيمة الافتراضية: auto التي تطبّق الاستدلالات المذكورة أعلاه) على prefilter. ويكون ذلك مفيدًا لفرض التصفية المسبقة في الحالات التي تكون فيها شروط التصفية الإضافية شديدة الانتقائية. على سبيل المثال، قد يستفيد الاستعلام التالي من التصفية المسبقة:
على افتراض أن عددًا ضئيلًا جدًا من الكتب فقط يقل سعره عن دولارين، فقد لا تُرجِع التصفية اللاحقة أي صفوف، لأن أقرب 10 مطابقات يُرجعها فهرس المتجهات قد تكون جميعها بأسعار تتجاوز دولارين. ومن خلال فرض التصفية المسبقة (بإضافة SETTINGS vector_search_filter_strategy = 'prefilter' إلى الاستعلام)، يعثر ClickHouse أولًا على جميع الكتب التي يقل سعرها عن دولارين، ثم يُجري بحثًا متجهيًا بالقوة الغاشمة على الكتب التي عُثر عليها. وكحل بديل للمشكلة المذكورة أعلاه، يمكن ضبط vector_search_index_fetch_multiplier (القيمة الافتراضية: 1.0، والحد الأقصى: 1000.0) على قيمة > 1.0 (على سبيل المثال، 2.0). ويُضرَب عدد أقرب الجيران التي يجري جلبها من فهرس المتجهات في قيمة هذا الإعداد، ثم يُطبَّق عامل التصفية الإضافي على تلك الصفوف لإرجاع عدد من الصفوف يساوي قيمة LIMIT. على سبيل المثال، يمكننا تنفيذ الاستعلام مرة أخرى، ولكن بمعامل 3.0:
سيجلب ClickHouse عددًا قدره 3.0 × 10 = 30 من أقرب الجيران من فهرس المتجهات في كل جزء، ثم يقيّم عوامل التصفية الإضافية. لن يُعاد إلا أقرب عشرة جيران. ونشير إلى أن ضبط vector_search_index_fetch_multiplier قد يخفف من هذه المشكلة، ولكن في الحالات القصوى (عندما يكون شرط WHERE انتقائيًا جدًا)، يظل من الممكن إعادة عدد أقل من N من الصفوف المطلوبة. إعادة التقييم تُجري skip indexes في ClickHouse عادةً التصفية على مستوى الـ حبيبة، أي إن عملية lookup في skip index (داخليًا) تُرجع قائمة بالـ حبيبات التي يُحتمل أن تتطابق، مما يقلل حجم read data في الفحص اللاحق. يعمل هذا جيدًا مع skip indexes عمومًا، لكن في حالة فهارس تشابه المتجهات، فإنه يسبب “عدم تطابق في المحببية”. وبمزيد من التفصيل، يحدد فهرس تشابه المتجهات أرقام الصفوف الخاصة بـ N من أكثر المتجهات تشابهًا بالنسبة إلى متجه مرجعي معيّن. مع الإعداد vector_search_with_rescoring = 1، يقرأ ClickHouse المتجهات الأصلية ذات الدقة الكاملة للصفوف المرشحة ويحسب المسافة النهائية ضمن مسار SQL العادي. وعندما تسمح query plan بذلك، يقيّد ClickHouse الفحص بالصفوف المرشحة التي يُرجعها فهرس المتجهات قبل حساب المسافة النهائية. وتُسمى هذه الخطوة إعادة التقييم، ويمكن أن تحسّن الدقة، وخاصةً مع فهارس المتجهات المُكمّاة، لأن الترتيب النهائي يستخدم المتجهات المخزنة بدلًا من المسافات القادمة من الفهرس. إذا كانت عوامل التصفية الإضافية تستبعد عددًا كبيرًا جدًا من المرشحين أو كانت هناك حاجة إلى قدر أكبر من الاسترجاع، فقم بزيادة الإعداد vector_search_index_fetch_multiplier لكي يُرجع فهرس المتجهات مزيدًا من الصفوف المرشحة لإعادة التقييم. لذلك يوفّر ClickHouse تحسينًا يعطّل إعادة التقييم ويُرجع أكثر المتجهات تشابهًا ومسافاتها مباشرةً من index. يكون هذا التحسين enabled افتراضيًا، راجع الإعداد vector_search_with_rescoring. وعلى مستوى عام، تعمل هذه الآلية بأن ClickHouse يوفّر أكثر المتجهات تشابهًا ومسافاتها على هيئة عمود افتراضي _distance. ولرؤية ذلك، شغّل query بحث متجهي باستخدام EXPLAIN header = 1:
قد يرجع الاستعلام الذي يُشغَّل دون إعادة التقييم (vector_search_with_rescoring = 0) ومع تفعيل النسخ المتماثلة المتوازية، تلقائيًا إلى إعادة التقييم.

ضبط الأداء

ضبط الضغط في معظم حالات الاستخدام، تكون المتجهات في العمود الأساسي كثيفة ولا تُضغط بكفاءة. ونتيجةً لذلك، فإن الضغط يبطئ عمليات الإدراج والقراءة في عمود المتجهات. لذلك نوصي بتعطيل الضغط. وللقيام بذلك، حدِّد CODEC(NONE) لعمود المتجهات كما يلي:
ضبط إنشاء الفهرس ترتبط دورة حياة فهارس تشابه المتجهات بدورة حياة الأجزاء. وبعبارة أخرى، كلما تم إنشاء جزء جديد يتضمّن فهرس تشابه متجهات معرّفًا، يُنشأ الفهرس أيضًا. ويحدث ذلك عادةً عند إدراج البيانات أو أثناء عمليات الدمج. وللأسف، يشتهر HNSW بطول زمن إنشاء الفهرس، ما قد يبطئ عمليات الإدراج والدمج بشكل ملحوظ. ومن الأفضل استخدام فهارس تشابه المتجهات فقط عندما تكون البيانات ثابتة أو نادرًا ما تتغير. لتسريع إنشاء الفهرس، يمكن استخدام الأساليب التالية: أولًا، يمكن تنفيذ إنشاء الفهرس بالتوازي. يمكن ضبط الحد الأقصى لعدد خيوط إنشاء الفهرس باستخدام إعداد الخادم max_build_vector_similarity_index_thread_pool_size. وللحصول على أفضل أداء، يجب ضبط قيمة الإعداد بما يساوي عدد أنوية CPU. ثانيًا، لتسريع عبارات INSERT، يمكن للمستخدمين تعطيل إنشاء فهارس تخطي البيانات على الأجزاء المُدرجة حديثًا باستخدام إعداد الجلسة materialize_skip_indexes_on_insert. وستعتمد استعلامات SELECT على هذه الأجزاء على البحث الدقيق كبديل. ونظرًا إلى أن الأجزاء المُدرجة تكون عادةً صغيرة مقارنةً بإجمالي حجم الجدول، فمن المتوقع أن يكون تأثير ذلك على الأداء ضئيلًا. ثالثًا، لتسريع عمليات الدمج، يمكن للمستخدمين تعطيل إنشاء فهارس تخطي البيانات على الأجزاء المدمجة باستخدام إعداد الجلسة materialize_skip_indexes_on_merge. ويوفّر هذا، بالاقتران مع العبارة ALTER TABLE […] MATERIALIZE INDEX […]، تحكمًا صريحًا في دورة حياة فهارس تشابه المتجهات. فعلى سبيل المثال، يمكن تأجيل إنشاء الفهرس إلى حين إدخال جميع البيانات أو إلى فترة يكون فيها حمل النظام منخفضًا، مثل عطلة نهاية الأسبوع. ضبط استخدام الفهرس تحتاج استعلامات SELECT إلى تحميل فهارس تشابه المتجهات إلى الذاكرة الرئيسية لاستخدامها. ولتجنب تحميل فهرس تشابه المتجهات نفسه إلى الذاكرة الرئيسية مرارًا، يوفّر ClickHouse ذاكرة تخزين مؤقت مخصّصة داخل الذاكرة لهذه الفهارس. وكلما زاد حجم ذاكرة التخزين المؤقت هذه، قلّ عدد عمليات التحميل غير الضرورية. يمكن ضبط الحد الأقصى لحجم ذاكرة التخزين المؤقت باستخدام إعداد الخادم vector_similarity_index_cache_size. وبشكل افتراضي، يمكن أن يصل حجم ذاكرة التخزين المؤقت إلى 5 GB. تشير رسائل السجل التالية (system.text_log) إلى أن فهرس تشابه المتجهات قيد التحميل. وإذا ظهرت هذه الرسائل بشكل متكرر مع استعلامات بحث متجهي مختلفة، فهذا يشير إلى أن حجم ذاكرة التخزين المؤقت منخفض جدًا.
تخزّن ذاكرة التخزين المؤقت لفهرس تشابه المتجهات حبيبات فهرس المتجهات. إذا كان حجم حبيبات فهرس المتجهات الفردية أكبر من حجم ذاكرة التخزين المؤقت، فلن تُخزَّن مؤقتًا. لذلك، يُرجى التأكد من حساب حجم فهرس المتجهات (استنادًا إلى الصيغة الواردة في “تقدير التخزين واستهلاك الذاكرة” أو system.data_skipping_indices) وتحديد حجم ذاكرة التخزين المؤقت بما يتناسب مع ذلك.
نؤكد مجددًا أن التحقق من ذاكرة التخزين المؤقت لفهرس المتجهات، وزيادتها عند الحاجة، يجب أن يكون الخطوة الأولى عند التحقيق في بطء استعلامات البحث المتجهي. يظهر الحجم الحالي لذاكرة التخزين المؤقت لفهرس تشابه المتجهات في system.metrics:
يمكن الحصول على عدد مرات الإصابة والإخفاق في ذاكرة التخزين المؤقت لاستعلام ذي Query id معيّن من system.query_log:
في بيئات الإنتاج، نوصي بأن يكون حجم ذاكرة التخزين المؤقت كبيرًا بما يكفي لإبقاء جميع فهارس المتجهات في الذاكرة طوال الوقت. ضبط التكميم التكميم هو أسلوب لتقليل البصمة الذاكرية للمتجهات وخفض التكلفة الحاسوبية لبناء فهارس المتجهات والتنقل فيها. تدعم فهارس المتجهات في ClickHouse خيارات التكميم التالية: يقلل التكميم من دقة البحث المتجهي مقارنةً بالبحث في قيم الفاصلة العائمة الأصلية كاملة الدقة (f32). ومع ذلك، في معظم مجموعات البيانات، يؤدي تكميم brain float بنصف الدقة (bf16) إلى فقدان طفيف جدًا في الدقة، لذا تستخدم فهارس تشابه المتجهات أسلوب التكميم هذا افتراضيًا. أما تكميم ربع الدقة (i8) والتكميم الثنائي (b1) فيؤديان إلى فقدان ملحوظ في دقة البحث المتجهي. ولا نوصي باستخدام هذين النوعين من التكميم إلا إذا كان حجم فهرس تشابه المتجهات أكبر بكثير من حجم DRAM المتاح. وفي هذه الحالة، نقترح أيضًا تمكين إعادة التقييم (vector_search_index_fetch_multiplier، vector_search_with_rescoring) لتحسين الدقة. ولا يُنصح بالتكميم الثنائي إلا في حالتين فقط: 1) عند استخدام تضمينات مُطبَّعة (أي إن طول المتجه = 1، وعادةً ما تكون نماذج OpenAI مُطبَّعة)، و2) عند استخدام مسافة جيب التمام بوصفها دالة المسافة. ويستخدم التكميم الثنائي داخليًا مسافة Hamming لبناء الرسم البياني للتقارب والبحث فيه. وتستخدم خطوة إعادة التقييم المتجهات الأصلية كاملة الدقة المخزنة في الجدول لتحديد أقرب الجيران عبر مسافة جيب التمام. ضبط نقل البيانات يُقدَّم المتجه المرجعي في استعلام البحث المتجهي من قِبل المستخدم، ويُسترجع عادةً عبر إجراء استدعاء إلى نموذج لغوي كبير (LLM). وقد يبدو نموذج شيفرة بايثون المعتاد الذي يُجري بحثًا متجهيًا في ClickHouse كما يلي
قد يكون لمتجهات التضمين (search_v في المقتطف أعلاه) عدد كبير جدًا من الأبعاد. فعلى سبيل المثال، توفّر OpenAI نماذج تُنشئ متجهات تضمين تضم 1536 أو حتى 3072 بُعدًا. في الشيفرة أعلاه، يستبدل برنامج تشغيل ClickHouse لبايثون متجه التضمين بسلسلة نصية مقروءة للبشر، ثم يرسل استعلام SELECT بالكامل كسلسلة نصية. وبافتراض أن متجه التضمين يتكوّن من 1536 قيمة فاصلة عائمة أحادية الدقة، فإن طول السلسلة المُرسلة يصل إلى 20 كيلوبايت. ويؤدي ذلك إلى ارتفاع استخدام CPU بسبب تقسيم النص إلى رموز، والتحليل، وإجراء آلاف التحويلات من سلسلة نصية إلى قيمة فاصلة عائمة. كما يتطلب ذلك مساحة كبيرة في ملف سجل ClickHouse server، مما يسبب أيضًا تضخمًا في system.query_log. لاحظ أن معظم نماذج LLM تُرجع متجه تضمين على هيئة قائمة أو مصفوفة NumPy من قيم فاصلة عائمة أصلية. لذلك نوصي تطبيقات بايثون بربط معلمة متجه المرجع بالصيغة الثنائية باستخدام الأسلوب التالي:
في هذا المثال، يُرسَل المتجه المرجعي كما هو في صيغة ثنائية، ويُعاد تفسيره على الخادم كمصفوفة من القيم العائمة. يوفّر ذلك وقت CPU على جانب الخادم، ويجنّب حدوث تضخّم في سجلات الخادم وsystem.query_log.

الإدارة والمراقبة

يمكن الحصول على حجم فهارس تشابه المتجهات على القرص من system.data_skipping_indices:
مثال على المخرجات:

الاختلافات عن فهارس تخطي البيانات العادية

مثل جميع فهارس تخطي البيانات العادية، تُنشأ فهارس تشابه المتجهات على مستوى الحبيبات، وتتألف كل كتلة مفهرسة من GRANULARITY = [N] حبيبات ([N] = 1 افتراضيًا في فهارس تخطي البيانات العادية). على سبيل المثال، إذا كانت دقة حبيبات الفهرس الأساسي للجدول 8192 (الإعداد index_granularity = 8192) وكانت GRANULARITY = 2، فستحتوي كل كتلة مفهرسة على 16384 صفًا. لكن بُنى البيانات والخوارزميات الخاصة ببحث أقرب الجيران التقريبي هي بطبيعتها موجّهة نحو الصفوف. فهي تخزّن تمثيلًا مضغوطًا لمجموعة من الصفوف، كما تُرجع صفوفًا لاستعلامات البحث المتجهي. ويؤدي ذلك إلى بعض الاختلافات غير البديهية نوعًا ما في سلوك فهارس تشابه المتجهات مقارنةً بفهارس تخطي البيانات العادية. عندما يعرّف المستخدم فهرس تشابه متجهات على عمود، ينشئ ClickHouse داخليًا “فهرسًا فرعيًا” لتشابه المتجهات لكل كتلة فهرس. ويكون الفهرس الفرعي “محليًا” بمعنى أنه لا يعرف إلا صفوف كتلة الفهرس التي ينتمي إليها. في المثال السابق، وبافتراض أن العمود يحتوي على 65536 صفًا، نحصل على أربع كتل فهرس (تمتد عبر ثماني حبيبات) وفهرس تشابه متجهات فرعي لكل كتلة فهرس. ويستطيع الفهرس الفرعي نظريًا أن يُرجع مباشرةً الصفوف التي تحتوي على أقرب N نقاط ضمن كتلة الفهرس الخاصة به. بالنسبة إلى الاستعلامات التي تستخدم vector_search_with_rescoring = 1، يمكن لـ ClickHouse استخدام مواضع الصفوف هذه لتصفية الصفوف قبل حساب المسافة النهائية من المتجهات المخزّنة عندما تسمح خطة الاستعلام بهذا التحسين. ومن دون إعادة التقييم، يستخدم ClickHouse المسافات من فهرس المتجهات مباشرةً عبر العمود الافتراضي _distance. ويظل كلا الوضعين يستخدمان نطاقات الحبيبات المحيطة لجدولة عمليات القراءة، وهذا يختلف عن فهارس تخطي البيانات العادية التي تتخطى البيانات على مستوى كتل الفهرس. تحدّد المعلمة GRANULARITY عدد فهارس تشابه المتجهات الفرعية التي يتم إنشاؤها. فكلما كانت قيم GRANULARITY أكبر، قلّ عدد فهارس تشابه المتجهات الفرعية ولكن ازداد حجمها، إلى أن نصل إلى حالة لا يكون فيها للعمود (أو لجزء بيانات العمود) سوى فهرس فرعي واحد فقط. في هذه الحالة، تكون لدى الفهرس الفرعي رؤية “شاملة” لجميع صفوف العمود، ويمكنه أن يُرجع مباشرةً جميع حبيبات العمود (الجزء) التي تحتوي على صفوف ذات صلة (ويكون عدد هذه الحبيبات بحد أقصى LIMIT [N]). عند استخدام vector_search_with_rescoring = 1، يستطيع ClickHouse بعد ذلك قراءة مواضع الصفوف المطابقة وحساب المسافة الدقيقة لتلك الصفوف. أما عند استخدام قيمة صغيرة لـ GRANULARITY، فيمكن لكل فهرس فرعي أن يُرجع ما يصل إلى LIMIT N من الصفوف المرشحة. ونتيجةً لذلك، قد يلزم قراءة عدد أكبر من الصفوف المرشحة وتصفيتها لاحقًا. لاحظ أن دقة البحث متساوية في كلتا الحالتين، والاختلاف الوحيد هو في أداء المعالجة. ويُوصى عمومًا باستخدام قيمة GRANULARITY كبيرة لفهارس تشابه المتجهات، واللجوء إلى قيم GRANULARITY أصغر فقط عند ظهور مشكلات مثل الاستهلاك المفرط للذاكرة من قِبل بُنى تشابه المتجهات. إذا لم يتم تحديد GRANULARITY لفهارس تشابه المتجهات، فإن القيمة الافتراضية هي 100 مليون.

مثال

الاستعلامات:
Query
Response
مجموعات بيانات إضافية للتجربة تستخدم البحث المتجهي التقريبي:

البحث المتجهي باستخدام مرمّزات الضغط المُكمّاة

مرمّز الضغط Quantized تجريبي. فعِّله باستخدام SET allow_experimental_codecs = 1. إذا واجهت أي مشكلات، يُرجى فتح بلاغ في مستودع ClickHouse.

مقدمة

يُجيب فهرس تشابه المتجهات على استعلامات أقرب الجيران عبر اجتياز رسم بياني، ويُحقق أداءً ممتازاً حين يمكن الاحتفاظ بالرسم البياني في الذاكرة. ثمة شرطان يُقيّدان نطاق تطبيقه:
  • الحجم. يُصبح وقت بناء الرسم البياني والذاكرة اللازمة لتخزينه — إضافةً إلى المتجهات ذاتها — التكلفة الأكثر هيمنةً.
  • التصفية. في ظل فلتر WHERE انتقائي، يفقد اجتياز الرسم البياني فاعليته؛ إذ إما أنه لا يستطيع الوصول إلى المجموعة الصغيرة من الصفوف التي تستوفي الشرط، أو يضطر إلى فحص عدد غير متناسب من المرشحين للعثور عليها.
الفحص الشامل لا يخضع لأيٍّ من هذين القيدين: فهو لا يحتاج إلى بنية مساعدة، وتندمج الأجزاء عبر التسلسل، ويُقلّص الفلتر ببساطة عدد الصفوف المراد فحصها. عيبه الوحيد هو حجم البيانات التي يجب قراءتها: إذ تهيمن عمليات الإدخال/الإخراج على فحص المتجهات المخزنة بدقة Float32 الكاملة، لأن عمود المتجهات بأكمله يجب قراءته من القرص (أو وحدة التخزين الكائنية) — وبالنسبة لعمود التضمين الكثيف، فهو أكبر عمود في الجدول، ويُضغط بصورة رديئة. يُعالج مرمّز العمود Quantized هذا العيب. يُخزَّن كل متجه مرتين: القيم الأصلية كاملة الدقة دون تغيير، إلى جانب شيفرة مُكثَّفة في مجرى مرافق. يبدأ استعلام البحث عن المتجهات بفحص الشيفرات باستخدام دالة مسافة خفيفة الحساب وصديقة لـ SIMD لتجميع قائمة مختصرة بأكثر المرشحين واعداً، ثم يُعيد ترتيب تلك القائمة مقارنةً بالمتجهات كاملة الدقة. ولأن الشيفرة لا تُمثّل سوى جزء صغير من حجم المتجه الخام، يقرأ فحص القائمة المختصرة بايتات أقل بكثير من التخزين — ولا يلمس عمود الدقة الكاملة إلا لعدد محدود من المرشحين المُدرجين في القائمة — في حين يظل الترتيب النهائي دقيقاً.

تعريف المرمّز

أرفِق المرمّز Quantized(...) بعمود من النوع Array(Float32) (أو Array(Float64) / Array(BFloat16)). هذا المرمّز تجريبي، لذا فعِّل allow_experimental_codecs أولًا:
تُخزَّن البيانات كاملة الدقة كالمعتاد؛ ولا يضيف المرمّز سوى مجرى الشيفرة المصاحب. يُثبَّت المرمّز عند إنشاء الجدول، ولا يمكن إضافته أو تغييره باستخدام ALTER TABLE.

طرق التكميم

تمثل كل طريقة نقطة مختلفة على مقياس المفاضلة بين الحجم / الدقة / المقياس. الوسيط dimensions هو طول المتجه.
  • Quantized('rabitq', dimensions) — بت إشارة واحد لكل إحداثي، بالإضافة إلى معامل تصحيح غير متحيز لجيب التمام (dimensions/8 + 4 بايت). خيار افتراضي صغير وقوي ومنخفض الكلفة حسابيًا مع popcount. لـ cosineDistance فقط.
  • Quantized('turboquant', dimensions) — بتّان لكل إحداثي (شيفرة MSE من 1 بت وشيفرة متبقية من 1 بت) للحصول على مرشحين أعلى دقة (dimensions/4 + 4 بايت). لـ cosineDistance فقط.
  • Quantized('int8', dimensions) — شيفرة Int8 واحدة لكل إحداثي، بالإضافة إلى معيار المتجه (dimensions + 4 بايت)؛ وهو أكبر ترميز مسطّح لكنه الأوفى تمثيلًا. يدعم L2Distance و cosineDistance.
  • Quantized('prefix', dimensions, leading_dimensions, 'int8'|'bf16') — Matryoshka: يحتفظ فقط بأول leading_dimensions من الإحداثيات، بصيغة Int8 (مع مقياس لكل متجه) أو BFloat16. ترميزات صغيرة جدًا للتضمينات المدرَّبة باستخدام Matryoshka Representation Learning. يدعم L2Distance و cosineDistance.
  • Quantized('product', dimensions, nbits, m) — Product Quantization: جدول ترميز لكل جزء مُدرَّب باستخدام k-means؛ ويتحول كل متجه إلى m ترميزات من nbits بتات (لذا يجب أن تكون dimensions من مضاعفات m). وهو الخيار الأكثر اكتنازًا والأعلى استرجاعًا لكل بايت، لكن على حساب خطوة تدريب أثناء الإدراج. يدعم L2Distance و cosineDistance.
يتطلب rabitq و turboquant أن تكون dimensions من مضاعفات 8.

البحث بشفافية

لا توجد صياغة خاصة لبناء جملة الاستعلام — اكتب استعلام top-k نفسه الذي ستستخدمه في البحث الدقيق:
باستخدام vector_search_use_quantized_codes = 1، يعيد المُحسِّن كتابة الاستعلام تلقائيًا إلى خطة من مرحلتين: إذ يفحص الشيفرات المكمّاة لتجميع قائمة مختصرة، ثم يعيد تقييم هذه القائمة المختصرة بمقارنتها مع vec كاملة الدقة. يكون هذا الإعداد غير مفعّل افتراضيًا، لذا من دونه يُنفَّذ الاستعلام نفسه بوصفه فحصًا دقيقًا مباشرًا — ولا يغيّر codec النتائج مطلقًا، بل يوفّر فقط مسارًا أسرع عندما تختار تفعيله. استخدم دالة مسافة تدعمها الطريقة المختارة: cosineDistance لجميع الطرق، وL2Distance أيضًا مع int8 وprefix وproduct.

الإعدادات

  • allow_experimental_codecs — يجب تمكينه لتعريف مرمّز Quantized (القيمة الافتراضية: 0).
  • vector_search_use_quantized_codes — يفعّل إعادة الكتابة على مرحلتين: إعداد قائمة مختصرة ثم إعادة التقييم (القيمة الافتراضية: 0). عند تعطيله، تمسح الاستعلامات المطابقة المتجهات كاملة الدقة بدقة تامة.
  • vector_search_index_fetch_multiplier — يحدد عدد المرشحين في القائمة المختصرة نسبةً إلى LIMIT الخاص بالاستعلام: يحتفظ الفحص بأفضل LIMIT × multiplier من الرموز قبل إعادة التقييم. تحسّن القيم الأكبر الاسترجاع على حساب زيادة إعادة التقييم. القيمة الافتراضية هي 1 (من دون أخذ عينات إضافية)، لذا يلزم عادةً رفعها — على سبيل المثال إلى 10 أو أكثر — للحصول على استرجاع جيد.

مصمم للتوسع

يُعد هذا المرمّز مناسبًا جدًا لـClickHouse، لأن الجزء المكلف — أي الفحص — هو بالضبط ما صُمم محرك ClickHouse ليبرع فيه:
  • متجهّي. كُتبت نوى الفحص للعمل مع SIMD، مع توجيه وقت التشغيل إلى أوسع التعليمات التي يدعمها CPU: استخدام popcount العتادي لطرائق sign-code (rabitq, turboquant)، واستخدام fused-multiply-add العريض للطرائق الأخرى.
  • بالتوازي عبر الأنوية والأجزاء. الفحص المسطح قابل للتوازي بسهولة، وClickHouse يتعامل معه على هذا النحو: إذ تُحسب المسافات عبر جميع الخيوط المتاحة وعبر جميع أجزاء الجدول في آنٍ واحد، ولا يُنفَّذ تسلسليًا إلا دمج top-k النهائي.
  • موزّع. في cluster مُجزّأ إلى shards، يتوزع العمل على الأجهزة — إذ يفحص كل shard الجزء الخاص به بالتوازي، ثم يدمج coordinator القوائم المختصرة.
  • عمودي وملائم للتصفية. تشغل الرموز المُكمّاة عمودًا خاصًا بها، وتُضغط وتُقرأ عبر مسار I/O نفسه المستخدم لكل عمود آخر، لذا فإن WHERE الانتقائي يترك عددًا أقل من الرموز لفحصه.
  • من دون خطوة بناء منفصلة. تُنتَج الرموز أثناء كتابة المتجهات وتُدمَج بالوصل — فلا يوجد فهرس يلزم إنشاؤه أو ضبطه أو إعادة بنائه، لذا يصبح الجدول جاهزًا للبحث بمجرد وصول بياناته.
لا تُستخدم هذه الرموز إلا لتوليد المرشحين؛ أما العمود كامل الدقة، والمحفوظ في مكانه، فيوفر الترتيب النهائي الدقيق.

البت المُكمَّم (QBit)

من الأساليب الشائعة لتسريع البحث المتجهي الدقيق استخدام نوع بيانات الفاصلة العائمة الأقل دقة. فعلى سبيل المثال، إذا خُزِّنت المتجهات بصيغة Array(BFloat16) بدلًا من Array(Float32)، فسينخفض حجم البيانات إلى النصف، ومن المتوقع أن تنخفض أزمنة تنفيذ الاستعلامات بنسبة مماثلة. وتُعرف هذه الطريقة باسم التكميم. وعلى الرغم من أنها تُسرّع العمليات الحسابية، فقد تقلل من دقة النتائج رغم إجراء فحص شامل لجميع المتجهات. في التكميم التقليدي، نفقد الدقة أثناء البحث وعند تخزين البيانات أيضًا. ففي المثال أعلاه، سنخزّن BFloat16 بدلًا من Float32، ما يعني أنه لن يعود بالإمكان إجراء بحث أكثر دقة لاحقًا، حتى لو أردنا ذلك. وأحد البدائل هو تخزين نسختين من البيانات: نسخة مكمّمة ونسخة كاملة الدقة. ورغم أن هذا الأسلوب ينجح، فإنه يتطلب تخزينًا زائدًا. تخيّل سيناريو تكون فيه Float64 هي البيانات الأصلية ونريد تشغيل عمليات بحث بدقات مختلفة (16-بت، أو 32-بت، أو 64-بت كاملة). عندها سنحتاج إلى تخزين ثلاث نسخ منفصلة من البيانات. يوفّر ClickHouse نوع البيانات Quantized Bit (QBit) لمعالجة هذه القيود من خلال:
  1. تخزين البيانات الأصلية كاملة الدقة.
  2. إتاحة تحديد دقة التكميم وقت الاستعلام.
ويتحقق ذلك عبر تخزين البيانات بتنسيق مُجمَّع بحسب البتات (أي تُخزَّن جميع البتات ذات الموضع i-th لكل المتجهات معًا)، مما يتيح القراءة بمستوى الدقة المطلوب فقط. وهكذا تحصل على مزايا السرعة الناتجة عن تقليل عمليات الإدخال/الإخراج والحوسبة بفضل التكميم، مع الاحتفاظ بجميع البيانات الأصلية وإتاحتها عند الحاجة. وعند اختيار أعلى دقة ممكنة، يصبح البحث دقيقًا تمامًا. للتصريح عن عمود من النوع QBit، استخدم البنية التالية:
حيث:
  • element_type – نوع كل عنصر في المتجه. والأنواع المدعومة هي Int8 وBFloat16 وFloat32 وFloat64
  • dimension – عدد العناصر في كل متجه
  • stride – اختياري. وهو قاسم لـdimension يُقسِّم الأبعاد إلى dimension / stride مجموعات متجاورة تُخزَّن في تدفقات منفصلة، بحيث تؤدي عملية البحث التي تقتصر على الأبعاد الأولى فقط إلى قراءة عدد أقل من التدفقات (وهو ما يفيد مع تضمينات Matryoshka). والقيمة الافتراضية هي dimension، وفي هذه الحالة يكون النوع مطابقًا على مستوى البايت لنوع QBit غير المستخدِم لـstride. راجع صفحة نوع البيانات QBit للتفاصيل.

إنشاء جدول QBit وإدراج البيانات فيه

لنبحث عن أقرب الجيران إلى متجه يمثّل الكلمة ‘lemon’ باستخدام مسافة L2. تحدد المعلمة الثالثة في دالة المسافة مقدار الدقة بالبتات؛ فكلما ارتفعت القيم زادت الدقة، لكنها تتطلب حسابات أكثر. يمكنك العثور على جميع دوال المسافة المتاحة لـ QBit هنا. بحث بالدقة الكاملة (64 بت):
البحث بدقة منخفضة:
لاحظ أنه مع التكميم إلى 12 بتًا، نحصل على تقريب جيد للمسافات مع تنفيذ الاستعلام بسرعة أكبر. ويظل الترتيب النسبي متسقًا إلى حدّ كبير، مع بقاء ‘apple’ أقرب تطابق.

اعتبارات الأداء

تنبع فائدة الأداء في QBit من تقليل عمليات الإدخال/الإخراج، إذ يلزم قراءة كمية أقل من البيانات من التخزين عند استخدام دقة أقل. بالإضافة إلى ذلك، عندما يحتوي QBit على بيانات Float32، وإذا كانت معلَمة precision تساوي 16 أو أقل، فستكون هناك فوائد إضافية ناتجة عن تقليل العمليات الحسابية. وتتحكم معلَمة precision مباشرةً في المفاضلة بين الدقة والسرعة:
  • دقة أعلى (أقرب إلى عرض البيانات الأصلي): نتائج أكثر دقة، واستعلامات أبطأ
  • دقة أقل: استعلامات أسرع مع نتائج تقريبية، وانخفاض في استخدام الذاكرة

المراجع

المدونات:
آخر تعديل في ٢٣ يوليو ٢٠٢٦