El raw vault guarda lo que pasó. Nadie ha interpretado nada todavía, y ese es justo su valor. Pero un almacén de datos que no interpreta no sirve a nadie: las preguntas del negocio están llenas de criterios.
Aquí es donde entran las reglas blandas, y con ellas dos capas más.
18.1 El business vault
El business vault tiene una propiedad que conviene tener grabada: es desechable. Todo lo que contiene se puede calcular a partir del raw vault, así que se puede tirar y reconstruir entero cuando el criterio cambie. Esa es toda la ganancia de haber mantenido el raw vault libre de interpretaciones.
Sus piezas más habituales son satélites calculados y tablas de apoyo al rendimiento.
18.1.1 Satélites calculados
Un satélite del business vault tiene la misma forma que uno del raw vault, pero sus columnas son el resultado de aplicar una regla:
-- models/business_vault/sat_alumno_bv.sqlselect hk_alumno, hd_alumno, load_date, email, nombre ||' '|| apellido as nombre_completo,lower(split_part(email, '@', 2)) as dominio_email,casewhenlower(split_part(email, '@', 2)) ='ejemplo.eus'then'interno'else'externo'endas tipo_correo, record_sourcefrom {{ ref('sat_alumno') }}
La clasificación entre correo interno y externo es una decisión de negocio: hoy el criterio es el dominio, mañana puede ser una lista de dominios permitidos. Al vivir aquí, cambiarla es editar el modelo y reconstruir; el histórico del raw vault no se toca.
Nótese que este satélite se materializa como tabla, no como incremental. No hay que preservar nada, porque su historia es la del satélite del que deriva.
18.1.2 Tablas point-in-time
El segundo tipo de pieza no aporta significado sino rendimiento. Ya vimos que consultar un satélite requiere una función de ventana para quedarse con la versión vigente, y que con varios satélites la consulta se complica rápido.
Una tabla PIT (point in time) precalcula, para cada clave y cada instante relevante, qué versión de cada satélite estaba vigente:
-- models/business_vault/pit_alumno.sqlwith fechas as (selectdistinct hk_alumno, load_datefrom {{ ref('sat_alumno') }})select fechas.hk_alumno, fechas.load_date as fecha_foto,max(sat.load_date) as load_date_satfrom fechasjoin {{ ref('sat_alumno') }} as saton sat.hk_alumno = fechas.hk_alumnoand sat.load_date <= fechas.load_dategroupby fechas.hk_alumno, fechas.load_date
Con ella, preguntar por el estado en una fecha se convierte en una unión por igualdad en lugar de una ventana:
utilidades.dbt("run", "--select", "business_vault")utilidades.consultar(""" select h.id_alumno, p.fecha_foto, s.email from main_business_vault.pit_alumno p join main_raw_vault.sat_alumno s on s.hk_alumno = p.hk_alumno and s.load_date = p.load_date_sat join main_raw_vault.hub_alumno h on h.hk_alumno = p.hk_alumno where h.id_alumno = 1 order by p.fecha_foto""")
id_alumno
fecha_foto
email
0
1
2026-01-10 03:00:00
iraitz@ejemplo.eus
1
1
2026-01-12 03:00:00
iraitz.montalban@ejemplo.eus
Ahí está la trayectoria completa de Iraitz: qué correo tenía en cada foto.
NotaTablas puente
El primo hermano de la PIT es la tabla bridge, que precalcula recorridos por varios enlaces. Si para responder una pregunta hay que atravesar tres enlaces y cuatro hubs, una bridge deja ese camino resuelto.
Ambas son optimizaciones puras, y como tales conviene añadirlas cuando el problema de rendimiento sea real y medible, no por adelantado.
18.2 La capa de consumo
Llegamos al final del recorrido. Y hay que decirlo claro: el usuario no debe ver el vault jamás. Nadie va a escribir una función de ventana para contar alumnos.
La capa de consumo aplana todo lo anterior al modelo dimensional que ya conocemos, con nombres que el negocio reconoce y sin una sola clave hash a la vista.
flowchart TB
subgraph vault ["Vault"]
hub["hub_alumno"]
sat["sat_alumno"]
bv["sat_alumno_bv"]
link["link_matricula"]
hubs["hub_asignatura"]
end
subgraph estrella ["Capa de consumo"]
dim1["dim_alumno"]
fct["fct_matriculas"]
dim2["dim_asignatura"]
end
hub --> dim1
bv --> dim1
sat --> bv
link --> fct
hubs --> dim2
dim1 --- fct
dim2 --- fct
%% Paleta por bloque del libro
classDef transformacion fill:#d7eddc,stroke:#4a9463,stroke-width:1.5px,color:#1c4a2e
classDef explotacion fill:#e6ddf5,stroke:#7457bd,stroke-width:1.5px,color:#332757
class hub,sat,bv,link,hubs transformacion
class dim1,fct,dim2 explotacion
Una dimensión es el vault aplanado a la versión vigente:
-- models/marts/dim_alumno.sqlwith vigente as (select hk_alumno, nombre_completo, email, dominio_email, tipo_correo, load_date,row_number() over (partitionby hk_alumno orderby load_date desc) as versionfrom {{ ref('sat_alumno_bv') }})select hub.id_alumno as alumno_id, vigente.nombre_completo, vigente.email, vigente.dominio_email, vigente.tipo_correo, hub.load_date as alta_en_almacen, vigente.load_date as ultima_modificacionfrom {{ ref('hub_alumno') }} as hubjoin vigente on vigente.hk_alumno = hub.hk_alumnowhere vigente.version =1
Y una tabla de hechos es un enlace con las claves traducidas a las que el usuario reconoce:
-- models/marts/fct_matriculas.sqlselectlink.hk_matricula as matricula_id, hub_a.id_alumno as alumno_id, hub_s.id_asignatura as asignatura_id,link.load_date as fecha_matricula,link.record_source as origenfrom {{ ref('link_matricula') }} aslinkjoin {{ ref('hub_alumno') }} as hub_a on hub_a.hk_alumno =link.hk_alumnojoin {{ ref('hub_asignatura') }} as hub_s on hub_s.hk_asignatura =link.hk_asignatura
1 of 27 OK created sql view model main_prep.stg_alumnos ........................ [OK in 0.15s]
2 of 27 OK created sql view model main_prep.stg_asignaturas .................... [OK in 0.10s]
3 of 27 OK created sql view model main_prep.stg_matriculas ..................... [OK in 0.11s]
4 of 27 OK created sql incremental model main_raw_vault.hub_alumno ............. [OK in 0.17s]
5 of 27 OK created sql incremental model main_raw_vault.sat_alumno ............. [OK in 0.07s]
6 of 27 OK created sql incremental model main_raw_vault.hub_asignatura ......... [OK in 0.08s]
7 of 27 OK created sql incremental model main_raw_vault.link_matricula ......... [OK in 0.08s]
23 of 27 OK created sql table model main_business_vault.pit_alumno ............. [OK in 0.15s]
24 of 27 OK created sql table model main_business_vault.sat_alumno_bv .......... [OK in 0.13s]
25 of 27 OK created sql table model main_marts.dim_asignatura .................. [OK in 0.11s]
26 of 27 OK created sql table model main_marts.fct_matriculas .................. [OK in 0.10s]
27 of 27 OK created sql table model main_marts.dim_alumno ...................... [OK in 0.10s]
Completed successfully
Done. PASS=27 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=27
Y comprobemos el resultado:
utilidades.consultar("select * from main_marts.dim_alumno order by alumno_id")
alumno_id
nombre_completo
email
dominio_email
tipo_correo
alta_en_almacen
ultima_modificacion
0
1
Iraitz Montalbán
iraitz.montalban@ejemplo.eus
ejemplo.eus
interno
2026-01-10 03:00:00
2026-01-12 03:00:00
1
2
Javier Garcia
javier@ejemplo.eus
ejemplo.eus
interno
2026-01-10 03:00:00
2026-01-10 03:00:00
2
3
Miguel Fernandez
miguel@ejemplo.eus
ejemplo.eus
interno
2026-01-10 03:00:00
2026-01-10 03:00:00
3
4
María Garcia
maria@ejemplo.eus
ejemplo.eus
interno
2026-01-12 03:00:00
2026-01-12 03:00:00
Ni claves hash, ni hashdiff, ni versiones. Cuatro alumnos con su información vigente y, de propina, dos fechas que responden a preguntas frecuentes: desde cuándo lo conocemos y cuándo cambió por última vez.
Ahora sí, la pregunta del principio se responde con SQL de andar por casa:
utilidades.consultar(""" select a.asignatura, count(*) as matriculados, string_agg(al.nombre_completo, ', ' order by al.nombre_completo) as alumnos from main_marts.fct_matriculas f join main_marts.dim_alumno al on al.alumno_id = f.alumno_id join main_marts.dim_asignatura a on a.asignatura_id = f.asignatura_id group by a.asignatura order by matriculados desc""")
asignatura
matriculados
alumnos
0
Biología
3
Javier Garcia, María Garcia, Miguel Fernandez
1
Historia
1
Iraitz Montalbán
Compárese con la consulta equivalente sobre el vault, que necesitaba unir cuatro tablas y resolver una ventana. Esa diferencia es exactamente la razón de existir de esta capa.
18.3 El recorrido completo
Merece la pena ver de un vistazo lo que ha pasado con un dato concreto. El correo de Iraitz entró en el sistema origen, aterrizó en staging, se hasheó en la preparación, se historificó en el satélite, se clasificó en el business vault y acabó aplanado en la dimensión:
utilidades.consultar(""" select 'staging' as capa, email as valor, _cargado_en as fecha from staging.alumnos where id_alumno = 1 union all select 'raw vault', s.email, s.load_date from main_raw_vault.sat_alumno s join main_raw_vault.hub_alumno h using (hk_alumno) where h.id_alumno = 1 union all select 'consumo', d.email, d.ultima_modificacion from main_marts.dim_alumno d where d.alumno_id = 1 order by fecha, capa""")
capa
valor
fecha
0
raw vault
iraitz@ejemplo.eus
2026-01-10 03:00:00
1
consumo
iraitz.montalban@ejemplo.eus
2026-01-12 03:00:00
2
raw vault
iraitz.montalban@ejemplo.eus
2026-01-12 03:00:00
3
staging
iraitz.montalban@ejemplo.eus
2026-01-12 03:00:00
Dos filas en el raw vault frente a una en staging y una en consumo. Esa asimetría es todo el argumento del Data Vault en una tabla: la historia vive en el medio, porque los extremos no pueden guardarla. El origen solo conoce el presente y el consumo solo quiere el presente.
Un origen nuevo se integra añadiendo satélites a los hubs existentes, sin migrar nada.
Un atributo nuevo es una columna en un satélite, o un satélite más.
Una regla que cambia se edita en el business vault y se reconstruye, sin tocar el histórico.
El estado en una fecha pasada se responde con una consulta, no con una copia de seguridad.
Y a un coste que conviene no esconder: más tablas, más SQL, y una capa que hay que mantener. Para un almacén de tres tablas y un origen, el Data Vault es un exceso evidente. La pregunta que decide no es cuántos datos hay, sino cuántos orígenes van a acabar entrando y cuánto va a cambiar el negocio.
Con el almacén construido y una capa de consumo lista, queda ponerlo en manos de quien tiene las preguntas. Es la explotación de los datos.