écrits/tutorial/2026/07
Tutorial27 juil. 2026·30 min

Construire un pipeline RAG arabe avec normalisation de texte en TypeScript

Apprenez à construire un pipeline RAG arabe prêt pour la production en TypeScript. Ce tutoriel couvre la normalisation Unicode du texte arabe, la génération d'embeddings multilingues, l'indexation avec LanceDB, et la génération de réponses arabes avec Claude Sonnet 5.

Prérequis

Avant de commencer, vous avez besoin de :

  • Node.js 20 ou plus récent
  • TypeScript 5+
  • Une clé OpenAI API (pour les embeddings)
  • Une clé Anthropic API (pour la génération avec Claude Sonnet 5)
  • Une connaissance basique de Next.js 15 App Router

Ce que vous allez construire

À la fin de ce tutoriel, vous disposerez d'un pipeline RAG arabe complet qui :

  1. Normalise le texte arabe — supprime les diacritiques, résout les variantes d'Alef et supprime les caractères stylistiques
  2. Génère des embeddings multilingues avec text-embedding-3-small d'OpenAI
  3. Stocke et récupère des segments de documents avec LanceDB
  4. Génère des réponses arabes fluides avec Claude Sonnet 5

Le pipeline expose une unique route API Next.js sur /api/arabic-rag qui accepte une question en arabe (ou toute autre langue) et retourne une réponse ancrée avec des citations de sources.

Pourquoi le texte arabe nécessite une normalisation

L'arabe s'écrit dans un sous-ensemble Unicode riche qui permet de nombreuses représentations d'un même mot. Sans normalisation, la récupération échoue silencieusement — la requête et le document indexé se correspondent sémantiquement mais diffèrent octet par octet, produisant un rappel nul d'une recherche vectorielle pourtant correcte techniquement.

Les quatre principales sources de divergence dans les textes arabes :

1. Les variantes d'Alef. La lettre Alef apparaît sous quatre points de code Unicode : Alef simple (ا), Alef avec Hamza au-dessus (أ), Alef avec Hamza en dessous (إ) et Alef avec Madda (آ). Une recherche sur "إسلام" rate "اسلام" si ces variantes ne sont pas normalisées vers la même forme.

2. Le Tashkeel (diacritiques). Les textes formels incluent des marques vocaliques — fatha, damma, kasra, sukun, shadda et les formes de tanwin. Les textes informels les omettent entièrement. "كِتَابٌ" et "كتاب" sont le même mot mais produisent des vecteurs d'embedding différents sans normalisation.

3. Le Tatweel. Le caractère ـ est un kashida utilisé pour étirer les lettres à des fins esthétiques. Il ne porte aucun sens sémantique et doit être supprimé avant l'embedding.

4. L'Alef Maqsurah. La lettre (ى) est souvent confondue avec (ي) dans l'écriture informelle. Sans normalisation, "على" et "علي" génèrent des vecteurs différents bien que dans de nombreux contextes ils représentent le même mot.

Étape 1 : Configuration du projet

Créez une application Next.js 15 avec TypeScript :

npx create-next-app@latest arabic-rag --typescript --app --src-dir --turbopack
cd arabic-rag

Installez les packages requis :

npm install @lancedb/lancedb openai @anthropic-ai/sdk
npm install -D tsx

Ajoutez les clés API dans .env.local :

OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...

Étape 2 : Le normalisateur arabe

Créez src/lib/arabic-normalizer.ts. Ce module est le fondement de tout le pipeline — chaque document et chaque requête le traverse avant d'être converti en embedding.

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);

La normalisation du Hamza est désactivée par défaut. Convertir (ؤ) en (و) et (ئ) en (ي) peut fusionner des mots sémantiquement distincts. Activez normalizeHamza: true uniquement pour des corpus dialectaux arabes où le Hamza est systématiquement omis dans les données sources.

La normalisation du Teh Marbuta (ة) est désactivée par défaut. Mapper (ة) vers (ه) améliore le rappel en recherche par mots-clés mais peut confondre des mots non liés. Les modèles d'embedding multilingues gèrent le Teh Marbuta au niveau sémantique sans nécessiter cette normalisation.

Étape 3 : Pipeline d'embedding

Créez 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 a été entraîné sur des données multilingues et comprend la sémantique arabe sans normalisation. La normalisation reste importante car elle garantit que "كِتَاب" et "كتاب" atterrissent dans le même voisinage vectoriel plutôt que de produire une distance cosinus mesurable entre deux représentations du même mot. En pratique, la normalisation améliore le rappel du RAG arabe de 15 à 30% sur les corpus typiques.

Étape 4 : Store vectoriel

Créez src/lib/vector-store.ts. LanceDB utilise l'inférence de schéma — passer un tableau typé de documents lors du premier write suffit à définir le schéma de la table. Pas besoin d'importer 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[];
}

Le répertoire .lancedb est un dossier sur le disque — pas de daemon, pas d'appel réseau, pas de configuration au-delà de npm install. Pour les déploiements en production, remplacez la chaîne de chemin par un URI S3 et passez storageOptions avec vos identifiants AWS.

LanceDB infère la colonne vectorielle depuis le champ vector automatiquement. Utiliser le nom conventionnel vector évite de devoir spécifier le nom de la colonne ou le type de métrique dans l'appel de recherche.

Étape 5 : Pipeline d'indexation

