Pular para o conteúdo

RAG per-tenant em escala: arquitetura para LMS B2B

RAG por tenant no Studeia: pgvector + Voyage AI embeddings (1024 dims), chunking 800 tokens, filtro tenantId+courseId, ingestão incremental autoSyncRag, fallback OpenAI. 50+ tenants em produção

2026-05-24 12 min
Resposta curta

RAG per-tenant no Studeia isola dados via filter tenantId+courseId mandatory em todas as queries pgvector, com tenantOnlyMode=true que ELIMINA fallback para conteudo global. Voyage AI gera embeddings 1024-dim (primary, fallback OpenAI text-embedding-3-large). Chunking semantico 800 tokens com 200 overlap. autoSyncRag dispara re-ingestao incremental no after() de cada edicao de aula. Em producao: 500K+ chunks, latencia p95 retrieval 47ms, zero leakage cross-tenant em 6 meses.

Por que multi-tenancy real em RAG e dificil

A maioria dos LMS com "IA tutor" usa abordagens problematicas:

  1. RAG global compartilhado — todos tenants veem mesma base. Funcional mas viola compliance e qualidade pedagogica.

  2. "Per-tenant" via metadata filter sem enforcement — chunks tem campo tenant_id mas filter e opcional na query. Bug em 1 endpoint = vazamento.

  3. Vector DB separado por tenant — overhead operacional brutal. Mil tenants = mil vector DBs.

Studeia resolveu com 3 invariantes arquiteturais.

Invariante 1: filter tenantId+courseId MANDATORY

Toda query pgvector no Studeia DEVE passar 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 so e true em rotas administrativas explicitas (admin global testando cobertura RAG). Em TUDO mais, throw.

Critical rule do projeto (regra 6 do CLAUDE.md): "Tenant isolation: todas as queries B2B filtram por tenantId". Auditoria automatizada via testes Vitest verifica que toda chamada a retrieve() em codigo de aplicacao passa tenantId.

Invariante 2: tenantOnlyMode no RetrievalAgent

Mesmo com filter correto, ha caso onde se quer fallback (ex: B2C sem tenant). Para garantir que B2B NUNCA vaza:

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

tenantOnlyMode: true significa: se nao houver chunks no tenant, retorna vazio, nao busca em global. Tutor responde "nao tenho material sobre isso no seu curso" em vez de inventar.

Invariante 3: RLS PostgreSQL como safety net

Supabase RLS policies adicionam camada de defesa:

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

Se um bug em codigo de aplicacao esquecer o filter, RLS bloqueia. Defesa em camadas.

Em producao tem custo: cada query Postgres avalia policy. Mas latencia adicional e ~2-5ms, aceitavel.

Pipeline de ingestao

POST /api/institution/courses/[id]/rag-ingest { mode: "full" | "incremental" }
  ↓
1. Lista aulas publicadas do curso
2. Para cada aula, extrai texto por tipo:
   - rich_text → strip HTML via DOMPurify
   - slides → join text elements + speaker notes
   - quiz → join question + explanation por questao
   - pdf → document-extractor (PyPDF + Adobe extract fallback se nativos falham)
   - video → LiveClassTranscription.transcriptionText (Whisper → Google STT fallback)
   - assignment → instructions
3. Chunking: 800 tokens, 200 overlap, preserva estrutura semantica
   (nao quebra paragrafo no meio, nao quebra codigo em meio funcao)
4. Embeddings via Voyage AI (1024 dims, fallback OpenAI text-embedding-3-large)
5. Cria ContentBlock + ContentEmbedding com metadata:
   { source: "course_lesson", courseId, lessonId, lessonTitle, moduleTitle, ingestionId }
6. Status final em CourseRagIngestion (pending → running → completed | failed)

Chunking semantico — por que importa

Naive chunking (every N chars) quebra contexto. Ex: aula tem trecho de codigo Python que se quebra em chunks diferentes — embedding individual de cada metade nao captura o significado.

Studeia usa recursive splitter com hierarquia de separadores:

  1. Tenta quebrar em paragrafo (\n\n)
  2. Senao, quebra em frase (. )
  3. Senao, quebra em palavra
  4. Senao (raro), trunca

E preserva blocos de codigo INTEIROS (entre triple backticks):

function recursiveChunk(text, maxTokens = 800, overlap = 200) {
  // Identifica blocos protegidos (code blocks, tables markdown)
  const protectedRanges = findProtectedRanges(text);

  // Quebra respeitando hierarchy + protecao
  return splitWithHierarchy(text, {
    separators: ['\n\n', '. ', ' ', ''],
    maxTokens,
    overlap,
    protectedRanges,
  });
}

Resultado: chunks de ~600-800 tokens com overlap 200, semanticamente coerentes.

Voyage AI vs OpenAI — por que primary diferente

Comecamos com OpenAI text-embedding-3-large. Migramos pra Voyage AI primary em 2026 H1. Razoes:

AspectoOpenAI text-emb-3-largeVoyage AI voyage-3
Custo / 1K tokens$0.00013$0.00005
Dimensoes nativas3072 (reduzivel via dimensions param)1024 nativo
MTEB benchmark (English)64.667.2
MIRACL benchmark (multilingual)mediomelhor
Rate limits free tier3K RPM3M tokens/min

Voyage e ~2.6x mais barato + benchmark melhor em retrieval educacional + multilingual robusto (importante pra es-ES + fr-FR do Studeia).

Fallback automatico pra OpenAI quando Voyage tem outage:

