Módulo 5: Ingestión incremental e idempotente

Actualizando solo lo que cambió

Descripción

Las lecciones anteriores probaron la idempotencia contra un corpus que nunca cambiaba de verdad — la evidencia central de la Lección 05 fue que reingestar el mismo RAW_DOCS dos veces no duplica nada. Esta lección prueba el otro caso, igual de importante: un documento que sí cambia. refund-policy gana una frase nueva en su sección de tiempos de reembolso, exactamente el tipo de edición que pasa todo el tiempo en producción — alguien aclara una política, corrige un dato, agrega una excepción.

La pregunta que esta lección responde con evidencia ejecutada: cuando un solo documento de 13 cambia, ¿cuánto trabajo hace reingest()? La respuesta no es "reprocesa todo el corpus" ni siquiera "reprocesa todo refund-policy" — es más precisa que eso: reparsea únicamente el documento que cambió, y dentro de ese documento, reescribe únicamente el chunk cuyo contenido efectivamente cambió.

Conexión con el módulo

Esta lección no agrega ninguna función nueva — usa reingest(), detect_changes y upsert_chunk exactamente como quedaron en las Lecciones 03-05, y mide con instrumentación real cuánto trabajo evitan hacer sobre un documento modificado. La Lección 07 hace el mismo ejercicio con un documento borrado.


Analogía: una sola página reemplazada, no la carpeta entera

El archivista de las lecciones anteriores recibe hoy una novedad puntual: la carpeta de "Refund Policy" tiene una página con una frase nueva, agregada al final de su sección de "Refund Amount and Timing". Las otras tres páginas de esa carpeta —"What Qualifies for a Refund", "What Does Not Qualify for a Refund", "How to Request a Refund"— no cambiaron ni una coma. Y las otras 12 carpetas del archivo tampoco cambiaron.

Un archivista sin criterio reharía la carpeta entera de "Refund Policy" —o peor, las 13 carpetas completas— cada vez que detecta cualquier cambio en cualquier parte. El archivista de este módulo, con su etiqueta de tapa y su libreta de huellas por página, hace algo más quirúrgico: la etiqueta de tapa de "Refund Policy" no coincide con la registrada, así que abre esa carpeta —ninguna otra—, escanea sus 4 páginas, y descubre que 3 de ellas tienen exactamente la misma huella de siempre. Solo reemplaza la página que cambió. El resto del archivo —53 páginas de los otros 12 documentos, más 3 de las 4 páginas de "Refund Policy"— queda exactamente como estaba, sin que nadie las vuelva a tocar.


El documento que cambia: refund-policy gana una frase

RAW_DOCS_V2 es una copia de RAW_DOCS con un único cambio: la sección "Refund Amount and Timing" de refund-policy gana una frase que aclara qué pasa cuando el reembolso es por un problema de la sede, no por una cancelación del socio. El resto de los 13 documentos —incluidas las otras tres secciones de refund-policy— queda idéntico, carácter por carácter.

from reservo_corpus import RAW_DOCS

RAW_DOCS_V2 = dict(RAW_DOCS)
fmt, raw = RAW_DOCS_V2["refund-policy"]

old_section = (
    "## Refund Amount and Timing\n\n"
    "Eligible refunds return the full amount charged for the booking to the "
    "original payment method. Processing takes up to 5 business days once "
    "the cancellation is confirmed in the booking system."
)
new_section = old_section + (
    " For refunds tied to a facility issue rather than a cancellation, "
    "processing begins from the date Reservo confirms the outage, not the "
    "date of the original booking."
)
assert old_section in raw  # confirms the anchor text is exactly what's there
RAW_DOCS_V2["refund-policy"] = (fmt, raw.replace(old_section, new_section))

print("len(raw original):", len(raw))
print("len(raw modificado):", len(RAW_DOCS_V2["refund-policy"][1]))
print("los otros 12 documentos son idénticos:",
      all(RAW_DOCS_V2[d] == RAW_DOCS[d] for d in RAW_DOCS if d != "refund-policy"))

Qué esperar (ejecutado):

len(raw original): 1014
len(raw modificado): 1178
los otros 12 documentos son idénticos: True

RAW_DOCS (el corpus canónico) no se toca — RAW_DOCS_V2 es un diccionario nuevo, copiado y con un solo valor reemplazado. Esto simula exactamente lo que pasaría en un sistema real: el directorio fuente tiene, en la corrida de hoy, 13 archivos donde 12 son bit por bit los mismos de ayer y uno cambió.


Reingestando RAW_DOCS_V2: solo un documento, solo un chunk