Créez 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[] {
  // Division sur la ponctuation de fin de phrase commune en arabe.
  // Note : le point d'interrogation arabe ؟ (U+061F) doit figurer avec ?
  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`
    );
  }
}

Une cible de 800 caractères par segment couvre environ 150-200 mots arabes — une densité sémantique similaire à un segment anglais de 300 mots à 1100 caractères. Le point d'interrogation arabe (U+061F) doit figurer dans le découpeur de phrases car les auteurs arabes mélangent ponctuation latine et arabe dans le même texte.

Étape 6 : Récupération

Créez 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,
  }));
}

Le retriever normalise la requête avec la même fonction utilisée au moment de l'indexation. C'est l'invariant le plus important de tout le pipeline. Si la normalisation de la requête diverge de celle de l'index — même d'un seul flag — le rappel se dégrade silencieusement. Les requêtes retournent des résultats, mais les bons documents sont manquants sans aucune erreur à diagnostiquer.

Étape 7 : Génération avec Claude Sonnet 5

Créez 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;
}

Étape 8 : Route API Next.js

Créez 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" est obligatoire — LanceDB utilise des bindings Rust natifs qui ne peuvent pas tourner sur le Vercel Edge runtime ni dans un Cloudflare Worker.

Étape 9 : Alimentation de données de test

Créez 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);
  });

Lancez le seeder puis testez :

npx tsx scripts/seed.ts
npm run dev
curl -X POST http://localhost:3000/api/arabic-rag \
  -H "Content-Type: application/json" \
  -d '{"query":"ما هو التعلم الآلي؟","lang":"ar"}'

Vous devriez recevoir une réponse JSON avec un champ answer contenant une explication en arabe ancrée dans les documents seedés, et un tableau context listant les segments récupérés.

Étape 10 : Évaluation de la qualité du pipeline

Trois tests de validation avant de passer en production :

Test de normalisation Alef. Indexez un document contenant "إسلام" (avec Hamza sous Alef). Recherchez "اسلام" (Alef simple, sans Hamza). Avec la normalisation activée, le rappel doit être de 100%. Sans elle, il est de 0% — un échec total invisible sans ce test.

Test de cycle Tashkeel. Indexez "الكِتَابُ" (avec Tashkeel complet). Recherchez "الكتاب" (sans diacritiques, comme la plupart des utilisateurs écrivent). Les formes normalisées doivent être identiques — la récupération doit réussir.

Test de symétrie. Désactivez temporairement la normalisation uniquement dans le chemin de requête (laissez-la dans l'indexeur). Si le rappel chute, vos normalisateurs sont déjà alignés. S'il reste identique, votre corpus est déjà propre et la normalisation est sans effet — les deux résultats sont acceptables, mais vous devez savoir lequel vous avez.

Résolution des problèmes

"Cannot find package @lancedb/lancedb" — Le package nécessite un binaire natif compatible avec votre OS et architecture. Sur Apple Silicon, utilisez simplement npm install @lancedb/lancedb et laissez-le télécharger automatiquement le bon binaire darwin-arm64.

Erreur OpenAI 429 — La fonction embedBatch envoie tous les textes en un seul appel API. Pour des corpus de milliers de segments, ajoutez un petit délai entre les itérations de la boucle.

Zéro résultat de la recherche vectorielle — Vérifiez que le champ vector dans vos documents est un tableau de 1536 nombres. Si vous avez passé un paramètre dimensions à l'appel d'embedding OpenAI, la taille de sortie change et le schéma stocké ne correspondra pas aux nouvelles recherches.

Erreur de permission de fichier LanceDB — Le répertoire .lancedb est créé dans le répertoire de travail du processus. Pour Next.js sur Vercel, utilisez /tmp/.lancedb (accessible en écriture dans les fonctions serverless) ou pointez vers un chemin de bucket S3.

Prochaines étapes

Maintenant que votre pipeline RAG arabe fonctionne, envisagez de l'étendre avec :

  • Recherche hybride — Ajoutez un index FTS sur la colonne normalizedText avec le FTS intégré de LanceDB, puis fusionnez les scores BM25 et vectoriels via la Reciprocal Rank Fusion. Le tutoriel LanceDB couvre cela en profondeur.
  • Reclassement — Faites passer les 20 meilleurs segments récupérés par un reranker cross-encoder (Cohere rerank-multilingual-v3.0 supporte nativement l'arabe) et envoyez seulement les 5 meilleurs à Claude.
  • Filtrage des mots vides — Supprimez les mots fonctionnels très fréquents (و، في، من، على، إلى، عن، مع) avant l'embedding pour réduire le bruit dans la récupération.
  • Support dialectal — Pour l'arabe égyptien, du Golfe ou levantin, étendez normalizeArabic avec des tables de substitution spécifiques aux dialectes.
  • Store vectoriel en mode serveur — Pour la production multi-tenant avec haute concurrence en écriture, migrez de LanceDB embarqué vers Qdrant.

Conclusion

Vous avez construit un pipeline RAG arabe complet en TypeScript : une couche de normalisation Unicode qui résout les variantes d'Alef, supprime Tashkeel et Tatweel, et unifie l'Alef Maqsurah ; un pipeline d'embedding par lots avec text-embedding-3-small ; un store vectoriel LanceDB avec inférence de schéma ; un module de récupération qui impose la symétrie de normalisation ; et Claude Sonnet 5 pour la génération de réponses arabes fluides.

L'invariant central est la symétrie de normalisation : les mêmes règles, appliquées au moment de l'indexation et au moment de la requête. Briser cet invariant dégrade le pipeline silencieusement — chaque requête retourne des résultats, mais les bons documents sont manquants.