Aller au contenu
Studeia Docs
AI-assisted translation — last updated 2026-05-24. For original (pt-BR or en-US), use the language switcher.

RAG per-tenant à l'échelle : architecture pour LMS B2B

RAG par tenant sur Studeia : pgvector + Voyage AI embeddings (1024 dims), chunking 800 tokens, filtre tenantId+courseId, ingestion incrémentale autoSyncRag, fallback OpenAI. 50+ tenants en production

2026-05-24 12 min
Resposta curta

Le RAG per-tenant de Studeia isole les données via un filtre tenantId+courseId obligatoire sur toutes les requêtes pgvector, avec tenantOnlyMode=true qui ÉLIMINE le fallback vers le contenu global. Voyage AI génère des embeddings 1024-dim (primaire, fallback OpenAI text-embedding-3-large). Chunking sémantique 800 tokens, overlap 200. autoSyncRag déclenche la ré-ingestion incrémentale après chaque édition de cours. En production : 500K+ chunks, latence p95 retrieval 47ms, zéro fuite cross-tenant en 6 mois.

Pourquoi le vrai multi-tenancy en RAG est difficile

La plupart des LMS avec "tuteur IA" utilisent des approches problématiques :

  1. RAG global partagé — tous les tenants voient la même base. Fonctionnel mais enfreint la conformité et la qualité pédagogique.

  2. "Per-tenant" via filtre de métadonnées sans enforcement — les chunks ont un champ tenant_id mais le filtre est optionnel dans la requête. Un bug dans 1 endpoint = une fuite.

  3. Vector DB séparé par tenant — surcharge opérationnelle brutale. Mille tenants = mille vector DBs.

Studeia a résolu cela avec 3 invariants architecturaux.

Invariant 1 : filtre tenantId+courseId OBLIGATOIRE

Toute requête pgvector dans Studeia DOIT passer par packages/core/src/ai/rag.ts :

export async function retrieve(params: RetrieveParams) {
  if (!params.tenantId && !params.allowGlobal) {
    throw new Error('tenantId required unless allowGlobal=true');
  }

  const filter = params.tenantId
    ? Prisma.sql`WHERE ce.tenant_id = ${params.tenantId}${params.courseId ? Prisma.sql` AND ce.course_id = ${params.courseId}` : Prisma.empty}`
    : Prisma.empty;

  return prisma.$queryRaw`
    SELECT ce.*, 1 - (ce.embedding <=> ${vectorStr}::vector) as similarity
    FROM content_embeddings ce
    ${filter}
    AND 1 - (ce.embedding <=> ${vectorStr}::vector) > 0.5
    ORDER BY similarity DESC LIMIT 10
  `;
}

allowGlobal n'est true que dans les routes administratives explicites (administrateur global testant la couverture RAG). Dans TOUT le reste, throw.

Règle critique du projet (règle 6 du CLAUDE.md) : "Tenant isolation : toutes les requêtes B2B filtrent par tenantId". Un audit automatisé via les tests Vitest vérifie que tout appel à retrieve() dans le code applicatif passe bien un tenantId.

Invariant 2 : tenantOnlyMode dans le RetrievalAgent

Même avec le bon filtre, il existe des cas où l'on souhaite un fallback (ex. : B2C sans tenant). Pour garantir que le B2B ne fuit JAMAIS :

const chunks = await retrieve({
  query,
  filters: { tenantId, courseId },
  tenantOnlyMode: true,  // <-- CRITIQUE
});

tenantOnlyMode: true signifie : s'il n'y a pas de chunks dans le tenant, retourner vide, sans chercher dans le global. Le tuteur répond "je n'ai pas de contenu sur ce sujet dans votre cours" plutôt que d'inventer.

Invariant 3 : RLS PostgreSQL comme filet de sécurité

Les politiques RLS de Supabase ajoutent une couche de défense :

CREATE POLICY tenant_isolation_content_embeddings
ON content_embeddings
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

Si un bug dans le code applicatif oublie le filtre, RLS bloque. Défense en profondeur.

En production, il y a un coût : chaque requête Postgres évalue la politique. Mais la latence supplémentaire est de ~2-5 ms, acceptable.

Pipeline d'ingestion

POST /api/institution/courses/[id]/rag-ingest { mode: "full" | "incremental" }
  ↓
1. Liste les leçons publiées du cours
2. Pour chaque leçon, extrait le texte selon le type :
   - rich_text → strip HTML via DOMPurify
   - slides → concatène les éléments texte + notes de présentation
   - quiz → concatène question + explication par question
   - pdf → document-extractor (PyPDF + fallback Adobe extract si les natifs échouent)
   - video → LiveClassTranscription.transcriptionText (Whisper → fallback Google STT)
   - assignment → instructions
