المتطلبات الأساسية
قبل البدء، تحتاج إلى:
- Node.js 20 أو أحدث
- TypeScript 5+
- مفتاح OpenAI API (للتضمينات)
- مفتاح Anthropic API (للتوليد باستخدام Claude Sonnet 5)
- معرفة أساسية بـ Next.js 15 App Router
ما الذي ستبنيه
بنهاية هذا الدرس، ستمتلك خط أنابيب RAG عربي متكامل يقوم بما يلي:
- تطبيع النصوص العربية — إزالة التشكيل وتوحيد صور الألف وحذف الأحرف الأسلوبية
- توليد تضمينات متعددة اللغات باستخدام
text-embedding-3-smallمن OpenAI - تخزين مقاطع المستندات واسترجاعها باستخدام LanceDB
- توليد إجابات عربية سلسة باستخدام Claude Sonnet 5
يكشف خط الأنابيب عن مسار API واحد في Next.js على /api/arabic-rag يقبل سؤالاً باللغة العربية (أو أي لغة أخرى) ويعيد إجابة موثّقة مع مصادر الاستشهاد.
لماذا تحتاج النصوص العربية إلى التطبيع
تُكتب اللغة العربية في نطاق Unicode غني يسمح بتمثيلات متعددة للكلمة الواحدة. بدون التطبيع، يفشل الاسترجاع بصمت — يتطابق الاستعلام والمستند المفهرس دلالياً لكنهما يختلفان على مستوى البايت، مما ينتج عنه صفر في معدل الاستدعاء من بحث متجهي صحيح من الناحية التقنية.
أربعة مصادر رئيسية للتباين في النصوص العربية:
1. صور الألف. يظهر حرف الألف في أربع نقاط Unicode: الألف البسيطة (ا)، والألف مع همزة فوق (أ)، والألف مع همزة تحت (إ)، والألف مع مدة (آ). يفوّت البحث عن "إسلام" كلمة "اسلام" إذا لم يُوحَّد شكل الألف.
2. التشكيل (الحركات). تتضمن النصوص الرسمية علامات الحركات — الفتحة والضمة والكسرة والسكون والشدة والتنوين. أما النصوص غير الرسمية فتحذفها تماماً. "كِتَابٌ" و"كتاب" كلمة واحدة لكنهما ينتجان متجهات تضمين مختلفة دون تطبيع.
3. التطويل (الكشيدة). الحرف ـ هو كشيدة تُستخدم لمد الحروف لأغراض جمالية (مثل كتابة "كتاب" بشكل "كتاااب"). لا تحمل أي معنى دلالي ويجب حذفها قبل التضمين.
4. الألف المقصورة. يُخلط كثيراً بين (ى) و(ي) في الكتابة غير الرسمية. بدون التطبيع، تنتج "على" و"علي" متجهات مختلفة رغم أنهما في سياقات كثيرة تمثلان الكلمة ذاتها.
الخطوة 1: إعداد المشروع
أنشئ تطبيق Next.js 15 بـ TypeScript:
npx create-next-app@latest arabic-rag --typescript --app --src-dir --turbopack
cd arabic-ragثبّت الحزم المطلوبة:
npm install @lancedb/lancedb openai @anthropic-ai/sdk
npm install -D tsxأضف مفاتيح API إلى .env.local:
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...الخطوة 2: منظّم النصوص العربية
أنشئ src/lib/arabic-normalizer.ts. هذه الوحدة هي أساس خط الأنابيب بأكمله — كل مستند وكل استعلام يمر عبرها قبل التضمين.
const TASHKEEL = /[ؐ-ًؚ-ٰٟ]/g;
const TATWEEL = /ـ/g;
const ALEF_VARIANTS = /[آأإٱ]/g;
const HAMZA_ON_WAW = /ؤ/g;
const HAMZA_ON_YA = /ئ/g;
const TEH_MARBUTA = /ة/g;
const ALEF_MAQSURAH = /ى/g;
const EXTRA_SPACES = /\s+/g;
export interface NormalizerOptions {
removeTashkeel?: boolean;
removeTatweel?: boolean;
normalizeAlef?: boolean;
normalizeHamza?: boolean;
normalizeTehMarbuta?: boolean;
normalizeAlefMaqsurah?: boolean;
}
const DEFAULT_OPTIONS: NormalizerOptions = {
removeTashkeel: true,
removeTatweel: true,
normalizeAlef: true,
normalizeHamza: false,
normalizeTehMarbuta: false,
normalizeAlefMaqsurah: true,
};
export function normalizeArabic(
text: string,
options: NormalizerOptions = DEFAULT_OPTIONS
): string {
let result = text.normalize("NFKC");
if (options.removeTashkeel) result = result.replace(TASHKEEL, "");
if (options.removeTatweel) result = result.replace(TATWEEL, "");
if (options.normalizeAlef) result = result.replace(ALEF_VARIANTS, "ا");
if (options.normalizeHamza) {
result = result.replace(HAMZA_ON_WAW, "و");
result = result.replace(HAMZA_ON_YA, "ي");
}
if (options.normalizeTehMarbuta)
result = result.replace(TEH_MARBUTA, "ه");
if (options.normalizeAlefMaqsurah)
result = result.replace(ALEF_MAQSURAH, "ي");
return result.replace(EXTRA_SPACES, " ").trim();
}
export const normalizeForIndexing = (text: string) =>
normalizeArabic(text, DEFAULT_OPTIONS);
export const normalizeForQuery = (text: string) =>
normalizeArabic(text, DEFAULT_OPTIONS);تطبيع الهمزة معطّل بشكل افتراضي. قد يؤدي تحويل (ؤ) إلى (و) و(ئ) إلى (ي) إلى دمج كلمات مختلفة المعنى يفرّق بينها الكاتب المتعلم. فعّل normalizeHamza: true فقط لمجموعات اللهجات العربية التي يُحذف فيها الهمزة بشكل منتظم في بيانات المصدر.
تطبيع التاء المربوطة (ة) معطّل بشكل افتراضي. يحسّن تحويل (ة) إلى (ه) معدل الاستدعاء في البحث بالكلمات المفتاحية، لكنه قد يخلط بين الكلمات غير المترابطة. تتعامل نماذج التضمين متعددة اللغات مع التاء المربوطة على مستوى المعنى دون الحاجة إلى هذا التطبيع.
الخطوة 3: خط أنابيب التضمين
أنشئ src/lib/embeddings.ts:
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export const EMBEDDING_MODEL = "text-embedding-3-small";
export const EMBEDDING_DIMENSIONS = 1536;
export async function embedText(text: string): Promise<number[]> {
const res = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: text,
encoding_format: "float",
});
return res.data[0].embedding;
}
export async function embedBatch(texts: string[]): Promise<number[][]> {
if (texts.length === 0) return [];
const res = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: texts,
encoding_format: "float",
});
return res.data.map((d) => d.embedding);
}تم تدريب text-embedding-3-small على بيانات متعددة اللغات ويفهم دلالات النصوص العربية بدون تطبيع. لكن التطبيع ضروري مع ذلك لأنه يضمن أن "كِتَاب" و"كتاب" يهبطان في نفس حي المتجه بدلاً من إنتاج مسافة كوساين قابلة للقياس بين تمثيلين لكلمة واحدة. عملياً، يحسّن التطبيع معدل الاستدعاء في RAG العربي بنسبة 15-30% على المجموعات النصية النموذجية.
الخطوة 4: مخزن المتجهات
أنشئ src/lib/vector-store.ts. يستخدم LanceDB الاستنتاج التلقائي للمخطط — تمرير مصفوفة مكتوبة من المستندات عند أول كتابة يكفي لتعريف مخطط الجدول. لا حاجة لاستيراد Apache Arrow.
import * as lancedb from "@lancedb/lancedb";
export interface ArabicDocument {
id: string;
text: string;
normalizedText: string;
vector: number[];
source: string;
metadata: string;
}
let conn: lancedb.Connection | null = null;
async function getConn(): Promise<lancedb.Connection> {
if (!conn) conn = await lancedb.connect(".lancedb");
return conn;
}
export async function addDocuments(
docs: ArabicDocument[],
tableName = "arabic_docs"
): Promise<void> {
const db = await getConn();
const tables = await db.tableNames();
if (tables.includes(tableName)) {
const table = await db.openTable(tableName);
await table.add(docs);
} else {
await db.createTable(tableName, docs);
}
}
export async function searchDocuments(
queryVector: number[],
limit = 5,
tableName = "arabic_docs"
): Promise<ArabicDocument[]> {
const db = await getConn();
const table = await db.openTable(tableName);
return (await table.search(queryVector).limit(limit).toArray()) as ArabicDocument[];
}دليل .lancedb هو مجلد على القرص — لا خادم، لا اتصال شبكي، لا إعداد يتجاوز npm install. للنشر في الإنتاج، استبدل سلسلة المسار بـ URI لـ S3 ومرّر storageOptions مع بيانات اعتماد AWS.
يُستنتج عمود المتجه من الحقل vector تلقائياً، وباستخدام الاسم الاصطلاحي vector تُجرى عمليات البحث دون الحاجة لتحديد اسم العمود أو نوع المقياس.
الخطوة 5: خط أنابيب الفهرسة
أنشئ src/lib/indexer.ts:
import { normalizeForIndexing } from "./arabic-normalizer";
import { embedBatch } from "./embeddings";
import { addDocuments, type ArabicDocument } from "./vector-store";
export interface RawDocument {
text: string;
source: string;
metadata?: Record<string, string>;
}
function chunkArabicText(text: string, maxChars = 800): string[] {
// تقسيم على علامات نهاية الجملة الشائعة في النصوص العربية.
// ملاحظة: علامة الاستفهام العربية ؟ (U+061F) يجب تضمينها مع ?
const sentences = text
.split(/[.!?؟\n]+/)
.map((s) => s.trim())
.filter((s) => s.length > 20);
const chunks: string[] = [];
let current = "";
for (const sentence of sentences) {
const candidate = current ? current + " " + sentence : sentence;
if (candidate.length > maxChars && current) {
chunks.push(current);
current = sentence;
} else {
current = candidate;
}
}
if (current) chunks.push(current);
return chunks;
}
export async function indexDocuments(
rawDocs: RawDocument[],
batchSize = 20
): Promise<void> {
type Chunk = {
text: string;
normalized: string;
source: string;
meta: string;
};
const allChunks: Chunk[] = [];
for (const doc of rawDocs) {
const chunks = chunkArabicText(doc.text);
for (const chunk of chunks) {
allChunks.push({
text: chunk,
normalized: normalizeForIndexing(chunk),
source: doc.source,
meta: JSON.stringify(doc.metadata ?? {}),
});
}
}
for (let i = 0; i < allChunks.length; i += batchSize) {
const batch = allChunks.slice(i, i + batchSize);
const embeddings = await embedBatch(batch.map((c) => c.normalized));
const docs: ArabicDocument[] = batch.map((chunk, j) => ({
id: crypto.randomUUID(),
text: chunk.text,
normalizedText: chunk.normalized,
vector: embeddings[j],
source: chunk.source,
metadata: chunk.meta,
}));
await addDocuments(docs);
process.stderr.write(
`Indexed ${i + batch.length}/${allChunks.length} chunks\n`
);
}
}هدف المقطع 800 حرف يغطي نحو 150-200 كلمة عربية — كثافة دلالية مشابهة لمقطع إنجليزي من 300 كلمة بـ 1100 حرف. علامة الاستفهام العربية (U+061F) يجب أن تكون في مقسّم الجمل لأن الكتّاب العرب يمزجون الترقيم اللاتيني والعربي في النص ذاته.
الخطوة 6: الاسترجاع
أنشئ src/lib/retriever.ts:
import { normalizeForQuery } from "./arabic-normalizer";
import { embedText } from "./embeddings";
import { searchDocuments } from "./vector-store";
export interface RetrievalResult {
text: string;
source: string;
rank: number;
}
export async function retrieve(
query: string,
topK = 5
): Promise<RetrievalResult[]> {
const normalizedQuery = normalizeForQuery(query);
const queryVector = await embedText(normalizedQuery);
const results = await searchDocuments(queryVector, topK);
return results.map((doc, i) => ({
text: doc.text,
source: doc.source,
rank: i + 1,
}));
}يطبّق المسترجع تطبيعاً للاستعلام بنفس الدالة المستخدمة وقت الفهرسة. هذا هو أهم ثابت في خط الأنابيب بأكمله. إذا اختلف تطبيع الاستعلام عن تطبيع الفهرسة — حتى بخيار علامة واحدة — تتدهور نسبة الاستدعاء بصمت. تعود الاستعلامات بنتائج، لكن المستندات الصحيحة مفقودة دون وجود خطأ للتشخيص.
الخطوة 7: التوليد بـ Claude Sonnet 5
أنشئ src/lib/generator.ts:
import Anthropic from "@anthropic-ai/sdk";
import type { RetrievalResult } from "./retriever";
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
type Lang = "ar" | "en" | "fr";
const SYSTEM_PROMPTS: Record<Lang, string> = {
ar: "أنت مساعد متخصص يجيب على الأسئلة بالعربية الفصحى الواضحة. استند فقط إلى السياق المقدم. إذا لم يتضمن السياق الإجابة، فأخبر المستخدم بذلك صراحةً.",
en: "You are a specialized assistant that answers questions in clear English. Base your answers only on the provided context. If the context does not contain the answer, say so explicitly.",
fr: "Tu es un assistant spécialisé qui répond en français clair. Base-toi uniquement sur le contexte fourni. Si le contexte ne contient pas la réponse, dis-le explicitement.",
};
export async function generateAnswer(
query: string,
context: RetrievalResult[],
lang: Lang = "ar"
): Promise<string> {
const contextText = context
.map((r) => `[${r.rank}] ${r.text}\nالمصدر: ${r.source}`)
.join("\n\n---\n\n");
const userPrompt =
lang === "ar"
? `السياق:\n\n${contextText}\n\nالسؤال: ${query}`
: `Context:\n\n${contextText}\n\nQuestion: ${query}`;
const response = await anthropic.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
system: SYSTEM_PROMPTS[lang],
messages: [{ role: "user", content: userPrompt }],
});
const block = response.content[0];
if (block.type !== "text") throw new Error("Unexpected content block type");
return block.text;
}الخطوة 8: مسار API في Next.js
أنشئ src/app/api/arabic-rag/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { retrieve } from "@/lib/retriever";
import { generateAnswer } from "@/lib/generator";
export const runtime = "nodejs";
export const maxDuration = 30;
export async function POST(req: NextRequest) {
try {
const { query, lang = "ar", topK = 5 } = (await req.json()) as {
query: string;
lang?: "ar" | "en" | "fr";
topK?: number;
};
if (!query || typeof query !== "string") {
return NextResponse.json({ error: "query is required" }, { status: 400 });
}
const context = await retrieve(query, topK);
if (context.length === 0) {
return NextResponse.json(
{
answer:
lang === "ar"
? "لم أجد معلومات كافية للإجابة على سؤالك."
: "No relevant documents found.",
context: [],
},
{ status: 200 }
);
}
const answer = await generateAnswer(query, context, lang);
return NextResponse.json({ answer, context });
} catch (err) {
console.error("[arabic-rag]", err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}runtime = "nodejs" إلزامي — يستخدم LanceDB ارتباطات Rust الأصيلة التي لا يمكنها العمل على Vercel Edge runtime أو في Cloudflare Worker.
الخطوة 9: زرع بيانات الاختبار
أنشئ scripts/seed.ts:
import { indexDocuments } from "../src/lib/indexer";
const sampleDocs = [
{
text: "الذكاء الاصطناعي هو محاكاة للذكاء البشري في الآلات التي تم برمجتها للتفكير مثل البشر وتقليد أفعالهم. يشمل هذا المصطلح أي آلة تُظهر سمات مرتبطة بالعقل البشري مثل التعلم وحل المشكلات.",
source: "مقدمة إلى الذكاء الاصطناعي",
metadata: { category: "تقنية" },
},
{
text: "التعلم الآلي هو تطبيق للذكاء الاصطناعي يوفر للأنظمة القدرة على التعلم التلقائي والتحسن من الخبرة دون أن تتم برمجتها صراحةً. يركز على تطوير برامج الكمبيوتر التي يمكنها الوصول إلى البيانات واستخدامها للتعلم الذاتي.",
source: "أساسيات التعلم الآلي",
metadata: { category: "تقنية" },
},
{
text: "معالجة اللغات الطبيعية هي فرع من فروع الذكاء الاصطناعي يتعلق بالتفاعل بين أجهزة الكمبيوتر والبشر باستخدام اللغة الطبيعية. الهدف النهائي هو قراءة النصوص وفهمها والاستجابة لها بطريقة ذات معنى.",
source: "معالجة اللغات الطبيعية",
metadata: { category: "تقنية" },
},
];
indexDocuments(sampleDocs)
.then(() => {
process.stderr.write("Seeding complete\n");
process.exit(0);
})
.catch((err) => {
console.error(err);
process.exit(1);
});شغّل البرنامج ثم اختبر:
npx tsx scripts/seed.ts
npm run devcurl -X POST http://localhost:3000/api/arabic-rag \
-H "Content-Type: application/json" \
-d '{"query":"ما هو التعلم الآلي؟","lang":"ar"}'يجب أن تتلقى استجابة JSON تحتوي على حقل answer بإجابة عربية مبنية على المستندات المُزرعة، ومصفوفة context تسرد المقاطع التي تم استرجاعها.
الخطوة 10: تقييم جودة خط الأنابيب
ثلاثة اختبارات للتحقق قبل النشر في الإنتاج:
اختبار تطبيع الألف. افهرس مستنداً يحتوي على "إسلام" (بهمزة تحت الألف). استعلم عن "اسلام" (ألف بسيطة، بدون همزة). مع تفعيل التطبيع، يجب أن يكون معدل الاستدعاء 100%. بدونه، يكون الاستدعاء صفراً — فشل صارم غير مرئي بدون هذا الاختبار.
اختبار دورة التشكيل. افهرس "الكِتَابُ" (بتشكيل كامل). استعلم عن "الكتاب" (بدون حركات، كما يكتب معظم المستخدمين). الصورتان المطبّعتان يجب أن تكونا متطابقتين — يجب أن ينجح الاسترجاع.
اختبار التماثل. عطّل التطبيع مؤقتاً في مسار الاستعلام فقط (اتركه في المفهرس). إذا انخفض معدل الاستدعاء، فمطبّعاك متوافقان فعلاً. إذا ظل معدل الاستدعاء متطابقاً، فمجموعتك النصية نظيفة بالفعل والتطبيع غير مؤثر — كلا النتيجتين مقبولة، لكن تحتاج لمعرفة أيهما لديك.
استكشاف الأخطاء وإصلاحها
"Cannot find package @lancedb/lancedb" — تتطلب الحزمة ثنائياً أصيلاً متوافقاً مع نظام التشغيل والمعمارية. على Apple Silicon، استخدم npm install @lancedb/lancedb البسيط ودع الأمر يُنزّل ثنائي darwin-arm64 الصحيح تلقائياً.
خطأ OpenAI 429 — تُرسل دالة embedBatch جميع النصوص في استدعاء API واحد. لمجموعات تضم آلاف المقاطع، أضف تأخيراً صغيراً بين تكرارات الحلقة.
صفر نتائج من البحث المتجهي — تأكد من أن الحقل vector في مستنداتك هو مصفوفة من 1536 رقماً. إذا مررت معلمة dimensions إلى استدعاء التضمين، يتغير حجم الإخراج ولن يتطابق المخطط المخزن مع عمليات البحث الجديدة.
خطأ في أذونات ملف LanceDB — يُنشأ دليل .lancedb في دليل عمل العملية. لـ Next.js على Vercel، استخدم /tmp/.lancedb (قابل للكتابة في الدوال بدون خادم) أو أشر إلى مسار حزمة S3.
الخطوات التالية
الآن بعد تشغيل خط أنابيب RAG العربي، فكّر في تطويره بـ:
- البحث الهجين — أضف فهرس بحث نصي على عمود
normalizedTextباستخدام FTS المدمج في LanceDB، ثم ادمج نتائج BM25 والمتجه بواسطة Reciprocal Rank Fusion. درس LanceDB يغطي هذا بعمق. - إعادة الترتيب — مرّر أفضل 20 مقطعاً مسترجعاً عبر مُعيد ترتيب cross-encoder (يدعم Cohere
rerank-multilingual-v3.0العربية أصلاً) وأرسل أفضل 5 فقط إلى Claude. - تصفية الكلمات الوظيفية — أزل الكلمات الوظيفية شائعة الاستخدام (و، في، من، على، إلى، عن، مع) قبل التضمين لتقليل الضوضاء في الاسترجاع.
- دعم اللهجات — للعربية المصرية أو الخليجية أو الشامية، وسّع
normalizeArabicبجداول استبدال خاصة باللهجة. - مخزن متجه على الخادم — للإنتاج متعدد المستأجرين ذي الكتابة المتزامنة العالية، انتقل من LanceDB المضمّن إلى Qdrant.
الخلاصة
لقد بنيت خط أنابيب RAG عربي متكامل في TypeScript: طبقة تطبيع Unicode تحل صور الألف وتزيل التشكيل والتطويل وتوحّد الألف المقصورة؛ خط أنابيب تضمين دفعي باستخدام text-embedding-3-small؛ مخزن متجه LanceDB مع استنتاج تلقائي للمخطط؛ وحدة استرجاع تطبّق تماثل التطبيع؛ وClaude Sonnet 5 لتوليد إجابات عربية طلقة.
الثابت المحوري هو تماثل التطبيع: نفس القواعد مطبّقة وقت الفهرسة ووقت الاستعلام. كسر هذا الثابت يُدهور خط الأنابيب بصمت — كل استعلام يعيد نتائج، لكن المستندات الصحيحة مفقودة.