Módulo 2: Indexar chunks para recuperación

Qué es un índice de recuperación

Descripción

Antes de construir un índice de verdad, vale la pena sentir el problema que resuelve. Esta lección arranca sin ningún índice: solo el corpus completo de Reservo (13 documentos, 57 chunks) y una función que busca respondiendo a una query de la forma más obvia posible — leyendo cada chunk, uno por uno, y contando cuántas palabras de la query aparecen en su texto. Vas a ejecutar esa función, ver que funciona, y también ver exactamente dónde empieza a fallar: no porque sea lenta (57 chunks es poco), sino porque contar coincidencias sin ningún criterio de peso produce empates que no distinguen lo relevante de lo casi-relevante.

Ese problema — contar sin pesar — es la puerta de entrada a todo lo que sigue en el módulo. Un índice de recuperación no es solo "una estructura más rápida que escanear todo"; es una estructura que además captura, de antemano, información que permite pesar mejor: en qué chunks aparece cada término (eso lo resuelve el índice invertido de la lección 03) y con qué frecuencia relativa (eso lo resuelven TF e IDF en la lección 04).

Conexión con el módulo

Esta lección es la motivación del módulo completo. No construye todavía el índice invertido ni BM25 — construye el "problema", con código real y ejecutado, para que las lecciones 03-05 tengan sentido como una solución a algo que viste fallar con tus propios ojos, no como una receta abstracta.


Analogía: buscar una palabra hojeando un libro sin índice

Imagina un libro de 300 páginas sin índice temático al final. Si necesitas encontrar todo lo que dice sobre "cancelación", tu única opción es hojear el libro de principio a fin, página por página, revisando si la palabra aparece. Funciona — eventualmente encuentras las páginas correctas — pero cada búsqueda nueva repite el trabajo completo desde cero: 300 páginas revisadas, sin importar si la palabra que buscas está en la página 3 o en la 290.

Peor: si hojeas contando simplemente "¿aparece la palabra sí o no?", dos páginas donde "cancelación" aparece una vez de pasada y una página entera dedicada al tema quedan indistinguibles para tu criterio de búsqueda. Necesitas algo más que presencia/ausencia — necesitas saber cuánto habla cada página del tema, y qué tan rara es la palabra que buscas (si buscas "el", cualquier página la tiene; si buscas "Phonebooth", solo un puñado). Un escaneo lineal sin pesos no te da ninguna de las dos cosas.


Ejemplo trabajado: un escaneo lineal, ejecutado

Empecemos por reconstruir el corpus fijo de Reservo como una lista de Chunk. Este es el mismo corpus que dejó el Módulo 1 — 13 documentos, 57 chunks (4 por cada política/FAQ, 5 por cada manual de sala, uno por sección), con chunk_id de 3 dígitos con ceros:

# reservo_corpus.py (fragmento — el archivo completo se arma en el mini-proyecto)
from dataclasses import dataclass


@dataclass(frozen=True)
class Chunk:
    chunk_id: str
    doc_id: str
    title: str
    section: str
    position: int
    text: str


DOCS = {
    "cancellation-policy": ("Cancellation Policy", [
        ("Basic Tier Cancellation Window",
         "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."),
        ("Pro Tier Cancellation Window",
         "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."),
        ("How to Cancel",
         "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."),
        ("Related Policies",
         "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."),
    ]),
    # ... los otros 12 documentos, misma forma (title, [(section, text), ...]) --
    # exactamente los que ingirió el Módulo 1 (05-chunking, 07-metadata)
}

DOC_ORDER = sorted(DOCS.keys())  # el mismo orden alfabético de load_corpus() en M1 L08


def build_corpus(max_size: int = 400):
    """chunk_by_structure (Módulo 1, L05) sobre cada documento -- con
    max_size=400 ninguna sección necesita sub-cortarse, así que cada chunk
    es, tal cual, una sección."""
    chunks = []
    for doc_id in DOC_ORDER:
        title, sections = DOCS[doc_id]
        for position, (section, text) in enumerate(sections):
            chunks.append(Chunk(
                chunk_id=f"{doc_id}-{position:03d}",
                doc_id=doc_id, title=title, section=section,
                position=position, text=text,
            ))
    return chunks

