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

PyPI Python CI License Try it in the browser

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.

Same question, two callers. Without the payroll role hr_compensation is absent from the prompt; with it, it is the first table.

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:

esquemaobjetosesquema completo, toda chamadaschemagate, médiaredução
Commerce422.483 tokens60476%
Clinical claims271.56854365%
Claims warehouse (estrela)513.31288073%
Bank ledger and trading392.25563772%
IoT telemetry402.12544879%
Hostile (4 esquemas, cópias de tudo)26016.09544497%

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

  1. Reflete o esquema via SQLAlchemy. Nenhum SQL de fornecedor em lugar algum.
  2. 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_shortfall parece ser sobre "shortfall" quando a coisa que você procuraria, reorder_point, está enterrada no seu SELECT.
  3. 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.
  4. 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.
  5. 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 objetos100%
recall@6, mesmo esquema, perguntas em palavras de negócio50%
recall@6, esquema clínico não relacionado de 27 objetos100%
recall@6, esquema hostil de 260 objetos100%
recall@6, esquema estrela de claims de 51 objetos com 15 cópias de backup/staging100%
recall@6, razão bancária e livro de trading de 39 objetos100%
recall@6, frota de telemetria IoT de 40 objetos100%
tabela real vence sua cópia de backup/staging, 19 casos entre esquemas19/19
recall sem expansão de chave estrangeira93,8%
tokens de prompt, esquema completo em toda chamada2.583
tokens de prompt, média do schemagate631 (−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.

SQLitecertificado, 10/10, no CI
PostgreSQLcertificado, 10/10 no PostgreSQL 16, mais a suíte completa de 260 objetos
Oraclecertificado 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 Serverainda não executado contra uma instância ao vivo
MySQL / MariaDBainda 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:

Deploy to Oracle Cloud

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ãocertificado em SQLite e PostgreSQL
MemoryStoreconcluído
Catalogação por IAconcluído, testado offline contra provedores falsos
CLIconcluído
Studio (schemagate studio, e a demo hospedada)concluído, dirigido por um navegador real em testes
Servidor MCPconcluído, testado através de um cliente MCP real
Retriever LangChainconcluído, testado contra langchain-core
OracleStoreescrito e verificado estaticamente, precisa de uma execução ao vivo
Armazenamento pgvectornã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.