Pourquoi le vrai multi-tenancy en RAG est difficile
La plupart des LMS avec "tuteur IA" utilisent des approches problématiques :
-
RAG global partagé — tous les tenants voient la même base. Fonctionnel mais enfreint la conformité et la qualité pédagogique.
-
"Per-tenant" via filtre de métadonnées sans enforcement — les chunks ont un champ
tenant_idmais le filtre est optionnel dans la requête. Un bug dans 1 endpoint = une fuite. -
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 :
- Tente de couper au niveau du paragraphe (\n\n)
- Sinon, coupe à la phrase (. )
- Sinon, coupe au mot
- 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 :
| Aspect | OpenAI text-emb-3-large | Voyage AI voyage-3 |
|---|---|---|
| Coût / 1K tokens | 0,00013 $ | 0,00005 $ |
| Dimensions natives | 3072 (réductible via le paramètre dimensions) | 1024 natif |
| Benchmark MTEB (anglais) | 64,6 | 67,2 |
| Benchmark MIRACL (multilingue) | moyen | meilleur |
| Limites de débit free tier | 3K RPM | 3M 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 :
liststrop bas : requêtes lentes (scan complet)liststrop élevé : index volumineux, insertions lentesprobesbas : 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 :
- Supprime les anciens chunks de la leçon (
WHERE lesson_id = X) - Ré-extrait le texte de la leçon mise à jour
- Ré-découpe en chunks
- Ré-embed
- 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étrique | Valeur |
|---|---|
| Total chunks en production | ~500 000 |
| Tenants actifs | 50+ |
| Cours avec RAG ingéré | 280+ |
| Plus grand tenant (chunks) | 47 000 |
| Latence p50 retrieve | 28 ms |
| Latence p95 retrieve | 47 ms |
| Latence p99 retrieve | 124 ms |
| Coût embedding mois précédent | 34 $ (proportionnel au volume de modifications) |
| Incidents de fuite cross-tenant | 0 (6 mois) |
Compromis honnêtes
Ce qui N'a PAS fonctionné :
-
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é.
-
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).
-
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.