RAG multi-tenant: metadados, filtros e permissões
Você está construindo um produto SaaS com RAG, ou um sistema interno com múltiplos departamentos, e a pergunta inevitável aparece: como garantir que o Cliente A nunca veja dados do Cliente B? Um filtro mal implementado e o LLM responde uma pergunta de RH com dados do jurídico. Este artigo mostra as 3 estratégias de isolamento, seus trade-offs e como implementar RBAC de verdade.
O problema real: vazamento entre tenants
Em um RAG simples (single-tenant), você indexa todos os documentos e busca em tudo. Em multi-tenant, a busca vetorial — por natureza — retorna os chunks mais similares do corpus inteiro. Sem isolamento adequado, uma pergunta do Cliente A pode trazer contexto do Cliente B para o LLM, que vai usá-lo sem hesitar.
O problema é traiçoeiro porque não gera erro — gera resposta errada com aparência de correta. O usuário do Cliente A recebe uma resposta fundamentada em políticas comerciais do Cliente B sem saber. Em contextos B2B com dados sensíveis (precificação, contratos, estratégia), isso é uma falha grave de segurança e um risco de compliance com a LGPD.
Veja o que é RAG e como funciona o retrieval para entender por que o problema acontece na camada de busca, não na geração.
Arquitetura geral de um RAG multi-tenant
Antes das estratégias específicas, o fluxo geral de um RAG multi-tenant seguro é:
┌─────────────────────────────────────────────────────────┐
│ CLIENTE / USUÁRIO │
│ (autenticado com JWT contendo tenant_id + roles) │
└──────────────────────────┬──────────────────────────────┘
│ query
▼
┌─────────────────────────────────────────────────────────┐
│ API / BACKEND │
│ 1. Valida JWT e extrai tenant_id │
│ 2. Constrói filtro de isolamento │
│ 3. Executa busca com filtro injetado │
│ 4. Valida que chunks retornados pertencem ao tenant │
│ 5. Monta prompt com contexto filtrado │
│ 6. Chama LLM │
└──────────────────────────┬──────────────────────────────┘
│ busca filtrada
▼
┌─────────────────────────────────────────────────────────┐
│ VECTOR STORE │
│ ┌───────────────┐ ┌───────────────┐ ┌─────────────┐ │
│ │ tenant_A │ │ tenant_B │ │ tenant_C │ │
│ │ [chunks...] │ │ [chunks...] │ │ [chunks..] │ │
│ └───────────────┘ └───────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
│ ingestão
▲
┌─────────────────────────────────────────────────────────┐
│ PIPELINE DE INGESTÃO │
│ Documento → Chunking → Embedding → Metadados │
│ (tenant_id, departamento, nível_acesso, data, tipo) │
└─────────────────────────────────────────────────────────┘
O ponto crítico: o filtro de isolamento é construído no backend com base no token de autenticação, não no frontend. O usuário nunca pode informar seu próprio tenant_id.
As 3 estratégias de isolamento: comparação
| Estratégia | Como funciona | Prós | Contras | Quando usar |
|---|---|---|---|---|
| Namespace por tenant | Cada tenant recebe um namespace lógico dentro da mesma collection. A busca é restrita ao namespace via parâmetro. | Simples de implementar; único vector store; baixo custo operacional | Isolamento lógico, não físico — um bug no código pode vazar. Nem todos os vector stores suportam namespaces. | Aplicações internas com nível moderado de sensibilidade; provas de conceito |
| Collection separada por tenant | Cada tenant tem sua própria collection/index no vector store. Impossível cruzar dados por design. | Isolamento forte; nenhum bug de filtro pode vazar dados; backup e exclusão por tenant simples | Custo de recursos escala com número de tenants; overhead operacional alto para 100+ tenants | SaaS B2B com dados críticos; conformidade regulatória rigorosa; até ~50 tenants |
| Filtro de metadados (campo tenant_id) | Todos os chunks numa collection única, com campo tenant_id. Todo query inclui filtro obrigatório where tenant_id == X. |
Escala para milhares de tenants; custo fixo de infraestrutura; simples de adicionar novos tenants | Depende de código correto; requer validação dupla no backend; menos auditável sem logs | Plataformas com muitos tenants pequenos; quando custo de infra é restrição |
Implementando filtro RBAC por metadado
A estratégia de filtro de metadados é a mais comum em produção por escalar bem. O segredo está em tornar o filtro impossível de ser bypassado pelo usuário.
Estrutura de metadados recomendada
Na ingestão, cada chunk deve receber metadados suficientes para filtrar com granularidade:
# Metadados que acompanham cada chunk no vector store
metadados = {
"tenant_id": "empresa-abc-001", # obrigatório, imutável
"departamento": "juridico", # opcional, para RBAC interno
"nivel_acesso": "confidencial", # público, interno, confidencial, restrito
"tipo_documento": "contrato", # para filtros por tipo
"data_vigencia": "2026-12-31", # para filtros temporais
"documento_id": "contrato-2024-0891", # rastreabilidade
"pagina": 3, # localização no original
"anonimizado": False # controle LGPD
}
Injeção de filtro obrigatório no backend
def buscar_com_isolamento(query: str, token_jwt: str) -> list:
# Extração de claims do token — nunca confiar em parâmetro do cliente
claims = jwt.decode(token_jwt, chave_publica, algorithms=["RS256"])
tenant_id = claims["tenant_id"]
roles = claims["roles"]
nivel_acesso = mapear_nivel_por_role(roles)
# Filtro construído no backend, nunca enviado pelo cliente
filtro = {
"must": [
{"key": "tenant_id", "match": {"value": tenant_id}},
{"key": "nivel_acesso", "match": {"any": nivel_acesso}}
]
}
# Busca vetorial com filtro injetado
resultados = vector_store.buscar(
vetor=embedder.encode(query),
filtro=filtro,
top_k=20
)
# Validação defensiva: rejeitar qualquer chunk fora do tenant
resultados_validados = [
r for r in resultados
if r.metadados["tenant_id"] == tenant_id
]
return resultados_validados
A "validação defensiva" no final pode parecer redundante, mas funciona como segurança em profundidade (defense in depth): se um bug no filtro do vector store passar um chunk errado, ele é eliminado antes de chegar ao LLM.
RBAC interno: permissões por departamento dentro do mesmo tenant
Além do isolamento entre tenants, muitos cenários exigem isolamento interno: um funcionário de vendas não deve acessar documentos do RH, mesmo que ambos sejam do mesmo tenant.
A abordagem é adicionar camadas ao filtro de metadados:
- Nível hierárquico: público, interno, confidencial, restrito. Usuários com role
adminveem tudo;uservê apenas público e interno;managervê até confidencial. - Departamento: o filtro pode incluir
departamento IN ["vendas", "geral"]para um usuário de vendas. Documentos jurídicos ficam invisíveis. - Lista de permissões de documento: para granularidade máxima, cada documento pode ter uma ACL (Access Control List) com IDs de usuários ou grupos autorizados.
Para casos de LGPD — dados pessoais de funcionários, informações de saúde — veja como tratar dados pessoais em aplicações LLM com LGPD.
Custo operacional: números reais
Antes de escolher a estratégia, estime o custo de cada uma para o seu cenário:
- Filtro de metadados (escala para N tenants): custo praticamente fixo. Um Qdrant com 8GB RAM suporta ~10M chunks com filtro eficiente. Novos tenants são apenas novos chunks — sem overhead de infraestrutura. Custo estimado: R$ 300–800/mês em cloud para PME média.
- Collection por tenant (até ~50 tenants): cada collection ocupa recursos separados. Weaviate cobra por tenant em modo multi-tenancy gerenciado. Qdrant suporta sharding por collection. Estimativa: R$ 80–200/tenant/mês em cloud managed.
- Namespace por tenant: similar ao filtro de metadados em custo. O Pinecone, por exemplo, cobra por namespace mas mantém custo por uso, não por número de namespaces.
Para um SaaS com 200+ tenants pequenos, filtro de metadados é quase sempre a escolha certa. Para 10 tenants com dados altamente sensíveis, collection separada paga o custo extra em segurança e auditabilidade.
Auditoria: quem perguntou o quê, e o que o sistema entregou
Multi-tenant sem auditoria é um risco de compliance. Para cada query, registre:
- Timestamp e identificador único da query
- user_id e tenant_id do solicitante
- Texto da query (hash ou completo, conforme política de privacidade)
- IDs dos chunks recuperados e seus metadados de permissão
- Confirmação de que o filtro foi aplicado
- Latência e custo da query
Esses logs são essenciais para responder a uma auditoria de LGPD ("quais dados de fulano foram acessados por quem?") e para detectar tentativas de prompt injection que buscam vazar dados de outros tenants. Veja mais em segurança em aplicações LLM com allowlist e sandbox.
Erros comuns que vi em implementações reais
- Confiar no tenant_id enviado pelo cliente: se o frontend envia
tenant_idcomo parâmetro e o backend usa sem validar o JWT, qualquer usuário pode se passar por outro tenant. - Filtrar apenas na busca, não na validação: bugs em bibliotecas de vector store já retornaram chunks fora do filtro em versões com problemas. A validação defensiva no backend é seguro essencial.
- Documentos sem metadados de tenant: durante migração ou ingestão manual, documentos podem entrar sem o campo
tenant_id. Esses chunks ficam "órfãos" e podem aparecer em buscas de qualquer tenant com filtros mal construídos. - Logs sem informação suficiente: logar apenas "query realizada" sem os chunks retornados impede auditoria real.