هندسة الذكاء الاصطناعيJune 13, 2026·8 دقيقة قراءة·Miracle KaluMiracle Kalu

بناء محرك مقالات ذات صلة باستخدام التضمينات المتجهية: تصميم جاهز للإنتاج

A glowing network of connected nodes representing vector embeddings and semantic similarity.

لماذا توقفت المقالات ذات الصلة المعتمدة على الوسوم عن العمل

لسنوات، كانت أداة المقالات ذات الصلة في موقعنا عبارة عن استعلام SQL بسيط. كانت تربط المقالات عبر الوسوم المشتركة، ثم ترتب النتيجة حسب تاريخ النشر وتتوقف عند هذا الحد. كانت التنفيذ رخيصة ومتوقعة وبعيدة عن الصلة في معظم الأحيان. كان القراء ينتهون من مقال حول استراتيجيات إبطال التخزين المؤقت، ثم يُعرض عليهم أحدث منشور موسوم بـ "DevOps" لأن شخصًا ما استخدم كلمة "نشر" في المقدمة.

الوسوم تصف النية التحريرية، لا المعنى الدلالي. إنها ممتازة للتنقل، لكنها أداة غير دقيقة للتوصيات. قد يتشارك مقالان كل وسومهما ومع ذلك يعالجان مشكلات مختلفة تمامًا. والأسوأ من ذلك، أن مقالين يحلان نفس المشكلة في مجالين مختلفين قد لا يتشاركان أي وسم. مع نمو أرشيفنا ليتجاوز الخمسمائة مقال، أصبحت الأداة دوامة من النتائج المتعلقة بشكل سطحي.

كنا بحاجة إلى طبقة توصية تفهم حقيماً ما يدور حوله المقال. تبين أن التضمينات المتجهية هي الأداة المناسبة، ولكن فقط بعد أن توقفنا عن التعامل معها كصندوق بحث سحري وبدأنا نتعامل معها كخط أنابيب بيانات في الإنتاج.

ما الذي تقدمه التضمينات فعليًا

التضمين هو متجه كثيف يحول نصًا ما إلى فضاء دلالي متعدد الأبعاد. النصوص ذات المعاني المتشابهة تنتهي قريبة من بعضها، حتى لو استخدمت كلمات مختلفة. هذه الخاصية توفر إشارة توصية لا يمكن للوسوم توفيرها: الصلة المفاهيمية.

النموذج الذي اخترناه يرى "إبطال التخزين المؤقت" و"إبقاء CDN متزامنًا مع حالة المصدر" كجيران. ويرى "خطوط نشر البرامج" و"التسليم المستمر" مرتبطين دون الحاجة إلى وسمهما كذلك. النتيجة هي أداة مقالات ذات صلة تعرض قراءات تالية مفيدة حقًا.

التضمينات ليست مجانية. فهي تكلف المال لتوليدها، وتشغل مساحة تخزين، وتضيف زمن استجابة إلى مسار الاستعلام. التحدي في الإنتاج ليس في توليد المتجهات، بل في إبقاء النظام سريعًا ورخيصًا وقابلًا للصيانة مع نمو الأرشيف.

نظرة عامة على البنية التحتية

يتكون النظام من ثلاثة مسارات مستقلة: الاستيعاب، والاستعلام، والإبطال.

خطأ شائع هو وضع نموذج التضمين مباشرة في مسار الطلب. هذا يربط حركة القراء بزمن استجابة النموذج وتوفر المزود. نحن نولد التضمينات في وقت الكتابة، ونخزنها في قاعدة بيانات متجهية، ونبقي مسار الاستعلام كعملية بحث عن أقرب الجيران مع تخزين مؤقت أمامه.

اختيار النموذج والمزود

بدأنا بنموذج OpenAI text-embedding-3-small لثلاثة أسباب: إنه رخيص، ونافذة السياق تغطي معظم مقالاتنا في عملية واحدة، وأبعاد المخرجات قابلة للتهيئة. بالنسبة لأداة التوصية، وجدنا أن 512 بُعدًا تلتقط إشارة كافية مع الحفاظ على تكلفة التخزين والاستعلام معقولة. الانتقال إلى 1536 بُعدًا حسّن الجودة بشكل هامشي فقط وضاعف حجم الفهرس.

