1. Database Strategy/
  2. Data Warehouse/

Granularidad equivocada: cuando la fact table no responde las preguntas correctas

Ivan Luminaria
Ivan Luminaria
DWH Architect · Project Manager · Oracle DBA & Performance Tuning · PL/SQL Senior & Mentor

La reunión había empezado bien. El director comercial de una empresa de distribución industrial — unos sesenta millones de facturación, tres mil clientes activos, un catálogo con doce mil referencias — había abierto la presentación del nuevo data warehouse con una sonrisa. Los números cuadraban, los dashboards lucían bien, los totales mensuales por agente y por zona coincidían con contabilidad.

Luego alguien hizo la pregunta equivocada. O mejor dicho, la correcta.

"¿Puedo ver cuánto compró el cliente Bianchi en marzo, línea por línea, producto por producto?"

Silencio.

El responsable de BI me miró. Yo miré la pantalla. La pantalla mostraba una fact table con una fila por cliente por mes: importe total facturado, cantidad total, número de facturas. Ningún detalle. Ninguna línea de factura. Ningún producto.

Esa fact table respondía a una sola pregunta: ¿cuánto facturó cada cliente en un mes determinado? Todo lo demás — por producto, por familia de productos, por factura individual — quedaba fuera de alcance.

🔍 El grain: la decisión que determina todo #

En el modelado dimensional , el grain (la granularidad) de la fact table es la primera decisión que se toma. No la segunda, no una entre tantas: la primera. Kimball lo repite en cada capítulo, y tiene razón.

El grain responde a la pregunta: ¿qué representa una fila individual de la fact table?

En el proyecto que describí, quien diseñó el modelo había elegido un grain mensual-cliente: una fila = un cliente en un mes. Las razones parecían razonables: el sistema fuente exportaba un resumen mensual, la carga era rápida, las tablas eran pequeñas, las consultas simples.

Pero el grain determina las preguntas que el data warehouse puede responder. Si la granularidad es el resumen mensual por cliente, no puedes bajar de ese nivel. No puedes hacer drill-down por producto. No puedes saber si el cliente Bianchi compró diez veces el mismo artículo o diez artículos diferentes. No puedes comparar márgenes por familia de productos.

Tienes un total. Punto.

📊 Los números del problema #

La fact table original tenía esta estructura:

CREATE TABLE fact_facturacion_mensual (
    sk_cliente        INT NOT NULL,
    sk_tiempo         INT NOT NULL,  -- mes (YYYYMM)
    sk_agente         INT NOT NULL,
    sk_zona           INT NOT NULL,
    importe_total     DECIMAL(15,2),
    cantidad_total    INT,
    num_facturas      INT,
    num_lineas        INT,
    FOREIGN KEY (sk_cliente) REFERENCES dim_cliente(sk_cliente),
    FOREIGN KEY (sk_tiempo)  REFERENCES dim_tiempo(sk_tiempo)
);

Filas por año: unas 180.000 (3.000 clientes × 12 meses × alguna variación). Pequeña, rápida, fácil de cargar. El ETL corría en menos de cinco minutos.

¿El problema? Las medidas aditivas ya estaban agregadas. importe_total era la suma de todas las líneas de factura del mes. Imposible rastrear la composición. Como tener el total de un ticket sin saber qué compraste.

🏗️ La reestructuración: bajar a la línea de factura #

La solución era una sola: cambiar el grain. Llevar la fact table al nivel más bajo disponible en el sistema fuente — la línea individual de factura.

CREATE TABLE fact_facturacion_linea (
    sk_linea_factura  INT PRIMARY KEY,
    sk_factura        INT NOT NULL,
    sk_cliente        INT NOT NULL,
    sk_producto       INT NOT NULL,
    sk_tiempo         INT NOT NULL,  -- día (YYYYMMDD)
    sk_agente         INT NOT NULL,
    sk_zona           INT NOT NULL,
    sk_familia        INT NOT NULL,
    cantidad          INT,
    precio_unitario   DECIMAL(12,4),
    importe_linea     DECIMAL(15,2),
    descuento_pct     DECIMAL(5,2),
    importe_neto      DECIMAL(15,2),
    coste_producto    DECIMAL(15,2),
    margen            DECIMAL(15,2),
    FOREIGN KEY (sk_cliente)  REFERENCES dim_cliente(sk_cliente),
    FOREIGN KEY (sk_producto) REFERENCES dim_producto(sk_producto),
    FOREIGN KEY (sk_tiempo)   REFERENCES dim_tiempo(sk_tiempo)
);

Filas por año: unos 2,4 millones (3.000 clientes × ~800 líneas/año en promedio). Un orden de magnitud más. Pero cada fila llevaba consigo el detalle completo: qué producto, qué factura, qué precio, qué descuento, qué margen.

⚡ El impacto en el ETL #

El cambio de grain tuvo un efecto en cascada sobre el ETL que nadie había previsto — o mejor dicho, que quien eligió el grain agregado había evitado enfrentar.

Nuevas dimensiones necesarias:

DimensiónCardinalidadNotas
dim_producto~12.000No existía: antes no era necesaria
dim_familia~180Jerarquía de productos a 3 niveles
dim_factura~45.000/añoCabecera de factura con datos maestros

Nueva ventana de carga:

FaseAntesDespués
Extracción40 seg3 min
Transformación1 min8 min
Carga fact30 seg4 min
Total~2 min~15 min

Quince minutos contra dos. Un precio aceptable para un data warehouse que ahora respondía preguntas reales.

🔬 Las consultas que antes eran imposibles #

Con el nuevo grain, las consultas que el negocio quería se volvieron triviales:

Detalle de compras de un cliente por producto:

