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

RAG per-tenant a escala: arquitectura para LMS B2B

RAG por tenant en Studeia: pgvector + Voyage AI embeddings (1024 dims), chunking 800 tokens, filtro tenantId+courseId, ingestión incremental autoSyncRag, fallback OpenAI. 50+ tenants en producción

2026-05-24 12 min
Resposta curta

RAG per-tenant en Studeia aísla datos mediante filter tenantId+courseId mandatory en todas las queries pgvector, con tenantOnlyMode=true que ELIMINA el fallback a contenido global. Voyage AI genera embeddings 1024-dim (primary, fallback OpenAI text-embedding-3-large). Chunking semántico 800 tokens con 200 de overlap. autoSyncRag dispara re-ingestión incremental en el after() de cada edición de clase. En producción: 500K+ chunks, latencia p95 retrieval 47ms, cero leakage cross-tenant en 6 meses.

Por qué el multi-tenancy real en RAG es difícil

La mayoría de los LMS con "IA tutor" usan enfoques problemáticos:

  1. RAG global compartido — todos los tenants ven la misma base. Funcional pero viola el compliance y la calidad pedagógica.

  2. "Per-tenant" vía metadata filter sin enforcement — los chunks tienen campo tenant_id pero el filter es opcional en la query. Un bug en 1 endpoint = filtración.

  3. 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:

  1. Intenta romper en párrafo (\n\n)
  2. Si no, rompe en frase (. )
  3. Si no, rompe en palabra
  4. 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:

AspectoOpenAI text-emb-3-largeVoyage AI voyage-3
Costo / 1K tokens$0.00013$0.00005
Dimensiones nativas3072 (reducible vía dimensions param)1024 nativo
MTEB benchmark (English)64.667.2
MIRACL benchmark (multilingual)mediomejor
Rate limits free tier3K RPM3M 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:

  • lists muy bajo: queries lentas (full scan)
  • lists muy alto: índice grande, inserts lentos
  • probes bajo: latencia OK, recall malo (chunks relevantes perdidos)
  • probes alto: 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:

  1. Elimina chunks antiguos de la clase (WHERE lesson_id = X)
  2. Re-extrae texto de la clase actualizada
  3. Re-chunking
  4. Re-embed
  5. 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étricaValor
Total chunks en producción~500K
Tenants activos50+
Cursos con RAG ingerido280+
Mayor tenant (chunks)47K
Latencia p50 retrieve28ms
Latencia p95 retrieve47ms
Latencia p99 retrieve124ms
Costo embedding mes anterior$34 (proporcional al volumen de edición)
Incidentes cross-tenant leakage0 (6 meses)

Trade-offs honestos

Lo que NO funcionó:

  1. Intentamos hierarchical retrieval (buscar en resumen primero, luego chunks completos). Implementación compleja, ganancia marginal de calidad en queries simples. Lo eliminamos.

  2. 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).

  3. 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.

Ver también

FAQ

¿Por qué RAG per-tenant en vez de RAG compartido?

Tres razones innegociables: (1) Compliance LGPD/GDPR — el material de una institución NO puede aparecer en respuestas para alumnos de otra institución. (2) Calidad pedagógica — las instituciones tienen material propio, enfoque propio, ejemplos contextualizados; mezclar contenido de Stanford con una academia de barrio contamina la respuesta. (3) Confidencialidad comercial — el material de una academia premium es IP de esa academia, no quiere exponerlo a sus competidores.

¿Cuál es el costo de los embeddings a escala?

Voyage AI cobra $0.00005 por 1K tokens (versión primary de Studeia). Curso promedio: 30 clases, ~50K palabras = ~70K tokens. Embedding inicial: ~$0.0035 por curso. Re-ingestión incremental: ~$0.0001 por clase editada. Para tenant con 100 cursos: ~$0.35 setup inicial + ~$5-10/mes en deltas. Costo despreciable comparado con el valor aportado.

¿Cuántos vectores aguanta pgvector antes de degradarse?

pgvector con IVFFlat aguanta millones de vectores con latencia <100ms si el índice está bien ajustado (lists = sqrt(N), probes = 10-20). HNSW (postgres 16+) es mejor para escala: 10M+ vectores con <50ms. Studeia probado con ~500K chunks en producción, latencia p95 de retrieval = 47ms. Por encima de 5M considerar partitioning por tenantId o pgvector-rs.

¿Cómo actualizar embeddings cuando una clase cambia?

Course.autoSyncRag=true activa la re-ingestión incremental automática vía Next.js after(). Toda edición vía API dispara: eliminar chunks antiguos de la clase + chunking del nuevo contenido + embed + insert. Sin downtime, sin rebuild completo. Para ediciones masivas: ejecutar /api/institution/courses/[id]/rag-ingest con mode='full' reconstruye desde cero.

Veja tambem

RAG per-tenant a escala: arquitectura para LMS B2B