بالنسبة للفرق ذات السياسات البيانية الأكثر صرامة، النماذج المستضافة ذاتيًا مثل sentence-transformers/all-MiniLM-L6-v2 بديل قابل للتطبيق. المقايضة هي العبء التشغيلي. فأنت عندئذٍ تقوم بتشغيل استدلال GPU، وإدارة إصدارات النماذج، ورصد زمن الاستجابة بنفسك. اخترنا الخدمة المدارة لأن حجمنا لم يبرر تكلفة البنية التحتية، لكن البنية التحتية تعمل بنفس الطريقة في الحالتين.

القرار المهم ليس أي مزود تختار. بل هو عزل المزود خلف واجهة بحيث يمكنك استبداله لاحقًا دون لمس كود الاستيعاب أو الاستعلام.

خط أنابيب الاستيعاب

في كل مرة يتم فيها نشر مقال أو تحديثه، يعمل مهام خلفية على تقسيم المحتوى، وتوليد التضمينات، وكتابة المتجهات إلى المخزن. نحن لا نضمّن المقال بأكمله كمتجه واحد. فالعناوين والعناوين الفرعية والفقرات تحمل أوزانًا دلالية مختلفة، والتضمين الواحد لمقال يبلغ ثلاثة آلاف كلمة يميل إلى تخفيف الإشارة.

استراتيجية تقسيم النص لدينا بسيطة لكنها راسخة في الرأي:

  • يحصل العنوان على تضمين خاص به.
  • يحصل كل عنوان فرعي من المستوى الثاني على تضمين خاص به.
  • يتم تقسيم النص الأساسي إلى فقرات تصل إلى 256 رمزًا، مع تداخل 32 رمزًا.
  • يحتفظ كل جزء بمؤشر إلى المقال الأصل، ولغته، وتاريخ نشره.

هذا يمنحنا عدة متجهات لكل مقال، مما يحسن الاسترجاع. عندما ينتهي القارئ من مقال، نستعلم عن جميع متجهات ذلك المقال، ونُجمّع أقرب الجيران حسب المقال الأصل، ونرتب المرشحين حسب التكرار والمسافة المتوسطة.

interface ArticleChunk {
  id: string;
  articleId: string;
  locale: string;
  kind: "title" | "heading" | "paragraph";
  text: string;
  publishedAt: string;
}

interface EmbeddedChunk extends ArticleChunk {
  embedding: number[];
}

class EmbeddingPipeline {
  constructor(
    private embedder: EmbeddingProvider,
    private store: VectorStore,
  ) {}

  async ingest(article: Article): Promise<void> {
    const chunks = this.chunk(article);
    const embeddings = await this.embedder.embed(
      chunks.map((c) => c.text),
    );

    const withVectors: EmbeddedChunk[] = chunks.map((chunk, i) => ({
      ...chunk,
      embedding: embeddings[i],
    }));

    await this.store.upsert(`article:${article.id}`, withVectors);
  }

  private chunk(article: Article): ArticleChunk[] {
    const base = {
      articleId: article.id,
      locale: article.locale,
      publishedAt: article.publishedAt,
    };

    const chunks: ArticleChunk[] = [
      {
        id: `${article.id}:title`,
        ...base,
        kind: "title",
        text: article.title,
      },
      ...article.headings.map((h, i) => ({
        id: `${article.id}:h:${i}`,
        ...base,
        kind: "heading",
        text: h,
      })),
      ...splitParagraphs(article.body, 256, 32).map((p, i) => ({
        id: `${article.id}:p:${i}`,
        ...base,
        kind: "paragraph",
        text: p,
      })),
    ];

    return chunks;
  }
}

المساعد splitParagraphs متعمدًا بسيط. نحن نقسم عند حدود الجمل عندما يكون ذلك ممكنًا، لكننا لا نستخدم نوافذ منزلقة عبر حدود الجمل. بالنسبة لجودة التوصية، الحفاظ على الحدود الدلالية أهم من تعظيم كثافة الرموز.

التعبئة العكسية دون تفجير الميزانية

في المرة الأولى التي تقوم فيها بتشغيل هذا النظام، ستحتاج إلى تضمين كل مقال في أرشيفك. إذا كان لديك ألف مقال وكل مقال يجري عدة استدعاءات API، فإن التعبئة العكسية البسيطة يمكن أن تصبح مكلفة بسرعة. استخدمنا قائمة انتظار محدودة المعدل مع تتبع التكلفة.

