Hugging Face · LLM Course · Capítulo 5
La librería Datasets: de un CSV local a un buscador semántico
El Capítulo 3 ya usó datasets para cargar y tokenizar. Este capítulo es todo lo que se
quedó afuera: datos que no están en el Hub, datasets que no entran en RAM, y — el cierre perfecto —
construir tu propio motor de búsqueda semántica desde cero.
Antes de leercap. 2 §3, tokenizers · cap. 3 §1, preparar datos
🎯 Al terminar vas a poder
- Cargar cualquier fuente — CSV, JSON, un DataFrame, un diccionario en memoria — como
Dataset, local o remota. - Usar
map,filter,remove_columnsywith_formatcon criterio: qué cambia el dato, qué cambia solo la vista, y cuándobatchedynum_procayudan de verdad. - Explicar por qué un dataset de 20 GB no necesita 20 GB de RAM, y cuándo pasar a
streaming=True. - Calcular un embedding de oración con mean pooling y attention mask, normalizarlo, y explicar por qué eso convierte el producto interno en similitud coseno.
- Armar un buscador semántico con FAISS en un notebook, y decir qué le falta para ser el RAG de producción que ya tenés.
1. Cuando el dataset no está en el Hub
load_dataset() no está atado al Hub — el primer argumento puede ser un tipo de archivo en
vez de un nombre de dataset:
| Formato | Script | Ejemplo |
|---|---|---|
| CSV / TSV | csv | load_dataset("csv", data_files="archivo.csv") |
| JSON / JSON Lines | json | load_dataset("json", data_files="archivo.jsonl") |
| Texto plano | text | load_dataset("text", data_files="archivo.txt") |
data_files acepta un path local, una URL remota, o un diccionario que mapea nombres de
split a archivos — y descomprime .gz/.zip/.tar automáticamente:
from datasets import load_dataset
data_files = {"train": "train.json.gz", "test": "test.json.gz"}
dataset = load_dataset("json", data_files=data_files, field="data")
Cargar desde una URL es literalmente lo mismo — data_files no distingue entre "local" y
"remoto", así que todo lo que aprendas acá aplica sin cambios a un archivo en tu disco o en un bucket.
2. Cortar y trocear
Para tener una primera impresión de un dataset grande, combiná shuffle() y
select() para tomar una muestra chica; usá filter() (con una lambda) para
sacar filas problemáticas antes de mapear una función que no tolera esos casos:
muestra = dataset["train"].shuffle(seed=42).select(range(1000))
# .map() sobre un valor None revienta — filtrar primero
dataset = dataset.filter(lambda x: x["condition"] is not None)
dataset = dataset.map(lambda x: {"condition": x["condition"].lower()})
El argumento batched=True de map() no es un detalle menor — es la diferencia
entre minutos y segundos. Los números reales del curso, tokenizando el mismo corpus:
| Configuración | Tokenizer rápido | Tokenizer lento |
|---|---|---|
batched=False | 59.2s | 5min 3s |
batched=True | 10.8s | 4min 41s |
batched=True, num_proc=8 | 6.5s | 41.3s |
Un tokenizer rápido con batched=True es ~30× más rápido que uno lento sin batchear — la
parte en Rust de tokenizers paraleliza internamente, algo que num_proc
(multiprocessing de Python) no puede igualar para el tokenizer rápido, pero sí ayuda mucho al lento.
Cuando una función de map() cambia el número de filas (por ejemplo, tokenizar con
return_overflowing_tokens=True parte una reseña larga en varios ejemplos), hay que decirle
explícitamente qué columnas viejas descartar o cómo remapearlas — si no, el error es un
ArrowInvalid por longitudes que no coinciden. La forma más simple es
remove_columns, que además es lo que querés casi siempre después de tokenizar — el modelo
no acepta columnas de texto:
def tokenize_and_split(examples):
return tokenizer(examples["review"], truncation=True, max_length=128,
return_overflowing_tokens=True) # una reseña larga → varios pedazos
tokenized = dataset.map(tokenize_and_split, batched=True,
remove_columns=dataset["train"].column_names) # quedan solo las columnas nuevas
print(len(dataset["train"]), len(tokenized["train"])) # p. ej. 138514 → 206772: más filas que antes
Para EDA con Pandas, set_format("pandas") cambia solo el formato de salida, no el
dato subyacente (que sigue siendo Arrow) — y train_test_split() te da un split de
validación con una línea, con semilla para reproducibilidad. Lo mismo vale para el formato que va al
modelo: with_format("torch") hace que cada acceso devuelva tensores en vez de listas, sin
convertir nada en disco. La diferencia entre las dos variantes es solo de estilo —
set_format modifica el objeto en el lugar, with_format devuelve uno nuevo:
ds = tokenized["train"]
print(type(ds[0]["input_ids"])) # <class 'list'>
ds_torch = ds.with_format("torch") # nuevo objeto; ds sigue devolviendo listas
print(type(ds_torch[0]["input_ids"])) # <class 'torch.Tensor'>
ds.set_format("torch", columns=["input_ids", "attention_mask", "label"]) # en el lugar, y solo esas columnas
ds.reset_format() # vuelve a listas / Python nativo
Lo que ninguna de las dos hace es tocar los datos: el archivo Arrow es el mismo antes y después. Si
querés una conversión real (una columna nueva con otro tipo), eso es un map.
3. Datasets que no entran en RAM: Apache Arrow y Memory-Mapping
La regla empírica clásica de Pandas establece que manipular un dataset requiere entre 5× y 10× su tamaño en memoria RAM. Si intentás cargar un archivo JSON o CSV de 20 GB en Pandas, el proceso colapsará exigiendo entre 100 y 200 GB de memoria física. La librería datasets de Hugging Face erradica este cuello de botella utilizando el estándar Apache Arrow y mapeo directo a memoria virtual del sistema operativo (memory mapping).
Arquitectura en Memoria: Filas de Python vs Columnas de Apache Arrow
¿Por qué Pandas y Python estándar sufren una explosión de memoria mientras Arrow permanece constante?
- Sobrecarga por Objeto en Python (Row-Oriented): En Python estándar, cada fila de una tabla es un diccionario o una lista que contiene punteros a instancias individuales de
PyObject. Un simple entero de 64 bits (que a nivel de CPU ocupa 8 bytes) en Python consume al menos 28 bytes de encabezado (tipo de objeto, conteo de referencias). Una cadena de caracteres pequeña añade 50 bytes adicionales más su longitud. Un dataset con 10 millones de filas y 10 columnas instancia 100 millones de objetos dispersos en el heap, provocando una fragmentación severa y destruyendo la localidad de caché del procesador (L1/L2 cache misses superiores al 40%). - Disposición Columnar de Apache Arrow: Arrow almacena los datos en memoria por columnas contiguas, sin envoltorios de Python. Para una columna de texto, Arrow utiliza únicamente tres buffers contiguos de bytes:
- Validity Bitmap: Un arreglo de bits contiguo donde cada bit (0 o 1) indica si el valor es nulo o válido (1 byte cubre 8 filas).
- Offsets Buffer: Un buffer contiguo de enteros de 32 o 64 bits que marca el inicio y fin en bytes de cada string dentro del buffer de datos.
- Data Buffer: Una secuencia contigua y compacta de bytes con todos los caracteres concatenados.
Zero-Copy IPC y Paginación por Demanda (Demand Paging)
Debido a que el formato de memoria de Arrow está estrictamente estandarizado, datasets implementa Zero-Copy IPC (Inter-Process Communication): transferir datos entre procesos de Python, extensiones de C++, Rust o motores analíticos (como Polars o DuckDB) no requiere serializar ni deserializar datos. Ambos procesos simplemente comparten un puntero a la misma región de memoria compartida sin costo de copia.
Cuando cargás un dataset local de 50 GB desde disco mediante load_from_disk() o load_dataset():
- El archivo
.arrowen disco se mapea al espacio de memoria virtual del proceso conmmap(MAP_SHARED). - La memoria residente (RSS) del proceso de Python permanece prácticamente en cero bytes al iniciar.
- Cuando el bucle de entrenamiento o el
DataLoadersolicita el batchi, la CPU genera Page Faults de hardware y el kernel de Linux transfiere únicamente las páginas físicas de 4 KB correspondientes a ese lote desde el disco al Page Cache de la RAM. - Una vez procesadas y copiadas a la VRAM de la GPU, si el sistema sufre presión de memoria, el kernel puede descartar inmediatamente esas páginas de RAM sin escribirlas a disco (ya que son de solo lectura).
Streaming Masivo con IterableDataset
Cuando el corpus supera la capacidad del almacenamiento local (por ejemplo, datasets de preentrenamiento como FineWeb o RedPajama, de decenas de terabytes), ni siquiera el disco es suficiente. En este escenario, streaming=True conmuta la arquitectura a un IterableDataset:
from datasets import load_dataset
# Descarga diferida (lazy evaluation) por HTTP chunked transfer
dataset = load_dataset("HuggingFaceFW/fineweb", split="train", streaming=True)
print(next(iter(dataset))) # Lee el primer ejemplo; el disco local no almacena nada
Un IterableDataset opera como un generador de Python sobre conexiones HTTP persistentes. Para mezclar ejemplos sin descargar el archivo completo, IterableDataset.shuffle(buffer_size=10_000) mantiene una ventana deslizante de 10.000 ejemplos en memoria RAM, extrayendo pseudo-aleatoriamente elementos del buffer y reponiéndolos con nuevos datos del flujo entrante.
La regla de Pandas dice que necesitás 5-10× la memoria del tamaño del dataset. datasets
no sigue esa regla porque no carga el dataset a RAM — lo trata como un archivo
memory-mapped en formato Apache Arrow: podés operar sobre 20 GB de datos con una
fracción de esa RAM, porque el sistema operativo trae a memoria solo las páginas que realmente tocás.
Cuando ni siquiera el disco alcanza (el ejemplo del original: 825 GB), streaming=True
cambia el tipo de retorno a IterableDataset — descarga y procesa un ejemplo a la vez, sin
bajar el dataset completo:
dataset = load_dataset("json", data_files=url, split="train", streaming=True)
next(iter(dataset)) # primer ejemplo, sin haber descargado el resto
IterableDataset.shuffle(buffer_size=...) mezcla dentro de un buffer, no el dataset entero
(no puede — no lo tiene completo en ningún lado), e interleave_datasets() combina varias
fuentes streameadas en una sola, alternando entre ellas.
streaming=True el dato ni
siquiera se descarga y los ejemplos pasan de a uno por un buffer. Es el mismo problema resuelto a dos
escalas: no cargar todo a RAM, y no bajar todo al disco.
🟢 Actualización 2026
El problema que este capítulo resuelve para "un dataset de texto" es, a escala de LLM, el problema por defecto: el preentrenamiento hoy se mide en billones de tokens, no en gigabytes. Memory mapping y streaming dejaron de ser un truco para casos extremos — son la forma normal de tocar cualquier corpus de preentrenamiento serio.
4. Crear tu propio dataset
El ejemplo del original — scrapear los issues de GitHub del propio repo de datasets vía la
REST API, filtrar pull requests, sumar los comentarios de cada issue — sigue siendo el mejor ejemplo
didáctico de un patrón real: API pública → JSON crudo → load_dataset("json", ...).
No hay nada especial en el formato final; cualquier fuente que puedas convertir a JSON Lines entra por
la misma puerta que usaste en la sección 1.
Y ni siquiera hace falta pasar por un archivo: si los datos ya están en memoria — un DataFrame que
armaste en Pandas, o listas paralelas que salieron de un scraper — Dataset.from_pandas y
Dataset.from_dict los convierten a Arrow directamente:
from datasets import Dataset
ds = Dataset.from_dict({"text": ["primer issue", "segundo issue"], "n_comments": [3, 0]})
ds = Dataset.from_pandas(df) # agrega una columna "__index_level_0__" si el índice del df no es el default
ds = Dataset.from_pandas(df, preserve_index=False) # ...salvo que le digas que no
print(ds.features) # los tipos se infieren: Value('string'), Value('int64')...
Es el camino inverso de set_format("pandas"): de Pandas a Arrow, en vez de una vista Arrow
con cara de Pandas. Un patrón habitual es hacer la limpieza fea en un DataFrame chico y pasar a
Dataset para el map tokenizado y el push_to_hub.
Una vez armado, subirlo es la misma llamada que ya conocés del Capítulo 4 — push_to_hub()
también existe en Dataset, no solo en modelos:
dataset.push_to_hub("mi-usuario/github-issues")
Y como con los modelos, un dataset sin documentar es un dataset a medio publicar — la
dataset card (mismo README.md, mismo principio) es lo que le permite a otra
persona (o a vos, en seis meses) saber qué hay adentro sin tener que inspeccionar filas a mano.
5. Búsqueda semántica con FAISS
Esta es la sección que más vale la pena mirar de cerca, porque es — a mano, en unas pocas líneas — exactamente lo que hace un sistema de RAG: convertir texto a vectores, e indexarlos para buscar por significado en vez de por palabra exacta.
- Embeber cada documento: un modelo encoder-only (Capítulo 1) convierte cada texto en un vector, agrupando el estado oculto del token
[CLS]como resumen de toda la secuencia. - Indexar los vectores:
Dataset.add_faiss_index(column="embeddings")arma una estructura de búsqueda por similitud sobre esa columna. - Embeber la pregunta con el mismo modelo, y pedirle al índice los vecinos más cercanos:
pregunta_emb = get_embeddings(["¿Cómo cargo un dataset offline?"])
scores, ejemplos = dataset.get_nearest_examples("embeddings", pregunta_emb, k=5)
Ni una palabra de la pregunta necesita aparecer literalmente en la respuesta — lo que se compara son
vectores de significado, no substrings. Esa es la diferencia de fondo entre búsqueda semántica y
Ctrl+F.
🔧 Esto es literalmente el RAG de tu propio proyecto
Cambiá multi-qa-mpnet-base-dot-v1 por intfloat/multilingual-e5-large,
add_faiss_index() por una collection de ChromaDB, y "issues de GitHub" por "81 obras de
Lacan y Freud", y tenés exactamente la memoria de
interpretante-lacaniano
— 45.904 pasajes embebidos, indexados, y consultados por similitud semántica. La diferencia entre el
ejemplo de este capítulo y tu sistema en producción no es de concepto, es de escala e infraestructura
de persistencia.
🟢 Actualización 2026
Dataset.add_faiss_index() es perfecto para un notebook con unos miles de filas — el
índice vive en memoria y desaparece con el proceso. Para algo persistente, que crezca con el tiempo y
soporte filtrar por metadata (por autor, por fecha, por idioma), la pieza que se usa en producción es
una base de datos vectorial dedicada (ChromaDB, Qdrant, pgvector...) — resuelve el
mismo problema de fondo, con almacenamiento en disco e inserciones incrementales en vez de reconstruir
el índice cada vez.
Un detalle de pooling que vale la pena señalar porque cambia según el modelo: el original usa
CLS pooling (tomar el vector del token especial [CLS]), pero muchos modelos de embeddings
modernos —incluido multilingual-e5-large— promedian los vectores de todos los
tokens en vez de quedarse con uno solo. Cuál usar no es una preferencia estética: hay que usar el que
el modelo específico espera, documentado en su model card.
Las dos variantes, en código. El detalle que define la validez matemática del mean pooling es la attention mask:
Formalmente, dado un lote de secuencias donde cada token tiene un estado oculto de salida y una máscara binaria (donde 1 representa texto real y 0 representa relleno [PAD]), el embedding condensado de la oración se define estrictamente como:
¿Por qué es indispensable este enmascaramiento y por qué no se puede simplemente dividir por la longitud total del tensor ?
- Los tokens de padding NO son vectores nulos (): Debido a las capas de Layer Normalization () y a los biases de las capas feed-forward en la arquitectura Transformer, las posiciones correspondientes a
[PAD]producen vectores con magnitud significativa. Si se promedian sin máscara, los tokens de padding actúan como un atractor artificial que desvía drásticamente el significado semántico del texto. - Sesgo de Dilución Dependiente de la Longitud: Si se dividiera la suma enmascarada por la longitud máxima del batch (ej. 512) en lugar de la longitud real (ej. 10 tokens), la representación de una oración corta sufriría una atenuación artificial de más de , distorsionando cualquier comparación de distancias o proyecciones.
import torch
from transformers import AutoTokenizer, AutoModel
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
model = AutoModel.from_pretrained(model_ckpt)
def cls_pooling(model_output):
return model_output.last_hidden_state[:, 0] # el vector del primer token ([CLS])
def mean_pooling(model_output, attention_mask):
tokens = model_output.last_hidden_state # [batch, seq, hidden]
mask = attention_mask.unsqueeze(-1).float() # [batch, seq, 1]: 1 en texto real, 0 en padding
return (tokens * mask).sum(1) / mask.sum(1).clamp(min=1e-9)
def get_embeddings(textos, pooling="mean"):
enc = tokenizer(textos, padding=True, truncation=True, return_tensors="pt")
with torch.no_grad():
out = model(**enc)
emb = mean_pooling(out, enc["attention_mask"]) if pooling == "mean" else cls_pooling(out)
return torch.nn.functional.normalize(emb, p=2, dim=1) # norma 1 → producto interno = coseno
Demostración Matemática: Equivalencia en la Hiperesfera Unitaria
La última línea del pipeline de embeddings (torch.nn.functional.normalize(emb, p=2, dim=1)) es la pieza angular que unifica la geometría del espacio vectorial. FAISS por defecto implementa índices basados en Distancia Euclidiana (), mientras que los modelos de representación semántica se entrenan maximizando la Similitud Coseno o el Producto Interno.
Veamos la demostración paso a paso de por qué, bajo normalización euclidiana, ambas métricas inducen exactamente el mismo orden topológico:
Sean dos vectores normalizados en la hiperesfera unitaria , de modo que . Desarrollemos el cuadrado de su distancia euclidiana:
Sustituyendo las normas unitarias ( y ):
Despejando la similitud coseno (o producto interno):
Dado que la función es estrictamente decreciente y monótona respecto al cuadrado de la distancia, se concluye la equivalencia de ranking:
Consecuencia práctica en FAISS y Bases Vectoriales: El índice por defecto de FAISS (IndexFlatL2) devuelve con exactitud matemática el mismo orden topológico de vecinos más cercanos que un índice de producto interno (IndexFlatIP). Sin normalizar, en cambio, un pasaje largo con norma superaría espuriamente en producto interno a un pasaje corto de norma , aun cuando el pasaje corto compartiera un ángulo coseno mucho más cercano a la consulta.
Para configurar explícitamente el índice en FAISS con producto interno sobre vectores unitarios:
import faiss
dataset = dataset.map(lambda x: {"embeddings": get_embeddings(x["text"]).numpy()}, batched=True, batch_size=64)
dataset.add_faiss_index(column="embeddings", metric_type=faiss.METRIC_INNER_PRODUCT)
pregunta_emb = get_embeddings(["¿Cómo cargo un dataset offline?"]).numpy()[0]
scores, ejemplos = dataset.get_nearest_examples("embeddings", pregunta_emb, k=5)
# con vectores normalizados, scores son cosenos: 1.0 = idéntico, 0 = nada que ver
Ese es el mismo e5-large + ChromaDB de tu proyecto, en su forma mínima: e5 espera mean
pooling y vectores normalizados (y los prefijos query: / passage: en el texto,
según su model card), y ChromaDB con hnsw:space = "cosine" hace exactamente lo que hace
acá METRIC_INNER_PRODUCT sobre vectores de norma 1.
6. Errores comunes
La mayoría viene de confundir "cambiar los datos" con "cambiar cómo los veo", o de esperar que map adivine.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
ArrowInvalid: Column 1 named review expected length 1000 but got length 1463 | La función de map devuelve más (o menos) filas que las que recibió y las columnas viejas no se pueden alinear | remove_columns=dataset.column_names en el map, o repetir las columnas viejas por cada pedazo nuevo (§2) |
num_proc=8 con un tokenizer rápido tarda igual o más que sin él | El tokenizer rápido ya paraleliza en Rust; ocho procesos de Python solo agregan overhead de serialización | batched=True y listo; reservar num_proc para funciones de Python puro o tokenizers lentos (§2) |
AttributeError: 'NoneType' object has no attribute 'lower' en medio de un map | Filas con None en la columna; el JSON de origen tenía nulos | filter(lambda x: x["col"] is not None) antes del map (§2) |
Hiciste set_format("torch"), guardaste con save_to_disk, y al recargar vuelven listas | El formato es una vista de salida, no una conversión: no se persiste con los datos | Volver a llamar set_format/with_format después de cargar; si necesitás el dato convertido, es un map (§2) |
El DataLoader falla con TypeError por una columna de strings | Quedaron columnas de texto en el dataset formateado a torch | remove_columns del texto original, o set_format("torch", columns=[...]) solo con las columnas numéricas (§2) |
| El proceso se queda sin RAM al iterar un dataset que "debería" entrar por memory mapping | Se convirtió todo a Python de golpe (dataset[:], list(dataset), to_pandas() sin filtrar) | Iterar por batches, select un rango, o streaming=True (§3) |
| La búsqueda semántica devuelve siempre los mismos documentos largos, sin importar la pregunta | Embeddings sin normalizar en un índice de producto interno: los vectores "grandes" ganan siempre | Normalizar L2 antes de indexar y antes de consultar (§5) |
| Resultados de búsqueda mediocres con un modelo que "debería" andar bien | Pooling incorrecto para el modelo (CLS donde espera mean, o al revés), o faltan los prefijos que el modelo espera en el texto | Leer la model card del modelo de embeddings y copiar exactamente su receta (§5) |
7. Resumen
load_dataset()no depende del Hub — un tipo de archivo (csv/json/text) másdata_filescubre cualquier fuente local o remota; yfrom_pandas/from_dictni siquiera necesitan un archivo.mapyfiltercambian los datos;set_format/with_formatcambian solo cómo los ves. Simapcambia el número de filas,remove_columnsno es opcional.map(batched=True)es la optimización más importante de este capítulo — hasta 30× más rápido combinado con un tokenizer rápido.- Memory mapping (Arrow) y streaming resuelven el mismo problema en dos escalas distintas: no cargar todo a RAM, y no descargar todo al disco.
- Crear un dataset propio es API pública → JSON →
load_dataset()→push_to_hub(), con una dataset card documentando qué hay adentro. - Embeddings + un índice de similitud es búsqueda semántica — la misma idea detrás de cualquier sistema de RAG, incluido el que ya corre en tu propio proyecto. Mean pooling con attention mask y normalización L2 son las dos líneas que separan "funciona" de "funciona bien".
8. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. ¿Qué diferencia hay entre dataset.map(f) y dataset.with_format("torch") en términos de lo que pasa en disco?
map ejecuta f sobre cada fila y escribe un archivo Arrow nuevo (cacheado) con el resultado: cambia los datos. with_format no escribe nada — registra que, al acceder, las columnas se devuelvan como tensores en vez de listas. Por eso el formato se pierde al guardar y recargar. (§2)
2. Tokenizás con return_overflowing_tokens=True y map da ArrowInvalid. ¿Por qué, y cuál es la salida más simple?
La función devolvió más filas de las que recibió (una reseña larga se partió en varias), y datasets no sabe qué hacer con las columnas viejas, que siguen teniendo el número original de filas. remove_columns=dataset.column_names descarta las viejas y deja solo las nuevas; alternativamente, repetís cada columna vieja según overflow_to_sample_mapping. (§2)
3. Un tokenizer rápido, batched=True: agregás num_proc=8 y no mejora. ¿Es un bug?
No. El tokenizer rápido ya paraleliza en Rust dentro de cada batch; ocho procesos de Python solo agregan el costo de serializar datos entre procesos. En la tabla de §2, el salto grande es batched=True (59 s → 11 s); num_proc ayuda mucho al tokenizer lento (5 min → 41 s), que sí es Python puro. (§2)
4. ¿Por qué datasets puede manejar 20 GB con 2 GB de RAM, y en qué caso ni eso alcanza?
Porque el dataset vive en disco en formato Arrow y se mapea a memoria: el sistema operativo trae solo las páginas que tocás. No alcanza cuando ni el disco alcanza (el ejemplo de 825 GB del original) — ahí streaming=True devuelve un IterableDataset que descarga y procesa de a un ejemplo. (§3)
5. En mean pooling, ¿qué pasaría si dividieras por seq_len en vez de por mask.sum(1)?
Las frases cortas de un batch con padding quedarían "diluidas": sumaste solo sus tokens reales (la mask ya anuló el padding) pero dividís por el largo total del tensor, así que su vector sale más chico en proporción a cuánto padding tuvieron. Como después normalizás L2, la dirección no cambia — pero si no normalizás, el sesgo se mete directo en el ranking. (§5)
6. ¿Por qué normalizar los embeddings hace que un índice de producto interno mida similitud coseno?
El coseno entre dos vectores es su producto interno dividido por el producto de sus normas. Si las dos normas son 1, la división no hace nada y el producto interno es el coseno. Y sobre vectores de norma 1, ordenar por distancia L2 da el mismo orden que ordenar por coseno — así que el índice default de FAISS también funciona. (§5)
9. Para practicar
✏️ Ejercicio 1 — datos vs. vista, con tus propios ojos
Armá un dataset en memoria, tokenizalo con remove_columns, y comprobá qué cambia con
with_format. Solo CPU; el tokenizer de DistilBERT pesa unos cientos de KB.
from datasets import Dataset
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("distilbert-base-uncased")
ds = Dataset.from_dict({
"text": ["cargar un dataset offline", "el modelo no entra en memoria", "cómo subo un dataset al hub", None],
"label": [0, 1, 0, 1],
})
print(ds.features)
ds = ds.filter(lambda x: x["text"] is not None) # sacá esta línea y mirá qué error da el map
tokenized = ds.map(lambda x: tok(x["text"], truncation=True), batched=True, remove_columns=["text"])
print(tokenized.column_names, type(tokenized[0]["input_ids"]))
t = tokenized.with_format("torch")
print(type(t[0]["input_ids"]), type(tokenized[0]["input_ids"])) # tensor vs. list: el original no cambió
print(t.cache_files == tokenized.cache_files) # mismo archivo Arrow detrás
Pista / qué esperar
Sin el filter, el map falla adentro del tokenizer con un error sobre NoneType. Después del map, column_names no incluye text. Y las dos últimas líneas muestran el punto de §2: el formato es una vista — mismos archivos de cache, distinto tipo al acceder.
✏️ Ejercicio 2 — mean pooling, normalización y coseno a mano
Con un modelo de embeddings chico que espera mean pooling, embebé cuatro frases y calculá la matriz de similitud. Comprobá que producto interno = coseno después de normalizar. CPU, ~90 MB de modelo.
import torch
from transformers import AutoTokenizer, AutoModel
ckpt = "sentence-transformers/all-MiniLM-L6-v2"
tok, model = AutoTokenizer.from_pretrained(ckpt), AutoModel.from_pretrained(ckpt)
def embed(textos):
enc = tok(textos, padding=True, truncation=True, return_tensors="pt")
with torch.no_grad():
h = model(**enc).last_hidden_state
mask = enc["attention_mask"].unsqueeze(-1).float()
return (h * mask).sum(1) / mask.sum(1)
frases = ["How do I load a dataset without internet?", "Loading data offline",
"The model runs out of GPU memory", "My cat sleeps all day"]
e = embed(frases)
print(e.norm(dim=1)) # normas distintas de 1 y distintas entre sí
en = torch.nn.functional.normalize(e, p=2, dim=1)
print((en @ en.T).round(decimals=2)) # producto interno de normalizados
print(torch.nn.functional.cosine_similarity(e[0], e[1], dim=0)) # coseno de los crudos: mismo número
Pista / qué esperar
La diagonal de la matriz es 1,0; las frases 1 y 2 tienen la similitud más alta del resto (hablan de lo mismo con palabras distintas); la del gato es la más baja contra todas. El coseno de los vectores crudos coincide con el producto interno de los normalizados hasta el último decimal: es la identidad de §5.
✏️ Ejercicio 3 — el buscador semántico completo, en 15 líneas
Sumá FAISS al ejercicio anterior: indexá las frases y consultá con una pregunta que no comparta ninguna
palabra con su respuesta. Necesitás pip install faiss-cpu.
import faiss
from datasets import Dataset
ds = Dataset.from_dict({"text": frases})
ds = ds.map(lambda x: {"emb": torch.nn.functional.normalize(embed(x["text"]), dim=1).numpy()}, batched=True)
ds.add_faiss_index(column="emb", metric_type=faiss.METRIC_INNER_PRODUCT)
q = torch.nn.functional.normalize(embed(["reading files with no network connection"]), dim=1).numpy()[0]
scores, ej = ds.get_nearest_examples("emb", q, k=2)
for s, t in zip(scores, ej["text"]):
print(f"{s:.2f} {t}")
Pista / qué esperar
Los dos primeros resultados son las frases sobre cargar datos offline, con scores claramente por encima de las otras dos, aunque "network connection" no aparece en ninguna. Probá sacar la normalización de la consulta (no del índice): el orden puede mantenerse pero los scores dejan de ser cosenos interpretables.
10. Fuentes y para seguir
- Hugging Face LLM Course — Chapter 5: The 🤗 Datasets library — el capítulo original, con el ejemplo completo de los issues de GitHub y el buscador con FAISS.
- Datasets — documentación — la referencia de
map,filter, formatos, streaming yadd_faiss_index; la guía "Process" es la que más vas a abrir. - Apache Arrow — el formato columnar detrás de todo el capítulo; leé la sección de memory mapping para entender §3 de verdad.
- FAISS (Facebook AI Similarity Search) — la librería de índices vectoriales; su wiki explica cuándo un índice plano deja de alcanzar y qué usar después (IVF, HNSW).
- Sentence-BERT (Reimers & Gurevych, 2019) — el paper que estableció mean pooling + coseno como la receta estándar de embeddings de oración.
- Text Embeddings by Weakly-Supervised Contrastive Pre-training (Wang et al., 2022) — el paper de E5, la familia del
multilingual-e5-largede tu RAG; explica los prefijosquery:/passage:.