Módulo 1: Parseo y chunking de documentos
Metadatos para poder citar
Descripción
Hasta ahora, cada chunk que produjiste en las lecciones 05 y 06 fue una cadena de texto suelta: "No-shows are charged the full amount of the booking.", sin nada más. Es texto correcto, cortado con criterio — pero si un usuario le pregunta al agente de Reservo "¿de dónde sacaste eso?", esa cadena de texto no tiene respuesta. No sabe de qué documento vino, en qué sección del documento estaba, ni en qué posición respecto a los demás chunks del mismo documento. Esta lección resuelve exactamente eso: convierte cada chunk en un objeto con metadatos —doc_id, título, sección, posición— que lo hace citable, trazable y reconstruible. Es la última pieza antes del mini-proyecto: con esto, el corpus completo de Reservo queda listo para que el Módulo 2 lo indexe.
Conexión con el módulo
Las lecciones 03 y 04 parsearon los tres formatos; las lecciones 05 y 06 cortaron el texto en chunks y razonaron su tamaño. Esta lección junta ambas cosas en una sola función de ingestión por documento, y le agrega lo único que faltaba: la identidad de cada chunk. La lección 08 (el mini-proyecto) toma exactamente esta función y la corre sobre los 13 documentos del corpus completo.
Analogía: el contenedor de comida preparada sin etiqueta
Si alguna vez preparaste comida para toda la semana y la guardaste en contenedores iguales sin etiquetar, ya conoces el problema: tres días después, abres el freezer y tienes diez contenedores idénticos, y ni idea de cuál es el guiso de lentejas y cuál es la salsa de tomate que sobró. El contenido está perfecto — el problema es que perdió su identidad en el momento en que lo guardaste sin anotar nada. Un chunk de texto sin metadatos es exactamente ese contenedor: el texto puede ser preciso y estar bien cortado, pero si no sabes de qué documento salió, no puedes citarlo, no puedes verificarlo, y no puedes mostrarle al usuario dónde está la fuente completa si necesita más contexto. Etiquetar el contenedor —con qué es, de qué receta, de qué día— es exactamente lo que hace esta lección con cada chunk.
El dataclass Chunk: los cuatro campos que hacen falta para citar
from dataclasses import dataclass
@dataclass(frozen=True)
class Chunk:
chunk_id: str # identificador único, p. ej. "cancellation-policy-002"
doc_id: str # de qué documento salió, p. ej. "cancellation-policy"
title: str # título legible del documento, p. ej. "Cancellation Policy"
section: str # sección dentro del documento, p. ej. "How to Cancel"
position: int # orden dentro del documento (0, 1, 2, ...)
text: str # el texto del chunk en sí
frozen=True hace que un Chunk, una vez creado, no se pueda modificar — coherente con lo que es: un fragmento de un documento que ya fue procesado, no algo que un llamador debería poder mutar por accidente más adelante en el pipeline (el índice del Módulo 2, la tool del Módulo 3). Cada campo, aparte de text, existe por una razón concreta:
chunk_id -> identifica ESTE chunk sin ambigüedad (necesario para actualizar/borrar en M5)
doc_id -> de qué documento fuente salió (necesario para citar "según cancellation-policy...")
title -> el nombre legible del documento (necesario para citar algo que un HUMANO entienda,
no un identificador técnico)
section -> en qué parte del documento vivía (necesario para citar con precisión: no solo
"según cancellation-policy" sino "en la sección Pro Tier Cancellation Window")
position -> el orden dentro del documento (necesario para reconstruir contexto: "mostrame
el chunk anterior y el siguiente a este, del mismo documento")
Sin doc_id, un chunk es texto anónimo. Sin section, puedes decir de qué documento vino pero no de qué parte. Sin position, no hay forma de pedir "el contexto alrededor de este chunk" — cada chunk queda aislado de sus vecinos, sin ningún orden reconstruible.
Ejemplo trabajado: de documento crudo a lista de Chunk
Juntemos todo lo que construimos en las lecciones 03-06 en una sola función. ingest_document recibe el doc_id, el formato y el texto crudo, elige el parser correcto según el formato, chunkea con chunk_by_structure (la estrategia consciente de estructura de la lección 05, la que nunca mezcla secciones), y envuelve cada resultado en un Chunk con sus metadatos:
def ingest_document(doc_id: str, fmt: str, raw_text: str, max_size: int = 400) -> list[Chunk]:
"""Parsea + (limpia) + chunkea un documento, adjuntando metadatos."""
if fmt == "md":
title, sections = parse_markdown(raw_text)
elif fmt == "html":
title, sections = parse_html(raw_text)
elif fmt == "txt":
cleaned = clean_text(raw_text)
title = "Operations Manual (raw, cleaned)"
sections = sectionize_cleaned(cleaned)
else:
raise ValueError(f"formato desconocido: {fmt}")
section_chunks = chunk_by_structure(sections, max_size=max_size)
chunks = []
for position, (section, text) in enumerate(section_chunks):
chunk_id = f"{doc_id}-{position:03d}"
chunks.append(Chunk(chunk_id, doc_id, title, section, position, text))
return chunks
Nota el chunk_id: se construye como f"{doc_id}-{position:03d}" — el doc_id primero, para que sea imposible que dos chunks de documentos distintos choquen, y position con relleno de ceros (003, no 3) para que los IDs se ordenen alfabéticamente en el mismo orden en que aparecen en el documento. Corrámoslo sobre tres documentos, uno de cada formato:
fmt, raw = "md", Path("corpus/cancellation-policy.md").read_text()
for c in ingest_document("cancellation-policy", fmt, raw):
print(c)
Qué esperar:
Chunk(chunk_id='cancellation-policy-000', doc_id='cancellation-policy', title='Cancellation Policy', section='Basic Tier Cancellation Window', position=0, text='Basic members can cancel a booking up to 24 hours before the reserved start time with no penalty. Cancellations made less than 24 hours in advance forfeit the full booking amount.')
Chunk(chunk_id='cancellation-policy-001', doc_id='cancellation-policy', title='Cancellation Policy', section='Pro Tier Cancellation Window', position=1, text='Pro members get a shorter, friendlier window: cancellations up to 4 hours before the reserved start time are free of charge. This is one of the perks of the pro tier, alongside the 20% discount on hourly rates.')
Chunk(chunk_id='cancellation-policy-002', doc_id='cancellation-policy', title='Cancellation Policy', section='How to Cancel', position=2, text='Cancellations go through the same booking system used to reserve the room. There is no phone line for cancellations; the system timestamp is what determines whether the cancellation was made in time.')
Chunk(chunk_id='cancellation-policy-003', doc_id='cancellation-policy', title='Cancellation Policy', section='Related Policies', position=3, text='See `refund-policy` for what happens to the money once a cancellation is processed, and `no-show-policy` for what happens if you simply do not show up without cancelling.')
fmt, raw = "html", Path("corpus/focus-room-manual.html").read_text()
for c in ingest_document("focus-room-manual", fmt, raw):
print(c)
Qué esperar:
Chunk(chunk_id='focus-room-manual-000', doc_id='focus-room-manual', title='Focus Room Manual', section='Overview', position=0, text="Focus is Reservo's single-occupancy room, designed for calls and deep work that needs a closed door. It is the smallest and least expensive room in the building.")
Chunk(chunk_id='focus-room-manual-001', doc_id='focus-room-manual', title='Focus Room Manual', section='Capacity & Layout', position=1, text='Capacity: 1 person. The room has one desk, one chair, and a soundproofed door. There is no window, by design, to minimize visual distraction.')
Chunk(chunk_id='focus-room-manual-002', doc_id='focus-room-manual', title='Focus Room Manual', section='Equipment', position=2, text='- 27-inch monitor with HDMI input; - Adjustable desk lamp; - Wall outlet with two USB-C ports; - Building-wide wifi (see wifi-and-equipment-faq)')
Chunk(chunk_id='focus-room-manual-003', doc_id='focus-room-manual', title='Focus Room Manual', section='Booking & Rate', position=3, text='Base rate: $25.00 per hour (2500 cents), the lowest rate in the building. Pro members receive the standard 20% discount on every booking.')
Chunk(chunk_id='focus-room-manual-004', doc_id='focus-room-manual', title='Focus Room Manual', section='House Rules', position=4, text="Focus is not soundproof against phone ringtones; members are asked to keep devices on silent. Food is allowed but no hot meals, due to the room's small size and lack of ventilation.")
fmt, raw = "txt", Path("corpus/operations-manual-raw.txt").read_text()
for c in ingest_document("operations-manual-raw", fmt, raw):
print(c)
Qué esperar:
Chunk(chunk_id='operations-manual-raw-000', doc_id='operations-manual-raw', title='Operations Manual (raw, cleaned)', section='Opening and Closing Procedures', position=0, text='The building opens at 07:00 and closes at 22:00 on weekdays. On weekends the building opens at 09:00 and closes at 18:00. Staff must complete a walkthrough of every room before opening to confirm no equipment was left running overnight.')
Chunk(chunk_id='operations-manual-raw-001', doc_id='operations-manual-raw', title='Operations Manual (raw, cleaned)', section='Cleaning Procedures', position=1, text='Rooms are cleaned between every booking when the gap is 30 minutes or longer. For back-to-back bookings under 30 minutes, cleaning is limited to wiping the table and checking for left-behind belongings. Boardroom receives a full clean every evening regardless of usage, because of its size.')
Chunk(chunk_id='operations-manual-raw-002', doc_id='operations-manual-raw', title='Operations Manual (raw, cleaned)', section='Key and Access Handling', position=2, text='Rooms with a physical door - Focus, Phonebooth, and Boardroom - use a keypad code that rotates weekly. Studio and Lounge are open-plan and do not require a code. Staff must update the keypad codes every Monday before 07:00 and log the change in the access log.')
Chunk(chunk_id='operations-manual-raw-003', doc_id='operations-manual-raw', title='Operations Manual (raw, cleaned)', section='Wifi Reset Procedure', position=3, text="If a member reports the wifi is down, staff should first check the router in the utility closet before escalating. A full reset takes approximately 3 minutes and drops every room's connection at once, so it should only be done between bookings, never during an active reservation.")
Los tres formatos —markdown, HTML, texto sucio ya limpio— convergen en la misma forma de objeto. Esa uniformidad es exactamente lo que le permite al Módulo 2 indexar los 13 documentos con el mismo código, sin importarle de qué formato salió cada chunk.
De Chunk a cita
Con los metadatos en mano, formatear una cita es trivial:
def format_citation(c: Chunk) -> str:
return f"[{c.doc_id} - {c.section}] {c.text}"
sample = ingest_document("no-show-policy", "md",
Path("corpus/no-show-policy.md").read_text())[1]
print(format_citation(sample))
Qué esperar:
[no-show-policy - What Happens on a No-Show] No-shows are charged the full amount of the booking. Unlike a late cancellation, there is no partial leniency: the no-show fee equals the entire reserved price, and it is never refunded under `refund-policy`.
Esa línea es exactamente lo que un agente (Módulo 3 en adelante) le puede mostrar a un usuario o citar en su respuesta: no solo el texto, sino de dónde salió, con precisión de sección.
Por qué position importa: reconstruir el contexto alrededor de un chunk
Aquí es donde position deja de ser un detalle contable y se vuelve útil. Si un chunk por sí solo no alcanza para responder bien, position permite pedir sus vecinos —el chunk anterior y el siguiente del MISMO documento— para darle al agente más contexto sin tener que devolver el documento entero:
def neighbors(chunks: list[Chunk], chunk_id: str) -> list[Chunk]:
"""Devuelve el chunk pedido junto a su vecino anterior y siguiente, mismo doc_id."""
target = next(c for c in chunks if c.chunk_id == chunk_id)
same_doc = sorted((c for c in chunks if c.doc_id == target.doc_id), key=lambda c: c.position)
idx = next(i for i, c in enumerate(same_doc) if c.chunk_id == chunk_id)
return same_doc[max(0, idx - 1):min(len(same_doc), idx + 2)]
chunks = ingest_document("no-show-policy", "md", Path("corpus/no-show-policy.md").read_text())
for c in neighbors(chunks, "no-show-policy-001"):
marker = ">>" if c.chunk_id == "no-show-policy-001" else " "
print(marker, c.chunk_id, "-", c.section)
Qué esperar:
no-show-policy-000 - What Counts as a No-Show
>> no-show-policy-001 - What Happens on a No-Show
no-show-policy-002 - Repeated No-Shows
Si el chunk [001] solo alcanzara para decir "se cobra el monto completo" pero el usuario preguntara "¿y qué cuenta como no-show, exactamente?", el vecino [000] —recuperable únicamente porque position existe— tiene justo esa definición. Sin position, esta función no tendría forma de saber cuál es "el chunk anterior": los chunks serían una bolsa desordenada de texto, no una secuencia reconstruible.
Errores comunes
-
Construir
chunk_idsin eldoc_idcomo prefijo. Si usaras solopositioncomo id ("000","001", ...), cada documento del corpus tendría chunks con el mismo id — un choque garantizado apenas tengas más de un documento. El prefijodoc_idno es cosmético: es lo que hace quechunk_idsea único en TODO el corpus, no solo dentro de un documento. -
Usar
doc_iden vez detitlepara mostrarle algo al usuario.doc_id("no-show-policy") es perfecto para lógica interna (comparar, indexar, actualizar) pero es feo para mostrarle a una persona.title("No-Show Policy") es el que va en una respuesta orientada a humanos. Mezclar los dos —mostrarledoc_ida un usuario, o usartitlecomo clave de comparación— es una fuente típica de bugs sutiles (dos documentos con el mismotitlepero distintodoc_idromperían cualquier lógica que comparara portitle). -
Olvidar que
frozen=Truesignifica que no puedes reasignar un campo después de crear elChunk. Si necesitas "corregir" un chunk después de crearlo (por ejemplo, normalizar su texto una vez más), la única forma es crear unChunknuevo condataclasses.replace(chunk, text=nuevo_texto)— intentarchunk.text = nuevo_textodirectamente lanzaFrozenInstanceError. Es una restricción deliberada, no un accidente: protege contra mutaciones accidentales una vez que el chunk ya viajó al índice. -
Asumir que
positiones un ID global.positionsolo tiene sentido DENTRO de undoc_id— el chunk[000]deno-show-policyy el chunk[000]derefund-policyson dos chunks completamente distintos que comparten el número de posición por pura coincidencia (ambos son el primer chunk de su documento). Buscar porpositionsolo, sin filtrar primero pordoc_id, mezcla documentos distintos como si fueran comparables.
Ejercicios
Ejercicio 1: Citar el chunk correcto (Fácil)
Ingiere payment-methods-faq.md con ingest_document y usa format_citation para imprimir la cita del chunk que responde "¿Reservo acepta efectivo?" (busca el chunk cuya sección coincida con esa pregunta).
Ver solución
chunks = ingest_document("payment-methods-faq", "md",
Path("corpus/payment-methods-faq.md").read_text())
target = next(c for c in chunks if "Cash" in c.section)
print(format_citation(target))
Salida esperada:
[payment-methods-faq - Does Reservo Accept Cash?] No. All bookings, deposits, and no-show charges are processed electronically through the payment method on file.
Explicación: filtrar por section (no por text) es la forma correcta de encontrar el chunk que responde a una pregunta cuando —como en este corpus— cada sección de una FAQ ya es, literalmente, una pregunta.
Ejercicio 2: Verificar que no hay colisión de chunk_id en dos documentos (Medio)
Ingiere cancellation-policy.md Y refund-policy.md por separado, junta sus chunks en una sola lista, y verifica con código (no a ojo) que ningún chunk_id se repite.
Ver solución
c1 = ingest_document("cancellation-policy", "md", Path("corpus/cancellation-policy.md").read_text())
c2 = ingest_document("refund-policy", "md", Path("corpus/refund-policy.md").read_text())
all_chunks = c1 + c2
ids = [c.chunk_id for c in all_chunks]
print("total chunks:", len(ids))
print("ids únicos:", len(set(ids)))
print("sin colisiones:", len(ids) == len(set(ids)))
Salida esperada:
total chunks: 8
ids únicos: 8
sin colisiones: True
Explicación: aunque ambos documentos tienen chunks en position 0, 1, 2, 3, sus chunk_id nunca chocan porque cada uno lleva el prefijo de su propio doc_id (cancellation-policy-000 vs. refund-policy-000). Esta es exactamente la razón de diseño detrás del primer "Errores comunes" de esta lección.
Ejercicio 3: ¿Hace falta un quinto campo de metadatos? (Difícil)
Un colega propone agregar un campo char_count: int al dataclass Chunk, calculado como len(text), para no tener que recalcularlo cada vez que hace falta. Argumenta si te parece un buen campo para agregar al dataclass, considerando que frozen=True significa que ese campo tendría que mantenerse sincronizado con text para siempre (¿qué pasa si alguien crea un Chunk con char_count que no coincide con len(text)?).
Ver solución
Es un caso clásico de dato derivado vs. dato fuente. char_count no aporta ninguna información que text no tenga ya — es 100% calculable a partir de text (len(text)), así que guardarlo como un campo aparte introduce la posibilidad de que los dos se desincronicen: nada en un @dataclass común impide crear Chunk(..., text="hola", char_count=9999), un estado inconsistente que un campo derivado nunca debería poder alcanzar. La alternativa más segura es NO agregarlo como campo, sino como una @property calculada al vuelo:
@dataclass(frozen=True)
class Chunk:
chunk_id: str
doc_id: str
title: str
section: str
position: int
text: str
@property
def char_count(self) -> int:
return len(self.text)
Con esto, chunk.char_count sigue siendo tan fácil de usar como un campo (chunk.char_count, sin paréntesis), pero es matemáticamente imposible que se desincronice de text, porque no es un dato guardado — es un cálculo que ocurre en el momento en que lo pides. La regla general: los cuatro campos de metadatos del Chunk (doc_id, title, section, position) son datos FUENTE —no se pueden derivar de text—, y por eso viven como campos reales; cualquier cosa calculable a partir de campos existentes es candidata a @property, no a campo nuevo.
Resumen y siguiente paso
- El dataclass
Chunk(chunk_id,doc_id,title,section,position,text,frozen=True) convierte un fragmento de texto suelto en algo citable, trazable y reconstruible. ingest_documentjunta todo lo anterior (parseo por formato, limpieza cuando aplica, chunking consciente de estructura) en una sola función que devuelvelist[Chunk], probada sobre un documento de cada uno de los tres formatos del corpus.format_citationconvierte cualquierChunken una línea citable:[doc_id - section] texto— ypositionpermite reconstruir el contexto alrededor de un chunk pidiendo sus vecinos en el mismo documento, algo imposible sin ese campo.- Los errores más comunes son de identidad:
chunk_idsin el prefijodoc_idcolisiona entre documentos;positionsolo tiene sentido dentro de undoc_id, nunca como identificador global.
Siguiente lección: 08 — Mini-proyecto: ingesta el corpus de Reservo. Corremos ingest_document sobre los 13 documentos completos del corpus, y cerramos el módulo con la lista completa de Chunk con metadatos — exactamente lo que el Módulo 2 va a indexar.
Recursos adicionales
- Python —
dataclasses— La referencia completa de@dataclass, incluidofrozen=Trueydataclasses.replace(). - Python —
property— El decorador usado en el Ejercicio 3 para exponer un dato derivado sin arriesgar que se desincronice del dato fuente. - Python — f-strings y formateo — La base de
format_citationy delf"{doc_id}-{position:03d}"que arma cadachunk_id. advanced-rag-techniques-guide— Cómo un sistema de producción con presupuesto para reranking usa exactamente estos mismos metadatos (doc_id,section) para explicar por qué un resultado se reordenó; fuera del alcance de esta guía.