3. Chunking : 800 tokens, 200 de chevauchement, préserve la structure sémantique
   (ne coupe pas un paragraphe au milieu, ne coupe pas du code au milieu d'une fonction)
4. Embeddings via Voyage AI (1024 dims, fallback OpenAI text-embedding-3-large)
5. Crée ContentBlock + ContentEmbedding avec métadonnées :
   { source: "course_lesson", courseId, lessonId, lessonTitle, moduleTitle, ingestionId }
6. Statut final dans CourseRagIngestion (pending → running → completed | failed)

Chunking sémantique — pourquoi c'est important

Le chunking naïf (tous les N caractères) brise le contexte. Exemple : une leçon contient un extrait de code Python qui se retrouve découpé en chunks différents — l'embedding individuel de chaque moitié ne capture pas le sens.

Studeia utilise un recursive splitter avec une hiérarchie de séparateurs :

  1. Tente de couper au niveau du paragraphe (\n\n)
  2. Sinon, coupe à la phrase (. )
  3. Sinon, coupe au mot
  4. Sinon (rare), tronque

Et préserve les blocs de code ENTIERS (entre les triple backticks) :

function recursiveChunk(text, maxTokens = 800, overlap = 200) {
  // Identifie les blocs protégés (blocs de code, tables markdown)
  const protectedRanges = findProtectedRanges(text);

  // Découpe en respectant la hiérarchie + la protection
  return splitWithHierarchy(text, {
    separators: ['\n\n', '. ', ' ', ''],
    maxTokens,
    overlap,
    protectedRanges,
  });
}

Résultat : chunks de ~600-800 tokens avec un chevauchement de 200, sémantiquement cohérents.

Voyage AI vs OpenAI — pourquoi un primary différent

Nous avons commencé avec OpenAI text-embedding-3-large. Nous avons migré vers Voyage AI en primary au premier semestre 2026. Les raisons :

AspectOpenAI text-emb-3-largeVoyage AI voyage-3
Coût / 1K tokens0,00013 $0,00005 $
Dimensions natives3072 (réductible via le paramètre dimensions)1024 natif
Benchmark MTEB (anglais)64,667,2
Benchmark MIRACL (multilingue)moyenmeilleur
Limites de débit free tier3K RPM3M tokens/min

Voyage est ~2,6× moins cher + meilleur benchmark en retrieval éducatif + robustesse multilingue (important pour l'es-ES + le fr-FR de Studeia).

Fallback automatique vers OpenAI en cas de panne de Voyage :

async function embedText(texts: string[]) {
  try {
    return await voyageEmbed(texts);
  } catch (err) {
    console.warn('[embed] Voyage a échoué, fallback OpenAI', err);
    return await openaiEmbed(texts, { dimensions: 1024 });  // réduit à 1024 pour la compatibilité
  }
}

Important : les deux produisent des vecteurs de 1024 dimensions, pgvector accepte donc sans changement de schéma.

Tuning pgvector en production

pgvector par défaut est optimal pour moins de 100 000 vecteurs. Au-delà, sans tuning, la latence se dégrade.

Configuration Studeia (testée avec 500 000+ chunks) :

-- Index IVFFlat
CREATE INDEX content_embeddings_embedding_idx
ON content_embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 700);  -- sqrt(500000) ≈ 700

-- La requête utilise probes
SET ivfflat.probes = 15;  -- plus de probes = meilleur rappel, latence plus élevée

Compromis :

  • lists trop bas : requêtes lentes (scan complet)
  • lists trop élevé : index volumineux, insertions lentes
  • probes bas : latence correcte, mauvais rappel (chunks pertinents manqués)
  • probes élevé : bon rappel, latence dégradée

Pour Studeia en production : lists=700, probes=15. Latence p95 = 47 ms pour un retrieval top-10 sur 500 000 chunks.

Pour une échelle à 5M+ : envisager HNSW (postgres 16+) ou le partitionnement par tenantId.

autoSyncRag — reconstruction incrémentale

Un cours est un organisme vivant. Un enseignant modifie la leçon 17. Ajoute une vidéo. Met à jour un quiz. Le système doit ré-embedder uniquement le delta, pas tout le cours.

Course.autoSyncRag: Boolean @default(false)

Lorsque true, toute modification de leçon via l'API :

// PATCH /api/institution/courses/[id]/modules/[mid]/lessons/[lid]
await prisma.courseLesson.update({ data: ... });

// En arrière-plan — ne bloque pas la requête
after(async () => {
  if (course.autoSyncRag) {
    await courseRagIngestionService.reingest({
      courseId,
      mode: "incremental",
      onlyLessonId: lessonId,
    });
  }
});

Ré-ingestion incrémentale :

  1. Supprime les anciens chunks de la leçon (WHERE lesson_id = X)
  2. Ré-extrait le texte de la leçon mise à jour
  3. Ré-découpe en chunks
  4. Ré-embed
  5. Insère les nouveaux chunks

Durée : ~3-8 s par leçon moyenne (selon la taille). L'élève ne rencontre JAMAIS un RAG obsolète.

Chiffres de production

MétriqueValeur
Total chunks en production~500 000
Tenants actifs50+
Cours avec RAG ingéré280+
Plus grand tenant (chunks)47 000
Latence p50 retrieve28 ms
Latence p95 retrieve47 ms
Latence p99 retrieve124 ms
Coût embedding mois précédent34 $ (proportionnel au volume de modifications)
Incidents de fuite cross-tenant0 (6 mois)

Compromis honnêtes

Ce qui N'a PAS fonctionné :

  1. Nous avons essayé le hierarchical retrieval (recherche d'abord dans le sommaire, puis dans les chunks complets). Implémentation complexe, gain marginal de qualité sur les requêtes simples. Supprimé.

  2. Nous avons essayé la reformulation de requête via LLM (passer la requête de l'élève par un LLM avant l'embedding pour la normaliser). Le coût a doublé (1 appel LLM supplémentaire), latence +400 ms, qualité marginalement meilleure uniquement sur les requêtes très vagues. Nous effectuons la reformulation dans le RetrievalAgent uniquement lorsque la requête est ambiguë (heuristique simple).

  3. Nous avons essayé le re-ranking via Cohere rerank-3. Coûteux (0,001 $ par re-rank), latence +200 ms. Pour 90 % des requêtes, cosine pgvector + boost par zones de faiblesse est suffisant. Nous gardons le rerank disponible mais désactivé par défaut.

Ce qui manquait il y a 2 ans

pgvector est arrivé à maturité en 2022. Voyage AI a lancé voyage-3 au second semestre 2024. Avant cela, les alternatives (Pinecone, Weaviate, Qdrant) étaient payantes et complexes à opérer en multi-tenant.

Aujourd'hui, avec pgvector mature + embeddings peu coûteux + RLS Supabase, le RAG per-tenant de niveau production est devenu accessible. Nous le recommandons à tout LMS B2B sérieux.

Voir aussi

FAQ

Pourquoi un RAG per-tenant plutôt qu'un RAG partagé ?

Trois raisons non négociables : (1) Conformité LGPD/RGPD — le contenu d'un établissement NE PEUT PAS apparaître dans les réponses adressées aux élèves d'un autre établissement. (2) Qualité pédagogique — les institutions ont leur propre contenu, leur propre approche, des exemples contextualisés ; mélanger le contenu de Stanford avec celui d'un centre de préparation local pollue les réponses. (3) Confidentialité commerciale — le contenu d'une école préparatoire premium est la propriété intellectuelle de cette école, qui ne souhaite pas l'exposer à ses concurrents.

Quel est le coût des embeddings à l'échelle ?

Voyage AI facture 0,00005 $ pour 1 000 tokens (version primary de Studeia). Un cours moyen : 30 leçons, ~50 000 mots = ~70 000 tokens. Embedding initial : ~0,0035 $ par cours. Ré-ingestion incrémentale : ~0,0001 $ par leçon modifiée. Pour un tenant avec 100 cours : ~0,35 $ de configuration initiale + ~5-10 $/mois en deltas. Coût négligeable par rapport à la valeur apportée.

Combien de vecteurs pgvector peut-il gérer avant de se dégrader ?

pgvector avec IVFFlat supporte des millions de vecteurs avec une latence <100 ms si l'index est bien configuré (lists = sqrt(N), probes = 10-20). HNSW (postgres 16+) est meilleur pour l'échelle : 10M+ vecteurs avec <50 ms. Studeia a été testé avec ~500 000 chunks en production, latence p95 du retrieval = 47 ms. Au-delà de 5M, envisager le partitionnement par tenantId ou pgvector-rs.

Comment mettre à jour les embeddings lorsqu'une leçon est modifiée ?

Course.autoSyncRag=true active la ré-ingestion incrémentale automatique via Next.js after(). Toute modification via l'API déclenche : suppression des anciens chunks de la leçon + découpage du nouveau contenu + embedding + insertion. Sans interruption de service, sans reconstruction complète. Pour les modifications en masse : exécuter /api/institution/courses/[id]/rag-ingest avec mode='full' reconstruit depuis zéro.

Veja tambem

RAG per-tenant à l'échelle : architecture pour LMS B2B