Módulo 5: Ingestión incremental e idempotente
Hashing de contenido para idempotencia
Descripción
La Lección 02 mostró el problema con números reales: reingestar sin ningún control duplica cada chunk, corrida tras corrida. Esta lección construye la primera pieza de la solución — la huella digital de cada chunk — y la usa para escribir un chunk store en sqlite3 que sabe, antes de escribir cualquier fila, si ese contenido exacto ya está guardado.
La idea central cabe en una frase: un chunk con el mismo contenido siempre produce el mismo hash, sin importar cuándo ni cuántas veces lo calcules. Esa propiedad —determinismo— es lo que convierte un hash en un identificador de contenido confiable, y es lo que le permite al chunk store responder, sin ambigüedad, la pregunta "¿ya tengo esto guardado?".
Conexión con el módulo
Esta lección resuelve directamente el problema que la Lección 02 dejó planteado: reemplaza el INSERT ciego de naive_ingest() por un upsert_chunk() que primero pregunta, con el hash, si hace falta escribir algo. El chunk store que se crea aquí (create_chunk_store, la tabla chunks con su columna content_hash) es la base sobre la que la Lección 04 agrega la detección a nivel de documento.
Analogía: la huella digital, no el nombre
Volviendo al archivista de las lecciones anteriores: hasta ahora, cuando llegaba una página, la única pregunta que sabía hacerse era "¿tengo ya una página con este mismo nombre de archivo?" — y ni siquiera eso: naive_ingest() de la Lección 02 no se hacía ninguna pregunta, archivaba directo. Esta lección le da al archivista una lupa y una libreta de huellas digitales. Antes de archivar cualquier página, la escanea de punta a punta y calcula un número corto que resume exactamente ese contenido — cambiar una sola coma en la página cambia por completo el número. Si esa huella exacta ya está en la libreta, no hace falta archivar nada de nuevo: la página que tiene es idéntica, byte a byte, a la que llegó. Si la huella es distinta —aunque el nombre del documento sea el mismo de siempre—, algo cambió, y sí hace falta archivar la versión nueva.
Esa lupa es hashlib.sha256. Esa libreta es la columna content_hash de la tabla chunks.
content_hash: la huella digital de un chunk
hashlib.sha256 toma cualquier secuencia de bytes y produce un resumen de 256 bits (32 bytes, representados como 64 caracteres hexadecimales con .hexdigest()). Dos propiedades lo hacen perfecto para esto:
- Determinista. El mismo contenido de entrada siempre produce exactamente el mismo hash — hoy, mañana, en otra máquina, no importa. No hay ningún componente aleatorio ni dependiente del reloj.
- Sensible a cualquier cambio. Cambiar un solo carácter del contenido produce un hash completamente distinto (el llamado "efecto avalancha") — no hay forma de que un cambio pequeño en el texto produzca un cambio pequeño en el hash; el hash nuevo no se parece en nada al viejo.
import hashlib
from reservo_corpus import Chunk
def content_hash(chunk: Chunk) -> str:
"""A stable fingerprint of a chunk's content: same content -> same hash,
always, on any machine, on any run, forever. Nothing time-based goes in."""
payload = f"{chunk.doc_id}\x1f{chunk.section}\x1f{chunk.position}\x1f{chunk.text}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
El hash no se calcula solo sobre chunk.text — se calcula sobre doc_id, section, position y text juntos, separados por \x1f (el carácter de control "unit separator", pensado exactamente para unir campos sin arriesgarse a que uno de los valores reales contenga el separador y arme un payload ambiguo). La razón: dos chunks con el mismo texto pero de documentos distintos, o el mismo texto en una sección distinta, deben tratarse como identidades distintas para el store — el Ejercicio 3 de esta lección explora qué pasaría si hasheáramos solo text.
from reservo_corpus import build_corpus
chunks = build_corpus()
sample = next(c for c in chunks if c.chunk_id == "no-show-policy-001")
h1 = content_hash(sample)
h2 = content_hash(sample)
print("chunk_id:", sample.chunk_id)
print("content_hash:", h1)
print("length:", len(h1), "hex characters")
print("same hash computed twice:", h1 == h2)
other = next(c for c in chunks if c.chunk_id == "refund-policy-001")
print("a different chunk has a different hash:", content_hash(other) != h1)
Qué esperar (ejecutado):
chunk_id: no-show-policy-001
content_hash: 61f4991efe35cd7375e2bd07bc1ed837c7196e68bb3843e82053e4904c629d0b
length: 64 hex characters
same hash computed twice: True
a different chunk has a different hash: True
no-show-policy-001 —el chunk que dice, textualmente, "No-shows are charged the full amount of the booking... the no-show fee equals the entire reserved price"— siempre produce ese mismo hash de 64 caracteres. Calculado una vez o mil veces, hoy o dentro de un año, el resultado es idéntico mientras el contenido del chunk no cambie ni un carácter. Esa es exactamente la propiedad que "detectar cambios" necesita: si el hash guardado coincide con el hash recién calculado, el contenido es —con una certeza práctica total— el mismo.
El chunk store: una tabla sqlite3 con content_hash
El store necesita, por cada chunk, los mismos seis campos que ya tiene Chunk (chunk_id, doc_id, title, section, position, text) más dos nuevos: content_hash (la huella con la que se guardó esa fila) e ingested_at (cuándo se escribió — usando siempre INGESTION_DATE, nunca datetime.now()). chunk_id es la clave primaria: por diseño de ingest_document (Módulo 1), cada chunk_id identifica una posición única dentro de un documento (f"{doc_id}-{i:03d}"), así que no puede haber dos filas legítimas con el mismo chunk_id.
import sqlite3
def create_chunk_store(path: str) -> sqlite3.Connection:
conn = sqlite3.connect(path)
conn.execute("""
CREATE TABLE IF NOT EXISTS chunks (
chunk_id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL,
title TEXT NOT NULL,
section TEXT NOT NULL,
position INTEGER NOT NULL,
text TEXT NOT NULL,
content_hash TEXT NOT NULL,
ingested_at TEXT NOT NULL
)
""")
conn.commit()
return conn
path acepta cualquier ruta de archivo, pero también el valor especial ":memory:" — una base de datos que vive solo en RAM, nunca toca el disco, y desaparece cuando el proceso termina. Todo este módulo usa ":memory:" porque el chunk store, aquí, es un ejercicio autocontenido y desechable; en un sistema de producción real ese path apuntaría a un archivo persistente o a un servidor de base de datos, pero la lógica de arriba no cambiaría en una sola línea.
PRIMARY KEY sobre chunk_id es lo que la tabla chunks_naive de la Lección 02 no tenía — es la restricción estructural que hace imposible, a nivel de la base de datos, que existan dos filas con el mismo chunk_id. Pero una clave primaria sola solo evita duplicados exactos de la fila completa si intentas un INSERT simple (fallaría con un error); lo que hace falta es decidir qué hacer cuando ya existe una fila con ese chunk_id: ignorarla si el contenido es igual, sobrescribirla si cambió. Eso es exactamente lo que hace upsert_chunk.
upsert_chunk: escribir solo si hace falta
from reservo_corpus import INGESTION_DATE
def upsert_chunk(conn: sqlite3.Connection, chunk: Chunk) -> bool:
"""Writes `chunk` only if it's new or its content_hash changed since the
last write. Returns True if a write happened, False if it was a no-op."""
h = content_hash(chunk)
row = conn.execute(
"SELECT content_hash FROM chunks WHERE chunk_id = ?", (chunk.chunk_id,)
).fetchone()
if row is not None and row[0] == h:
return False # identical content already stored -- idempotent no-op
conn.execute("""
INSERT INTO chunks (chunk_id, doc_id, title, section, position, text, content_hash, ingested_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chunk_id) DO UPDATE SET
title=excluded.title, section=excluded.section, position=excluded.position,
text=excluded.text, content_hash=excluded.content_hash, ingested_at=excluded.ingested_at
""", (chunk.chunk_id, chunk.doc_id, chunk.title, chunk.section, chunk.position,
chunk.text, h, INGESTION_DATE))
return True
Tres pasos, en orden:
- Calcular el hash del chunk que quieres escribir. Es el hash del contenido nuevo, todavía no comparado con nada.
- Buscar si ya hay una fila con ese
chunk_id, y si la tiene, comparar su hash guardado contra el nuevo. Si coinciden, no hace falta ningúnINSERTniUPDATE— el store ya tiene exactamente este contenido, y tocar la fila sería trabajo (y una escritura a disco) sin ningún beneficio. - Si no coinciden (o la fila no existía), escribir.
INSERT ... ON CONFLICT(chunk_id) DO UPDATE SET ...es la cláusula upsert de SQLite: intenta insertar una fila nueva, y si ya existe una con esa clave primaria, en vez de fallar, actualiza sus columnas con los valores nuevos. Cubre los dos casos —chunk nuevo y chunk modificado— con una sola sentencia.
if __name__ == "__main__":
chunks = build_corpus()
conn = create_chunk_store(":memory:")
writes_run1 = sum(upsert_chunk(conn, c) for c in chunks)
total_run1 = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
print(f"run 1: {writes_run1} writes, {total_run1} rows in the store")
writes_run2 = sum(upsert_chunk(conn, c) for c in chunks)
total_run2 = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
print(f"run 2: {writes_run2} writes, {total_run2} rows in the store")
Qué esperar (ejecutado):
run 1: 57 writes, 57 rows in the store
run 2: 0 writes, 57 rows in the store
Este es el resultado que la Lección 02 no lograba: la corrida 2, sobre el mismo corpus, no escribe ni una sola fila —upsert_chunk calculó el hash de cada uno de los 57 chunks, encontró que coincidía exactamente con lo ya guardado, y no tocó nada— y el total se queda en 57, no en 114. sum(upsert_chunk(conn, c) for c in chunks) suma True/False como 1/0, así que ese número es literalmente la cantidad de filas que de verdad cambiaron.
Errores comunes
- Calcular el hash de todo el chunk como un objeto Python (
hash(chunk)), no de su contenido conhashlib. La función builtinhash()de Python no es determinista entre procesos para varios tipos (por seguridad, Python aleatoriza el hash de strings entre ejecuciones distintas del intérprete, salvo que fijesPYTHONHASHSEED). Un identificador de contenido para un sistema de producción necesita ser estable entre procesos y máquinas — por esohashlib.sha256, nuncahash(). - Comparar el hash nuevo contra el hash de la corrida anterior en memoria, en vez de contra lo que hay guardado en la tabla. Si el proceso se reinicia (lo normal: la ingestión corre como un job nuevo cada vez, no como un proceso que vive para siempre), cualquier estado en memoria de la corrida anterior desaparece. La comparación tiene que ser siempre contra el store persistente (
SELECT content_hash FROM chunks WHERE chunk_id = ?), nunca contra una variable de la corrida actual. - Usar
INSERTsimple en vez deINSERT ... ON CONFLICT DO UPDATE. UnINSERTsimple sobre una clave primaria que ya existe falla consqlite3.IntegrityError— no actualiza la fila. Sin la cláusulaON CONFLICT, tendrías que envolver cadaINSERTen untry/excepty hacer unUPDATEmanual en elexcept; el upsert hace las dos cosas en una sola sentencia atómica. - Olvidar que
upsert_chunkdevuelveFalseno es un error — es información valiosa. Es tentador tratar cualquierFalsecomo "algo salió mal". Aquí significa exactamente lo contrario: el store ya estaba correcto, y no hacer nada fue la decisión correcta. Contar cuántosupsert_chunkdevolvieronTrueen una corrida (como hacewrites_run1/writes_run2arriba) es, de hecho, la métrica más útil para saber cuánto trabajo real hizo una ingestión.
Ejercicios
Ejercicio 1: El hash de otro chunk (Fácil)
Calcula el content_hash del chunk payment-methods-faq-001 (la sección "Does Reservo Accept Cash?"). Confirma que es distinto del hash de no-show-policy-001 visto en el ejemplo trabajado, y que calcularlo dos veces da el mismo resultado.
Ver solución
sample = next(c for c in chunks if c.chunk_id == "payment-methods-faq-001")
h = content_hash(sample)
print("chunk_id:", sample.chunk_id)
print("section:", sample.section)
print("content_hash:", h)
print("same hash twice:", h == content_hash(sample))
Salida esperada:
chunk_id: payment-methods-faq-001
section: Does Reservo Accept Cash?
content_hash: fc3bb46622971a8a8e2285a5346e8e2df5288f86120aa0beda320c4078a45a67
same hash twice: True
Explicación: el hash es completamente distinto al de no-show-policy-001 (61f4991e... contra fc3bb466...) — ningún patrón visible en común, aunque los dos chunks son cortos fragmentos de FAQ/política con estructura similar. Eso es exactamente el efecto avalancha: dos entradas distintas, aunque se parezcan como texto, producen hashes que no comparten ningún parecido. Calcularlo dos veces confirma el determinismo: mismo chunk, mismo hash, siempre.
Ejercicio 2: Un solo carácter cambia todo (Medio)
Usando dataclasses.replace, crea una copia de payment-methods-faq-001 con un único cambio: el último carácter de text (un punto .) reemplazado por un signo de exclamación !. Calcula el content_hash de la copia y confirma que no se parece en nada al original, más allá de ese único carácter cambiado.
Ver solución
from dataclasses import replace
sample = next(c for c in chunks if c.chunk_id == "payment-methods-faq-001")
mutated = replace(sample, text=sample.text[:-1] + "!")
print("original last char:", repr(sample.text[-1]))
print("mutated last char:", repr(mutated.text[-1]))
print("original hash:", content_hash(sample))
print("mutated hash: ", content_hash(mutated))
print("hashes differ:", content_hash(sample) != content_hash(mutated))
Salida esperada:
original last char: '.'
mutated last char: '!'
original hash: fc3bb46622971a8a8e2285a5346e8e2df5288f86120aa0beda320c4078a45a67
mutated hash: 16b2fd0d73732c9fb4df5c642d2b0f96f2b3c3100b5761508136ae7381ca86c5
hashes differ: True
Explicación: un solo carácter del texto —el punto final, cambiado a un signo de exclamación— produce un hash que no comparte ni el primer carácter con el original. Esto es exactamente lo que hace que content_hash sea confiable para detectar cambios: no hay forma de que una edición mínima "casi" coincida con el hash viejo y se cuele como un falso "sin cambios". Si el hash guardado y el hash recién calculado difieren en aunque sea un bit del contenido, van a ser dos strings de 64 caracteres completamente distintos.
Ejercicio 3: ¿Por qué no hashear solo text? (Difícil)
content_hash arma el payload con doc_id, section, position y text, no solo text. Imagina dos chunks hipotéticos, de documentos distintos, que por pura coincidencia tuvieran exactamente el mismo texto (por ejemplo, dos manuales de sala que terminan compartiendo la frase exacta "Building-wide wifi is included at no extra cost." en su sección de equipamiento). Si content_hash calculara el hash solo sobre text, ¿qué problema aparecería al guardarlos en el chunk store? Piensa la respuesta antes de mirar la solución, después confirma con código que en el corpus real de 57 chunks no existe ningún par de textos idénticos.
Ver solución
from collections import Counter
texts = Counter(c.text for c in chunks)
duplicated_texts = [text for text, n in texts.items() if n > 1]
print("pares de texto idéntico en el corpus real:", len(duplicated_texts))
Salida esperada:
pares de texto idéntico en el corpus real: 0
Explicación: en el chunk store de esta lección, chunk_id —no content_hash— es la clave primaria, así que un hash solo-de-text no rompería la unicidad de las filas: dos chunks con chunk_id distinto seguirían siendo dos filas distintas. El problema aparecería en un lugar más sutil: si dos chunks de documentos diferentes tuvieran el mismo text, un hash solo-de-text les asignaría el mismo content_hash — y cualquier lógica que use el hash como señal de identidad de contenido (por ejemplo, "¿este texto ya apareció en algún otro lado del corpus?", una pregunta real en sistemas de deduplicación de documentos) confundiría dos chunks genuinamente distintos, de fuentes distintas, como si fueran la misma pieza de información. Incluir doc_id, section y position en el payload asegura que la huella digital identifica este chunk en este lugar, no solo este texto en algún lugar — una distinción que no importa para la unicidad de filas de esta lección, pero sí importaría si el hash se reutilizara, en un sistema más grande, como identificador de contenido entre documentos. El corpus real de Reservo, de hecho, no tiene ningún par de chunks con texto idéntico (el Counter de arriba lo confirma), así que la distinción es teórica para este corpus puntual — pero el diseño no depende de esa casualidad para ser correcto.
Resumen y siguiente paso
content_hash(chunk)usahashlib.sha256sobredoc_id,section,positionytextpara producir una huella de 64 caracteres hexadecimales, determinista: mismo contenido, mismo hash, siempre.- El chunk store es una tabla
sqlite3(chunks, conchunk_idcomo clave primaria y una columnacontent_hash) creada concreate_chunk_store. upsert_chunkcompara el hash nuevo contra el hash guardado antes de escribir cualquier cosa: si coinciden, no hace nada (idempotente); si no, usaINSERT ... ON CONFLICT DO UPDATEpara escribir en una sola sentencia, sin importar si el chunk era nuevo o ya existía.- Ejecutado dos veces sobre el corpus completo sin cambios: la primera corrida escribe 57 filas, la segunda escribe cero — exactamente el problema de la Lección 02, resuelto.
Lo que todavía falta: upsert_chunk funciona chunk por chunk, pero para reingestar de forma eficiente hace falta saber, a nivel de documento, cuáles de los 13 documentos cambiaron — para no tener que volver a parsear y chunkear los que no. Eso es exactamente el trabajo de la próxima lección.
Siguiente lección: 04 — Detectando documentos nuevos, modificados y borrados. Una tabla documents con el hash de cada texto crudo, y una función que clasifica cada doc_id sin volver a parsear nada que no cambió.
Recursos adicionales
- Python —
hashlib—sha256(),.hexdigest(), y por quéhash()builtin no sirve para esto (hash aleatorizado de strings entre procesos). - Python —
dataclasses.replace— la función usada en el Ejercicio 2 para crear una copia de unChunkcon un solo campo modificado. - SQLite — Upsert (
INSERT ... ON CONFLICT) — la cláusula exacta detrás deupsert_chunk. - Python —
sqlite3—connect(":memory:"),PRIMARY KEY, y el resto de la API usada en el chunk store de esta lección.