تقسيم النصوص مع مراعاة التوكنز لتقنية RAG في TypeScript (2026)
٢٨ يونيو ٢٠٢٦
لتقسيم النص لتقنيات RAG في TypeScript، قم بتقسيم المستندات بناءً على عدد التوكنز (tokens) بدلاً من عدد الحروف: استخدم RecursiveCharacterTextSplitter مع وظيفة حساب طول تعتمد على js-tiktoken بحيث يناسب كل جزء ميزانية نموذج التضمين (embedding model) الخاص بك، وأضف تداخلاً (overlap) بنسبة عشرة إلى عشرين بالمائة، واحترم بنية Markdown أو الكود قبل الرجوع إلى فواصل أصغر.
ملخص
التقسيم (Chunking) هو الخطوة التي تحدد جودة RAG، ومن السهل اختيار الوحدة الخاطئة: التقسيم بناءً على الحروف بينما تفكر نماذج التضمين بالتوكنز. يبني هذا الدليل مقسماً واعياً بالتوكنز في TypeScript باستخدام @langchain/textsplitters 1.0.1 و js-tiktoken 1.0.21. ستتعلم كيفية عد التوكنز بنفس الترميز الذي يستخدمه نموذج التضمين الخاص بك، وتقسيم المستند إلى أجزاء ذات حجم مناسب مع تداخل، وكتابة مقسم صغير من الصفر لفهم الخوارزمية، وتقسيم Markdown بناءً على بنيته الحقيقية، وإرفاق البيانات الميتا (metadata) للمصدر من أجل الاستشهادات، وقياس توزيع حجم التوكنز لأجزائك. تم تجميع كل كتل الكود باستخدام tsc وتشغيلها باستخدام tsx قبل النشر؛ والأرقام التي تراها هي مخرجات حقيقية. لا يلزم وجود مفتاح API ولا تحميل نموذج — فالتقسيم يحدث بالكامل على جهازك.
ما ستتعلمه
- لماذا يؤدي تحديد حجم الأجزاء بالحروف إلى تدهور جودة الاسترجاع بصمت
- كيفية عد التوكنز بنفس الترميز الذي يستخدمه نموذج التضمين الخاص بك (
cl100k_base) - كيفية التقسيم بناءً على التوكنز باستخدام
RecursiveCharacterTextSplitterووظيفة حساب الطول - كيف تعمل الخوارزمية التكرارية، من خلال بناء مقسم واعٍ بالتوكنز من الصفر
- كيفية اختيار حجم الجزء والتداخل لنوع الاستعلام الخاص بك
- كيفية تقسيم Markdown والكود بناءً على البنية بدلاً من نوافذ الحروف العشوائية
- كيفية إرفاق البيانات الميتا للمصدر حتى يتمكن الجزء المسترجع من الاستشهاد بمصدره
- كيفية قياس توزيع حجم الأجزاء واكتشاف الأجزاء التي تتجاوز الميزانية قبل عملية التضمين
المتطلبات الأساسية
- Node.js 20+ (تم الاختبار على Node 22.22.3). كل من
@langchain/textsplittersو@langchain/coreيتطلبانengines.node >= 20.12 - الإلمام بـ TypeScript و
async/await. - محرر نصوص ومحطة طرفية (terminal). لا تحتاج إلى مفتاح OpenAI، أو GPU، أو قاعدة بيانات متجهة (vector database) — يتوقف هذا الدليل عند النقطة التي تكون فيها الأجزاء جاهزة للتضمين.
أنشئ مشروعاً وثبّت كل تبعية لضمان إمكانية إعادة إنتاج البناء الخاص بك:
npm init -y
npm pkg set type=module
npm install @langchain/textsplitters@1.0.1 @langchain/core@1.2.1 js-tiktoken@1.0.21
npm install -D TypeScript@6.0.3 tsx@4.22.4 @types/node@26.0.1
تعتبر @langchain/core تبعية نظيرة (peer dependency) لـ @langchain/textsplitters — حيث يعيد المقسم كائنات Document الخاصة بـ @langchain/core من createDocuments، لذا قم بتثبيتها صراحةً بدلاً من الاعتماد على مدير الحزم لرفعها تلقائياً.1 أضف ملف tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src"]
}
لماذا يؤدي عد الحروف إلى كسر استرجاع RAG بصمت
يقوم نظام RAG باسترجاع فقرات من مستنداتك وتغذيتها للنموذج كسياق، لذا فإن الفقرات التي تخزنها هي الحد الأقصى لجودة الإجابة.3 عملية التقسيم تنتج تلك الفقرات، ومن الأخطاء الشائعة والمكلفة قياس حجم الجزء بعدد الحروف.
تقرأ نماذج التضمين التوكنز، وليس الحروف. التوكن الواحد يعادل تقريباً أربعة حروف من النص الإنجليزي، لكن هذه النسبة هي قاعدة تقريبية وليست ثابتة — فالكود، وعلامات الترقيم، والنصوص غير الإنجليزية يتم تحويلها إلى توكنز بشكل مختلف تماماً.4 المستند النموذجي المستخدم في هذا الدليل يتكون من 2,736 حرفاً ولكن 511 توكن فقط، بنسبة حوالي 5.4 حرف لكل توكن بدلاً من 4. إذا قمت بضبط حجم الجزء على "256" وكنت تقصد بصمت 256 حرفاً، فأنت تخصص ميزانية تبلغ حوالي 50 توكن — أي حوالي خمس ما كنت تنويه — وسيكون الاسترجاع الخاص بك مليئاً بشظايا ضيقة ومفتقرة للسياق.
الحل هو جعل كل قياس يعتمد على التوكنز، باستخدام نفس المحلل (tokenizer) الذي يستخدمه نموذج التضمين الخاص بك. بقية هذا الدليل مبنية على هذا القرار الواحد.
الخطوة 1 — عد التوكنز بنفس الطريقة التي يتبعها نموذج التضمين الخاص بك
js-tiktoken هو منفذ بلغة JavaScript الخالصة لمكتبة OpenAI المسماة tiktoken. وهو يشحن جداول ترميز زوج البايت (byte-pair-encoding) داخل الحزمة، لذا فهو يعد التوكنز بالكامل دون اتصال بالإنترنت — لا حاجة لطلب شبكة ولا تحميل نموذج.5 التفصيل المهم هو اختيار الترميز الصحيح. تستخدم عائلات النماذج المختلفة محللات مختلفة، وتختلف حدود التوكنز بينها:
| النموذج | الترميز (js-tiktoken) |
|---|---|
text-embedding-3-small | cl100k_base |
text-embedding-3-large | cl100k_base |
text-embedding-ada-002 | cl100k_base |
gpt-4o, gpt-4o-mini | o200k_base |
gpt-4 | cl100k_base |
تم إنتاج التعيين أعلاه عن طريق استدعاء getEncodingNameForModel لكل نموذج. النتيجة العملية: يستخدم كل من text-embedding-3-small و text-embedding-3-large من OpenAI ترميز cl100k_base (كما يفعل النموذج الأقدم text-embedding-ada-002)، لذا فهذا هو الترميز الذي يجب أن تعد به عندما تخصص ميزانية الأجزاء لها.56 العد باستخدام o200k_base (ترميز دردشة GPT-4o) سيعطيك حدوداً مختلفة قليلاً وميزانية مضللة.
بدلاً من كتابة اسم الترميز بشكل ثابت، دع اسم النموذج يختاره. تقوم وظيفة encodingForModel بتحديد الترميز الصحيح لك. أنشئ ملف src/tokens.ts:
import { encodingForModel, type TiktokenModel } from "js-tiktoken";
// Pick the encoding that matches YOUR embedding model.
// The text-embedding-3-* models all use cl100k_base.
export function tokenCounter(model: TiktokenModel = "text-embedding-3-small") {
const enc = encodingForModel(model);
return (text: string): number => enc.encode(text).length;
}
تعيد وظيفة tokenCounter دالة تحول أي سلسلة نصية إلى عدد التوكنز الخاص بها. هذه الدالة المرتجعة هي بالضبط الشكل الذي يحتاجه مقسم النصوص لوظيفة حساب الطول (length function)، وهو ما يجعل الخطوة التالية تعمل.
الخطوة 2 — التقسيم بناءً على التوكنز، وليس الحروف
RecursiveCharacterTextSplitter هو المقسم الأساسي. يقوم بالمرور على قائمة مرتبة من الفواصل — افتراضياً ["\n\n", "\n", " ", ""] — محاولاً التقسيم عند أكبر حد طبيعي أولاً (فواصل الفقرات)، ثم حدود أصغر تدريجياً (الأسطر، المسافات، وأخيراً الحروف الفردية) حتى يصبح كل جزء تحت حد الحجم. ثم يقوم بدمج الأجزاء المتجاورة مرة أخرى حتى الحد الأقصى ويضيف التداخل المكون.7
يتم قياس حد الحجم بواسطة lengthFunction الخاصة بالمقسم. القيمة الافتراضية لها هي text.length — أي الحروف. قم بتمرير عداد التوكنز من الخطوة 1 وسيصبح رقم chunkSize الآن يعني التوكنز. أنشئ ملف src/chunk.ts:
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { tokenCounter } from "./tokens";
const countTokens = tokenCounter("text-embedding-3-small");
export function makeSplitter(chunkSize = 256, chunkOverlap = 32) {
return new RecursiveCharacterTextSplitter({
chunkSize,
chunkOverlap,
lengthFunction: countTokens, // <-- chunkSize now counts TOKENS
});
}
لمعرفة سبب أهمية ذلك، قم بتشغيل نفس المستند عبر المقسم مرتين — مرة باستخدام وظيفة طول الحروف الافتراضية ومرة باستخدام الوعي بالتوكنز. مع المستند النموذجي المكون من 511 توكن و chunkSize: 256، فإن النتائج ليست متقاربة:
- وضع الحروف (
256تعني حروفاً): 19 قطعة، بأحجام توكنات[8, 50, 33, 7, 46, 47, 17, 9, 49, 47, 19, 7, 54, 46, 9, 7, 47, 44, 7]. - وضع التوكنات (
256تعني توكنات): 3 قطع، بأحجام توكنات[202, 228, 97].
نفس الرقم، نفس المستند، نتيجة مختلفة تماماً. وضع الحروف يحطم المستند إلى تسعة عشر شظية بمتوسط حوالي 30 توكن لكل منها — وهو جزء ضئيل من قطعة تضمين (embedding chunk) مفيدة — لأن 256 حرفاً تعادل حوالي 50 توكن فقط، وخطوط العناوين القصيرة تنفصل إلى قطع صغيرة خاصة بها. أما وضع التوكنات فينتج ثلاث قطع تملأ بالفعل الميزانية التي حددتها. الشظايا الناتجة عن وضع الحروف ستتحول كل منها إلى متجه (vector) غامض يطابق كل شيء ولا يجيب على شيء.
الخطوة 3 — كيف يعمل المقسم، من الصفر
الاعتماد على مكتبة أمر جيد، لكن يجب أن تفهم الخوارزمية التي تشغلها، لأن الحالات الاستثنائية هي المكان الذي يحدث فيه خطأ في التقطيع (chunking). الفكرة الأساسية هي التعبئة الجشعة (greedy packing): تجميع وحدات صغيرة من النص حتى يؤدي إضافة الوحدة التالية إلى تجاوز الميزانية، ثم إصدار قطعة، ثم بدء القطعة التالية بذيل متداخل. أنشئ src/scratch.ts:
export interface SplitOptions {
chunkSize: number;
chunkOverlap: number;
countTokens: (text: string) => number;
}
export function splitByTokens(text: string, opts: SplitOptions): string[] {
const { chunkSize, chunkOverlap, countTokens } = opts;
// Break into atomic units: paragraphs, then sentences. Never split a unit further.
const units = text
.split(/\n\n+/)
.flatMap((p) => p.match(/[^.!?]+[.!?]*\s*/g) ?? [p]);
const chunks: string[] = [];
let cur: string[] = [];
for (const unit of units) {
// Peek BEFORE committing: if this unit would overflow, close the chunk first.
if (cur.length && countTokens([...cur, unit].join("")) > chunkSize) {
chunks.push(cur.join("").trim());
// Carry an overlapping tail into the next chunk.
const tail: string[] = [];
while (
cur.length &&
countTokens([cur[cur.length - 1], ...tail].join("")) <= chunkOverlap
) {
tail.unshift(cur.pop() as string);
}
cur = tail;
}
cur.push(unit);
}
if (cur.join("").trim()) chunks.push(cur.join("").trim());
return chunks;
}
التفصيل المهم هو كلمة نظرة خاطفة (peek). النسخة الساذجة تدفع الوحدة أولاً وتتحقق من الحجم بعد ذلك، مما يسمح للقطعة بتجاوز الميزانية بجملة كاملة — في الاختبار، أنتجت النسخة الساذجة قطعاً بحجم 270 و 272 توكن مقابل ميزانية 256. التحقق قبل الالتزام يبقي كل قطعة عند الحد المسموح به أو أقل منه. عند التشغيل على المستند النموذجي باستخدام 256/32، تعيد هذه النسخة 3 قطع بأحجام [242, 239, 51]، ولا يوجد أي منها فوق الميزانية.
هناك حالة واحدة لا تستطيع حتى نسخة "النظرة الخاطفة" إصلاحها: وحدة واحدة هي بحد ذاتها أكبر من الميزانية — جملة مكونة من 400 توكن، أو سطر طويل من الكود المضغوط (minified) بدون مسافات. لا يمكن للمعبئ الجشع إلا أن يصدرها بحجم زائد. وهذا هو بالضبط سبب كون RecursiveCharacterTextSplitter متكرراً (recursive): عندما تكون الوحدة كبيرة جداً، فإنه ينتقل إلى الفاصل التالي ويقسم تلك الوحدة أكثر، مكرراً العملية وصولاً إلى الحروف الفردية إذا اضطر لذلك. للإنتاج، يفضل استخدام المكتبة؛ النسخة المبنية من الصفر موجودة هنا حتى تتوقف المكتبة عن كونها صندوقاً أسود.
الخطوة 4 — اختيار حجم القطعة والتداخل
لا يوجد حجم مثالي عالمي، ولكن المقايضات مفهومة جيداً، ويمكنك التفكير فيها بدلاً من التخمين.8 القطع الصغيرة (حوالي 128–256 توكن) تجعل كل تضمين مركزاً بشكل حاد، مما يساعد في عمليات البحث عن الحقائق حيث تكون الإجابة جملة أو جملتين. القطع الأكبر (حوالي 256–512 توكن) تحافظ على سياق محيط أكبر، مما يساعد في الأسئلة التحليلية التي تحتاج إلى فقرة من الاستدلال. معظم خطوط المعالجة تستقر في مكان ما في نطاق 256–512 كنقطة بداية وتضبط من هناك باستخدام تقييم الاسترجاع.
بالنسبة للتداخل (overlap)، فإن نقطة البداية الشائعة هي عشرة إلى عشرين بالمائة من حجم القطعة — وهو ما يكفي لنقل جملة عبر الحدود دون تخزين نص شبه مكرر.8 الافتراضي 256/32 المستخدم في هذا الدليل هو تداخل بنسبة 12.5 بالمائة، وهو يقع بشكل مريح داخل هذا النطاق. التداخل ليس مجانياً: عند تداخل بنسبة عشرين بالمائة، فإن حوالي خمس النص الذي تخزنه يكون مكرراً، مما يؤدي إلى تضخم عدد المتجهات وفاتورة التضمين الخاصة بك. تداخل قليل جداً وقد تصبح حقيقة تتوزع على الحدود غير قابلة للاسترجاع؛ تداخل كبير جداً وسيستمر نفس الاستعلام في إرجاع قطع متجاورة شبه مكررة.
من السهل رؤية التداخل في المخرجات الحقيقية. مع 256/32، تشترك نهاية القطعة 0 وبداية القطعة 1 في نفس العنوان:
chunk 0 (202 tokens) ... ends: "...preserving enough context to stand alone.
## Tokens are the real unit, not characters"
chunk 1 (228 tokens) starts: "## Tokens are the real unit, not characters
Embedding models read tokens, not characters..."
يظهر العنوان ## Tokens are the real unit في ذيل إحدى القطع ورأس القطعة التالية. إذا سأل مستخدم عن التوكنات، يمكن استرجاع أي من القطعتين وستظل تحمل العنوان الذي يحدد إطار القسم.
الخطوة 5 — احترام هيكل المستند: Markdown والكود
التقسيم بناءً على فواصل \n\n/\n/مسافة خام يعامل المستند كتدفق مسطح من النص. يؤدي ذلك إلى فصل العنوان عن قسمه وتقطيع كتل الكود إلى نصفين. يقوم RecursiveCharacterTextSplitter.fromLanguage بتبديل قائمة الفواصل لتناسب تنسيقاً معيناً، بحيث يكسر المقسم عند الحدود الهيكلية أولاً.7 بالنسبة لـ Markdown، تبدأ قائمة الفواصل بالعناوين وعلامات الكتل:
import { RecursiveCharacterTextSplitter from "@langchain/textsplitters";
import { tokenCounter from "./tokens";
const countTokens = tokenCounter("text-embedding-3-small");
const mdSplitter = RecursiveCharacterTextSplitter.fromLanguage("markdown", {
chunkSize: 256,
chunkOverlap: 0,
lengthFunction: countTokens,
});
فواصل Markdown، بالترتيب، هي العناوين من المستوى الثاني إلى السادس (\n## … \n###### )، ونهاية كتلة كود مسيجة، والخطوط الأفقية (***، ---، ___)، ثم الفقرة المعتادة، والسطر، والمسافة، والحروف كبدائل أخيرة. (لاحظ أن المستوى الأول # ليس في القائمة — تبدأ الفواصل من المستوى الثاني، لأن المستند عادةً ما يحتوي على عنوان واحد فقط.) عند التشغيل على المستند النموذجي باستخدام 256/0، ينتج عن ذلك 3 قطع بأحجام [193, 221, 97]، كل واحدة منها محاذية لعنوان قسم بدلاً من إزاحة حرف عشوائية.
تنطبق نفس الفكرة على الكود المصدري: يدعم fromLanguage العديد من اللغات، وبالنسبة للكود فإنه يقسم عند حدود الدوال والفئات قبل الأسطر، لذلك نادراً ما يتم قطع دالة إلى نصفين. عندما تستوعب محتوى مختلطاً، قم بتوجيه كل ملف إلى المقسم الذي يطابق نوعه — Markdown للمستندات، واللغة المطابقة للكود — بدلاً من تشغيل كل شيء من خلال مقسم عام واحد.
الخطوة 6 — إرفاق البيانات الوصفية حتى تتمكن من الاستشهاد بالمصادر
تكون القطعة أكثر فائدة بكثير عندما تتذكر من أين أتت. يأخذ createDocuments مصفوفة من النصوص ومصفوفة موازية من البيانات الوصفية، ويعيد كائنات Document تحمل كلاً من نص القطعة وبياناتها الوصفية — بما في ذلك نطاق الأسطر الذي تمتد عليه القطعة في المستند الأصلي. قم بتوسيع src/chunk.ts:
export async function chunkText(text: string, source: string) {
const splitter = makeSplitter();
const docs = await splitter.createDocuments([text], [{ source ]);
return docs.map((d) => ({
text: d.pageContent,
tokens: countTokens(d.pageContent),
metadata: d.metadata,
}));
}
بالنسبة للقطعة الأولى، تبدو metadata كالتالي:
{ "source": "rag-intro.md", "loc": { "lines": { "from": 1, "to": 9 } } }
حقل source هو أي شيء قمت بتمريره؛ ونطاق loc.lines يضاف بواسطة المقسم. احمل هذه البيانات الوصفية طوال الطريق إلى مخزن المتجهات الخاص بك، وعندما يتم استرجاع قطعة ما، يمكنك أن تظهر للمستخدم بالضبط أي ملف وأي أسطر استندت إليها الإجابة — الفرق بين "تقول الوثائق X" و "تقول الوثائق X (rag-intro.md، الأسطر 1-9)". هذا المصدر هو أيضاً ما يسمح لك بتصحيح الإجابات السيئة والعودة إلى المقطع الذي تسبب فيها.
الخطوة 7 — قياس توزيع القطع الخاص بك
يجب ألا تشحن أبداً تكوين تقطيع لم تقم بقياسه. خطأ برمجي من سطر واحد في الفواصل الخاصة بك أو قطعة زائدة الحجم بشكل عشوائي سوف يتجاوز بهدوء حد إدخال نموذج التضمين الخاص بك أو يهدر التوكنات على شظايا شبه فارغة. أنشئ src/measure.ts:
export function measure(
chunks: string[],
countTokens: (t: string) => number,
budget: number
) {
const sizes = chunks.map(countTokens);
return {
count: sizes.length,
min: Math.min(...sizes),
max: Math.max(...sizes),
avg: Math.round(sizes.reduce((a, b) => a + b, 0) / sizes.length),
overBudget: sizes.filter((s) => s > budget).length,
;
}
overBudget هو الرقم الأكثر أهمية: يجب أن يكون صفراً. هناك تفصيل دقيق يستحق المعرفة — يمكن أن تنحرف أحجام القطع توكناً أو اثنين فوق الميزانية حتى عندما يعمل المقسم بشكل صحيح، لأن تحويل النص إلى توكنات (tokenization) عند نقطة الربط يعتمد على السياق (قطعتان يتم ترميز كل منهما إلى N توكن يمكن أن يتم ترميزهما إلى N+1 عند دمجهما). في اختبار منفصل بميزانية 128 توكن، استقرت القطع عند أحجام مثل [127, 128, 127, 129, 128, ...]. الدفاع العملي هو ترك حوالي عشرة بالمائة من مساحة الأمان: إذا كان سقفك الصارم هو 512 توكن، فاجعل ميزانية القطعة 460-480، وليس 512.
التحقق
قم بربط القطع معاً في src/main.ts وقم بتشغيله:
import { RecursiveCharacterTextSplitter from "@langchain/textsplitters";
import { tokenCounter from "./tokens";
import { measure from "./measure";
import { doc from "./sample";
const countTokens = tokenCounter("text-embedding-3-small");
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 256,
chunkOverlap: 32,
lengthFunction: countTokens,
);
const docs = await splitter.createDocuments([doc], [{ source: "rag-intro.md" ]);
for (const [i, dof docs.entries()) {
console.log(
`chunk ${i}: ${countTokens(d.pageContent)} tokens, ` +
`lines ${d.metadata.loc.lines.from}-${d.metadata.loc.lines.to}`
);
}
console.log(measure(docs.map((d) => d.pageContent), countTokens, 256));
ضع أي مستند Markdown متعدد الفقرات في src/sample.ts وقم بتصديره كـ doc، ثم قم بتشغيل:
npx tsx src/main.ts
مع المستند النموذجي المكون من 511 توكناً، تكون المخرجات:
chunk 0: 202 tokens, lines 1-9
chunk 1: 228 tokens, lines 15-23
chunk 2: 97 tokens, lines 29-31
{ count: 3, min: 97, max: 228, avg: 176, overBudget: 0 }
ثلاثة أجزاء (chunks)، كل منها يقع بشكل مريح تحت ميزانية الـ 256 توكن، overBudget: 0، وكل جزء موسوم بالأسطر التي جاء منها. قم بتشغيل npx tsc بجانبه وسيجتاز المشروع فحص الأنواع (type-checks) بنجاح تحت وضع strict. لديك الآن أجزاء جاهزة للتحويل إلى تمثيلات رقمية (embeddings).
الأخطاء الشائعة
"أجزائي صغيرة جدًا — عشرات الشظايا بدلاً من حفنة قليلة." لقد تركت دالة طول الأحرف الافتراضية في مكانها، لذا فإن chunkSize: 256 تعني 256 حرفًا (~50 توكن)، وليس 256 توكن. قم بتمرير lengthFunction: countTokens كما في الخطوة 2.
"الجزء يزيد بضعة توكنات عن حدي الأقصى." هذا متوقع: التقطيع (tokenization) عند الحدود يعتمد على السياق، لذا قد تنحرف الأحجام بمقدار توكن أو اثنين عن الميزانية. اضبط ميزانية الأجزاء الخاصة بك بنسبة 10% تقريبًا أقل من الحد الصارم للنموذج (الخطوة 7) بدلاً من ضبطها عليه تمامًا.
"جزء واحد يزيد بمئات التوكنات عن الميزانية." هناك وحدة ذرية واحدة — سطر طويل جدًا أو كتلة كود غير منقطعة — أكبر من الميزانية، ولا يستطيع المجمع الجشع (greedy packer) تقسيمها. استخدم RecursiveCharacterTextSplitter (وليس النسخة المبنية من الصفر)، والتي تتغلغل في فواصل أصغر وصولاً إلى الأحرف الفردية، وبالنسبة للكود استخدم fromLanguage حتى يتم التقسيم بناءً على بناء الجملة (syntax).
"عدد التوكنات يبدو غير دقيق مقارنة بفاتورة نموذجي." أنت تقوم بالعد باستخدام ترميز خاطئ. نماذج الـ embedding في عائلة text-embedding-3-* تستخدم cl100k_base؛ بينما عائلة دردشة GPT-4o تستخدم o200k_base. اترك encodingForModel يختارها بناءً على اسم النموذج (الخطوة 1).
"العناوين وكتل الكود يتم تقسيمها في منتصف القسم." أنت تستخدم الفواصل العامة. انتقل إلى fromLanguage("markdown") للمستندات واللغة المطابقة للكود (الخطوة 5) بحيث يتم تجربة الحدود الهيكلية أولاً.
الخطوات التالية
لديك الآن مقسم أجزاء (chunker) مدرك للتوكنات: يقوم بالعد باستخدام ترميز نموذج الـ embedding الخاص بك، ويقسم بناءً على الهيكل، ويتداخل من أجل السياق، ويسم كل جزء بأسطر المصدر الخاصة به، ويقدم تقريرًا عن التوزيع يمكنك التحقق منه. تقسيم الأجزاء هو مرحلة الإدخال في عملية الاسترجاع (retrieval)، وضبطه بشكل صحيح هو أحد أرخص المكاسب النوعية في خط الإنتاج.
من هنا، هناك ثلاثة اتجاهات تستحق وقتك. أولاً، المحلل اللغوي (tokenizer) نفسه: نفس ترميزات js-tiktoken تدير ميزانية نافذة السياق، وهو ما تمت تغطيته في عد التوكنات في TypeScript لنافذة السياق. ثانيًا، ما يحدث لهذه الأجزاء بعد ذلك — تحويلها لتمثيلات رقمية واسترجاعها — هو موضوع بناء نظام RAG قوي. ثالثًا، بمجرد تفعيل الاسترجاع، يتم تغطية ضبط دقة الاستدعاء (recall) والملاءمة في تقنيات تحسين RAG؛ حجم الجزء والتداخل الذي اخترته هنا هما أول المفاتيح التي ستقوم بضبطها.
Footnotes
-
@langchain/textsplitters— npm package (peer dependency on@langchain/core,engines.node >= 20). https://www.npmjs.com/package/@langchain/textsplitters ↩ ↩2 -
@langchain/core— npm package. https://www.npmjs.com/package/@langchain/core ↩ -
Nerd Level Tech — "Building a Robust RAG System." /building-a-robust-rag-system-a-complete-implementation-guide ↩
-
OpenAI Help Center — "What are tokens and how to count them." https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them ↩
-
js-tiktoken— npm package (pure-JS port of OpenAI's tiktoken; encodings bundled). https://www.npmjs.com/package/js-tiktoken ↩ ↩2 -
OpenAI Cookbook — "How to count tokens with tiktoken" (third-generation embedding models use the
cl100k_baseencoding). https://GitHub.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb ↩ -
LangChain.js —
@langchain/textsplitterssource forRecursiveCharacterTextSplitter(default separators, merge logic, andgetSeparatorsForLanguage). https://GitHub.com/langchain-ai/langchainjs/blob/main/libs/langchain-textsplitters/src/text_splitter.ts ↩ ↩2 -
Firecrawl — "Best Chunking Strategies for RAG (2026)" (chunk size and overlap ranges). https://www.firecrawl.dev/blog/best-chunking-strategies-rag ↩ ↩2