async function embedText(texts: string[]) {
  try {
    return await voyageEmbed(texts);
  } catch (err) {
    console.warn('[embed] Voyage falhou, fallback OpenAI', err);
    return await openaiEmbed(texts, { dimensions: 1024 });  // reduzimos pra 1024 pra compatibilidade
  }
}

Importante: ambos producem vetores de 1024 dims, entao pgvector aceita sem schema change.

pgvector tuning em producao

Default pgvector e otimo pra <100K vetores. Acima disso, sem tuning, latencia degrada.

Studeia config (testada com 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

-- Query usa probes
SET ivfflat.probes = 15;  -- mais probes = melhor recall, mais latencia

Trade-off:

  • lists muito baixo: queries lentas (full scan)
  • lists muito alto: index grande, inserts lentos
  • probes baixo: latencia OK, recall ruim (chunks relevantes perdidos)
  • probes alto: recall alto, latencia degrada

Para Studeia em producao: lists=700, probes=15. Latencia p95 = 47ms para retrieve top-10 em 500K chunks.

Para escala 5M+: avaliar HNSW (postgres 16+) ou particionamento por tenantId.

autoSyncRag — incremental rebuild

Curso e organismo vivo. Professor edita aula 17. Acrescenta video. Atualiza quiz. Sistema precisa re-embed apenas o delta, nao o curso todo.

Course.autoSyncRag: Boolean @default(false)

Quando true, toda edicao de aula via API:

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

// Background — nao bloqueia request
after(async () => {
  if (course.autoSyncRag) {
    await courseRagIngestionService.reingest({
      courseId,
      mode: "incremental",
      onlyLessonId: lessonId,
    });
  }
});

Re-ingestao incremental:

  1. Delete chunks antigos da aula (WHERE lesson_id = X)
  2. Re-extrai texto da aula atualizada
  3. Re-chunk
  4. Re-embed
  5. Insert novos chunks

Tempo: ~3-8s por aula medio (depende do tamanho). Aluno NUNCA experiencia stale RAG.

Numeros de producao

MetricaValor
Total chunks em producao~500K
Tenants ativos50+
Cursos com RAG ingerido280+
Maior tenant (chunks)47K
Latencia p50 retrieve28ms
Latencia p95 retrieve47ms
Latencia p99 retrieve124ms
Custo embedding mes anterior$34 (proportional ao volume de edicao)
Incidentes cross-tenant leakage0 (6 meses)

Trade-offs honestos

O que NAO funcionou:

  1. Tentamos hierarchical retrieval (busca em sumario primeiro, depois full chunks). Implementation complexa, ganho marginal de qualidade em queries simples. Removemos.

  2. Tentamos query reformulation via LLM (passar query do aluno por LLM antes de embed pra normalizar). Custo dobrou (mais 1 LLM call), latencia +400ms, qualidade marginalmente melhor apenas em queries muito vagas. So fazemos reformulation em RetrievalAgent quando query e ambigua (heuristica simples).

  3. Tentamos re-ranking via Cohere rerank-3. Cara ($0.001 por re-rank), latencia +200ms. Para 90% das queries, pgvector cosine + boost por weak areas e suficiente. Mantemos rerank disponivel mas off por default.

O que faltava 2 anos atras

pgvector chegou produtivamente em 2022. Voyage AI lancou voyage-3 em 2024 H2. Antes disso, alternativas (Pinecone, Weaviate, Qdrant) eram pagas + operacionalmente complexas pra multi-tenant.

Hoje, com pgvector maduro + embeddings baratos + RLS Supabase, RAG per-tenant production-grade ficou acessivel. Recomendamos pra qualquer LMS B2B serio.

Veja tambem

FAQ

Por que RAG per-tenant ao inves de RAG compartilhado?

Tres razoes nao-negociaveis: (1) Compliance LGPD/GDPR — material de uma escola NAO pode aparecer em respostas pra alunos de outra escola. (2) Qualidade pedagogica — instituicoes tem material proprio, abordagem propria, exemplos contextualizados; misturar conteudo de Stanford com cursinho de bairro polui resposta. (3) Confidencialidade comercial — material de cursinho premium e IP do cursinho, nao quer expor pra concorrentes.

Qual o custo de embeddings em escala?

Voyage AI cobra $0.00005 por 1K tokens (versao primary do Studeia). Curso medio: 30 aulas, ~50K palavras = ~70K tokens. Embedding inicial: ~$0.0035 por curso. Re-ingestao incremental: ~$0.0001 por aula editada. Para tenant com 100 cursos: ~$0.35 setup inicial + ~$5-10/mes em deltas. Custo desprezivel comparado a value.

Quantos vetores pgvector aguenta antes de degradar?

pgvector com IVFFlat aguenta milhoes de vetores com latencia <100ms se index esta well-tuned (lists = sqrt(N), probes = 10-20). HNSW (postgres 16+) e melhor pra escala: 10M+ vetores com <50ms. Studeia testado com ~500K chunks em producao, latencia p95 de retrieval = 47ms. Acima de 5M considerar partitioning por tenantId ou pgvector-rs.

Como atualizar embeddings quando aula muda?

Course.autoSyncRag=true ativa re-ingestao incremental automatica via Next.js after(). Toda edicao via API dispara: delete chunks antigos da aula + chunk novo conteudo + embed + insert. Sem downtime, sem rebuild completo. Para edicoes em massa: rodar /api/institution/courses/[id]/rag-ingest com mode='full' rebuilda do zero.

Veja tambem

RAG per-tenant em escala: arquitetura para LMS B2B | Studeia Docs