Por que multi-tenancy real em RAG e dificil
A maioria dos LMS com "IA tutor" usa abordagens problematicas:
-
RAG global compartilhado — todos tenants veem mesma base. Funcional mas viola compliance e qualidade pedagogica.
-
"Per-tenant" via metadata filter sem enforcement — chunks tem campo
tenant_idmas filter e opcional na query. Bug em 1 endpoint = vazamento. -
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:
- Tenta quebrar em paragrafo (\n\n)
- Senao, quebra em frase (. )
- Senao, quebra em palavra
- 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:
| Aspecto | OpenAI text-emb-3-large | Voyage AI voyage-3 |
|---|---|---|
| Custo / 1K tokens | $0.00013 | $0.00005 |
| Dimensoes nativas | 3072 (reduzivel via dimensions param) | 1024 nativo |
| MTEB benchmark (English) | 64.6 | 67.2 |
| MIRACL benchmark (multilingual) | medio | melhor |
| Rate limits free tier | 3K RPM | 3M 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:
listsmuito baixo: queries lentas (full scan)listsmuito alto: index grande, inserts lentosprobesbaixo: latencia OK, recall ruim (chunks relevantes perdidos)probesalto: 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:
- Delete chunks antigos da aula (
WHERE lesson_id = X) - Re-extrai texto da aula atualizada
- Re-chunk
- Re-embed
- Insert novos chunks
Tempo: ~3-8s por aula medio (depende do tamanho). Aluno NUNCA experiencia stale RAG.
Numeros de producao
| Metrica | Valor |
|---|---|
| Total chunks em producao | ~500K |
| Tenants ativos | 50+ |
| Cursos com RAG ingerido | 280+ |
| Maior tenant (chunks) | 47K |
| Latencia p50 retrieve | 28ms |
| Latencia p95 retrieve | 47ms |
| Latencia p99 retrieve | 124ms |
| Custo embedding mes anterior | $34 (proportional ao volume de edicao) |
| Incidentes cross-tenant leakage | 0 (6 meses) |
Trade-offs honestos
O que NAO funcionou:
-
Tentamos hierarchical retrieval (busca em sumario primeiro, depois full chunks). Implementation complexa, ganho marginal de qualidade em queries simples. Removemos.
-
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).
-
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.