AL.
🇺🇸 EN
Volver al blog
Bases de datos · 11 min de lectura

Postgres JSON vs JSONB: Guardando Chunks de Datos para Procesamiento con IA

Las diferencias reales entre JSON y JSONB en PostgreSQL, como guarda cada uno tus datos, cuando gana el JSON plano, y como JSONB se gana su lugar guardando metadata de chunks en un pipeline de IA.


Hace poco empecé a guardar chunks de documentos para procesarlos con IA. Cada chunk arrastra una bolsa de metadata, esa metadata tenía que vivir en algún lado, y Postgres ofrece dos tipos de columna para eso: json y jsonb.

Aceptan la misma entrada y guardan cosas distintas

La forma más rápida de ver la diferencia es darles a los dos tipos el mismo payload un poco hostil: espacios raros y una clave duplicada.

SELECT '{"b": 1,  "a": 2, "a": 3}'::json  AS stored_as_json;
SELECT '{"b": 1,  "a": 2, "a": 3}'::jsonb AS stored_as_jsonb;
      json
---------------------------
 {"b": 1,  "a": 2, "a": 3}

 jsonb
------------------
 {"a": 3, "b": 1}

La columna json me devolvió los bytes exactos que le mandé, con el doble espacio y la clave duplicada incluidos. La columna jsonb reordenó las claves, colapsó la duplicada al último valor y normalizó los espacios, porque en ningún momento guardó mi texto. Parseó el documento una sola vez, al escribir, a un formato binario, y lo que me devuelve es un render de esa estructura.

Cual es el costo de esta diferencia

  • Lecturas. Cada operador que aplicás sobre un valor json re-parsea el documento entero desde el texto. jsonb navega su estructura binaria directamente.
  • Escrituras. jsonb paga el costo de parseo y conversión en el insert.
  • Índices. jsonb soporta índices GIN sobre el documento completo. Escribí una guía de tipos de índices en Postgres que acompaña este post y habla sobre índices GIN.
  • Almacenamiento. jsonb tiende a ser un poco más grande en reposo. Los dos tipos pasan por la compresión TOAST para valores grandes. Más abajo menciono qué es TOAST.

La tabla que armé

El chunking para procesamiento con IA tiene una forma específica. Un documento se parte en pedazos, cada pedazo se embebe y se recupera después, y cada pedazo arrastra su metadata: de dónde salió, su posición, cuántos tokens tiene, la cadena de títulos que lo contiene. El problema es que cada tipo de fuente produce una conjunto diferente. Un PDF tiene páginas y capaz un flag de OCR. Una página HTML tiene una jerarquía de headings. Una transcripción tiene timestamps y personajes.

Esa división, estable versus volátil, es la regla de diseño. Los campos por los que siempre filtro o joineo se vuelven columnas de verdad, con tipos de verdad y constraints de verdad. La cola larga va a una sola columna jsonb.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title text NOT NULL
);

CREATE TABLE chunks (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  document_id bigint NOT NULL REFERENCES documents (id),
  chunk_index int    NOT NULL,
  content     text   NOT NULL,
  embedding   vector(1536),
  metadata    jsonb  NOT NULL DEFAULT '{}',
  created_at  timestamptz NOT NULL DEFAULT now(),
  UNIQUE (document_id, chunk_index)
);

CREATE INDEX chunks_metadata_idx ON chunks USING gin (metadata jsonb_path_ops);

Dos notas sobre este DDL. La columna embedding y la línea de la extensión requieren pgvector; ahí vive el lado de retrieval de este pipeline, y se merece su propio post. Si querés correr los ejemplos sin eso, borrá esas dos líneas. El content es una columna text plana a propósito, no una clave adentro de la metadata, por razones que van a ser obvias al final.

Para que los planes de consulta de abajo sean honestos, acá va un seed que genera un millón de chunks con el tipo de metadata variada que emite un pipeline real:

INSERT INTO documents (title)
SELECT 'Document ' || i FROM generate_series(1, 50) AS i;

INSERT INTO chunks (document_id, chunk_index, content, metadata)
SELECT
  (i % 50) + 1,
  i / 50,
  'Body of chunk ' || i,
  jsonb_build_object(
    'source_type', (ARRAY['pdf', 'html', 'transcript'])[1 + i % 3],
    'page',        1 + i % 40,
    'headings',    jsonb_build_array('Chapter ' || (i % 12)),
    'token_count', 200 + i % 300
  ) || CASE WHEN i % 97 = 0 THEN '{"ocr": true}' ELSE '{}' END::jsonb
