الوحدة 3 — التقطيع: الحجم والتداخل ومراعاة البنية
الوحدة السابقة أعطتنا نصًّا نظيفًا مع ما وراء البيانات. لا يمكن مع ذلك إرسال مستند من عشرين صفحة إلى نموذج تضمين ولا الإجابة انطلاقًا من كامله في التعليمة. لا بدّ من تقطيعه إلى مقاطع أصغر، ولكن ليست عملية آلية باردة: التقطيع الجيّد يرفع الاسترجاع ارتفاعًا حادًّا، والتقطيع السيّئ يقتل النظام قبل ولادته.
لماذا التقطيع أصلًا
للتقطيع ثلاثة مبرّرات متكاملة. أوّلها تقني: نماذج التضمين لها سقف مُدخلات (512 رمزًا للنماذج الشائعة، آلاف قليلة للحديثة)، وكذلك النماذج المولّدة. ثانيها دلالي: تضمين وثيقة من عشر صفحات إلى شعاع واحد يُذوّب المعاني، فيصير الشعاع الناتج قريبًا من كلّ شيء وبعيدًا عن أيّ شيء دقيق. وثالثها اقتصادي: كلّما كان المقطع المُرسَل مع السؤال أصغر، كانت التعليمة أقصر والكلفة أقلّ.
أربع مقاربات، من الأخشن إلى الأدقّ
1) التقطيع الثابت
نقسم النصّ إلى كتل بحجم ثابت، كلّ 1000 حرف مثلًا. سهل جدًّا، ويعطي أساسًا معقولًا للانطلاق. عيبه أنّه يقطع في منتصف الكلمة أو الجملة، وأحيانًا في منتصف جدول أو مثال شيفرة.
2) التقطيع بالجملة
نقسم عند نهاية الجمل (., !, ؟)، ثم نُجمّع الجمل حتّى بلوغ حجم مستهدف. أفضل من الثابت لأنّه يحترم الحدود الطبيعية للمعنى.
3) التقطيع القسمي (المُتراجع)
الأكثر شيوعًا اليوم، ويُتيحه RecursiveCharacterTextSplitter في langchain. يجرّب فواصل قوية أوّلًا (\n\n بين الفقرات)، فإن كان القسم أطول من الحدّ يجرّب \n ثم . ثم الفاصلة ثم المسافة. ينتج مقاطع تحترم بنية الوثيقة قدر الإمكان.
4) التقطيع البنيوي والهرمي
نستعمل التصنيف الذي أعطتنا إيّاه Unstructured (عنوان، نصّ سرديّ، عنصر قائمة، جدول). نقطع عند العناوين، ونحتفظ بالجداول ككائن واحد لا يُقسَم أبدًا. ونحفظ الترتيب الهرمي: مقطع تحت «المادّة 14 → القسم 3 → الفصل الثاني»، وهذا ينفع لاحقًا في الفلاتر وإعادة الترتيب.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=800, # حجم مستهدف بالأحرف (ليس بالرموز)
chunk_overlap=120, # 15 % تداخل
separators=["\n\n", "\n", "。", ".", "؟", "!", "،", " ", ""],
length_function=len,
)
texts = splitter.split_documents(documents)
الحجم بالأحرف أم بالرموز؟
الأدبيات تتحدّث عن الأحرف والكلمات والرموز، وهذا مصدر التباس. الرموز (tokens) هي وحدة نموذج التضمين ونموذ ج التوليد، ونصّ عربي وسطيًّا يستخدم رمزين إلى ثلاثة لكلّ كلمة (النسبة أعلى منها للفرنسية والإنجليزية بسبب طريقة عمل مُقسّمات الرموز الحالية).
المصلحة إذن قياس الحجم بالرموز لا بالأحرف، خاصّة قرب سقف النموذج:
import tiktoken
encoder = tiktoken.get_encoding("cl100k_base")
splitter = RecursiveCharacterTextSplitter(
chunk_size=256, # هذه المرّة بالرموز
chunk_overlap=40,
length_function=lambda t: len(encoder.encode(t)),
)
اختيار الحجم: مفاضلة بين ثلاث خصائص
لا حجم «صحيح» بالمطلق، بل مفاضلة بين ثلاث خصائص:
| حجم المقطع | الاسترجاع | الدقّة | الكلفة |
|---|---|---|---|
| صغير جدًّا (100 رمز) | مقاطع كثيرة، نسبة عالية لتفويت | كلّ مقطع دقيق التركيز | استفسارات كثيرة، فهرس ضخم |
| متوسّط (250 – 500 رمز) | توازن معقول | جيّدة | مقبولة |
| كبير (1000 – 2000 رمز) | يستوعب فقرة كاملة | تضمين مُذوَّب، يصعب على النموذج تمييز المهمّ | تعليمة أطول |
للفيل الأحمر (إجراءات مؤسّسية) القيم بين 300 و500 رمز بتداخل نحو 15 % تعطي عادةً أفضل توازن. لكنّها فرضية، لا حقيقة: القياس على 100 سؤال موصوف (الوحدة 8) يُحسم النقاش.
التداخل: لِم لَمْ نتخلَّ عنه
قد يبدو التداخل هدرًا: إعادة نفس النصّ في مقطعين. مبرِّره حقيقي: المعلومات الحاسمة تقع أحيانًا على حدود المقاطع. جملة «تدخل هذه المادّة حيّز التنفيذ بدءًا من 1 كانون الثاني 2026» قد تنفصل عن الرقم 1 إن قسم القطع في منتصف الجملة. تداخل من 10 إلى 20 % يضمن أنّ الجملة الحدّية تحضر كاملة في أحد المقطعين على الأقلّ.
السياق الوالد: مقطع قصير، جواب واسع
فكرة عملية جدًّا: نضمِّن مقاطع صغيرة (100 رمز)، لكن حين يُنتقى مقطع، نُرسِل إلى النموذج المقطع الوالد (500 – 1000 رمز): الفقرة الكاملة أو القسم كلّه. هكذا نجمع بين دقّة الاسترجاع وسعة السياق للإجابة.
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000)
retriever = ParentDocumentRetriever(
vectorstore=store, # يخزّن المقاطع الصغيرة (child)
docstore=InMemoryStore(), # يخزّن المقاطع الكبيرة (parent)
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
retriever.add_documents(documents)
تجربة مقارنة على الفيل الأحمر
نُجري القياس على مجموعة الإجراءات الداخلية بأربع استراتيجيات، بمعيار Recall@5 (نسبة الأسئلة التي يظهر المقطع الصحيح ضمن أوّل خمسة نتائج) على 100 سؤال موصوف يدويًّا:
| الاستراتيجية | Recall@5 | ملاحظة |
|---|---|---|
| ثابت 1000 حرف | 0.61 | قطع في الجداول ملحوظ |
| جملي 500 حرف | 0.68 | تحسّن على الأسئلة الحرفية |
| قسمي 400 رمز، تداخل 60 | 0.79 | معياري ومنافس |
| بنيوي بالعناوين | 0.83 | الأفضل على المستندات ذات المواد المرقّمة |
هذه أرقام توضيحية على مجموعة معيَّنة، ولا تنقل حرفيًّا. القاعدة الوحيدة القابلة للتعميم: قِس على بياناتك أنت.
أشيع فشل التقطيع: مقطع يبدأ بأسطر ثلاثة من جدول ومقطع تالٍ يحوي بقيّة الجدول بلا رأس. نموذج التوليد يقرأ «12، 5، 30» ولا يعرف إلامَ تعود. علاج هذا: كشف الجدول في مرحلة الاستخراج والاحتفاظ به ككائن مصان، وتضمينه كاملًا في مقطع واحد ولو تجاوز الحدّ.
احفظ في ما وراء البيانات لكلّ مقطع عدد رموزه الفعلي، ومصدره، وموقعه ضمن المستند. حين تُشخِّص لاحقًا ضعف الاسترجاع، ستقدر على قول «مشكلة المقاطع الأصغر من 80 رمزًا»، بدل تخمين عام.
في الخلاصة
- التقطيع مبرَّر تقنيًّا (سقف الرموز)، ودلاليًّا (تجنّب التضمين المُذوَّب)، واقتصاديًّا (تعليمة أقصر).
- المقاربات أربع: ثابت، جملي، قسمي (
RecursiveCharacterTextSplitter)، ثم بنيوي يحترم العناوين والجداول. - قياس الحجم بالرموز لا بالأحرف قرب سقف النموذج؛ 300 – 500 رمزًا مع تداخل 10 – 20 % نقطة انطلاق سليمة.
- السياق الوالد يفصل بين ما نضمِّنه (صغير) وما نمرّره (كبير)، فيجمع الدقّة والسعة.
الوحدة التالية: التضمينات وقواعد البيانات المتّجهية، وهنا ننتقل من نصّ إلى شعاع، ومن شعاع إلى فهرس قابل للاستفسار السريع.