import PQueue from "p-queue";

async function backfill(
  articles: Article[],
  pipeline: EmbeddingPipeline,
  budgetCents: number,
): Promise<void> {
  const queue = new PQueue({ concurrency: 4 });
  const costPer1kTokens = 0.02; // USD
  let estimatedCost = 0;

  for (const article of articles) {
    const tokens = estimateTokens(article.body);
    estimatedCost += (tokens / 1000) * costPer1kTokens;

    if (estimatedCost * 100 > budgetCents) {
      console.warn("نفدت ميزانية التعبئة العكسية");
      break;
    }

    queue.add(() => pipeline.ingest(article));
  }

  await queue.onIdle();
}

كان التزامن بمقدار أربع عمليات هو النقطة المثالية لمزود التضمينات لدينا. أدى التزامن الأعلى إلى تشغيل حدود المعدل دون تحسين الإنتاجية بشكل ملحوظ. كما قمنا بتشغيل التعبئة العكسية خارج ساعات الذروة وتسجيل كل فشل حتى نتمكن من إعادة محاولة مقالات محددة دون إعادة تشغيل الدفعة بأكملها.

الاستعلام عن المقالات ذات الصلة

مسار الاستعلام هو المكان الذي يصبح فيه التخزين المؤقت والتصفية حاسمين. لا ينبغي لقارئ النسخة الألمانية من مقال ما أن يرى توصيات باللغة الإنجليزية. ولا ينبغي لقارئ مقال عن أداء الواجهة الأمامية أن يرى مقالات عن قواعد بيانات الواجهة الخلفية فقط لأنها تشترك في كلمة "استعلام".

نستعلم من مخزن المتجهات باستخدام جميع أجزاء المقال الحالي، ثم نُجمّع معرّفات المقالات المرشحة عبر النتائج. يحصل كل مرشح على درجة بناءً على عدد أجزائه التي ظهرت في أقرب الجيران k الأوائل ومدى قرب تلك الجيران. كما نعزز المقالات الحديثة قليلاً حتى لا تعرض الأداة دائمًا مقالات عمرها خمس سنوات.

interface RelatedArticlesOptions {
  articleId: string;
  locale: string;
  category?: string;
  limit?: number;
}

class RelatedArticlesService {
  constructor(
    private store: VectorStore,
    private cache: Cache,
    private fallback: TagBasedFallback,
  ) {}

  async findRelated(opts: RelatedArticlesOptions): Promise<Article[]> {
    const cacheKey = `related:${opts.articleId}:${opts.locale}:${opts.category ?? "all"}`;
    const cached = await this.cache.get(cacheKey);
    if (cached) return cached;

    try {
      const chunks = await this.store.getArticleChunks(opts.articleId);
      if (chunks.length === 0) {
        return this.fallback.find(opts);
      }

      const candidates = await this.store.queryNeighbors(chunks, {
        excludeArticleId: opts.articleId,
        locale: opts.locale,
        category: opts.category,
        topK: 20,
      });

      const ranked = this.rank(candidates).slice(0, opts.limit ?? 5);
      await this.cache.set(cacheKey, ranked, { ttlSeconds: 3600 });
      return ranked;
    } catch (err) {
      console.error("فشل استعلام التضمين، الانتقال إلى البديل", err);
      return this.fallback.find(opts);
    }
  }

  private rank(candidates: Candidate[]): Article[] {
    const byArticle = new Map<string, Candidate[]>();
    for (const c of candidates) {
      const list = byArticle.get(c.articleId) ?? [];
      list.push(c);
      byArticle.set(c.articleId, list);
    }

    const scored = Array.from(byArticle.entries()).map(([articleId, hits]) => {
      const avgDistance =
        hits.reduce((sum, h) => sum + h.distance, 0) / hits.length;
      const recencyBoost = recencyScore(hits[0].publishedAt);
      return {
        articleId,
        score: hits.length * 0.6 + (1 - avgDistance) * 0.3 + recencyBoost * 0.1,
      };
    });

    return scored
      .sort((a, b) => b.score - a.score)
      .map((s) => ({ id: s.articleId }));
  }
}

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

استراتيجية التخزين المؤقت

التخزين المؤقت هو الفرق بين أداة مقالات ذات صلة تضيف عشرات المليثانية وتلك التي تضيف مئات المليثانية. نستخدم Redis مع مفتاح منظم ونمط stale-while-revalidate للمقالات الشائعة.

