Módulo 2: Darle el esquema al LLM
Descripciones de columnas: centavos y dominios
Descripción
El esquema serializado —DDL o forma compacta— le dice al modelo cómo se llaman las columnas y de qué tipo son. Pero hay una capa de significado que ni el nombre ni el tipo capturan: que price_cents es un INTEGER, sí, pero en centavos; que status es TEXT, pero solo vale 'confirmed' o 'cancelled'. Esa capa es la de las descripciones de columnas, y es donde un contexto pobre se convierte en uno rico.
En esta cápsula vemos dos tipos de descripción que marcan la diferencia entre un SQL correcto y uno que corre pero miente: la unidad (centavos) y el dominio (los valores admitidos). Y lo vemos ejecutado: el mismo modelo, con y sin la nota, produciendo SQL que da resultados distintos.
Conexión con el módulo: las cápsulas 02 y 03 mostraron la brecha y el esqueleto del contexto. Esta añade la carne semántica —lo que el esquema no declara pero el modelo necesita saber—. Es, junto con las relaciones (cápsula 06), lo que más eleva la calidad del SQL generado.
Una analogía: la receta que dice "2 tazas" pero no de qué
Una receta que dice "añade 2 de harina" es ambigua: ¿dos tazas, dos kilos, dos cucharadas? El número está, la unidad falta, y el pastel sale mal. Peor aún si la receta dice "hornea hasta que esté listo" sin decir a qué temperatura ni cuánto tiempo: cada quien interpreta "listo" a su manera.
El esquema sin descripciones es esa receta. price_cents INTEGER es "2 de harina": hay un número, pero el modelo no sabe si son centavos o dólares, y si asume mal, la respuesta sale con un factor de 100 de error. status TEXT es "hasta que esté listo": el modelo no sabe qué valores cuentan como "listo", y adivina 'active' cuando la cocina usa 'confirmed'. Las descripciones son las que ponen la unidad y el dominio: "2 tazas de harina", "confirmado = 'confirmed'".
Descripción tipo 1: la unidad (dinero en centavos)
Este es el ejemplo canónico, y el que más dinero real ha costado en sistemas de verdad. Reservo guarda el dinero en centavos, como enteros: Focus a 2500 (no 25.00), una reserva a 6000 (no 60.00). El tipo INTEGER es correcto —los centavos son enteros exactos, sin los errores del punto flotante—, pero el tipo no dice "centavos". Se ve price_cents INTEGER y el modelo tiene que adivinar la unidad.
Ejemplo trabajado — el SQL con y sin la nota
Pregunta del usuario:
"¿Cuánto ingresó Reservo en total, contando solo reservas confirmadas?"
Con contexto POBRE (el esquema dice price_cents INTEGER, sin más), un SQL realista que claude-sonnet-5 produciría es:
-- SQL de ejemplo "del modelo", con contexto pobre (no sabe que es centavos).
SELECT SUM(price_cents) AS total FROM bookings WHERE status = 'confirmed';
Ejecútalo contra Reservo:
SELECT SUM(price_cents) AS total FROM bookings WHERE status = 'confirmed';
Qué esperar:
┌────────┐
│ total │
├────────┤
│ 211900 │
└────────┘
El SQL corrió sin error. Devolvió 211900. Y aquí está la trampa: el asistente reportaría "$211,900" —una cifra absurda para un coworking en seis meses—. El total real es $2,119.00. El error es un factor de 100, y el motor no lo caza porque, a nivel de SQL, sumar centavos es perfectamente válido; lo que está mal es la interpretación.
Con contexto RICO (el esquema añade price_cents INTEGER -- dinero en CENTAVOS), el modelo sabe que debe dividir entre 100 para dar dólares. Un SQL realista es:
-- SQL de ejemplo "del modelo", con la nota "dinero en centavos".
SELECT ROUND(SUM(price_cents) / 100.0, 2) AS total_usd
FROM bookings WHERE status = 'confirmed';
SELECT ROUND(SUM(price_cents) / 100.0, 2) AS total_usd
FROM bookings WHERE status = 'confirmed';
Qué esperar:
┌───────────┐
│ total_usd │
├───────────┤
│ 2119.0 │
└───────────┘
Dos mil ciento diecinueve dólares. La única diferencia entre el 211900 engañoso y el 2119.0 correcto fue una línea de descripción en el contexto: decirle al modelo que la columna está en centavos.
El detalle del
100.0. Fíjate que la versión rica divide entre100.0, no entre100. En SQLite,211900 / 100da2119(división entera, se pierde el decimal), mientras que211900 / 100.0da2119.0(división de punto flotante). Con este total redondo da igual, pero con211950 / 100obtendrías2119en vez de2119.5. Un buen contexto no solo dice "centavos"; puede decir "divide entre 100.0 para dólares" y ahorrarle al modelo también esa trampa.
Descripción tipo 2: el dominio (qué valores admite una columna)
El segundo tipo de descripción son los dominios: la lista de valores que una columna puede tomar. status no es "cualquier texto"; es 'confirmed' o 'cancelled'. tier es 'basic' o 'pro'. kind es 'charge' o 'refund'. Si el modelo no conoce el dominio, inventa valores que suenan plausibles pero no existen.
Recuerda de la cápsula 02 el fallo silencioso: sin conocer el dominio de status, el modelo filtra por 'active', la consulta corre y devuelve cero filas. Reveámoslo, ahora enfocados en la cura:
-- Lo que pasa sin conocer el dominio: filtro por un valor inexistente.
SELECT COUNT(*) AS n FROM bookings WHERE status = 'active';
Qué esperar:
┌───┐
│ n │
├───┤
│ 0 │
└───┘
Cero. El asistente respondería "no hay reservas activas", una mentira. Con el dominio en el contexto (status TEXT -- 'confirmed' o 'cancelled'), el modelo nunca escribe 'active'; usa el valor real. Confírmalo mirando los valores que de verdad existen:
SELECT status, COUNT(*) AS n FROM bookings GROUP BY status;
Qué esperar:
┌───────────┬────┐
│ status │ n │
├───────────┼────┤
│ cancelled │ 3 │
│ confirmed │ 20 │
└───────────┴────┘
Los únicos valores son cancelled y confirmed. Eso es el dominio, y es exactamente lo que la descripción le entrega al modelo.
El regalo del DDL: los CHECK ya son dominios
Aquí conviene recordar algo de la cápsula 03: si serializas el esquema como DDL completo, los dominios ya vienen incluidos, porque Reservo los declara como restricciones CHECK:
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('confirmed','cancelled'))
Ese CHECK (status IN ('confirmed','cancelled')) es la descripción del dominio, ya en el esquema. Un modelo que ve el DDL ve el dominio sin que se lo digas aparte. Por eso el DDL es tan buen punto de partida cuando cabe: los dominios vienen gratis. Solo tienes que añadir a mano lo que ninguna restricción declara —como la unidad "centavos"—.
Si usas la forma compacta (que tira los CHECK), tienes que reponer los dominios como descripciones. En cualquier caso, la meta es la misma: que el dominio llegue al modelo.
Profundización: dónde nacen las descripciones y cómo se guardan
Las descripciones son la parte del contexto que no se puede introspeccionar automáticamente de un esquema cualquiera. El DDL te da los CHECK si el diseñador los puso, pero la nota "esto está en centavos" no vive en ninguna parte de la base de datos —es conocimiento del negocio—. Por eso las descripciones se escriben una vez, a mano, y se guardan junto al código que genera el contexto.
La forma habitual es un diccionario que mapea tabla.columna a su nota:
COLUMN_NOTES = {
"rooms.hourly_cents": "precio por hora en CENTAVOS (2500 = 25.00 USD)",
"members.tier": "plan del socio: 'basic' o 'pro' (los pro pagan 20% menos)",
"bookings.status": "'confirmed' o 'cancelled' (solo confirmed cuenta como ingreso)",
"bookings.price_cents": "precio total de la reserva en CENTAVOS, ya con descuento",
"payments.amount_cents": "monto en CENTAVOS",
"payments.kind": "'charge' (cobro) o 'refund' (reembolso)",
}
Luego, al armar el contexto, para cada columna buscas su nota en el diccionario y la añades como comentario. Esto es exactamente lo que hará la función del mini-proyecto (cápsula 08). La ventaja de tenerlo en un diccionario aparte: las descripciones viven versionadas con tu código, no se pierden, y se pueden mejorar sin tocar la introspección.
¿Qué merece una descripción? No hace falta describir todo —id o name se explican solos—. Vale la pena describir:
- Unidades no obvias: centavos, milisegundos, bytes, grados.
- Dominios que no están como
CHECK: valores admitidos que el esquema no restringe formalmente. - Semántica de negocio: que "solo
confirmedcuenta como ingreso", que "losprotienen descuento" —reglas que el modelo no puede deducir del esquema—. - Columnas con nombres engañosos: una columna
amountque en realidad guarda un porcentaje, o undateque es texto y no fecha.
Este es el nudo del benchmark BIRD de text-to-SQL: a diferencia de benchmarks anteriores, BIRD mide qué tanto ayuda el "conocimiento externo" —justo estas descripciones— a que el modelo acierte en esquemas realistas. La lección de BIRD, en una frase: el esquema desnudo no basta; las buenas descripciones suben la exactitud de forma medible.
Errores comunes
-
Asumir que el tipo comunica la unidad.
INTEGERno dice "centavos",TEXTno dice "fecha ISO",REALno dice "kilómetros". El tipo es la representación; la unidad es semántica que hay que añadir. Este es el error que produce el clásico factor-de-100 en el dinero. -
Describir lo obvio y olvidar lo importante. Gastar una descripción en
id INTEGER -- identificador únicoes ruido; el modelo ya lo sabe. Lo que importa esprice_cents -- CENTAVOSystatus -- 'confirmed'/'cancelled'. Describe lo que el modelo no puede deducir. -
Poner las descripciones y luego usar la forma compacta que las tira. Si eliges la forma compacta porque ahorra tokens, pero no repones los dominios y unidades como descripciones, has abaratado el contexto a costa de reintroducir las alucinaciones. La forma compacta barata + descripciones clave es la combinación ganadora, no la forma compacta pelada.
-
Confiar en que el modelo "sabrá que es dinero por el nombre
price". A veces acierta —pricesugiere dinero—, pero no puede adivinar la unidad del nombre. Y hay nombres peores:amount,value,totalno dicen ni la moneda ni la escala. La descripción explícita elimina la lotería. -
Escribir las descripciones dentro del código de introspección. Mézclalas en un diccionario aparte (
COLUMN_NOTES), no incrustadas en la función que recorre las tablas. Así se versionan solas, se revisan fácil y no se pierden cuando refactorizas la introspección.
Ejercicios
Ejercicio 1: ¿Qué columnas de Reservo merecen descripción?
Repasa las cuatro tablas de Reservo (rooms, members, bookings, payments). Lista las columnas que sí merecen una descripción y las que no, con una frase de justificación por cada grupo.
Solución
Merecen descripción (el modelo no puede deducir su significado del tipo/nombre):
rooms.hourly_cents,bookings.price_cents,payments.amount_cents— todas son centavos, y el tipoINTEGERno lo dice. Sin la nota, factor-de-100.members.tier— dominio'basic'/'pro'+ la regla de negocio (lospropagan 20% menos), que es puro conocimiento externo.bookings.status— dominio'confirmed'/'cancelled'+ la semántica "solo confirmed cuenta como ingreso".payments.kind— dominio'charge'/'refund'.
No las necesitan (se explican solas):
iden todas las tablas — es la clave primaria, evidente por elPKdel contexto.rooms.name,members.name— un nombre es un nombre.rooms.capacity— número de personas, el nombre lo dice.bookings.start_at,bookings.end_at— aunque el formato (ISO) merece una muestra (cápsula 05), el significado "inicio/fin" es claro.
Regla: describe unidades, dominios y semántica de negocio; deja en paz lo que el nombre ya comunica.
Ejercicio 2: Corrige el reporte engañoso
Un asistente con contexto pobre respondió: "Boardroom ingresó 105600." Sabiendo que el dinero está en centavos y que ese total incluye reservas canceladas, escribe la consulta que da el ingreso correcto en dólares de Boardroom, contando solo reservas confirmadas.
Solución
Dos correcciones: dividir entre 100.0 (centavos → dólares) y filtrar status = 'confirmed':
SELECT ROUND(SUM(b.price_cents) / 100.0, 2) AS revenue_usd
FROM bookings b
JOIN rooms r ON b.room_id = r.id
WHERE r.name = 'Boardroom' AND b.status = 'confirmed';
Qué esperar:
┌─────────────┐
│ revenue_usd │
├─────────────┤
│ 1056.0 │
└─────────────┘
Boardroom ingresó $1,056.00, no "105600". (Da la casualidad de que ninguna reserva de Boardroom está cancelada, así que el filtro de status no cambia el número aquí; pero la división entre 100.0 sí, y es la corrección clave.) Ambas correcciones —la unidad y el dominio de status— vienen de descripciones que un contexto rico llevaría.
Ejercicio 3: Escribe descripciones que eviten un error
Imagina una tabla nueva en Reservo: discounts(id INTEGER PK, member_id INTEGER, pct INTEGER, valid_until TEXT), donde pct guarda el porcentaje de descuento como entero (20 significa 20%, no 0.20) y valid_until es una fecha ISO. Escribe la descripción de pct y de valid_until de forma que un modelo no cometa un error de interpretación.
Solución
COLUMN_NOTES = {
"discounts.pct": "porcentaje de descuento como ENTERO (20 = 20%, NO 0.20)",
"discounts.valid_until": "fecha limite, texto ISO 'YYYY-MM-DD' (comparar como texto)",
}
pct: sin la nota, el modelo podría tratar20como0.20y calcularprice * pct(multiplicando por 20 en vez de por 0.20), o al revés. La nota fija la escala: 20 significa 20%, así que el cálculo esprice * pct / 100.valid_until: sin la nota, el modelo no sabe el formato ni que es texto (no un tipoDATEnativo —SQLite guarda fechas como texto—). La nota le da el formato ISO y le dice que las comparaciones son de texto (que funcionan porque el formato ISO ordena igual como texto que como fecha).
Ambas descripciones cierran una puerta a un fallo silencioso —cálculo con la escala equivocada, comparación de fechas mal formada— que ninguna restricción del esquema atraparía.
Resumen y siguiente paso
- El esquema serializado dice cómo se llaman y de qué tipo son las columnas, pero no su significado: la unidad y el dominio. Las descripciones aportan esa capa.
- La unidad:
price_centsesINTEGERpero en centavos. Sin la nota, el modelo suma crudo (211900) y reporta un factor-de-100 de error; con ella, divide entre100.0y da2119.0. - El dominio:
statussolo vale'confirmed'/'cancelled'. Sin conocerlo, el modelo inventa'active'y devuelve cero filas —el fallo silencioso—. - El DDL regala los dominios que estén como
CHECK; la unidad "centavos" no vive en ninguna restricción y siempre hay que añadirla a mano. - Las descripciones se guardan en un diccionario aparte (
COLUMN_NOTES), versionado con el código. Describe unidades, dominios y semántica de negocio; deja en paz lo obvio.
Siguiente cápsula: Filas de muestra en el contexto — Verás que unas pocas filas por tabla le enseñan al modelo el formato real de los datos (fechas ISO, forma de los valores) mejor que cualquier descripción.