Partiendo de un store que ya tiene el corpus original ingerido (como al final de la Lección 05), reingestar RAW_DOCS_V2:

conn = create_store(":memory:")
reingest(conn, RAW_DOCS)  # corrida 1: el corpus original, 57 chunks

r2 = reingest(conn, RAW_DOCS_V2)  # corrida 2: refund-policy cambió
total = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]

print("modified:", r2["modified"])
print("unchanged_count:", r2["unchanged_count"])
print("chunks_written:", r2["chunks_written"])
print("chunks_purged:", r2["chunks_purged"])
print("total chunks en el store:", total)

Qué esperar (ejecutado):

modified: ['refund-policy']
unchanged_count: 12
chunks_written: 1
chunks_purged: 0
total chunks en el store: 57

detect_changes clasificó a refund-policy como el único documento modified, y a los otros 12 como unchanged — exactamente lo que la construcción de RAW_DOCS_V2 garantiza. chunks_written: 1 es el número que importa: de los 4 chunks que tiene refund-policy, reingest() reescribió uno solo. El total del store se queda en 57 —no bajó ni subió— porque nada se agregó ni se borró, solo se actualizó un chunk existente en el lugar.

Mirando los 4 chunks de refund-policy uno por uno, con su content_hash antes y después:

from reservo_corpus import ingest_document

old_chunks = ingest_document("refund-policy", "md", raw)
new_chunks = ingest_document("refund-policy", "md", RAW_DOCS_V2["refund-policy"][1])

for oc, nc in zip(old_chunks, new_chunks):
    changed = content_hash(oc) != content_hash(nc)
    print(f"{oc.chunk_id}: len_old={len(oc.text)} len_new={len(nc.text)} changed={changed}")

Qué esperar (ejecutado):

refund-policy-000: len_old=225 len_new=225 changed=False
refund-policy-001: len_old=192 len_new=356 changed=True
refund-policy-002: len_old=226 len_new=226 changed=False
refund-policy-003: len_old=219 len_new=219 changed=False

Solo refund-policy-001 —la sección "Refund Amount and Timing", exactamente donde se agregó la frase nueva— cambió de longitud y de content_hash. Los otros tres chunks del mismo documento (-000, -002, -003) tienen exactamente el mismo largo y el mismo hash que antes: upsert_chunk, llamado igual para los 4, escribió solo el que de verdad cambió — el mismo comportamiento que ya viste en la Lección 03, ahora confirmado dentro de un documento que sí tuvo una edición real.


Cuánto trabajo se evitó: instrumentando ingest_document

El número chunks_written: 1 ya demuestra que el store escribió poco. Para confirmar que el pipeline tampoco reparseó los documentos que no cambiaron —no solo que no los reescribió—, se puede envolver ingest_document con un contador antes de correr la reingesta:

parsed_docs = []
_original_ingest_document = ingest_document


def counting_ingest_document(doc_id, fmt, raw_text, max_size=400):
    parsed_docs.append(doc_id)
    return _original_ingest_document(doc_id, fmt, raw_text, max_size=max_size)


# Reemplaza el nombre global `ingest_document` -- upsert_doc_chunks (Lección
# 05) lo busca por nombre en el momento de llamarlo, así que a partir de
# aquí cualquier llamada que haga pasa primero por el contador.
ingest_document = counting_ingest_document

conn2 = create_store(":memory:")
reingest(conn2, RAW_DOCS)
parsed_docs.clear()  # solo interesa contar la corrida 2

reingest(conn2, RAW_DOCS_V2)
print("documentos reparseados en la corrida 2:", parsed_docs)
print(f"total: {len(parsed_docs)} de los {len(RAW_DOCS_V2)} documentos del corpus")

Qué esperar (ejecutado):

documentos reparseados en la corrida 2: ['refund-policy']
total: 1 de los 13 documentos del corpus

De los 13 documentos del corpus, ingest_document se llamó una sola vez, y solo para refund-policy. Los otros 12 —cancellation-policy, no-show-policy, los 5 manuales de sala, y el resto— nunca pasaron por parse_markdown, parse_html ni clean_text en esta corrida: detect_changes los descartó de entrada, comparando únicamente sus doc_hash, sin necesidad de abrirlos. Esta es la prueba directa de la afirmación central de la Lección 04: separar la detección (barata) del procesamiento (caro) significa que el costo de una corrida de reingest() escala con cuánto cambió, no con el tamaño total del corpus.


