Por qué el multi-tenancy real en RAG es difícil
La mayoría de los LMS con "IA tutor" usan enfoques problemáticos:
-
RAG global compartido — todos los tenants ven la misma base. Funcional pero viola el compliance y la calidad pedagógica.
-
"Per-tenant" vía metadata filter sin enforcement — los chunks tienen campo
tenant_idpero el filter es opcional en la query. Un bug en 1 endpoint = filtración. -
Vector DB separado por tenant — overhead operacional brutal. Mil tenants = mil vector DBs.
Studeia lo resolvió con 3 invariantes arquitecturales.
Invariante 1: filter tenantId+courseId MANDATORY
Toda query pgvector en Studeia DEBE pasar por 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 solo es true en rutas administrativas explícitas (admin global probando cobertura RAG). En TODO lo demás, throw.
Regla crítica del proyecto (regla 6 del CLAUDE.md): "Tenant isolation: todas las queries B2B filtran por tenantId". Auditoría automatizada vía tests Vitest verifica que toda llamada a retrieve() en código de aplicación pase tenantId.
Invariante 2: tenantOnlyMode en el RetrievalAgent
Incluso con filter correcto, hay casos donde se quiere fallback (ej: B2C sin tenant). Para garantizar que B2B NUNCA filtre:
const chunks = await retrieve({
query,
filters: { tenantId, courseId },
tenantOnlyMode: true, // <-- CRÍTICO
});
tenantOnlyMode: true significa: si no hay chunks en el tenant, retorna vacío, no busca en global. El tutor responde "no tengo material sobre esto en tu curso" en vez de inventar.
Invariante 3: RLS PostgreSQL como safety net
Las policies RLS de Supabase añaden una capa de defensa:
CREATE POLICY tenant_isolation_content_embeddings
ON content_embeddings
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
Si un bug en el código de aplicación olvida el filter, RLS bloquea. Defensa en capas.
En producción tiene un costo: cada query Postgres evalúa la policy. Pero la latencia adicional es ~2-5ms, aceptable.
Pipeline de ingestión
POST /api/institution/courses/[id]/rag-ingest { mode: "full" | "incremental" }
↓
1. Lista clases publicadas del curso
2. Para cada clase, extrae texto por tipo:
- rich_text → strip HTML vía DOMPurify
- slides → join text elements + speaker notes
- quiz → join question + explanation por pregunta
- pdf → document-extractor (PyPDF + Adobe extract fallback si los nativos fallan)
- video → LiveClassTranscription.transcriptionText (Whisper → Google STT fallback)
- assignment → instructions
3. Chunking: 800 tokens, 200 overlap, preserva estructura semántica
(no rompe párrafo a la mitad, no rompe código en medio de una función)
4. Embeddings vía Voyage AI (1024 dims, fallback OpenAI text-embedding-3-large)
5. Crea ContentBlock + ContentEmbedding con metadata:
{ source: "course_lesson", courseId, lessonId, lessonTitle, moduleTitle, ingestionId }
6. Estado final en CourseRagIngestion (pending → running → completed | failed)
Chunking semántico — por qué importa
El chunking naive (every N chars) rompe el contexto. Ej: una clase tiene un fragmento de código Python que se divide en chunks distintos — el embedding individual de cada mitad no captura el significado.
Studeia usa recursive splitter con jerarquía de separadores:
- Intenta romper en párrafo (\n\n)
- Si no, rompe en frase (. )
- Si no, rompe en palabra
- Si no (raro), trunca
Y preserva bloques de código COMPLETOS (entre triple backticks):
function recursiveChunk(text, maxTokens = 800, overlap = 200) {
// Identifica bloques protegidos (code blocks, tables markdown)
const protectedRanges = findProtectedRanges(text);
// Rompe respetando jerarquía + protección
return splitWithHierarchy(text, {
separators: ['\n\n', '. ', ' ', ''],
maxTokens,
overlap,
protectedRanges,
});
}
Resultado: chunks de ~600-800 tokens con overlap 200, semánticamente coherentes.
Voyage AI vs OpenAI — por qué el primary es diferente
Empezamos con OpenAI text-embedding-3-large. Migramos a Voyage AI como primary en H1 2026. Razones:
| Aspecto | OpenAI text-emb-3-large | Voyage AI voyage-3 |
|---|---|---|
| Costo / 1K tokens | $0.00013 | $0.00005 |
| Dimensiones nativas | 3072 (reducible vía dimensions param) | 1024 nativo |
| MTEB benchmark (English) | 64.6 | 67.2 |
| MIRACL benchmark (multilingual) | medio | mejor |
| Rate limits free tier | 3K RPM | 3M tokens/min |
Voyage es ~2.6x más barato + mejor benchmark en retrieval educacional + multilingüe robusto (importante para es-ES + fr-FR de Studeia).
Fallback automático a OpenAI cuando Voyage tiene un outage:
async function embedText(texts: string[]) {
try {
return await voyageEmbed(texts);
} catch (err) {
console.warn('[embed] Voyage falló, fallback OpenAI', err);
return await openaiEmbed(texts, { dimensions: 1024 }); // reducimos a 1024 para compatibilidad
}
}
Importante: ambos producen vectores de 1024 dims, por lo que pgvector los acepta sin cambio de schema.
pgvector tuning en producción
El pgvector por defecto es óptimo para <100K vectores. Por encima de eso, sin tuning, la latencia se degrada.
Configuración de Studeia (probada con 500K+ chunks):
-- IVFFlat index
CREATE INDEX content_embeddings_embedding_idx
ON content_embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 700); -- sqrt(500000) ≈ 700
-- La query usa probes
SET ivfflat.probes = 15; -- más probes = mejor recall, más latencia
Trade-off:
listsmuy bajo: queries lentas (full scan)listsmuy alto: índice grande, inserts lentosprobesbajo: latencia OK, recall malo (chunks relevantes perdidos)probesalto: recall alto, latencia se degrada
Para Studeia en producción: lists=700, probes=15. Latencia p95 = 47ms para retrieve top-10 en 500K chunks.
Para escala 5M+: evaluar HNSW (postgres 16+) o particionamiento por tenantId.
autoSyncRag — incremental rebuild
El curso es un organismo vivo. El profesor edita la clase 17. Añade un video. Actualiza el quiz. El sistema necesita re-embeder solo el delta, no el curso entero.
Course.autoSyncRag: Boolean @default(false)
Cuando es true, toda edición de clase vía API:
// PATCH /api/institution/courses/[id]/modules/[mid]/lessons/[lid]
await prisma.courseLesson.update({ data: ... });
// Background — no bloquea el request
after(async () => {
if (course.autoSyncRag) {
await courseRagIngestionService.reingest({
courseId,
mode: "incremental",
onlyLessonId: lessonId,
});
}
});
Re-ingestión incremental:
- Elimina chunks antiguos de la clase (
WHERE lesson_id = X) - Re-extrae texto de la clase actualizada
- Re-chunking
- Re-embed
- Inserta nuevos chunks
Tiempo: ~3-8s por clase promedio (depende del tamaño). El alumno NUNCA experimenta RAG desactualizado.
Números de producción
| Métrica | Valor |
|---|---|
| Total chunks en producción | ~500K |
| Tenants activos | 50+ |
| Cursos con RAG ingerido | 280+ |
| Mayor tenant (chunks) | 47K |
| Latencia p50 retrieve | 28ms |
| Latencia p95 retrieve | 47ms |
| Latencia p99 retrieve | 124ms |
| Costo embedding mes anterior | $34 (proporcional al volumen de edición) |
| Incidentes cross-tenant leakage | 0 (6 meses) |
Trade-offs honestos
Lo que NO funcionó:
-
Intentamos hierarchical retrieval (buscar en resumen primero, luego chunks completos). Implementación compleja, ganancia marginal de calidad en queries simples. Lo eliminamos.
-
Intentamos query reformulation vía LLM (pasar la query del alumno por un LLM antes de embeder para normalizarla). El costo se duplicó (1 LLM call más), latencia +400ms, calidad marginalmente mejor solo en queries muy vagas. Solo hacemos reformulation en RetrievalAgent cuando la query es ambigua (heurística simple).
-
Intentamos re-ranking vía Cohere rerank-3. Caro ($0.001 por re-rank), latencia +200ms. Para el 90% de las queries, pgvector cosine + boost por weak areas es suficiente. Mantenemos rerank disponible pero desactivado por defecto.
Lo que faltaba hace 2 años
pgvector llegó de forma productiva en 2022. Voyage AI lanzó voyage-3 en H2 2024. Antes de eso, las alternativas (Pinecone, Weaviate, Qdrant) eran de pago + operacionalmente complejas para multi-tenant.
Hoy, con pgvector maduro + embeddings baratos + RLS Supabase, el RAG per-tenant production-grade se volvió accesible. Lo recomendamos para cualquier LMS B2B serio.