FROM generate_series(0, 999999) AS i;

ANALYZE chunks;

Consultando un conjunto

Los dos operadores que usás todo el tiempo son ->, que devuelve jsonb, y ->>, que devuelve text. Acá un ejemplo de como funciona realmente:

SELECT metadata ->> 'source_type'  AS source_type,
       metadata -> 'headings' -> 0 AS first_heading
FROM chunks
WHERE id = 42;
 source_type | first_heading
-------------+---------------
 transcript  | "Chapter 5"

El heading conservó las comillas porque -> devolvió un valor jsonb, no un string. Encadená con -> mientras navegás, terminá con ->> cuando querés texto.

El operador que justifica el índice GIN es contención, @>, que pregunta si el documento contiene un sub-documento dado:

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT id, content
FROM chunks
WHERE metadata @> '{"ocr": true}';
 Bitmap Heap Scan on chunks (actual rows=10310 loops=1)
   Recheck Cond: (metadata @> '{"ocr": true}'::jsonb)
   Heap Blocks: exact=10310
   ->  Bitmap Index Scan on chunks_metadata_idx (actual rows=10310 loops=1)
         Index Cond: (metadata @> '{"ocr": true}'::jsonb)
 Planning Time: 0.090 ms
 Execution Time: 30.110 ms

Diez mil chunks con OCR sobre un millón, encontrados por el índice, en 30 milisegundos. Sin el índice esto sería un scan completo de la tabla en cada llamada.

El índice usó jsonb_path_ops, que es una elección deliberada frente al default jsonb_ops. La variante de path solo acelera el operador contención, y a cambio el índice es bastante más chico y más rápido justo para ese operador. La variante default soporta además los operadores de existencia de claves como ?. Todo mi filtrado es contención, así que me quedo con el índice más chico. Si necesitaras consultas del estilo “¿existe esta clave?”, deberías usar el default.

Cuando una clave escalar es muy usada, un índice btree de expresión le gana a GIN:

CREATE INDEX chunks_source_type_idx ON chunks ((metadata ->> 'source_type'));

Eso le da a los filtros de igualdad sobre metadata ->> 'source_type' un btree común, con estadísticas comunes del planificador, algo que GIN no ofrece. Además es el único tipo de índice que podría tener una columna json plana.

Para condiciones que los operadores no pueden expresar, jsonb habla el lenguaje de paths SQL/JSON:

SELECT count(*)
FROM chunks
WHERE metadata @? '$.token_count ? (@ > 450)';

El string de path: ‘$.token_count ? (@ > 450)’

  • $ es la raíz del documento JSON, así que para cada fila significa “el valor de metadata de esta fila”.
  • $.token_count navega hasta la clave token_count y produce ese valor.
  • ? (…) es una expresión de filtro. Toma lo que el path produjo hasta ahí y se queda solo con los ítems que cumplen la condición entre paréntesis.
  • @ adentro del filtro significa “el ítem que se está evaluando”, el mismo rol que cumple x en una lambda como x => x > 450.

El path completo se lee: “andá hasta token_count y quedate con el valor solo si es mayor a 450.”

Y desde Postgres 17 existe JSON_TABLE, que aplana documentos en filas en medio de la consulta, para que el resto de tu SQL trate el conjunto como una tabla. Acá desanida el array de headings y agrega sobre él:

SELECT jt.source_type, jt.heading, count(*) AS chunks
FROM chunks,
     JSON_TABLE(metadata, '$' COLUMNS (
       source_type text PATH '$.source_type',
       NESTED PATH '$.headings[*]' COLUMNS (heading text PATH '$')
     )) AS jt
GROUP BY jt.source_type, jt.heading
ORDER BY chunks DESC, jt.source_type, jt.heading
LIMIT 3;

FROM chunks, JSON_TABLE(metadata, …) es un lateral join implícito: por cada fila de chunks, Postgres le pasa la metadata de esa fila a JSON_TABLE, que produce cero o más filas, y cada fila producida se une a la fila de chunk que la generó.