Errores comunes

  1. Medir "cuánto se optimizó" solo con chunks_written, sin mirar ingest_document. chunks_written: 1 confirma que el store escribió poco, pero no confirma, por sí solo, que el pipeline evitó parsear los documentos sin cambios — para eso hace falta la instrumentación de más arriba, o confiar en que detect_changes (Lección 04) nunca llama a ingest_document, que es justamente lo que garantiza el diseño.
  2. Pensar que "un documento modificado" siempre reescribe todos sus chunks. Si la edición cae dentro de una sola sección (como en este ejemplo), solo el chunk de esa sección cambia de hash. Si la edición reordenara secciones enteras o cambiara el max_size de chunking, más chunks podrían verse afectados — pero eso no es lo típico de una edición de contenido real, y upsert_chunk maneja cualquiera de los dos casos correctamente sin que el código cambie.
  3. Olvidar parsed_docs.clear() antes de medir la corrida que importa. Si no se limpia la lista después de la corrida 1 (la ingestión inicial, que sí reparsea los 13 documentos porque todos son new), el conteo de la corrida 2 queda contaminado con los 13 documentos de la corrida anterior, y el resultado deja de mostrar el ahorro real.
  4. Suponer que la instrumentación con monkey-patching es necesaria en producción. El truco de reemplazar ingest_document con una versión que cuenta llamadas es una herramienta de esta lección, para demostrar y verificar el comportamiento — no algo que un pipeline real necesite en su código de producción. En un sistema real, esta misma información se obtendría con logging normal (logger.info(f"parsing {doc_id}")) o una métrica exportada, no con un parche en tiempo de ejecución.

Ejercicios

Ejercicio 1: Modifica un documento distinto (Fácil)

Construye RAW_DOCS_V2B, una copia de RAW_DOCS donde wifi-and-equipment-faq gana una quinta pregunta al final ("## Does the Wifi Password Ever Change Without Notice?" con una respuesta corta cualquiera). Reingesta contra un store que ya tiene el corpus original y confirma qué documento aparece como modified y cuántos chunks nuevos escribe.

Ver solución
RAW_DOCS_V2B = dict(RAW_DOCS)
fmt, raw = RAW_DOCS_V2B["wifi-and-equipment-faq"]
new_raw = raw.rstrip("\n") + (
    "\n\n## Does the Wifi Password Ever Change Without Notice?\n\n"
    "No. Password changes are always posted on the room card and the "
    "booking confirmation screen at least one day in advance."
)
RAW_DOCS_V2B["wifi-and-equipment-faq"] = (fmt, new_raw)

conn3 = create_store(":memory:")
reingest(conn3, RAW_DOCS)
r = reingest(conn3, RAW_DOCS_V2B)
total = conn3.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
print("modified:", r["modified"])
print("chunks_written:", r["chunks_written"])
print("total chunks:", total)

Salida esperada:

modified: ['wifi-and-equipment-faq']
chunks_written: 1
total chunks: 58

Explicación: a diferencia del ejemplo trabajado, aquí la edición agrega una sección nueva, no extiende una existente — así que wifi-and-equipment-faq pasa de 4 a 5 chunks, y el chunk nuevo (wifi-and-equipment-faq-004) es justamente el que cuenta como escrito. El total del store sube de 57 a 58, porque esta vez sí se agregó un chunk genuinamente nuevo, no solo se reescribió uno existente. Los otros 12 documentos, sin tocar, siguen sin generar ningún trabajo.

Ejercicio 2: Confirma que el resto del corpus no perdió nada (Medio)

Después del Ejercicio 1, escribe una consulta que confirme que los 53 chunks de los 12 documentos sin modificar (todo excepto wifi-and-equipment-faq) siguen siendo exactamente los mismos chunk_id que produce build_corpus() para esos documentos — ni uno de más, ni uno de menos, ni uno con contenido distinto.

Ver solución
from reservo_corpus import build_corpus

expected_other_ids = {c.chunk_id for c in build_corpus() if c.doc_id != "wifi-and-equipment-faq"}
stored_other_ids = {
    row[0] for row in conn3.execute(
        "SELECT chunk_id FROM chunks WHERE doc_id != 'wifi-and-equipment-faq'"
    ).fetchall()
}
print("coinciden exactamente:", expected_other_ids == stored_other_ids)
print("cantidad:", len(stored_other_ids))

Salida esperada:

coinciden exactamente: True
cantidad: 53

Explicación: 57 - 4 = 53 chunks pertenecen a los 12 documentos que nunca cambiaron (build_corpus() da 57 en total, y wifi-and-equipment-faq original tenía 4). El conjunto de chunk_id guardado en el store para esos 12 documentos coincide, exactamente, con lo que produciría reconstruir el corpus completo desde cero y filtrar el documento modificado — la confirmación directa de que actualizar un documento no tiene ningún efecto secundario sobre los demás.