SELECT
    c.razon_social,
    p.codigo_producto,
    p.descripcion,
    SUM(f.cantidad)       AS piezas,
    SUM(f.importe_neto)   AS facturado_neto,
    SUM(f.margen)         AS margen_total
FROM fact_facturacion_linea f
JOIN dim_cliente  c ON f.sk_cliente  = c.sk_cliente
JOIN dim_producto p ON f.sk_producto = p.sk_producto
JOIN dim_tiempo   t ON f.sk_tiempo   = t.sk_tiempo
WHERE c.razon_social = 'Bianchi Srl'
  AND t.anio = 2024
  AND t.mes = 3
GROUP BY c.razon_social, p.codigo_producto, p.descripcion
ORDER BY facturado_neto DESC;

Top 10 productos por margen en un trimestre:

SELECT
    p.codigo_producto,
    p.descripcion,
    fm.desc_familia,
    SUM(f.importe_neto)  AS facturado,
    SUM(f.margen)        AS margen,
    ROUND(SUM(f.margen) / NULLIF(SUM(f.importe_neto), 0) * 100, 1) AS margen_pct
FROM fact_facturacion_linea f
JOIN dim_producto p  ON f.sk_producto = p.sk_producto
JOIN dim_familia  fm ON f.sk_familia  = fm.sk_familia
JOIN dim_tiempo   t  ON f.sk_tiempo   = t.sk_tiempo
WHERE t.anio = 2024
  AND t.trimestre = 1
GROUP BY p.codigo_producto, p.descripcion, fm.desc_familia
ORDER BY margen DESC
LIMIT 10;

Comparación entre agentes: facturación media por línea:

SELECT
    a.nombre_agente,
    COUNT(*)                        AS num_lineas,
    SUM(f.importe_neto)             AS facturado_total,
    ROUND(AVG(f.importe_neto), 2)   AS media_por_linea
FROM fact_facturacion_linea f
JOIN dim_agente a ON f.sk_agente = a.sk_agente
JOIN dim_tiempo t ON f.sk_tiempo = t.sk_tiempo
WHERE t.anio = 2024
GROUP BY a.nombre_agente
ORDER BY facturado_total DESC;

Ninguna de estas consultas era posible con el grain mensual-cliente. Ninguna. No era un problema de tuning o de índices — era un problema estructural, escrito en el ADN del modelo.

📋 La regla de Kimball que habíamos ignorado #

Ralph Kimball lo dice claramente: “siempre modelar al nivel de detalle más fino disponible en el sistema fuente.”

No es una sugerencia. No es una opción entre varias. Es el principio fundacional del modelado dimensional. Y la razón es simple: siempre se puede agregar del detalle al total, pero nunca se puede desagregar un total en su detalle.

La agregación es una operación irreversible. Como mezclar colores: del rojo y el amarillo puedes obtener el naranja, pero del naranja nunca vuelves a los colores originales.

En nuestro proyecto, la elección del grain agregado fue dictada por pereza de diseño, no por una restricción técnica. El sistema fuente tenía el detalle por línea de factura — simplemente nadie había querido enfrentar la complejidad de modelarlo, gestionar las dimensiones adicionales, extender la ventana del ETL.

¿El resultado? Un data warehouse que tuvo que reconstruirse desde cero seis meses después del go-live.

🎯 Cuándo el grain agregado tiene sentido #

La granularidad fina no siempre es la única respuesta. Hay casos legítimos para fact tables agregadas:

  • Tablas de agregación (aggregate fact table) junto a la tabla de detalle, para acelerar las consultas más frecuentes
  • Snapshots periódicos donde el negocio razona por período (saldo mensual de una cuenta, inventario a fin de semana)
  • Restricciones de origen cuando el sistema fuente no expone el detalle y no hay forma de obtenerlo

Pero la regla es: parte del detalle, luego agrega. Nunca al revés. Las aggregate fact tables son una optimización, no un sustituto de la granularidad fina.

En nuestro caso, después de la reestructuración, también creamos una vista materializada con el resumen mensual por cliente — la misma estructura de antes — para los dashboards ejecutivos que no necesitaban el detalle. Lo mejor de ambos mundos, sin sacrificar nada.

Lo que aprendí #

Ese proyecto me enseñó algo que llevo conmigo en cada encargo posterior: la primera media hora de diseño de un data warehouse, aquella en la que se decide el grain, vale más que todas las optimizaciones que vendrán después. Un ETL perfecto, índices calibrados, hardware potente — nada de esto compensa un grain equivocado.

Si tu fact table no responde las preguntas del negocio, no es culpa de las consultas. Es culpa del modelo. Y el modelo se decide en el grain.


Glosario #

Grain — El nivel de detalle (granularidad) de una fact table en el data warehouse. Determina qué representa cada fila y qué preguntas puede responder el modelo. Es la primera decisión en el diseño dimensional.

Fact table — Tabla central del star schema que contiene las medidas numéricas (importes, cantidades, márgenes) y las claves foráneas hacia las dimensiones. Su granularidad determina el nivel de análisis posible.

Additive Measure — Medida numérica que puede sumarse a lo largo de todas las dimensiones (ej. importe, cantidad). Una vez agregada a nivel superior, el detalle original se pierde irreversiblemente.

Drill-down — Navegación en reportes desde el nivel agregado al detalle, a lo largo de una jerarquía. Solo posible si la fact table contiene datos a un nivel de granularidad suficiente.

Star Schema — Modelo de datos con una fact table central y tablas dimensionales enlazadas. La estructura más usada en data warehouses por la simplicidad de las consultas analíticas.

ETL — Extract, Transform, Load: el proceso de extracción, transformación y carga de datos en el data warehouse. Un cambio de grain impacta directamente tiempos y complejidad del ETL.