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

PyPI Python CI License Try it in the browser

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.

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

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:

esquemaobjetosesquema completo, cada llamadaschemagate, promedioreducción
Commerce422,483 tokens60476%
Clinical claims271,56854365%
Claims warehouse (estrella)513,31288073%
Bank ledger and trading392,25563772%
IoT telemetry402,12544879%
Hostil (4 esquemas, copias de todo)26016,09544497%

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

  1. Refleja el esquema a través de SQLAlchemy. Sin SQL de proveedor en ningún lugar.
  2. 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_shortfall parece que trata sobre "shortfall" cuando lo que buscarías, reorder_point, está enterrado en su SELECT.
  3. 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.
  4. 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.
  5. 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 objetos100%
recall@6, mismo esquema, preguntas formuladas en palabras de negocio50%
recall@6, esquema clínico no relacionado de 27 objetos100%
recall@6, esquema hostil de 260 objetos100%
recall@6, esquema estrella de reclamos de 51 objetos con 15 copias de respaldo/ensayo100%
recall@6, libro de contabilidad y trading bancario de 39 objetos100%
recall@6, flota de telemetría IoT de 40 objetos100%
la tabla real supera a su copia de respaldo/ensayo, 19 casos entre esquemas19/19
recall sin expansión de claves foráneas93.8%
tokens del prompt, esquema completo en cada llamada2,583
tokens del prompt, promedio de schemagate631 (−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.

SQLitecertificado, 10/10, en CI
PostgreSQLcertificado, 10/10 en PostgreSQL 16, más la suite completa de 260 objetos
Oraclecertificado 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 Serveraún no ejecutado contra una instancia en vivo
MySQL / MariaDBaú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:

Deploy to Oracle Cloud

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óncertificada en SQLite y PostgreSQL
MemoryStorehecho
Catalogación con IAhecho, probado offline contra proveedores falsos
CLIhecho
Studio (schemagate studio, y la demo alojada)hecho, impulsado por un navegador real en pruebas
Servidor MCPhecho, probado a través de un cliente MCP real
OracleStoreescrito y verificado estáticamente, necesita una ejecución en vivo
Almacén pgvectorno 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.