Sua busca vetorial é o vazamento de tenant mais silencioso da sua stack
Você pode trancar cada tabela e ainda vazar entre orgs na camada de embeddings.
Apollo Space Research
Apollo Space
Um agent servindo um cliente faz uma pergunta simples, “o que combinamos sobre renovações no último trimestre?”, e a camada de retrieval responde com um trecho. O trecho é relevante. O trecho é bem-formado. O trecho pertence a uma empresa diferente. Nenhum erro disparou. Nenhum alarme tocou. O log mostra uma query bem-sucedida retornando um match de alta similaridade, que é exatamente o que você pediu para ele fazer.
Esse é o vazamento. Ele não parece uma brecha. Ele parece um bom resultado de busca.
Você pode trancar cada tabela e ainda vazar entre orgs na camada de embeddings.
Este post é sobre por que o lugar que você mais cuidadosamente protegeu, seus dados relacionais, não é o lugar onde sua fronteira multi-tenant de fato quebra, e o que é preciso para fechar a lacuna que suas regras de row-level nunca cobriram.
A fronteira que você construiu, e a porta que você deixou aberta
A forma padrão de isolar tenants é bem compreendida e, para o banco de dados, genuinamente sólida. Cada linha carrega um org id. Cada query filtra por ele. Row-level security força o filtro no engine, então até uma query que esquece a cláusula WHERE volta vazia para o tenant errado. Você testa com uma sonda cross-org, conecta como org A, pede as linhas da org B, não recebe nada, e o teste passa. A fronteira relacional segura.
Aí você adiciona retrieval, porque os agents precisam lembrar coisas e buscar sobre documentos. Você faz o embedding dos arquivos do cliente, suas notas, suas conversas passadas. Você joga os vetores num index. E o index, por padrão, é um grande espaço compartilhado onde similaridade é a única coisa que decide o que volta.
Aqui está a parte silenciosa. Similaridade não sabe nada sobre tenants.
Uma busca de vizinhos mais próximos encontra os vetores mais próximos do seu vetor de query, ponto final. Ela não se importa com qual org os produziu. Duas empresas no mesmo setor escrevem sobre as mesmas coisas, as mesmas cláusulas contratuais, os mesmos nomes de produto, os mesmos problemas recorrentes, então seus embeddings pousam perto uns dos outros no espaço. Peça por “nossos termos de renovação” e o match mais próximo pode ser o seu, ou pode ser o da empresa três linhas adiante cujo contrato usou linguagem quase idêntica. A matemática está funcionando perfeitamente. Ela só está respondendo uma pergunta que você não quis fazer.
A fronteira do banco de dados é uma parede. O index vetorial, deixado sozinho, é uma parede com uma porta recortada nela que ninguém desenhou na planta baixa.
Por que esse vazamento é tão difícil de ver
A razão de isso sobreviver em produção é que cada modo de falha para o qual você tem instintos está ausente.
Um SQL injection lança erros e dispara scanners. Uma checagem de auth ausente retorna um 403 que alguém nota. Um vazamento cross-tenant na sua camada relacional falha alto no momento em que seu teste de sonda roda. Você construiu uma carreira inteira de reflexos em torno de falhas de fronteira que se anunciam.
O vazamento vetorial não anuncia nada. A query tem sucesso. A latência é normal. O resultado tem um score de similaridade alto, que seu monitoramento lê como um retrieval bom, não suspeito. O agent, lá adiante, tece o trecho estrangeiro numa resposta fluente, porque é isso que ele faz com qualquer coisa que o retrieval entrega. A pessoa lendo a resposta não tem como saber que uma frase veio dos dados de outra pessoa. Nem seu error budget tem.
Um vazamento relacional falha como um arrombamento. Um vazamento vetorial falha como um bom resultado de busca.
Então o vazamento não aparece onde você procura por vazamentos. Ele aparece como uma resposta um-pouco-bem-informada-demais, ocasionalmente, a uma pergunta que tocou um tópico que dois clientes tinham em comum, que é o sinal mais difícil possível de distinguir do seu retrieval simplesmente funcionando bem. Quando alguém suspeita, ele já está respondendo perguntas há semanas.
O conserto ingênuo: filtre os resultados depois da busca
O primeiro instinto, uma vez que você vê o problema, é o óbvio. Rode a busca de similaridade sobre o index compartilhado, traga os top matches de volta, e então descarte qualquer resultado cujo org id não bate com o do chamador. Filtre depois do retrieval. Problema resolvido.
Não está resolvido, e a razão vale desacelerar, porque é a armadilha na qual a maioria dos times cai primeiro.
Um index de vizinhos mais próximos retorna um número fixo de candidatos, digamos os dez vetores mais próximos. Se você pede dez e então joga fora os sete que pertencem a outros tenants, você não retornou os três melhores matches do tenant. Você retornou aqueles dos matches do tenant que calharam de sobreviver à poda, e você silenciosamente derrubou o resto do conjunto de resultados no chão. Num espaço compartilhado onde os documentos de outros tenants lotam a vizinhança, o trecho mais relevante do próprio tenant pode ficar em décimo primeiro, passado o corte, e nunca aparecer. Você superbusca para compensar, pedindo os top cem para garantir, e agora você tornou cada query mais lenta, mais vazável em trânsito, e ainda não garantidamente correta, porque não há tamanho de busca que seja comprovadamente suficiente quando você não sabe quantos vizinhos estrangeiros ficam entre o chamador e seus próprios dados.
Pós-filtragem trata a fronteira como faxina. Mas uma fronteira que você aplica depois do fato é uma fronteira que a busca já cruzou. Os vetores foram comparados. Os vizinhos foram ranqueados. Os dados estrangeiros foram, por um momento, uma resposta candidata, e um momento é tudo que um vazamento precisa.
O conserto não é faxinar depois da busca. É garantir que a busca nunca veja os vetores do outro tenant em primeiro lugar.
Nosso jeito: o org id é parte da pergunta, não um filtro na resposta
A fronteira tem que viver dentro do retrieval, não em torno dele. A org não é uma condição que você checa no final. Ela é parte do que “mais próximo” significa.
Há algumas formas honestas de fazer isso, e qual delas encaixa depende da escala, mas elas compartilham um princípio: a query de um tenant só é comparada contra os vetores daquele tenant. Os dados estrangeiros não são ranqueados e descartados. Eles nunca estão na pool de candidatos.
A versão mais limpa dá a cada tenant seu próprio index, um espaço vetorial separado por org, então uma busca fisicamente não consegue alcançar os embeddings de outro tenant porque eles não estão na mesma estrutura. Não há nada para filtrar porque não há nada estrangeiro presente. O custo são muitos index pequenos em vez de um grande, que é peso operacional real, mas a fronteira vira uma propriedade de onde os dados vivem em vez de uma regra que você lembra de aplicar.
Quando tantos index ficam caros, o próximo design honesto mantém um index mas faz o org id uma pré-condição dura da própria busca, o index é particionado por tenant, e a query é escopada à sua partição antes de uma única distância ser computada. A busca roda dentro da fatia do tenant. A matemática nunca cruza a linha, porque a linha é desenhada antes de a matemática começar.
E por baixo de tudo isso, a mesma disciplina de cinto-e-suspensórios que usamos em todo lugar: o filtro explícito de org está também presente, toda vez, mesmo quando a partição ou o index separado já deveriam tornar impossível cruzar. Duas coisas independentes ambas têm que falhar para um vazamento acontecer. Não confiamos em um mecanismo para ser a fronteira inteira, porque toda a lição deste post é que a fronteira em que você está mais confiante é a que tem a porta não desenhada.
Como provamos isso, em vez de torcer
Uma fronteira que você não consegue testar é uma fronteira que você não tem. Esta é a parte que separa um design de uma garantia.
A sonda relacional que todo mundo roda, conecte como um tenant, peça as linhas de outro, confirme que não recebe nada, tem uma gêmea vetorial, e a rodamos de propósito. Semeie o index com documentos de dois tenants escritos para serem deliberadamente similares: mesmo setor, mesmo vocabulário, frases quase idênticas, para que os embeddings fiquem bem em cima uns dos outros. Esse é o pior caso, aquele em que a pós-filtragem silenciosamente falha e a matemática mais quer te entregar o vizinho errado. Então faça a query como um tenant e afirme que nada, nem um trecho, nem um fragmento, volta do outro. Se um único vetor estrangeiro vem à tona, a fronteira está quebrada, e preferimos aprender isso de um teste do que de um cliente.
Ela roda como parte da mesma suíte de isolamento que a sonda relacional, porque para nós elas são o mesmo requisito vestindo dois fantasias. Uma fronteira de tenant que segura no banco de dados e vaza no retrieval não é uma fronteira que em geral segura. É uma fronteira que não segura, testada no único lugar que calhou de passar.
O número que importa aqui não é uma latência ou uma cifra de recall. É zero, zero trechos estrangeiros retornados no caso adversarial cross-tenant, em toda execução, ou o build não sai. Uma taxa de vazamento que é “geralmente zero” é uma taxa de vazamento. Não há quantidade aceitável dos dados de outra pessoa na sua resposta.
A virada: isolamento é uma promessa que você faz a uma pessoa que nunca vai conhecer
Em algum lugar lá adiante de tudo isso há uma pessoa que nunca vai ler seu diagrama de arquitetura e não se importaria se lesse. Ela toca um negócio. Ela coloca seus contratos, suas notas de cliente, suas decisões inacabadas numa ferramenta porque a ferramenta prometeu lembrá-las, e todo o peso dessa promessa é que os dados dela permaneçam dela. Não na maior parte. Não exceto na rara colisão de similaridade. Dela.
Essa promessa não é mantida pela parte do sistema que é fácil de raciocinar. Ela é mantida na parte que falha silenciosamente, a busca que retorna uma resposta linda, relevante, fluente, montada, só desta vez, das palavras da empresa errada. Ninguém pegaria. É exatamente por isso que tem que ser impossível em vez de improvável. Você não pode se apoiar na camada se comportar bem quando o modo de falha é invisível. Você tem que construí-la de forma que os dados estrangeiros nunca estejam na sala.
Projetamos a Apollo de forma que a query de um tenant só é comparada contra os próprios vetores de um tenant, espaço separado, busca escopada, filtro redundante, e um teste adversarial que o prova em todo build. Porque você pode trancar cada tabela e ainda vazar entre orgs na camada de embeddings, e o cliente que recebe de volta o contrato de outra pessoa nunca saberá a diferença entre uma colisão de similaridade e uma traição. Ele só sabe que confiou em você com a coisa que menos podia se dar ao luxo de compartilhar.
Esse é o padrão que mantemos na Apollo Space, uma fronteira que segura nos lugares barulhentos e nos quietos, testada onde ela falharia sem um som. Se você já lançou um sistema multi-tenant e sentiu uma pequena pergunta fria sobre a camada que você protegeu por último, esta é a camada. Vale mais uma olhada antes que alguém que você nunca vai conhecer encontre a porta que você não desenhou.
A Apollo cuida da operação repetitiva da sua empresa pro seu time não precisar.
Entre na lista de espera: acesso antecipado, preço de usuário fundador e um lugar na primeira fila enquanto a gente constrói.
Entrar na lista de esperaO imposto oculto dos agents em paralelo é um diamante de migrations
Seis agents escrevendo para um schema conflitam no banco de dados, não no código, e a CI morre em "multiple heads".
EngenhariaUm orchestrator que não sobrevive ao próprio crash não é um
Um crash que apaga o raciocínio do orchestrator perde a única coisa que você não consegue reconstruir.
EngenhariaColoque um portão determinístico na frente do seu revisor mais esperto
A pega-defeito mais barata é um script burro que checa se duas branches mergeadas ainda sobem antes de qualquer julgamento.