Ejercicio 3: ¿Qué pasa si la edición cruza el límite de max_size? (Difícil)

ingest_document usa max_size=400 por defecto (Módulo 1). Construye una edición de refund-policy mucho más agresiva que la del ejemplo trabajado: reemplaza la sección "Refund Amount and Timing" completa por un texto de más de 400 caracteres (por ejemplo, repite la frase de aclaración del ejemplo trabajado tres veces). ¿Cuántos chunks tiene esa sección después de reingestar? ¿Sigue siendo solo un chunk el que cambia?

Ver solución
RAW_DOCS_V2C = dict(RAW_DOCS)
fmt, raw = RAW_DOCS_V2C["refund-policy"]
old_section = (
    "## Refund Amount and Timing\n\n"
    "Eligible refunds return the full amount charged for the booking to the "
    "original payment method. Processing takes up to 5 business days once "
    "the cancellation is confirmed in the booking system."
)
extra = (
    " For refunds tied to a facility issue rather than a cancellation, "
    "processing begins from the date Reservo confirms the outage, not the "
    "date of the original booking."
)
new_section = old_section + extra * 3  # deliberately long, past max_size=400
RAW_DOCS_V2C["refund-policy"] = (fmt, raw.replace(old_section, new_section))

section_chunks = ingest_document("refund-policy", "md", RAW_DOCS_V2C["refund-policy"][1])
for c in section_chunks:
    print(c.chunk_id, c.section, len(c.text))

Salida esperada:

refund-policy-000 What Qualifies for a Refund 225
refund-policy-001 Refund Amount and Timing 356
refund-policy-002 Refund Amount and Timing 327
refund-policy-003 What Does Not Qualify for a Refund 226
refund-policy-004 How to Request a Refund 219

Explicación: una vez que la sección "Refund Amount and Timing" supera los 400 caracteres, chunk_by_structure (Módulo 1, Lección 05) la subdivide en dos chunks por oración en vez de uno — refund-policy pasa de 4 a 5 chunks en total, y las posiciones de todo lo que viene después de la sección editada se corren un lugar: lo que antes era refund-policy-002 ("What Does Not Qualify for a Refund") ahora es refund-policy-003. Esto es distinto del caso del ejemplo trabajado, donde la edición cabía dentro del límite y solo un chunk_id cambiaba de contenido: aquí, upsert_chunk termina reescribiendo varios chunk_id —no porque su contenido "cambió" en el sentido de una edición, sino porque la posición estructural de cada sección después del punto de corte se corrió. Es un caso real a tener en cuenta: una edición que cruza el límite de tamaño de una sección no es tan quirúrgica como una que se queda adentro, aunque el mecanismo de idempotencia (content_hash por chunk_id) siga funcionando correctamente para detectar y reescribir exactamente lo que cambió.


Resumen y siguiente paso

  • Un documento modificado (refund-policy, con una frase nueva en una sección) hace que detect_changes lo clasifique como modified — y solo a él; los otros 12 documentos quedan unchanged.
  • reingest() reparsea (ingest_document) únicamente el documento modificado, confirmado con instrumentación real: 1 de 13 documentos pasó por el parser en la corrida 2.
  • Dentro de ese documento, upsert_chunk reescribe únicamente el chunk cuyo content_hash cambió (refund-policy-001, de 4 chunks totales) — los otros 3 quedan exactamente como estaban, mismo hash, misma fila.
  • Si la edición cruza el límite de max_size de una sección, el chunking puede correr las posiciones de las secciones siguientes, y entonces más de un chunk_id se reescribe — un caso real que vale la pena reconocer, no un fallo del sistema de idempotencia.

Siguiente lección: 07 — Manejando un documento borrado. phonebooth-room-manual deja de existir en el corpus (la sala cierra por renovación) — purge_doc retira sus 5 chunks del store, y se confirma, con una consulta, que no queda ningún chunk huérfano.


Recursos adicionales

  1. Python — reemplazar atributos de un módulo en tiempo de ejecución — la base de la técnica de instrumentación (sys.modules[__name__].ingest_document = ...) usada para contar llamadas reales.
  2. production-rag-and-document-ingestion-guide — Módulo 1, Lección 05 (05-chunking-strategies-fixed-vs-structure-aware.md) y Lección 06 (06-the-chunk-size-tradeoff.md): chunk_by_structure y por qué max_size=400 es la elección correcta para el corpus canónico — la base del Ejercicio 3 de esta lección.
  3. Python — dict y comparación de igualdad — la base de la verificación RAW_DOCS_V2[d] == RAW_DOCS[d] usada para confirmar que los otros 12 documentos quedaron intactos.