interface CacheEntry<T> {
  data: T;
  staleAt: number;
  expiresAt: number;
}

class RedisRelatedCache {
  constructor(private redis: RedisClient) {}

  async get<T>(key: string): Promise<T | null> {
    const raw = await this.redis.get(key);
    if (!raw) return null;

    const entry: CacheEntry<T> = JSON.parse(raw);
    if (Date.now() > entry.expiresAt) {
      await this.redis.del(key);
      return null;
    }

    return entry.data;
  }

  async set<T>(
    key: string,
    data: T,
    opts: { ttlSeconds: number; staleSeconds?: number },
  ): Promise<void> {
    const now = Date.now();
    const entry: CacheEntry<T> = {
      data,
      staleAt: now + (opts.staleSeconds ?? opts.ttlSeconds) * 1000,
      expiresAt: now + opts.ttlSeconds * 1000,
    };

    await this.redis.set(key, JSON.stringify(entry), "EX", opts.ttlSeconds);
  }

  async isStale(key: string): Promise<boolean> {
    const raw = await this.redis.get(key);
    if (!raw) return false;

    const entry: CacheEntry<unknown> = JSON.parse(raw);
    return Date.now() > entry.staleAt;
  }
}

تتضمن مفاتيح التخزين المؤقت لدينا معرف المقال واللغة واختياريًا عامل تصفية الفئة. لا نتضمن هوية المستخدم لأن الأداة متطابقة لكل قارئ لنفس المقال. هذا يبقي معدل إصابة التخزين المؤقت مرتفعًا والتنوع منخفضًا.

المدة الافتراضية للبقاء هي ساعة واحدة، مع نافذة قديمة مدتها خمس دقائق. عندما تجد طلب ما إدخالًا قديمًا، نقوم بإرجاعه فورًا وتشغيل التحديث في الخلفية. هذا يمنع المقالات الباردة من حظر قارئ في أي وقت مع إبقاء المقالات الشائعة محدثة.

يحدث إبطال التخزين المؤقت عند النشر والحذف. يحذف عامل الاستيعاب مفاتيح التخزين المؤقت للمقال المحدّث وأي مقال كان يشير إليه سابقًا. نحن لا نحاول أن نكون جراحيين. الإبطال رخيص؛ أما التوصيات القديمة فهي مكلفة.

التصفية والتقسيم الفرعي

التشابه المتجهي الخام غير كافٍ. قد يحتوي مدونة سفر على مقالين عن "باريس" متقاربين دلاليًا، لكن أحدهما دليل ميزانية والآخر مراجعة فندق فاخر. إذا كان قارئك على دليل الميزانية، فربما لا ترغب في إرساله إلى مراجعة الفندق الفاخر.

ندعم نوعين من عوامل التصفية: عوامل تصفية صارمة وعوامل تصفية مرنة. تستبعد عوامل التصفية الصارمة المرشحين قبل أن تُعيد استعلام المتجهات النتائج، مثل اللغة وحالة النشر. تُطبق عوامل التصفية المرنة بعد الترتيب، مثل تفضيل الفئة أو وقت القراءة. يمكن تجاوز عوامل التصفية المرنة إذا كانت المطابقة الدلالية قوية بما يكفي.

تختلف مخازن المتجهات على نطاق واسع في مدى دعمها لتصفية البيانات الوصفية أثناء استعلامات ANN. يتعامل Qdrant وPinecone معها بشكل جيد. يعمل PostgreSQL مع pgvector للأرشيفات الصغيرة لكنه يعاني مع استعلامات المتجهات والبيانات الوصفية المجمعة على نطاق واسع. اخترنا مخزننا على وجه التحديد لأنه يمكنه تطبيق عوامل تصفية اللغة والفئة داخل بحث ANN، مما يتجنب الحاجة إلى جلب مجموعات كبيرة من المرشحين وتصفيتها لاحقًا.

المراقبة ومراقبة التكاليف

الأنظمة الإنتاجية التي تعتمد على واجهة برمجة تطبيقات تابعة لجهة خارجية تحتاج إلى ضمانات. نتتبع ثلاث مقاييس: زمن استجابة التضمين، وزمن استجابة الاستعلام، والتكلفة لكل ألف مقال. زمن استجابة التضمين يهم في الغالب أثناء التعبئة العكسية والتحديثات المجمعة. زمن استجابة الاستعلام يهم القراء وهو سبب وجود التخزين المؤقت.