El archivo completo, con los 13 documentos, se arma en el mini-proyecto (lección 08); por ahora trabajamos contra build_corpus() asumiendo que ya existe. Ahora, el escaneo lineal: para cada chunk, tokenizamos su texto y contamos cuántos términos de la query aparecen en él.

import re

_TOKEN_RE = re.compile(r"[a-z0-9]+")


def tokenize(text):
    """Minúsculas + separar en secuencias de letras/dígitos."""
    return _TOKEN_RE.findall(text.lower())


def naive_scan(query, chunks):
    """Lee TODOS los chunks, cuenta cuántos términos de la query aparecen
    en cada uno (sin pesar frecuencia ni rareza). O(chunks) por búsqueda."""
    query_terms = set(tokenize(query))
    hits = []
    for chunk in chunks:
        chunk_terms = set(tokenize(chunk.text))
        overlap = query_terms & chunk_terms
        if overlap:
            hits.append((len(overlap), chunk))
    hits.sort(key=lambda pair: pair[0], reverse=True)
    return hits

Lo corremos con una de las queries ancla del módulo:

chunks = build_corpus()
print("total de chunks en el corpus:", len(chunks))

hits = naive_scan("What equipment is in the Focus room?", chunks)
print("chunks con al menos un término en común:", len(hits))
print()
for overlap, chunk in hits[:5]:
    print(f"  overlap={overlap}  {chunk.chunk_id:28s} ({chunk.doc_id})")

Qué esperar:

total de chunks en el corpus: 57
chunks con al menos un término en común: 57

  overlap=5  cancellation-policy-002      (cancellation-policy)
  overlap=5  wifi-and-equipment-faq-002   (wifi-and-equipment-faq)
  overlap=5  focus-room-manual-000        (focus-room-manual)
  overlap=4  booking-faq-002              (booking-faq)
  overlap=4  wifi-and-equipment-faq-000   (wifi-and-equipment-faq)

Ahí está el problema, ejecutado, y con 57 chunks se ve todavía más marcado que con un corpus chico: los 57 chunks del corpus comparten al menos una palabra con la query — normal, dado que palabras como "the", "is", "in" aparecen en casi todo el corpus. Y en el primer lugar del ranking, empatado a tres bandas con focus-room-manual-000 (la respuesta correcta), aparecen cancellation-policy-002 (un chunk sobre cómo cancelar una reserva) y wifi-and-equipment-faq-002 (equipo común, no el de una sala en particular) — ninguno de los dos tiene relación directa con el equipo de la sala Focus. El escaneo lineal encontró el chunk relevante, sí, pero lo dejó empatado en el primer lugar con dos chunks fuera de tema, sin ningún criterio para separarlos.


Por qué el empate no es casualidad

cancellation-policy-002 comparte con la query las palabras "what", "is", "room", "in" y "the" — todas comunes, ninguna específica del tema "equipo del Focus". focus-room-manual-000 comparte "focus", "in", "is", "room" y "the" — con "focus" ahí sí específico del tema, pero naive_scan cuenta cada coincidencia exactamente igual: un punto por palabra, sin importar si es "the" (aparece en casi todo el corpus) o "focus" (aparece en un puñado de chunks). El chunk que de verdad describe el equipo (focus-room-manual-002, la sección "Equipment") ni siquiera entra al top-5: comparte una sola palabra con la query ("equipment"), porque es una lista de ítems corta, sin los artículos y pronombres que inflan el overlap de los demás. Ese es precisamente el defecto que un índice de recuperación de verdad tiene que corregir: no toda coincidencia de palabra pesa lo mismo.


La familia de índices de recuperación

