schemagate
Retorna apenas as tabelas do banco de dados que a identidade chamadora pode ler, antes de o modelo ver o esquema - seleção de esquema com escopo de identidade para texto-para-SQL.
Documentação
schemagate
Seleciona o punhado de tabelas que um modelo NL2SQL realmente precisa e nunca mostra tabelas que a pessoa que pergunta não tem permissão de ler.

Mesma pergunta, mesma pessoa. À esquerda: sem papel payroll, hr_compensation está ausente
do prompt — não ranqueada baixo, ausente. À direita: papel adicionado, é a primeira
tabela. Essa decisão acontece antes de qualquer SQL ser escrito.
Experimente no navegador — sem
instalação, sem banco de dados, sem chamada de modelo.
pip install schemagate
schemagate demo
Isso roda contra um esquema de 42 objetos incluído. Sem banco de dados, sem chave, nada para configurar. Depois experimente com as perguntas que as pessoas realmente digitam:
schemagate demo "which customers owe us money"
schemagate demo "salary by employee" # restricted table absent
schemagate demo "salary by employee" --principal okta:hr --role payroll # now it's there
schemagate demo "late shipments by carrier" --prompt # the DDL the model gets
Contra seu próprio banco de dados, é o mesmo formato:
schemagate select "revenue by month" --url postgresql://localhost/app --principal okta:jdoe --role finance
schemagate studio --url postgresql://localhost/app # the same thing, as a page
schemagate studio abre uma página local onde você digita perguntas, alterna os papéis de quem
chama, edita dicas e observa o que chega ao prompt e o que não chega. A mesma
página roda publicamente em https://ashishsinha1602.github.io/schemagate/ nos seis
esquemas incluídos, no seu navegador, sem servidor por trás. O seletor nessa página é um port
em JavaScript desta biblioteca, e um teste roda ambos contra 1.789 casos e exige
ranqueamentos idênticos.
Se você vem do Vanna (arquivado em março de 2026), docs/migrating-from-vanna.md
é a versão curta: o Vanna aplicava identidade quando o SQL executava; o schemagate aplica
antes de o modelo ver o esquema. Seu User mapeia para um Principal em uma
linha.
O que isso economiza
Toda chamada de texto-para-SQL paga pelo esquema no prompt. Despeje tudo
e você paga por cada tabela em cada pergunta; entregue seis tabelas ao modelo e
você paga por seis. Medido nos esquemas de teste, média sobre suas perguntas
douradas, mesmo estimador embutido do tests/bench.py:
| esquema | objetos | esquema completo, toda chamada | schemagate, média | redução |
|---|---|---|---|---|
| Commerce | 42 | 2.483 tokens | 604 | 76% |
| Clinical claims | 27 | 1.568 | 543 | 65% |
| Claims warehouse (estrela) | 51 | 3.312 | 880 | 73% |
| Bank ledger and trading | 39 | 2.255 | 637 | 72% |
| IoT telemetry | 40 | 2.125 | 448 | 79% |
| Hostile (4 esquemas, cópias de tudo) | 260 | 16.095 | 444 | 97% |
A última linha é a que importa: a seleção permanece em torno de seis tabelas não importa o tamanho do esquema, então a economia cresce com o esquema. Bancos de dados reais são a última linha, não a primeira.
Exemplo prático, com um preço que você deve substituir pelo seu: um esquema de 260 objetos, 5.000 perguntas por dia, preço de entrada de $3 por milhão de tokens. Esquema completo: 16.095 × 5.000 × 30 = 2,4 bilhões de tokens por mês, cerca de $7.200. Com schemagate: 444 × 5.000 × 30 = 67 milhões, cerca de $200. A demonstração no navegador tem esses dois números como campos editáveis sob as estatísticas, para você inserir seu próprio volume e preço e ver recalcular contra qualquer pergunta que fizer.
Mais duas coisas que não custam nada aqui e custam dinheiro em outros lugares: o seletor em si nunca chama um modelo (BM25 mais um embedder com hash, offline, milissegundos), e as descrições opcionais podem ser escritas por qualquer janela de chat que você já paga em vez de uma chave de API — veja Sem chave de API.
O problema que isso resolve
Duas coisas dão errado quando você aponta um LLM para um esquema de banco de dados.
A primeira é o custo. A maioria dos sistemas cola o esquema inteiro no prompt em cada pergunta. Isso é aceitável para vinte tabelas e ruinoso para duas mil.
A segunda é pior, e é a razão pela qual escrevi isso. A seleção de esquema acontece antes de a consulta rodar, então acontece antes de a segurança em nível de linha poder fazer qualquer coisa. Se sua etapa de seleção não está ciente de identidade, o modelo recebe uma tabela que quem chama não pode ler. Ele escreve SQL perfeitamente bom. RLS ou VPD filtra cada linha. O usuário vê "nenhum registro encontrado" e acredita.
Isso não é uma mensagem de acesso negado. É uma resposta errada com tom confiante, e o usuário não tem como distinguir. Filtrar o catálogo por identidade primeiro é a única maneira que conheço de evitar isso.
from schemagate import Catalog, Principal
cat = Catalog().bootstrap("postgresql://localhost/app")
cat.hint("invoice_draft", "pre-issue drafts only, not real revenue")
cat.restrict("hr_compensation", ["payroll"])
sel = cat.select("revenue by month", top_k=6,
principal=Principal("okta:jdoe", roles={"finance"}))
sel.prompt_fragment() # compact DDL, ready for the system prompt
sel.object_list # [{'owner': ..., 'name': ...}]
sel.explain() # why each object was picked
hr_compensation não está nesse resultado e seu nome não aparece em nenhum lugar
no texto do prompt.
Instalação
pip install schemagate
Isso é tudo. Uma dependência (SQLAlchemy), sem chave de API, sem download de modelo. O embedder padrão é um vetorizador de n-gramas com hash que roda offline e dá resultados byte-idênticos em todas as máquinas.
Extras, todos opcionais:
pip install 'schemagate[postgres]' 'schemagate[oracle]'
pip install 'schemagate[mssql]' 'schemagate[mysql]'
pip install 'schemagate[anthropic]' 'schemagate[openai]' 'schemagate[gemini]'
pip install 'schemagate[huggingface]'
Como seleciona
- Reflete o esquema via SQLAlchemy. Nenhum SQL de fornecedor em lugar algum.
- Indexa nomes, colunas, comentários, dicas e definições de views. Isso último
importa mais do que parece: uma view expõe apenas suas colunas de saída, então
v_stock_shortfallparece ser sobre "shortfall" quando a coisa que você procuraria,reorder_point, está enterrada no seu SELECT. - Recupera com fusão de rank recíproco sobre BM25 e similaridade vetorial.
Nenhum dos dois sozinho é bom o suficiente. Vetores perdem identificadores exatos; BM25 perde
"nos devem dinheiro" →
balance. - Percorre chaves estrangeiras para puxar tabelas de junção que a pergunta nunca menciona. Na minha experiência, essa é a maior causa única de SQL gerado que faz parsing mas não roda.
- Aplica a identidade de quem chama em cada etapa acima.
Números
Seis esquemas de teste acompanham a biblioteca. Rode python tests/bench.py e você
recebe tudo isso impresso de volta. TESTING.md é o registro completo do que foi
testado, o que quebrou e o que se descobriu ser o banco de dados em vez do schemagate.
| recall@6, 12 perguntas, esquema de 42 objetos | 100% |
| recall@6, mesmo esquema, perguntas em palavras de negócio | 50% |
| recall@6, esquema clínico não relacionado de 27 objetos | 100% |
| recall@6, esquema hostil de 260 objetos | 100% |
| recall@6, esquema estrela de claims de 51 objetos com 15 cópias de backup/staging | 100% |
| recall@6, razão bancária e livro de trading de 39 objetos | 100% |
| recall@6, frota de telemetria IoT de 40 objetos | 100% |
| tabela real vence sua cópia de backup/staging, 19 casos entre esquemas | 19/19 |
| recall sem expansão de chave estrangeira | 93,8% |
| tokens de prompt, esquema completo em toda chamada | 2.583 |
| tokens de prompt, média do schemagate | 631 (−75,6%) |
As contagens de tokens vêm de um estimador embutido no benchmark para que o número seja
reproduzível sem rede e sem instalação extra. pip install tiktoken e
o mesmo script alterna para contagens exatas de cl100k_base. A proporção se mantém de qualquer
forma.
Seis esquemas em vez de um porque um único esquema cujas perguntas por acaso
compartilham vocabulário com seus próprios nomes de tabela vai lisonjear qualquer recuperador. O
segundo é um domínio completamente diferente. O terceiro é 260 objetos de sabotagem
deliberada: uma cópia _archive e _stg de cada tabela, o mesmo nome de tabela em
três esquemas, uma cadeia de chave estrangeira com 8 níveis, um ciclo de referência, chaves
compostas, uma tabela de 320 colunas, identificadores de 100 caracteres e nomes em espanhol e
japonês. O quarto é um esquema estrela de warehouse de claims construído para que várias
tabelas sejam plausíveis para cada pergunta e uma esteja certa: o mesmo fato em
quatro granularidades, uma dimensão de membro de mudança lenta com tabela de histórico, uma
dimensão de data unida de cinco maneiras diferentes, tabelas de ponte e quinze cópias
_bkp, _old, _v2, _tmp e stg_ das importantes. O quinto é um
banco: um razão em três granularidades, trades versus posições versus liquidações, FX
tanto como tabela diária quanto como view as-of, empréstimos e as tabelas KYC e AML
que a maioria dos chamadores nunca deve ver. O sexto é uma frota IoT: leituras em
granularidades bruta, de um minuto e horária, seis tabelas de partição mensal, um ciclo de vida
de alarme espalhado por três tabelas. Todos os seis são inventados. Nenhum esquema real
de lugar algum está neste repositório.
Essa linha de 50% é a honesta. Leia-a antes de adotar isso.
A linha de 50% e o que fazer sobre ela
O embedder padrão combina subpalavras, não significado. Pergunte por "coisas que estamos
ficando sem" e ele não encontrará v_stock_shortfall, porque essas duas
strings não têm nada em comum. Pergunte sobre stock_shortfall e ele é
excelente.
Se seus usuários digitam perguntas em formato de identificador, você está pronto e nunca precisa de chave de API. Se digitam como pessoas, dê descrições ao catálogo. Há duas maneiras, e nenhuma é obrigatória.
Sem chave de API
Qualquer janela de chat que você já tem — ChatGPT, Gemini, Copilot, um modelo local — pode escrever as descrições. O schemagate dá o prompt e recebe a resposta:
schemagate describe --url postgresql://localhost/app --out prompt.txt
# paste prompt.txt into a chat; save its JSON reply as reply.json
schemagate describe --url postgresql://localhost/app --apply reply.json --config catalog.json
schemagate select --url postgresql://localhost/app "things we're running out of" --config catalog.json
O prompt é apenas metadados — nomes, tipos, comentários, chaves estrangeiras, nunca linhas
— e uma colagem cobre todos os objetos não descritos. A resposta cai no
bloco describe de catalog.json, ao lado dos seus blocos restrict e hint,
e select, studio e o servidor MCP (SCHEMAGATE_CATALOG_CONFIG) todos
leem. Do Python é a mesma ideia: cat.describe_prompt() e
cat.describe({"v_stock_shortfall": "Items below their reorder level."}).
Com sua própria chave
from schemagate.ai import SchemaDescriber, AnthropicProvider
cat.describe(SchemaDescriber(AnthropicProvider(model="claude-sonnet-4-5"),
cache_path=".schemagate-cache.json"))
Uma frase por tabela, escrita pelo modelo, indexada como qualquer outro texto de esquema. No esquema incluído, isso leva a linha de palavras de negócio de 50% para 100% sem mudança nas perguntas em formato de identificador.
Anthropic, OpenAI e Gemini são suportados. Qualquer outra coisa passa por
CallableProvider, que também é sua saída de emergência quando um fornecedor muda seu
SDK e você não quer esperar por um lançamento meu.
from schemagate.ai import (AnthropicProvider, OpenAIProvider, GeminiProvider,
CallableProvider, auto_provider, available_providers)
AnthropicProvider(model="claude-sonnet-4-5") # ANTHROPIC_API_KEY
OpenAIProvider(model="gpt-4.1-mini") # OPENAI_API_KEY
GeminiProvider(model="gemini-2.5-flash") # GEMINI_API_KEY
OpenAIProvider(model="…", base_url="http://localhost:11434/v1") # anything local
CallableProvider(lambda system, prompt: my_llm(system, prompt))
available_providers() # ['AnthropicProvider'] — names, never key values
auto_provider(model="…") # picks whichever key is set
model é obrigatório. Não vou enviar um ID de modelo padrão, porque IDs de modelo
mudam a cada poucos meses e um hardcoded eventualmente dá 404 para todos que
instalaram a versão anterior à correção.
Três coisas que valem saber antes de ativar isso:
O que sai da sua rede. Nomes de tabela, nomes de coluna, tipos, nulabilidade,
comentários existentes, chaves estrangeiras. Nem uma linha de dados — ObjectDoc não tem campo
que pudesse conter uma, e há testes afirmando ambas as metades disso. Nada
é enviado a menos que você chame describe().
Quanto custa. Uma chamada curta por objeto não descrito, uma vez. Objetos que já têm um comentário de banco ou uma dica são pulados por padrão. Os resultados são armazenados em cache por conteúdo, então reexecutar é grátis e apenas tabelas alteradas são re-descritas. Pergunte antes de pagar:
describer.estimate_calls(docs) # calls describe() would actually bill for
describer.preview(doc) # the exact text that would be sent
O que acontece quando falha. O objeto é pulado, a catalogação continua e
describer.failures lista o que foi perdido. Passe strict=True se preferir
que ele levante erro. Um hint() que você escreveu à mão sempre vence uma descrição gerada, então
corrigir uma ruim não custa nada.
Você também pode trocar o embedder por um hospedado, mas faça benchmark primeiro. Em texto de esquema pesado em identificadores, o embedder offline costuma ser igualmente bom e não custa nada por consulta.
from schemagate.ai import APIEmbedder, OpenAIProvider
provider = OpenAIProvider(model="gpt-4.1-mini",
embed_model="text-embedding-3-small")
cat = Catalog(embedder=APIEmbedder(provider, dim=1536))
Bancos de dados
A reflexão usa apenas o Inspector agnóstico de dialeto do SQLAlchemy. Não há
SQL escrito à mão em schemagate.introspect e um teste falha o build se algum
aparecer, então em princípio qualquer dialeto que o SQLAlchemy suporte funcionará.
Em princípio não é evidência, então há um script:
python scripts/certify_dialect.py 'postgresql+psycopg://user:pw@host/db'
python scripts/certify_dialect.py 'oracle+oracledb://user:pw@host:1521/?service_name=FREEPDB1'
python scripts/certify_dialect.py 'mssql+pyodbc://user:pw@host/db?driver=ODBC+Driver+18+for+SQL+Server'
python scripts/certify_dialect.py 'mysql+pymysql://user:pw@host/db'
Ele cria três tabelas schemagate_cert_, reflete-as, roda seleção e
escopo de identidade de ponta a ponta, remove-as novamente e sai com código não zero se algo
falhou. Aponte-o para um esquema de rascunho.
| SQLite | certificado, 10/10, no CI |
| PostgreSQL | certificado, 10/10 no PostgreSQL 16, mais a suíte completa de 260 objetos |
| Oracle | certificado ao vivo no Oracle AI Database 26ai (Autonomous Database), set 2026: script de certificação 10/10, a suíte de conformidade nativa de armazenamento VECTOR(512, FLOAT32) e a suíte de dialeto. Também testado sob estresse contra um esquema de 127 objetos e 3 domínios com ~7M linhas |
| SQL Server | ainda não executado contra uma instância ao vivo |
| MySQL / MariaDB | ainda não executado contra uma instância ao vivo |
As duas últimas dizem o que dizem porque não tive uma instância ao vivo para executá-las, não porque espero problemas. Rode o script e me conte o que acontece.
As mesmas verificações rodam sob pytest se você exportar uma URL, que é como o CI certifica um dialeto para valer:
export SCHEMAGATE_POSTGRES_URL='postgresql+psycopg://…'
export SCHEMAGATE_ORACLE_URL='oracle+oracledb://…'
export SCHEMAGATE_MSSQL_URL='mssql+pyodbc://…'
export SCHEMAGATE_MYSQL_URL='mysql+pymysql://…'
pytest tests/test_dialects.py -v
Usando a partir de um agente
Se você já tem um agente que escreve SQL, o caminho mais rápido é fazer com que ele chame o schemagate como uma ferramenta, em vez de integrar a biblioteca ao seu código.
MCP. Cursor, Windsurf, Zed ou qualquer outra coisa que fale o Model Context Protocol:
pip install 'schemagate[mcp]'
SCHEMAGATE_DATABASE_URL=postgresql://localhost/app python -m schemagate.mcp_server
Configuração do cliente MCP:
{"mcpServers": {"schemagate": {
"command": "python", "args": ["-m", "schemagate.mcp_server"],
"env": {"SCHEMAGATE_DATABASE_URL": "postgresql://localhost/app"}}}}
Três ferramentas: select_schema (o DDL para uma pergunta, com escopo para o chamador),
list_objects (o que este chamador pode ver), describe_object (o DDL completo de um
objeto). Todas as três recebem principal e roles. Se o cliente as omitir,
o chamador é anônimo e vê apenas objetos sem restrição. Um objeto
restrito e um ausente retornam o mesmo erro, então a existência não vaza.
SCHEMAGATE_DATABASE_URL=demo serve o esquema incluído.
Para hospedá-lo para uma equipe em vez de um único desktop:
SCHEMAGATE_MCP_TRANSPORT=streamable-http SCHEMAGATE_MCP_PORT=8765 python -m schemagate.mcp_server
Ele foi construído para não morrer. O índice vive na memória após a inicialização, então
a queda do banco de dados não derruba o servidor junto — select_schema continua
respondendo com base na última reflexão válida, e refresh_catalog relata a
falha em vez de lançar uma exceção. Cada ferramenta captura tudo e retorna
{"error": ...}; uma solicitação inválida não pode encerrar a sessão para outros clientes.
health informa a um balanceador de carga em que estado ele está. Um teste lança 125 tipos de
lixo em cada ferramenta e depois verifica se a próxima solicitação válida ainda funciona, e
outro faz o mesmo através de um cliente real via stdio. Funciona no MCP SDK 1.x
e 2.x; a renomeação da versão 2.0 quebrou uma instalação nova uma vez, e agora há um shim e um
teste para isso.
LangChain. Um BaseRetriever adequado, então ele compõe:
pip install 'schemagate[langchain]'
from schemagate.integrations.langchain import SchemagateRetriever, prompt_fragment
retriever = SchemagateRetriever(catalog=cat, top_k=6,
principal=Principal("okta:jdoe", roles={"finance"}))
chain = retriever | RunnableLambda(prompt_fragment) | your_sql_prompt | llm
O principal é vinculado na construção de propósito. Crie um retriever por chamador; uma cadeia não pode esquecer de passar a identidade se o retriever já a tiver.
No Oracle Cloud
Certificado ao vivo no Oracle AI Database 26ai. Duas formas de acesso, nenhuma das quais precisa de uma chave de API — a catalogação roda no OCI Generative AI sob sua própria identidade OCI, então os prompts (somente metadados de esquema, nunca linhas) permanecem no seu tenancy.
Do Cloud Shell, cerca de um minuto, sem VM:
pip install --user 'schemagate[oracle,oci]'
schemagate describe --url 'oracle+oracledb://@' --provider oci \
--model google.gemini-2.5-pro --config catalog.json
Ou com um clique, para um endpoint MCP que permanece ativo para sua equipe:
Isso abre o Resource Manager no seu próprio tenancy com a stack carregada — uma
VM elegível para Always-Free executando o servidor MCP contra um Autonomous Database
que ela cria, ou um que você já tenha. Detalhes e o Terraform: oci/.
Mantendo o índice no Oracle
MemoryStore reconstrói a cada início de processo. Tudo bem para algumas centenas de objetos,
errado para um serviço de longa duração. OracleStore mantém vetores no tipo nativo
VECTOR do Oracle 23ai, para que a busca pelo vizinho mais próximo rode no banco de dados:
from schemagate.stores.oracle import OracleStore
store = OracleStore(dsn="user/pw@host:1521/FREEPDB1", dim=512)
store.create_schema() # idempotent
cat = Catalog(store=store).bootstrap("oracle+oracledb://…")
Passe connection= em vez de dsn= para reutilizar o pool do seu aplicativo. Ele não fechará uma
conexão que não abriu.
O escopo é um predicado dentro da subconsulta pontuada, não um filtro aplicado depois que as linhas voltam. Uma linha que o chamador não pode ver nunca é classificada e nunca sai do banco de dados.
Mesma ressalva de antes: 26 testes fixam o SQL, os tipos de bind e o predicado de escopo, e cada declaração é verificada contra um parser Oracle independente, mas nada disso foi executado contra uma instância 23ai ao vivo ainda. Para fazer isso:
export SCHEMAGATE_ORACLE_DSN='user/password@host:1521/FREEPDB1'
pytest tests/test_store_conformance.py -v
O Always Free ATP do Oracle Cloud é suficiente.
Coisas que vão te morder
Gêmeos de arquivo e staging são tratados, mas saiba como. Se o seu warehouse tiver
orders, orders_bkp e stg_orders, as cópias carregam as mesmas palavras do nome em um
documento mais curto, e a similaridade de cosseno gosta de documentos curtos. Deixado
sozinho, uma cópia de três colunas _tmp vence a tabela de vinte e cinco colunas da qual foi copiada,
mesmo com uma dica na real — eu vi acontecer. Então um objeto
cujo nome é o nome de um objeto real mais _bkp, _old, _tmp, _v2,
_archive e assim por diante, ou stg_/tmp_ na frente, é classificado abaixo do objeto que
ele sombreia. Somente quando esse objeto existe: um pricing_v2 solitário sem pricing
é deixado em paz. Somente no mesmo esquema. E nunca quando você nomeia a cópia
diretamente — pedir por fact_claim_line_v2 te dá fact_claim_line_v2. As
listas são DEFAULT_SHADOW_SUFFIXES e DEFAULT_SHADOW_PREFIXES; passe as
suas para Catalog(...), ou tuplas vazias para desligar. cat.shadows() mostra
o que foi detectado.
Comprimento do identificador. O PostgreSQL trunca nomes para 63 bytes na criação. Isso é o banco de dados fazendo isso, não o schemagate, e não há nada a ser feito deste lado.
Esquemas não-ingleses funcionam, incluindo chinês, japonês e coreano, e
acentos dobram nos dois sentidos, então uma busca por facturacion encontra facturación. Mas uma
pergunta em inglês não encontrará uma tabela nomeada em espanhol. Nada lexical pode
superar isso. Descrições podem.
top_k não é um limite rígido. A expansão de chave estrangeira roda após a seleção e
adiciona tabelas de junção por cima. Isso é deliberado — SQL que referencia uma tabela que você
não incluiu não será executado — mas dimensione seu orçamento de prompt para isso.
Status
v0.1. Alpha, e a API ainda pode mudar.
| Reflexão | certificado em SQLite e PostgreSQL |
MemoryStore | concluído |
| Catalogação por IA | concluído, testado offline contra provedores falsos |
| CLI | concluído |
Studio (schemagate studio, e a demo hospedada) | concluído, dirigido por um navegador real em testes |
| Servidor MCP | concluído, testado através de um cliente MCP real |
| Retriever LangChain | concluído, testado contra langchain-core |
OracleStore | escrito e verificado estaticamente, precisa de uma execução ao vivo |
| Armazenamento pgvector | não iniciado |
import schemagate nunca importa nenhum SDK de provedor, e há um teste que afirma isso.
As embeddings padrão são estáveis entre processos, máquinas e versões de Python,
então vetores em cache ou persistidos permanecem válidos. Isso é garantido por um teste que
executa o embedder em subprocessos novos sob diferentes valores de PYTHONHASHSEED,
porque foi quebrado uma vez e nada mais pegou.
Apache-2.0. Ashish Sinha.