كما نحد الإنفاق اليومي على التضمين. إذا قامت عملية ترحيل محتوى أو استيراد فجأة بإدراج عشرات الآلاف من المقالات في قائمة الانتظار، فلا نريد فاتورة مفاجئة. يتحقق العامل من عداد الميزانية اليومي في Redis قبل كل دفعة ويتوقف عند الوصول إلى الحد الأقصى.

async function checkDailyBudget(
  redis: RedisClient,
  costCents: number,
  maxCents: number,
): Promise<boolean> {
  const key = `embed:budget:${new Date().toISOString().slice(0, 10)}`;
  const spent = await redis.incrby(key, Math.ceil(costCents));
  if (spent <= maxCents) {
    await redis.expire(key, 86_400);
  }
  return spent <= maxCents;
}

أخيرًا، نسجل كل استعلام يرجع إلى التوصيات المعتمدة على الوسوم. الزيادة المستمرة في معدل الانتقال إلى البديل عادةً ما تكون أول علامة على مشكلة في مخزن المتجهات أو تعبئة عكسية مفقودة.

الخصوصية والاحتفاظ بالبيانات

إرسال محتوى المقالات إلى واجهة برمجة تطبيقات للتضمين له آثار. حتى لو كان المحتوى عامًا بالفعل، فقد تحتفظ مزودات التضمين بالمدخلات لتحسين النماذج اعتمادًا على شروطها. نعطل استخدام التدريب حيث تسمح API بذلك، ونقوم بتدقيق سياسة بيانات المزود أثناء مراجعة العقد.

بالنسبة للمحتوى الداخلي أو المحتوى المدفوع، الاستضافة الذاتية هي الخيار الأكثر أمانًا. لا تتغير البنية التحتية؛ يتغير فقط واجهة المزود. لهذا السبب تكون التجريد مهمة للغاية.

النتائج والتحذيرات

بعد ستة أسابيع، ارتفع معدل النقر على أداة المقالات ذات الصلة بنسبة 34 في المائة. تحسن الوقت المستغرق على الموقع بشكل معتدل. التغيير النوعي الأكبر كان انخفاض الشكاوى من المحررين بأن الأداة تعرض توصيات خارجة عن الموضوع.

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

كما أنها تتطلب الصيانة. يتم إيقاف النماذج. تتغير أسعار المزودين. تنجرف المتجهات مع تطور استراتيجية المحتوى لديك. أنت تضيف خط أنابيب بيانات جديد، وليس مجرد أداة.

النتائج المستخلصة

  • الوسوم تصف النية التحريرية؛ التضمينات تلتقط المعنى الدلالي. استخدم الإشارة الصحيحة للتوصيات.
  • ولد التضمينات في وقت الكتابة، لا في وقت الطلب. يجب أن يكون مسار الاستعلام عملية بحث سريعة مع تخزين مؤقت أمامها.
  • قسّم المحتوى بذكاء. العناوين والعناوين الفرعية والفقرات تستحق متجهات منفصلة.
  • قدم دائمًا بديلاً للتوصيات المعتمدة على الوسوم أو الشعبية. الميزات التي يراها القراء لا يمكن أن تفشل بصمت.
  • خزّن مؤقتًا بقوة باستخدام مفاتيح منظمة و stale-while-revalidate. المقالات ذات الصلة لا تحتاج إلى أن تكون في الوقت الفعلي.
  • تجرد من مزود التضمينات ومخزن المتجهات خلف واجهات. ستحتاج في النهاية إلى استبدال أحدهما.
  • راقب التكلفة وزمن الاستجابة ومعدل الانتقال إلى البديل. هذه هي المقاييس التي تخبرك بصحة النظام.
  • عامل التضمينات كخط أنابيب بيانات، وليس كإضافة. إنه يحتاج إلى تعبئة عكسية وإبطال وميزانية وسياسات احتفاظ.

مشاركة:

XLinkedIn
Miracle Kalu

كتبه

Miracle Kalu

Senior Full Stack Engineer

أعجبك ما قرأته؟

أنا متاح لأدوار الهندسة الأولى والاستشارات التقنية. لنتحدث.

تواصل →

نُشر 13 يونيو 2026 · 8 دقيقة قراءة

مواصلة القراءة