metadata es el documento a consumir, y ’$’ es el path generador de filas: “empezá desde la raíz del documento”. Con ’$’ como patrón de fila, cada documento produce inicialmente una fila.

La cláusula COLUMNS define el esquema de la tabla virtual, una entrada por columna de salida:

  • source_type text PATH ‘$.source_type’ declara una columna source_type de tipo SQL text, que se llena evaluando el path $.source_type contra el documento de la fila actual. El path es relativo al patrón de fila, así que $ acá significa “el objeto metadata”.
  • NESTED PATH ‘$.headings[]’ COLUMNS (heading text PATH ’$’) es la parte que desanida. $.headings[] significa “cada elemento del array headings”, y el COLUMNS anidado corre una vez por elemento. Adentro, el path ’$’ ahora se refiere al elemento actual del array, no a la raíz del documento; el significado de $ se re-ancla en cada nivel de anidado. Así que un chunk cuya metadata tiene tres headings produce tres filas, cada una repitiendo el mismo source_type al lado de un heading distinto.
 source_type |  heading  | chunks
-------------+-----------+--------
 html        | Chapter 1 |  83334
 pdf         | Chapter 0 |  83334
 pdf         | Chapter 3 |  83334

La trampa de los updates

Esta es la parte que me hubiera gustado que alguien me explicara antes de tener que enfrentarla. Postgres guarda cualquier valor de columna más grande que unos dos kilobytes a través de TOAST: comprimido, rebanado y movido fuera de la fila principal. Un documento jsonb pesado pasa exactamente por esa maquinaria, y de ahí se desprenden dos consecuencias. Más sobre TOAST acá

Primero, leer una sola clave de un documento TOASTeado des-TOASTea el documento entero. Segundo, y peor, no existe el update parcial. Aunque esto parece quirúrgico:

UPDATE chunks
SET metadata = jsonb_set(metadata, '{status}', '"embedded"')
WHERE id = 42;

No lo es. jsonb_set arma un documento nuevo completo en memoria y el UPDATE escribe una versión nueva completa de la fila, TOAST incluido. Cambiá un flag de estado en un chunk cuya metadata pesa 50 KB y acabás de reescribir 50 KB, más el mantenimiento de índices.

La regla de diseño: el estado mutable no vive adentro de un documento jsonb grande. Estado de procesamiento, contadores de reintentos, timestamps que cambian: todos son escalares chicos que se actualizan seguido, así que van a sus propias columnas, donde un update toca bytes en lugar de kilobytes. La misma lógica explica por qué content es una columna text en mi tabla y no una clave en la metadata. El cuerpo del chunk es lo más grande de la fila, y me niego a arrastrarlo por una reescritura porque cambió un flag. Datos que cambian menos van en el conjunto, datos más frecuentes en las columnas.

¿Esto no es para lo que existen las bases documentales?

Sería una pregunta justa. Una columna jsonb adentro de una tabla relacional es Postgres absorbiendo en silencio el caso de uso de las bases documentales: almacenamiento sin esquema, consultas indexadas sobre estructura arbitraria, y mantenés transacciones, joins, claves foráneas y SQL alrededor. Para el pipeline de chunks, esa combinación es el argumento propiamente dicho. La metadata no tiene esquema, pero los chunks igual necesitan integridad referencial con sus documentos y los embeddings viven en la misma fila. Un document store separado o una base vectorial dedicada me darían dos sistemas más que operar y un problema de consistencia que mantener. Hay proyectos donde los sistemas dedicados se justifican; el mío no era una de esos.

Conclusiones

La decisión en sí sigue siendo chica. jsonb por defecto, json solo cuando el punto son los bytes originales. Lo que hizo que el tipo se ganara su lugar en mi pipeline fue todo lo que rodea esa elección: columnas de verdad para los campos que toco siempre, un índice GIN que coincide con el operador que uso de verdad, y el estado mutable afuera del conjunto para que TOAST nunca castigue un cambio menor.

La regla con la que me quedé es que las columnas guardan lo que sé, jsonb guarda lo que todavía no puedo saber, y las claves se promueven a columnas cuando demuestran que son permanentes.

Si estás por guardar chunks, payloads o salidas de modelos y estabas por agarrar una segunda base de datos, probá primero con una columna jsonb y un índice GIN.