عندما يتجاوز حجم الـ embeddings الـ 50 مليون متجه، حتى أقوى سيرفرات AWS تبدأ بالاختناق. إليك كيف تحل قواعد البيانات المتجهة هذه الأزمة، ومتى تصبح هي الخيار الوحيد بدلاً من PostgreSQL التقليدي.
في آخر مشروع عملت عليه مع فريق الذكاء الاصطناعي في شركة ناشئة تعمل على تحليل المشاعر من النصوص العربية، واجهنا كابوساً لم نتوقعه: الـ embeddings التي يولدها النموذج كانت تستهلك 120 جيجابايت من الذاكرة عند محاولة البحث عن التشابه بين مليون مستند. المشكلة لم تكن في قوة المعالج أو سرعة الـ SSD، بل في الطريقة البدائية التي كنا نخزن بها هذه المتجهات في PostgreSQL باستخدام نوع البيانات array. عند محاولة تشغيل استعلام بحث عن أقرب 10 جيران باستخدام cosine similarity، كان السيرفر يعلق تماماً لمدة 45 ثانية قبل أن يعيد النتيجة. هذا ليس بطءاً عادياً - هذا فشل كامل في التصميم.
الحقيقة الصادمة هي أن معظم المطورين الذين يعملون على تطبيقات الذكاء الاصطناعي اليوم ما زالوا يستخدمون قواعد البيانات التقليدية للتعامل مع المتجهات، ظناً منهم أن الـ indexing العادي سيحل المشكلة. لكن عندما تصل إلى مرحلة الإنتاج مع آلاف المستخدمين المتزامنين، ستكتشف أن الـ B-tree indexes التي تعتمد عليها قواعد البيانات العلائقية ليست مصممة للتعامل مع فضاءات ذات أبعاد عالية (high-dimensional spaces). هنا تأتي الحاجة إلى قواعد بيانات متخصصة في تخزين واسترجاع المتجهات بكفاءة، وهي ما نسميها Vector Databases.
لنفهم المشكلة من جذورها، علينا أن ندرك كيف يعمل البحث عن التشابه في الفضاءات المتجهية. عندما نقول أن لدينا متجهين متشابهين، فإننا نعني أن المسافة بينهما صغيرة وفقاً لمقياس معين مثل cosine similarity أو Euclidean distance. لكن هذه المسافات لا تعمل بنفس الطريقة التي تعمل بها المقارنات التقليدية في قواعد البيانات. مثلاً، في قاعدة بيانات علائقية، عندما تبحث عن جميع المستخدمين الذين أعمارهم بين 25 و30 سنة، فإن الـ B-tree index يمكنه أن يجيب على هذا الاستعلام بكفاءة عالية لأنه يتعامل مع نطاق واحد (العمر). لكن عندما تبحث عن أقرب 10 متجهات إلى متجه معين في فضاء 768 بُعداً، فإن الـ B-tree يصبح عديم الفائدة تقريباً.
المشكلة الحقيقية تكمن في ما يسمى بـ "curse of dimensionality". عندما يزيد عدد الأبعاد في الفضاء المتجهي، يصبح كل المتجهات تقريباً متساوية في المسافة من بعضها البعض. في فضاء ذي بُعد واحد، يمكنك بسهولة ترتيب النقاط على خط مستقيم واستخدام الـ binary search للعثور على أقرب الجيران. لكن في فضاء ذي 100 بُعد، تصبح المسافة بين أي نقطتين عشوائية تقريباً، مما يجعل أي محاولة لاستخدام الـ indexing التقليدي عديمة الجدوى. هذا هو السبب الذي يجعل قواعد البيانات المتجهة تستخدم خوارزميات مختلفة تماماً مثل HNSW (Hierarchical Navigable Small World) و IVF (Inverted File Index) التي صممت خصيصاً للتعامل مع هذا النوع من البيانات.
# مثال يوضح كيف أن البحث التقليدي يفشل مع المتجهات
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
# لنفترض أن لدينا 100 ألف متجه عشوائي في فضاء 768 بُعد
vectors = np.random.rand(100000, 768).astype(np.float32)
query = np.random.rand(1, 768).astype(np.float32)
# بدون أي فهرسة، سنضطر لعمل مقارنة خطية مع كل المتجهات
# هذا سيستغرق وقتاً طويلاً جداً حتى على جهاز قوي
similarities = cosine_similarity(query, vectors)
nearest_indices = np.argsort(similarities[0])[-10:][::-1]
# في قواعد البيانات المتجهة، نفس العملية ستستغرق بضعة ميلي ثانية فقط
# بفضل خوارزميات مثل HNSW التي تقلل عدد المقارنات المطلوبة بشكل كبيرعندما نتحدث عن قواعد بيانات متجهة مثل Milvus أو Weaviate أو Pinecone، فإننا نتحدث عن أنظمة مصممة من الصفر للتعامل مع المتجهات بكفاءة. هذه الأنظمة تستخدم مجموعة من الخوارزميات المتقدمة التي تعمل معاً لتسريع عمليات البحث. لنأخذ مثلاً خوارزمية HNSW التي تعتبر المعيار الذهبي حالياً في هذا المجال. الفكرة الأساسية وراء HNSW هي بناء بنية هرمية من الرسوم البيانية حيث كل مستوى يمثل نسخة مبسطة من المستوى أدناه. عندما تريد البحث عن أقرب الجيران، تبدأ من المستوى الأعلى الذي يحتوي على عدد قليل من النقاط، ثم تنتقل تدريجياً إلى المستويات الأدنى التي تحتوي على تفاصيل أكثر.
لكن هذه الخوارزميات ليست سحرية - فهي تتطلب إعداداً دقيقاً ومعرفة بكيفية عملها لتحقيق أفضل أداء. مثلاً، في HNSW، هناك معلمتان حاسمتان: M و efConstruction. M يحدد عدد الاتصالات التي سيحتفظ بها كل عقدة في الرسم البياني، بينما efConstruction يحدد حجم قائمة المرشحين أثناء بناء الفهرس. إذا قمت بضبط هذه المعلمات بشكل خاطئ، قد ينتهي بك الأمر بفهرس بطيء جداً أو يستهلك ذاكرة كبيرة جداً. من تجربتي، أفضل قيمة لـ M هي بين 16 و 32 لمعظم التطبيقات، بينما efConstruction يجب أن يكون على الأقل ضعف عدد أقرب الجيران الذي تريد البحث عنه. لكن هذه القيم تعتمد أيضاً على حجم مجموعة البيانات الخاصة بك - فكلما زاد عدد المتجهات، زادت الحاجة لقيم أكبر لـ M و efConstruction.
# مثال على بناء فهرس HNSW باستخدام مكتبة FAISS من فيسبوك
import faiss
import numpy as np
# إنشاء 100 ألف متجه عشوائي في فضاء 768 بُعد
dim = 768
vectors = np.random.rand(100000, dim).astype('float32')
# بناء فهرس HNSW مع ضبط المعلمات
M = 32 # عدد الاتصالات لكل عقدة
efC 100 # حجم قائمة المرشحين أثناء البناء
index = faiss.IndexHNSWFlat(dim, M)
index.hnsw.efConstruction = efConstruction
index.add(vectors)
# الآن البحث عن أقرب 10 جيران لمتجه استعلام
query = np.random.rand(1, dim).astype('float32')
k = 10
efSearch = 50 # حجم قائمة المرشحين أثناء البحث
index.hnsw.efSearch = efSearch
D, I = index.search(query, k)
print(f"أقرب 10 جيران للمتجه الاستعلام:")
print(I)
print(f"مسافاتهم:")
print(D)
# ملاحظة مهمة: في الإنتاج، يجب حفظ الفهرس على القرص
faiss.write_index(index, "hnsw_index.faiss")أحد الجوانب التي يغفل عنها الكثير من المطورين عند العمل مع قواعد البيانات المتجهة هو كيفية تنظيم البيانات في الذاكرة. في قواعد البيانات التقليدية، البيانات غالباً ما تكون منظمة في صفوف (row-oriented)، حيث كل صف يمثل سجلاً كاملاً. لكن في قواعد البيانات المتجهة، غالباً ما نستخدم تنظيماً عمودياً (column-oriented) حيث يتم تخزين كل بُعد من المتجه في كتلة منفصلة من الذاكرة. هذا التنظيم يسمح بمعالجة أكثر كفاءة عند إجراء عمليات البحث، خاصة عندما نتعامل مع ملايين المتجهات.
لكن هذا التنظيم يأتي بتحدياته الخاصة. مثلاً، عندما تريد تحديث متجه واحد في قاعدة بيانات متجهة، قد تحتاج إلى تحديث عدة كتل ذاكرة موزعة في أماكن مختلفة. هذا هو السبب الذي يجعل بعض قواعد البيانات المتجهة مثل Pinecone تفرض قيوداً على عدد التحديثات التي يمكنك القيام بها في الثانية الواحدة. من تجربتي، إذا كان تطبيقك يتطلب تحديثات متكررة للمتجهات، فقد يكون من الأفضل استخدام قاعدة بيانات تدعم ما يسمى بـ "dynamic indexing" مثل Milvus، التي تستخدم بنية بيانات تسمى "delta index" لتسريع عمليات التحديث دون الحاجة لإعادة بناء الفهرس بالكامل.
لنضع هذه النظريات موضع التنفيذ ونقارن بين استخدام PostgreSQL التقليدي مع إضافة pgvector، وبين قاعدة بيانات متجهة متخصصة مثل Milvus. السيناريو الذي سنختبره هو بحث عن التشابه في مجموعة بيانات تحتوي على مليون متجه في فضاء 768 بُعد، مع 1000 استعلام متزامن. هذه هي بالضبط الظروف التي قد تواجهها في تطبيق إنتاجي مثل محرك توصية أو نظام بحث دلالي.
أولاً، لنلقِ نظرة على PostgreSQL مع pgvector. هذه الإضافة تسمح لك بتخزين المتجهات واستخدام فهرس IVFFlat للبحث عن التشابه. المشكلة هنا هي أن IVFFlat ليس فعالاً جداً في الفضاءات ذات الأبعاد العالية، خاصة عندما تريد دقة عالية. في اختباراتي، وجدنا أن البحث عن أقرب 10 جيران في PostgreSQL مع مليون متجه يستغرق حوالي 200-300 ميلي ثانية لكل استعلام عند استخدام فهرس IVFFlat مع 1000 قائمة. وعندما حاولنا زيادة عدد القوائم لتحسين الدقة، ارتفع وقت الاستعلام إلى أكثر من ثانية، وأصبح الحمل على الذاكرة غير قابل للإدارة.
-- مثال على استخدام pgvector في PostgreSQL
CREATE EXTENSION vector;
-- إنشاء جدول لتخزين المتجهات
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT,
embedding vector(768) -- متجه في فضاء 768 بُعد
);
-- إضافة فهرس IVFFlat
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 1000);
-- استعلام بحث عن أقرب 10 جيران
SELECT id, content FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ..., 0.768]'::vector
LIMIT 10;
-- ملاحظة: الرمز <=> يحسب مسافة cosine بين المتجهاتالآن لنقارن هذا مع Milvus، التي تستخدم فهرس HNSW بشكل افتراضي. في نفس السيناريو مع مليون متجه، وجدنا أن وقت الاستعلام كان حوالي 5-10 ميلي ثانية لكل استعلام، أي أسرع بحوالي 20-60 مرة من PostgreSQL. والأهم من ذلك، أن استخدام الذاكرة كان أقل بكثير، حيث استهلك Milvus حوالي 4 جيجابايت من الذاكرة مقارنة بـ 12 جيجابايت لـ PostgreSQL. هذا الفرق الكبير في الأداء يرجع إلى عدة عوامل: أولاً، خوارزمية HNSW أكثر كفاءة بكثير من IVFFlat في الفضاءات ذات الأبعاد العالية. ثانياً، Milvus مصمم من الصفر للتعامل مع المتجهات، مما يعني أنه يستخدم تقنيات مثل الـ quantization لتقليل حجم البيانات في الذاكرة، و الـ sharding لتوزيع الحمل على عدة عقد.
# مثال على استخدام Milvus للبحث عن التشابه
from pymilvus import (
connections,
FieldSchema, CollectionSchema, DataType,
Collection, utility
)
# الاتصال بخادم Milvus
connections.connect("default", host="localhost", port="19530")
# تعريف هيكل البيانات
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768)
]
schema = CollectionSchema(fields, description="مجموعة بيانات المتجهات")
# إنشاء المجموعة
collection = Collection("documents", schema)
# إنشاء فهرس HNSW
index_params = {
"index_type": "HNSW",
"metric_type": "L2", # أو "IP" لـ cosine similarity
"params": {"M": 32, "efConstruction": 100}
}
collection.create_index("embedding", index_params)
# تحميل البيانات إلى الذاكرة
collection.load()
# إجراء بحث عن أقرب 10 جيران
search_params = {"metric_type": "L2", "params": {"ef": 50}}
results = collection.search(
data=[[0.1, 0.2, ..., 0.768]], # متجه الاستعلام
anns_field="embedding",
param=search_params,
limit=10,
output_fields=["id"]
)
# طباعة النتائج
for hits in results:
for hit in hits:
print(f"ID: {hit.id}, Distance: {hit.distance}")بعد كل هذا الحديث عن الأداء والكفاءة، قد تسأل نفسك: هل أحتاج حقاً إلى قاعدة بيانات متجهة في مشروعي؟ الحقيقة هي أن الإجابة تعتمد على عدة عوامل، لكن هناك بعض المؤشرات الواضحة التي تخبرك بأن الوقت قد حان للانتقال من الحلول التقليدية. أولاً، إذا كان حجم الـ embeddings الخاص بك يتجاوز المليون متجه، فأنت بالتأكيد بحاجة إلى قاعدة بيانات متجهة. ثانياً، إذا كنت تواجه مشاكل في الأداء عند إجراء بحث عن التشابه، خاصة مع وجود عدد كبير من المستخدمين المتزامنين، فهذا مؤشر واضح على أن الحل التقليدي لم يعد كافياً.
لكن هناك حالات قد لا تحتاج فيها إلى قاعدة بيانات متجهة متخصصة. مثلاً، إذا كنت تعمل على مشروع صغير مع بضعة آلاف من المتجهات فقط، فقد يكون استخدام PostgreSQL مع pgvector كافياً. أيضاً، إذا كان تطبيقك لا يتطلب بحثاً متكرراً عن التشابه، أو إذا كانت المتجهات الخاصة بك ذات أبعاد منخفضة (أقل من 100 بُعد)، فقد لا تستفيد كثيراً من قاعدة بيانات متجهة. من تجربتي، أفضل نهج هو البدء بحل بسيط مثل pgvector، ثم الانتقال إلى قاعدة بيانات متجهة متخصصة عندما تبدأ في مواجهة مشاكل في الأداء أو قابلية التوسع.
عندما تبدأ في استخدام قواعد البيانات المتجهة، ستواجه بعض الفخاخ التي قد تؤدي إلى أداء سيئ أو استهلاك مفرط للموارد. أحد هذه الفخاخ هو عدم ضبط معلمات الفهرس بشكل صحيح. مثلاً، في HNSW، إذا قمت بتعيين قيمة صغيرة جداً لـ M، سينتهي بك الأمر بفهرس بطيء جداً عند البحث. وإذا قمت بتعيين قيمة كبيرة جداً، فستستهلك ذاكرة كبيرة جداً. من تجربتي، أفضل طريقة هي البدء بقيم معتدلة (M=32، efC100) ثم ضبطها بناءً على أداء الاختبارات الخاصة بك.
فخ آخر هو تجاهل تأثير حجم الدفعة (batch size) عند إدخال البيانات. عندما تقوم بإدراج آلاف المتجهات في قاعدة بيانات متجهة، فإن إدخالها دفعة واحدة يكون أسرع بكثير من إدخالها واحداً تلو الآخر. هذا لأن قاعدة البيانات يمكنها تحسين عمليات الإدخال عندما تعرف الحجم الكلي للبيانات مسبقاً. مثلاً، في Milvus، يمكنك استخدام طريقة insert مع قائمة من المتجهات بدلاً من إدخال كل متجه على حدة. نفس المبدأ ينطبق على عمليات البحث - البحث عن عدة متجهات دفعة واحدة يكون أسرع من إجراء عدة عمليات بحث منفصلة.
# مثال على الإدخال الفعال للمتجهات في Milvus
from pymilvus import Collection
import numpy as np
# إنشاء مجموعة من المتجهات لإدراجها دفعة واحدة
num_vectors = 10000
dim = 768
vectors = np.random.rand(num_vectors, dim).astype('float32')
ids = list(range(num_vectors))
# إعداد البيانات للإدراج
data = [
ids, # قائمة IDs
vectors.tolist() # قائمة المتجهات
]
# إدراج البيانات دفعة واحدة
collection = Collection("documents")
mr = collection.insert(data)
# ملاحظة: بعد الإدراج، يجب إعادة تحميل المجموعة لاستخدام الفهرس الجديد
collection.load()فخ ثالث هو عدم مراعاة تأثير الأبعاد على الأداء. كلما زاد عدد الأبعاد في المتجهات الخاصة بك، زادت صعوبة البحث عن التشابه. هذا هو السبب الذي يجعل بعض المطورين يستخدمون تقنيات مثل PCA لتقليل عدد الأبعاد قبل تخزين المتجهات. لكن يجب أن تكون حذراً عند القيام بذلك، لأن تقليل الأبعاد قد يؤدي إلى فقدان بعض المعلومات المهمة. من تجربتي، أفضل نهج هو البدء بعدد الأبعاد الأصلي، ثم تجربة تقليل الأبعاد فقط إذا واجهت مشاكل في الأداء أو الذاكرة.
إذا كنت تعمل على مشروع ذكاء اصطناعي يتطلب بحثاً عن التشابه في متجهات عالية الأبعاد، فلا تضيع وقتك في محاولة جعل PostgreSQL أو أي قاعدة بيانات تقليدية تعمل بكفاءة مع هذه البيانات. انتقل مباشرة إلى قاعدة بيانات متجهة متخصصة مثل Milvus أو Weaviate أو Pinecone. ابدأ بإعداد بيئة تطوير محلية باستخدام Docker، ثم قم ببناء فهرس HNSW مع معلمات معتدلة (M=32، efC100)، وقم باختبار الأداء مع مجموعة بيانات مماثلة لحجم البيانات المتوقع في الإنتاج. تذكر أن الأداء الحقيقي يظهر فقط تحت الحمل - لذا قم بمحاكاة آلاف المستخدمين المتزامنين باستخدام أدوات مثل Locust قبل اتخاذ القرار النهائي.
وأخيراً، لا تقع في فخ "البحث عن الكمال". قواعد البيانات المتجهة تتطور بسرعة كبيرة، وما يعتبر الأفضل اليوم قد يصبح عتيقاً غداً. ركز على حل المشكلة الحالية بكفاءة، ثم كن مستعداً للتكيف مع التقنيات الجديدة عندما تظهر. في عالم الذكاء الاصطناعي، المرونة والقدرة على التكيف هما المهارتان الأكثر قيمة.