Un índice de recuperación es cualquier estructura que, dada una query, devuelve candidatos relevantes sin tener que revisar el corpus completo cada vez — y, idealmente, con un criterio de peso mejor que "cuenta coincidencias". Hay dos grandes familias, y esta guía usa una sola de ellas a propósito:

Índices LÉXICOS (coincidencia de términos, pesada)
  → Índice invertido + TF-IDF/BM25 — lo que este módulo construye
  → $0, determinista, sin red, sin modelo de embeddings
  → Mismo algoritmo que Elasticsearch/OpenSearch en producción

Índices SEMÁNTICOS (similitud de significado, vía vectores)
  → Un embedding (un modelo convierte texto en un vector) + un vector
    index (HNSW, IVF...) que encuentra vectores cercanos
  → Requiere un modelo de embeddings (pago o con pesos locales) y,
    en producción, una base de datos vectorial (Chroma, Pinecone,
    Weaviate, Qdrant)
  → Teoría del embedding: embeddings-deep-dive-guide
  → Cómo funciona un vector index por dentro y cómo operar uno real:
    vector-databases-fundamentals-guide

Esta guía construye la primera familia, completa y honesta, porque es $0, determinista y no requiere red — y porque, en producción real, sigue siendo la mitad "lexical" de casi cualquier sistema de hybrid search serio. La segunda familia no se reimplementa aquí: se nombra, en el punto exacto donde aplicaría, cada vez que el límite léxico se hace visible (empezando por la lección 06 de este mismo módulo).


Errores comunes

  1. Pensar que el problema de naive_scan es la velocidad. Con 57 chunks, revisar todos toma microsegundos — la velocidad no es el problema en este corpus de juguete. El problema real, el que sí importa incluso con un corpus chico, es la falta de criterio de peso: contar coincidencias sin pesarlas produce rankings malos, no solo lentos.

  2. Confundir "0 resultados" con "el índice está roto". En el ejemplo de arriba, los 57 de 57 chunks tuvieron algún overlap. Un índice de recuperación no elimina los falsos positivos por completo — los pesa mejor. Ver muchos candidatos con overlap bajo es normal; lo que hay que revisar es si el top del ranking es el correcto.

  3. Saltarse directamente a BM25 sin entender el índice invertido. BM25 es una fórmula de scoring — necesita, como insumo, saber en qué chunks aparece cada término y con qué frecuencia. Esa información es exactamente lo que construye el índice invertido de la lección 03. Sin él, no hay sobre qué calcular nada.

  4. Asumir que cualquier índice de recuperación entiende significado. No es así por definición — "índice de recuperación" es la categoría general (léxico o semántico); lo que entiende significado es específicamente un embedding semántico, una sola rama de esa familia. BM25, la rama que construye este módulo, es lexical de principio a fin.


Ejercicios

Ejercicio 1: Corre el escaneo con otra query (Fácil)

Ejecuta naive_scan con la query "Is there wifi in the Lounge?" sobre build_corpus(). Reporta cuántos chunks tuvieron al menos un término en común y cuáles son los tres primeros del ranking.

Ver solución
chunks = build_corpus()
hits = naive_scan("Is there wifi in the Lounge?", chunks)
print("chunks con overlap:", len(hits))
for overlap, chunk in hits[:5]:
    print(f"  overlap={overlap}  {chunk.chunk_id:28s} ({chunk.doc_id})")

Salida real:

chunks con overlap: 57

  overlap=4  cancellation-policy-002      (cancellation-policy)
  overlap=4  wifi-and-equipment-faq-000   (wifi-and-equipment-faq)
  overlap=4  operations-manual-raw-003    (operations-manual-raw)
  overlap=3  no-show-policy-000           (no-show-policy)
  overlap=3  no-show-policy-001           (no-show-policy)

Explicación: esta vez el escaneo lineal NO acierta sin ambigüedad — el primer lugar es un empate a tres bandas con overlap=4, y ninguno de los tres es el chunk que de verdad menciona wifi y Lounge en la misma oración (wifi-and-equipment-faq-002, que ni siquiera entra al top-5). cancellation-policy-002 y operations-manual-raw-003 comparten "the", "is", "in", "there" — palabras genéricas, sin relación con wifi ni con Lounge. wifi-and-equipment-faq-000 sí es del documento correcto, pero por su sección "Is Wifi Included in Every Room?", no por mencionar la sala Lounge en particular. Este resultado, más débil que el que muestra el ejemplo trabajado con la query del Focus, confirma el mismo punto con otra query: sin ningún criterio de peso, un empate de overlap puede dejar fuera del top al chunk que de verdad responde la pregunta.

Ejercicio 2: Cuenta el vocabulario compartido (Medio)

Sin ejecutar naive_scan, calcula a mano (o con una línea de Python) cuántas palabras de la query "What payment methods does Reservo accept?" son palabras "comunes" (aparecen en muchos chunks del corpus) contra cuántas son específicas del tema pagos. Luego ejecuta naive_scan con esa query y confirma si tu intuición sobre qué chunk debería ganar se cumplió.

Ver solución
chunks = build_corpus()
query_terms = tokenize("What payment methods does Reservo accept?")
print("términos de la query:", query_terms)

hits = naive_scan("What payment methods does Reservo accept?", chunks)
for overlap, chunk in hits[:3]:
    print(f"  overlap={overlap}  {chunk.chunk_id:28s} ({chunk.doc_id})")

Salida real:

términos de la query: ['what', 'payment', 'methods', 'does', 'reservo', 'accept']

  overlap=3  booking-faq-001              (booking-faq)
  overlap=1  cancellation-policy-002      (cancellation-policy)
  overlap=1  cancellation-policy-003      (cancellation-policy)

Explicación: este es un caso todavía más incómodo que el ejercicio 1 — el chunk correcto, payment-methods-faq-000, ni siquiera aparece en el top-3. Su texto real es "Reservo accepts major credit and debit cards on file with the account..." — nunca dice "payment" ni "methods" en el cuerpo (esas palabras solo viven en el título de la sección, "What Payment Methods Does Reservo Accept?", que es metadato, no texto indexado), y dice "accepts", no "accept" — un token distinto sin stemming. Su overlap real con la query es de una sola palabra ("reservo"). Quien gana, booking-faq-001 (overlap=3: "what", "payment", "methods"), lo hace porque su texto menciona "the payment method on file" y, sobre todo, la referencia cruzada entre backticks `payment-methods-faq` — que al tokenizarse se parte en "payment", "methods" y "faq", tres términos que matchean la query sin que el chunk sea, en sí, sobre métodos de pago. Tu intuición de que "payment", "methods" y "accept" deberían ser específicos del tema era razonable — pero el corpus real no siempre repite en el cuerpo del texto las mismas palabras que usa en el título de su sección, y ese hueco es exactamente lo que este ejercicio expone.

Ejercicio 3: Diseña una query que rompa naive_scan (Difícil)

Usando el corpus fijo de Reservo, escribe (sin ejecutar nada primero) una query de tu invención que predigas va a producir un empate de overlap entre el chunk correcto y al menos un chunk irrelevante. Luego ejecútala y confirma si tu predicción se cumplió.

Ver solución

No hay una única respuesta correcta — el punto es razonar sobre el vocabulario del corpus. Un ejemplo que funciona, y de forma más contundente de lo esperado:

chunks = build_corpus()
hits = naive_scan("Can members book a room for a meeting?", chunks)
for overlap, chunk in hits[:8]:
    print(f"  overlap={overlap}  {chunk.chunk_id:28s} ({chunk.doc_id})")

Salida real:

  overlap=4  booking-faq-002              (booking-faq)
  overlap=4  lounge-room-manual-000       (lounge-room-manual)
  overlap=3  cancellation-policy-000      (cancellation-policy)
  overlap=3  no-show-policy-002           (no-show-policy)
  overlap=3  booking-faq-000              (booking-faq)
  overlap=3  booking-faq-003              (booking-faq)
  overlap=3  membership-tiers-faq-001     (membership-tiers-faq)
  overlap=3  payment-methods-faq-002      (payment-methods-faq)

Explicación: dos chunks empatan en el primer lugar con overlap=4, y ninguno de los dos es la respuesta más directa (booking-faq-000, "How Do I Book a Room?"). booking-faq-002 ("¿Puedo reservar más de una sala a la vez?") gana por compartir "a", "can", "for" y "room" — palabras mayormente genéricas, con una relación solo tangencial al tema de la query. lounge-room-manual-000 empata arriba por una razón que vale la pena mirar de cerca: su Overview dice literalmente "Lounge is Reservo's informal meeting room..." — comparte "a", "for", "meeting" y "room" con la query, incluido el token exacto meeting (singular). El token exacto "book" (del verbo "reservar") tampoco ayuda a la respuesta más directa: en todo el corpus, "book" como verbo exacto aparece en un único chunk (no-show-policy-002, "lose the ability to book same-day reservations") — el corpus usa casi siempre "booking"/"bookings" (sustantivo), un token distinto para un escaneo que compara cadenas exactas. Por eso booking-faq-000 queda recién en el segundo grupo, con overlap=3, empatado con cancellation-policy-000, no-show-policy-002, booking-faq-003, membership-tiers-faq-001 y payment-methods-faq-002 — chunks de cancelación, no-shows, membresías y tarjetas declinadas que tampoco son la respuesta. Esto confirma el patrón con el peor caso posible: cuando ninguna palabra de la query es lo bastante rara como para discriminar, naive_scan puede poner en primer lugar chunks que no son la respuesta. IDF (lección 04) va a resolver buena parte de esto, pesando "room" o "for" — que aparecen en casi todos los chunks — mucho menos que una palabra rara. Pero hay un límite que ni IDF arregla: el token exacto meeting (singular) aparece en un único chunk de todo el corpus (lounge-room-manual-000) — el otro manual de sala que habla de reuniones (boardroom-room-manual) usa "meetings" (plural), un token distinto para un índice sin normalización morfológica. Ese matiz — mismo concepto, forma de palabra distinta, cero coincidencia léxica entre singular y plural — es la misma familia de límite que la lección 06 va a demostrar con "refund" vs "reimbursement", solo que ahí es un sinónimo completo en vez de un plural.


Resumen y siguiente paso

  • Un índice de recuperación es cualquier estructura que evita escanear todo el corpus para responder una query — y, más importante, que aporta un criterio de peso mejor que "cuenta coincidencias".
  • Ejecutamos naive_scan, un escaneo lineal sobre los 57 chunks del corpus de Reservo: encuentra candidatos relevantes, pero empata chunks relevantes con chunks irrelevantes porque cuenta cada coincidencia de palabra por igual, sin distinguir palabras comunes de palabras raras.
  • Hay dos familias de índices de recuperación: léxicos (coincidencia de términos pesada — lo que este módulo construye, con BM25) y semánticos (similitud vía embeddings — embeddings-deep-dive-guide + vector-databases-fundamentals-guide).
  • El primer paso para pesar mejor es saber, de antemano, en qué chunks aparece cada término — eso es exactamente lo que construye el índice invertido, el tema de la próxima lección.

Siguiente lección: 03 — El índice invertido. Construimos, ejecutado, la estructura término → lista de chunks que hace posible calcular TF e IDF sin volver a escanear el corpus completo.


Recursos adicionales

  1. Python — re (expresiones regulares) — La librería usada para el tokenizador de esta lección y de todo el módulo.
  2. Wikipedia — Full-text search — Panorama general de por qué un escaneo lineal no escala en sistemas de búsqueda reales.
  3. Elastic — What is Elasticsearch? — Un sistema de producción real que usa exactamente la familia de índice léxico (invertido + BM25) que este módulo construye a mano.
  4. Python 3.14 — What's New — La versión con la que se ejecuta todo el código de este módulo.