schemagate
Devuelve solo las tablas de la base de datos que la identidad que llama puede leer, antes de que el modelo vea el esquema: selección de esquema con alcance de identidad para texto a SQL.
Documentación
schemagate
Selecciona el puñado de tablas que un modelo NL2SQL realmente necesita, y nunca le muestra tablas que la persona que pregunta no tiene permiso de leer.

Misma pregunta, misma persona. Izquierda: sin rol payroll, hr_compensation está ausente
del prompt — no aparece en último lugar, está ausente. Derecha: con el rol añadido, es la primera
tabla. Esa decisión ocurre antes de que se escriba cualquier SQL.
Pruébalo en el navegador — sin
instalación, sin base de datos, sin llamada a un modelo.
pip install schemagate
schemagate demo
Eso se ejecuta contra un esquema incluido de 42 objetos. Sin base de datos, sin clave, nada que configurar. Luego pruébalo con las preguntas que la gente realmente escribe:
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 tu propia base de datos tiene la misma forma:
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 una página local donde escribes preguntas, cambias los roles del
solicitante, editas pistas y observas qué llega al prompt y qué no. La misma
página se ejecuta públicamente en https://ashishsinha1602.github.io/schemagate/ sobre los seis
esquemas incluidos, en tu navegador, sin servidor detrás. El selector en esa página es un puerto
en JavaScript de esta biblioteca, y una prueba ejecuta ambos contra 1,789 casos y exige
clasificaciones idénticas.
Si vienes de Vanna (archivado en marzo de 2026), docs/migrating-from-vanna.md
es la versión corta: Vanna aplicaba la identidad cuando el SQL se ejecutaba; schemagate la aplica
antes de que el modelo vea el esquema. Tu User se asigna a un Principal en una
línea.
Lo que ahorra
Cada llamada de texto a SQL paga por el esquema en el prompt. Vuelca todo
y pagas por cada tabla en cada pregunta; dale al modelo seis tablas y
pagas por seis. Medido en los esquemas de prueba, promedio sobre sus preguntas
doradas, con el mismo estimador integrado que tests/bench.py:
| esquema | objetos | esquema completo, cada llamada | schemagate, promedio | reducción |
|---|---|---|---|---|
| Commerce | 42 | 2,483 tokens | 604 | 76% |
| Clinical claims | 27 | 1,568 | 543 | 65% |
| Claims warehouse (estrella) | 51 | 3,312 | 880 | 73% |
| Bank ledger and trading | 39 | 2,255 | 637 | 72% |
| IoT telemetry | 40 | 2,125 | 448 | 79% |
| Hostil (4 esquemas, copias de todo) | 260 | 16,095 | 444 | 97% |
La última fila es la que importa: la selección se mantiene alrededor de seis tablas sin importar cuán grande sea el esquema, así que el ahorro crece con el esquema. Las bases de datos reales son la última fila, no la primera.
Ejemplo práctico, con un precio que deberías reemplazar por el tuyo: un esquema de 260 objetos, 5,000 preguntas al día, un precio de entrada de $3 por millón de tokens. Esquema completo: 16,095 × 5,000 × 30 = 2.4 mil millones de tokens al mes, unos $7,200. Con schemagate: 444 × 5,000 × 30 = 67 millones, unos $200. La demostración en el navegador tiene estos dos números como campos editables bajo las estadísticas, para que pongas tu propio volumen y precio y veas cómo se recalcula según la pregunta que hagas.
Dos cosas más que no cuestan nada aquí y dinero en otros lugares: el selector en sí nunca llama a un modelo (BM25 más un embedder con hash, sin conexión, milisegundos), y las descripciones opcionales pueden ser escritas por cualquier ventana de chat que ya pagas en lugar de una clave API — ver Sin clave API.
El problema que resuelve
Dos cosas salen mal cuando apuntas un LLM a un esquema de base de datos.
La primera es el costo. La mayoría de los sistemas pegan todo el esquema en el prompt en cada pregunta. Eso está bien para veinte tablas y es ruinoso para dos mil.
La segunda es peor, y es la razón por la que escribí esto. La selección de esquema ocurre antes de que la consulta se ejecute, así que ocurre antes de que la seguridad a nivel de fila pueda hacer algo. Si tu paso de selección no es consciente de la identidad, el modelo recibe una tabla que el solicitante no puede leer. Escribe SQL perfectamente bueno. RLS o VPD filtra cada fila. El usuario ve "no se encontraron registros" y lo cree.
Eso no es un mensaje de acceso denegado. Es una respuesta incorrecta con un tono seguro, y el usuario no tiene forma de notar la diferencia. Filtrar el catálogo por identidad primero es la única forma que conozco de evitarlo.
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 no está en ese resultado y su nombre no aparece en ningún lugar
del texto del prompt.
Instalación
pip install schemagate
Eso es todo. Una dependencia (SQLAlchemy), sin clave API, sin descarga de modelo. El embedder predeterminado es un vectorizador de n-gramas con hash que se ejecuta sin conexión y da resultados byte-idénticos en cada máquina.
Extras, todos opcionales:
pip install 'schemagate[postgres]' 'schemagate[oracle]'
pip install 'schemagate[mssql]' 'schemagate[mysql]'
pip install 'schemagate[anthropic]' 'schemagate[openai]' 'schemagate[gemini]'
pip install 'schemagate[huggingface]'
Cómo selecciona
- Refleja el esquema a través de SQLAlchemy. Sin SQL de proveedor en ningún lugar.
- Indexa nombres, columnas, comentarios, pistas y definiciones de vistas. Eso último
importa más de lo que parece: una vista expone solo sus columnas de salida, así que
v_stock_shortfallparece que trata sobre "shortfall" cuando lo que buscarías,reorder_point, está enterrado en su SELECT. - Recupera con fusión de rango recíproco sobre BM25 y similitud vectorial.
Ninguno solo es suficiente. Los vectores pierden identificadores exactos; BM25 pierde
"nos deben dinero" →
balance. - Recorre claves foráneas para traer tablas de unión que la pregunta nunca menciona. En mi experiencia, esta es la causa individual más grande de SQL generado que se analiza pero no se ejecuta.
- Aplica la identidad del solicitante en cada paso anterior.
Números
Seis esquemas de prueba se incluyen con la biblioteca. Ejecuta python tests/bench.py y
obtienes todo esto impreso. TESTING.md es el registro completo de lo que se
probó, lo que falló y lo que resultó ser la base de datos en lugar de schemagate.
| recall@6, 12 preguntas, esquema de 42 objetos | 100% |
| recall@6, mismo esquema, preguntas formuladas en palabras de negocio | 50% |
| recall@6, esquema clínico no relacionado de 27 objetos | 100% |
| recall@6, esquema hostil de 260 objetos | 100% |
| recall@6, esquema estrella de reclamos de 51 objetos con 15 copias de respaldo/ensayo | 100% |
| recall@6, libro de contabilidad y trading bancario de 39 objetos | 100% |
| recall@6, flota de telemetría IoT de 40 objetos | 100% |
| la tabla real supera a su copia de respaldo/ensayo, 19 casos entre esquemas | 19/19 |
| recall sin expansión de claves foráneas | 93.8% |
| tokens del prompt, esquema completo en cada llamada | 2,583 |
| tokens del prompt, promedio de schemagate | 631 (−75.6%) |
Los conteos de tokens provienen de un estimador integrado en el benchmark para que el número sea
reproducible sin red y sin instalación adicional. pip install tiktoken y
el mismo script cambia a conteos exactos de cl100k_base. La proporción se mantiene de cualquier
forma.
Seis esquemas en lugar de uno porque un solo esquema cuyas preguntas casualmente
comparten vocabulario con sus propios nombres de tablas halagará a cualquier recuperador. El
segundo es un dominio completamente diferente. El tercero son 260 objetos de sabotaje
deliberado: una copia _archive y _stg de cada tabla, el mismo nombre de tabla en
tres esquemas, una cadena de claves foráneas de 8 niveles, un ciclo de referencia, claves compuestas,
una tabla de 320 columnas, identificadores de 100 caracteres y nombres en español y
japonés. El cuarto es un esquema estrella de almacén de reclamos construido para que varias
tablas sean plausibles para cada pregunta y una sea la correcta: el mismo hecho en
cuatro granularidades, una dimensión de miembro de cambio lento con una tabla de historial, una
dimensión de fecha unida de cinco formas diferentes, tablas puente y quince copias _bkp,
_old, _v2, _tmp y stg_ de las importantes. El quinto es un
banco: un libro mayor en tres granularidades, operaciones versus posiciones versus liquidaciones,
FX tanto como tabla diaria como vista as-of, préstamos y las tablas KYC y AML
que la mayoría de los solicitantes nunca deben ver. El sexto es una flota IoT: lecturas en
granularidades cruda, de un minuto y por hora, seis tablas de partición mensual, un ciclo de vida
de alarmas repartido en tres tablas. Los seis son inventados. Ningún esquema real
de ningún lugar está en este repositorio.
Esa fila del 50% es la honesta. Léela antes de adoptar esto.
La fila del 50%, y qué hacer al respecto
El embedder predeterminado coincide con subpalabras, no con significado. Pídele "cosas de las que
nos estamos quedando sin" y no encontrará v_stock_shortfall, porque esas dos
cadenas no tienen nada en común. Pregúntale sobre stock_shortfall y es
excelente.
Si tus usuarios escriben preguntas con forma de identificador, ya terminaste y nunca necesitas una clave API. Si escriben como personas, dale descripciones al catálogo. Hay dos formas, y ninguna es obligatoria.
Sin clave API
Cualquier ventana de chat que ya tengas — ChatGPT, Gemini, Copilot, un modelo local — puede escribir las descripciones. schemagate te da el prompt y toma la respuesta:
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
El prompt es solo metadatos — nombres, tipos, comentarios, claves foráneas, nunca filas
— y un pegado cubre cada objeto no descrito. La respuesta aterriza en el
bloque describe de catalog.json, junto a tus bloques restrict y hint,
y select, studio y el servidor MCP (SCHEMAGATE_CATALOG_CONFIG) todos lo
leen. Desde Python es la misma idea: cat.describe_prompt() y
cat.describe({"v_stock_shortfall": "Items below their reorder level."}).
Con tu propia clave
from schemagate.ai import SchemaDescriber, AnthropicProvider
cat.describe(SchemaDescriber(AnthropicProvider(model="claude-sonnet-4-5"),
cache_path=".schemagate-cache.json"))
Una oración por tabla, escrita por el modelo, indexada como cualquier otro texto de esquema. En el esquema incluido, eso lleva la fila de palabras de negocio del 50% al 100% sin cambios en las preguntas con estilo de identificador.
Anthropic, OpenAI y Gemini son compatibles. Cualquier otra cosa pasa por
CallableProvider, que también es tu vía de escape cuando un proveedor cambia su
SDK y no quieres esperar una versión mía.
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 es obligatorio. No voy a incluir un ID de modelo predeterminado, porque los IDs de modelo
cambian cada pocos meses y uno codificado eventualmente da 404 para todos los que
instalaron la versión anterior a la corrección.
Tres cosas que vale la pena saber antes de activar esto:
Qué sale de tu red. Nombres de tablas, nombres de columnas, tipos, nulabilidad,
comentarios existentes, claves foráneas. Ni una fila de datos — ObjectDoc no tiene un campo
que pueda contener una, y hay pruebas que afirman ambas mitades de eso. Nada
se envía a menos que llames a describe().
Cuánto cuesta. Una llamada corta por objeto no descrito, una vez. Los objetos que ya tienen un comentario de base de datos o una pista se omiten por defecto. Los resultados se almacenan en caché por contenido, así que volver a ejecutar es gratis y solo las tablas cambiadas se vuelven a describir. Pregunta antes de pagar:
describer.estimate_calls(docs) # calls describe() would actually bill for
describer.preview(doc) # the exact text that would be sent
Qué pasa cuando falla. El objeto se omite, el catalogado continúa y
describer.failures lista lo que se perdió. Pasa strict=True si prefieres
que lance una excepción. Un hint() que escribiste a mano siempre supera a una descripción generada, así que
arreglar una mala no cuesta nada.
También puedes cambiar el embedder por uno alojado, pero primero haz un benchmark. En texto de esquema con muchos identificadores, el embedder sin conexión a menudo es igual de bueno y no cuesta 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))
Bases de datos
La reflexión usa solo el Inspector agnóstico de dialecto de SQLAlchemy. No hay
SQL escrito a mano en schemagate.introspect y una prueba hace fallar la compilación si aparece
alguno, así que en principio cualquier dialecto que SQLAlchemy soporte funcionará.
En principio no es evidencia, así que hay un 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'
Crea tres tablas schemagate_cert_, las refleja, ejecuta la selección y el
alcance de identidad de principio a fin, las elimina de nuevo y sale con código no cero si algo
falló. Apúntalo a un esquema de prueba.
| SQLite | certificado, 10/10, en CI |
| PostgreSQL | certificado, 10/10 en PostgreSQL 16, más la suite completa de 260 objetos |
| Oracle | certificado en vivo en Oracle AI Database 26ai (Autonomous Database), septiembre de 2026: script de certificación 10/10, la suite de conformidad del almacén VECTOR(512, FLOAT32) nativo y la suite de dialectos. También probado bajo estrés contra un esquema de 127 objetos y 3 dominios con ~7M de filas |
| SQL Server | aún no ejecutado contra una instancia en vivo |
| MySQL / MariaDB | aún no ejecutado contra una instancia en vivo |
Las dos últimas filas dicen lo que dicen porque no he tenido una instancia en vivo contra la cual ejecutarlas, no porque espere problemas. Ejecuta el script y cuéntame qué pasa.
Las mismas verificaciones se ejecutan bajo pytest si exportas una URL, que es como CI certifica un dialecto para siempre:
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
Usándolo desde un agente
Si ya tienes un agente que escribe SQL, la forma más rápida de integrarlo es hacer que llame a schemagate como una herramienta en lugar de conectar la biblioteca a tu código.
MCP. Cursor, Windsurf, Zed o cualquier otra cosa que hable el Protocolo de Contexto de Modelos:
pip install 'schemagate[mcp]'
SCHEMAGATE_DATABASE_URL=postgresql://localhost/app python -m schemagate.mcp_server
Configuración del cliente MCP:
{"mcpServers": {"schemagate": {
"command": "python", "args": ["-m", "schemagate.mcp_server"],
"env": {"SCHEMAGATE_DATABASE_URL": "postgresql://localhost/app"}}}}
Tres herramientas: select_schema (el DDL para una pregunta, limitado al llamador),
list_objects (lo que este llamador puede ver), describe_object (el DDL completo de un
objeto). Las tres aceptan principal y roles. Si el cliente las omite,
el llamador es anónimo y solo ve objetos sin restricciones. Un objeto
restringido y uno inexistente devuelven el mismo error, por lo que la existencia no se filtra.
SCHEMAGATE_DATABASE_URL=demo sirve el esquema incluido.
Para alojarlo para un equipo en lugar de un escritorio:
SCHEMAGATE_MCP_TRANSPORT=streamable-http SCHEMAGATE_MCP_PORT=8765 python -m schemagate.mcp_server
Está diseñado para no morir. El índice vive en memoria después del inicio, por lo que
la caída de la base de datos no se lleva el servidor consigo — select_schema sigue
respondiendo desde la última reflexión válida, y refresh_catalog informa del
fallo en lugar de lanzar una excepción. Cada herramienta captura todo y devuelve
{"error": ...}; una solicitud incorrecta no puede terminar la sesión para otros clientes.
health le dice a un balanceador de carga en qué estado está. Una prueba lanza 125 tipos de
basura a cada herramienta y luego verifica que la siguiente solicitud válida siga funcionando, y
otra hace lo mismo a través de un cliente real por stdio. Funciona con MCP SDK 1.x
y 2.x; el cambio de nombre en 2.0 rompió una instalación nueva una vez y ahora hay un shim y una
prueba para ello.
LangChain. Un BaseRetriever adecuado, por lo que se compone:
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
El principal se vincula en la construcción a propósito. Construye un recuperador por llamador; una cadena no puede olvidar pasar la identidad si el recuperador ya la tiene.
En Oracle Cloud
Certificado en vivo en Oracle AI Database 26ai. Dos formas de entrar, ninguna de las cuales necesita una clave API — el catálogo se ejecuta en OCI Generative AI bajo tu propia identidad OCI, por lo que los prompts (solo metadatos de esquema, nunca filas) permanecen en tu tenancy.
Desde Cloud Shell, alrededor de un minuto, sin VM:
pip install --user 'schemagate[oracle,oci]'
schemagate describe --url 'oracle+oracledb://@' --provider oci \
--model google.gemini-2.5-pro --config catalog.json
O con un clic, para un endpoint MCP que permanezca activo para tu equipo:
Eso abre Resource Manager en tu propio tenancy con la pila cargada — una
VM elegible para Always-Free que ejecuta el servidor MCP contra una Autonomous Database
que crea, o una que ya tengas. Detalles y el Terraform: oci/.
Mantener el índice en Oracle
MemoryStore se reconstruye en cada inicio de proceso. Está bien para unos cientos de objetos,
pero es incorrecto para un servicio de larga duración. OracleStore mantiene los vectores en el tipo
nativo VECTOR de Oracle 23ai para que la búsqueda del vecino más cercano se ejecute en la base de datos:
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://…")
Pasa connection= en lugar de dsn= para reutilizar el pool de tu aplicación. No cerrará una
conexión que no abrió.
El alcance es un predicado dentro de la subconsulta puntuada, no un filtro aplicado después de que lleguen las filas. Una fila que el llamador no puede ver nunca se clasifica y nunca sale de la base de datos.
Misma advertencia que arriba: 26 pruebas fijan el SQL, los tipos de bind y el predicado de alcance, y cada declaración se verifica contra un parser independiente de Oracle, pero nada de eso se ha ejecutado contra una instancia 23ai en vivo todavía. Para hacerlo:
export SCHEMAGATE_ORACLE_DSN='user/password@host:1521/FREEPDB1'
pytest tests/test_store_conformance.py -v
El Always Free ATP de Oracle Cloud es suficiente.
Cosas que te morderán
Los gemelos de archivo y staging están manejados, pero debes saber cómo. Si tu almacén tiene
orders, orders_bkp y stg_orders, las copias llevan las mismas palabras de nombre en un
documento más corto, y la similitud de coseno prefiere documentos cortos. Sin intervención,
una copia de tres columnas de _tmp supera a la tabla de veinticinco columnas de la que se copió,
incluso con una pista en la real — lo vi suceder. Así que un objeto
cuyo nombre es el nombre de un objeto real más _bkp, _old, _tmp, _v2,
_archive y así sucesivamente, o stg_/tmp_ al frente, se clasifica por debajo del objeto que
sombrea. Solo cuando ese objeto existe: un pricing_v2 solitario sin pricing
se deja solo. Solo en el mismo esquema. Y nunca cuando nombras la copia
directamente — pedir fact_claim_line_v2 te da fact_claim_line_v2. Las
listas son DEFAULT_SHADOW_SUFFIXES y DEFAULT_SHADOW_PREFIXES; pasa las
tuyas a Catalog(...), o tuplas vacías para desactivarlo. cat.shadows() muestra
lo que se detectó.
Longitud del identificador. PostgreSQL trunca los nombres a 63 bytes en la creación. Eso lo hace la base de datos, no schemagate, y no hay nada que hacer desde este lado.
Esquemas no ingleses funcionan, incluidos chino, japonés y coreano, y
los acentos se pliegan en ambas direcciones, por lo que una búsqueda de facturacion encuentra facturación. Pero una
pregunta en inglés no encontrará una tabla nombrada en español. Nada léxico puede
salvar esa brecha. Las descripciones sí pueden.
top_k no es un límite duro. La expansión de claves foráneas se ejecuta después de la selección y
agrega tablas de unión encima. Eso es deliberado — el SQL que referencia una tabla que
no incluiste no se ejecutará — pero dimensiona tu presupuesto de prompt para ello.
Estado
v0.1. Alfa, y la API aún puede moverse.
| Reflexión | certificada en SQLite y PostgreSQL |
MemoryStore | hecho |
| Catalogación con IA | hecho, probado offline contra proveedores falsos |
| CLI | hecho |
Studio (schemagate studio, y la demo alojada) | hecho, impulsado por un navegador real en pruebas |
| Servidor MCP | hecho, probado a través de un cliente MCP real |
OracleStore | escrito y verificado estáticamente, necesita una ejecución en vivo |
| Almacén pgvector | no iniciado |
import schemagate nunca importa ningún SDK de proveedor, y hay una prueba que lo afirma.
Los embeddings predeterminados son estables entre procesos, máquinas y versiones de Python,
por lo que los vectores en caché o persistidos siguen siendo válidos. Eso está garantizado por una prueba que
ejecuta el embedder en subprocesos nuevos bajo diferentes valores de PYTHONHASHSEED,
porque se rompió una vez y nada más lo detectó.
Apache-2.0. Ashish Sinha.