كتابة سكربت لتحويل مقال كامل إلى Embeddings وحفظه محلياً

دقائق القراءة: 5

كتابة سكربت لتحويل مقال كامل إلى Embeddings وحفظه محلياً

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

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

المعمارية العامة لتدفق البيانات

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

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

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

المتطلبات التقنية قبل التنفيذ

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

سنستخدم هنا مكتبات بسيطة وواضحة:

  • openai لإرسال النصوص إلى نموذج التضمينات.
  • json لحفظ النتائج محلياً.
  • pathlib لإدارة المسارات.

تنصيب المكتبات

pip install openai python-dotenv

تصميم هيكل الملفات

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

project/
├── article.txt
├── .env
├── generate_embeddings.py
└── output/
    └── article_embeddings.json

فكرة التقسيم الذكي للنص

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

الهدف من overlap هو منع انقطاع المعنى عند نهاية كل مقطع، بحيث تتكرر بضعة أحرف أو جمل بين كل جزأين متتاليين.

السكربت الكامل لتحويل المقال إلى تضمينات

import os
import json
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

ARTICLE_PATH = Path("article.txt")
OUTPUT_DIR = Path("output")
OUTPUT_FILE = OUTPUT_DIR / "article_embeddings.json"

CHUNK_SIZE = 800
CHUNK_OVERLAP = 120
EMBEDDING_MODEL = "text-embedding-3-small"


def read_article(file_path: Path) -> str:
    text = file_path.read_text(encoding="utf-8")
    return " ".join(text.split())


def chunk_text(text: str, chunk_size: int = 800, overlap: int = 120) -> list[str]:
    chunks = []
    start = 0

    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end]
        chunks.append(chunk)

        if end >= len(text):
            break

        start = end - overlap

    return chunks


def get_embedding(text: str, model: str = EMBEDDING_MODEL) -> list[float]:
    response = client.embeddings.create(
        model=model,
        input=text
    )
    return response.data[0].embedding


def build_embedding_records(chunks: list[str]) -> list[dict]:
    records = []

    for index, chunk in enumerate(chunks):
        vector = get_embedding(chunk)
        records.append({
            "chunk_id": index,
            "text": chunk,
            "embedding": vector,
            "length": len(chunk)
        })

    return records


def save_to_json(records: list[dict], output_file: Path) -> None:
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

    payload = {
        "source_file": str(ARTICLE_PATH),
        "embedding_model": EMBEDDING_MODEL,
        "total_chunks": len(records),
        "records": records
    }

    output_file.write_text(
        json.dumps(payload, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )


def main():
    article_text = read_article(ARTICLE_PATH)
    chunks = chunk_text(article_text, CHUNK_SIZE, CHUNK_OVERLAP)
    records = build_embedding_records(chunks)
    save_to_json(records, OUTPUT_FILE)

    print(f"Saved {len(records)} chunks to {OUTPUT_FILE}")


if __name__ == "__main__":
    main()

كيف يعمل هذا السكربت داخلياً؟

1) قراءة المقال وتنظيفه

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

2) تقسيم النص إلى Chunks

الدالة chunk_text() تقسم النص بطول ثابت تقريباً. في المشاريع المتقدمة يمكن استبدال هذا بأسلوب يعتمد على الفقرات أو الجمل أو حتى Tokenization، وهي نقطة ترتبط أيضاً بحساب الاستهلاك كما شرحنا في مقال حساب التكلفة وإدارة الرموز Tokens.

3) استدعاء نموذج التضمين

الدالة get_embedding() ترسل كل مقطع إلى نموذج text-embedding-3-small. الناتج ليس نصاً، بل قائمة أرقام عشرية تمثل الموقع الدلالي لذلك المقطع في فضاء رياضي عالي الأبعاد.

4) بناء سجل محلي قابل للفهرسة

بدلاً من حفظ المتجه فقط، قمنا بتخزين أربع وحدات أساسية:

  • المعرف chunk_id.
  • النص الأصلي للمقطع.
  • المتجه العددي embedding.
  • طول النص لمراقبة التجزئة والتحسين لاحقاً.

هذه البنية تمنحك مرونة عالية إذا قررت لاحقاً إدخال البيانات في FAISS أو Chroma أو أي Vector Store آخر.

مثال على شكل الملف الناتج

{
  "source_file": "article.txt",
  "embedding_model": "text-embedding-3-small",
  "total_chunks": 3,
  "records": [
    {
      "chunk_id": 0,
      "text": "بداية النص...",
      "embedding": [0.0123, -0.0456, 0.0789],
      "length": 800
    }
  ]
}

تحسينات هندسية مقترحة

السكربت السابق عملي ومباشر، لكنه يمثل طبقة أولى فقط. في الأنظمة الإنتاجية يمكن تطويره بعدة اتجاهات:

  • إضافة metadata مثل عنوان المقال، القسم، وتاريخ الإنشاء.
  • استخدام تقسيم دلالي بدل التقسيم بالحروف.
  • منع تكرار معالجة المقال إذا كان له بصمة hash محفوظة.
  • إدخال المتجهات مباشرة إلى قاعدة متجهة بدلاً من JSON.
  • ربط هذا المسار مع سلسلة استرجاع عبر إنشاء أول سلسلة Chain باستخدام LangChain.

متى يكون الحفظ المحلي كافياً؟

الحفظ المحلي مناسب جداً في ثلاث حالات: التعلم، النماذج الأولية، والمواقع الصغيرة ذات المحتوى المحدود. أما إذا كنت تتعامل مع آلاف المقالات أو مستندات كثيرة، فالأفضل نقل الطبقة التخزينية إلى Vector DB حقيقية مع فهرسة سريعة وتشابه كوني محسوب مثل cosine similarity.

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

خلاصة هندسية

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

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

7 comments

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *