ruta rag en profundidad / módulo 2

M2 — Embeddings e indexado vectorial (ANN)

Outcome del módulo: entender por qué una búsqueda vectorial sin índice recorre todo el corpus (lento), y medir la diferencia al agregar HNSW o IVFFlat en pgvector, defendiendo con datos el trade-off recall vs latencia. Este es el mayor salto de rendimiento que vas a hacer en scholar-rag.


Concepto 1 — Qué es un embedding, de verdad

Idea núcleo: un embedding es un vector de números que ubica un texto en un espacio donde “cerca” significa “parecido en significado”. Toda la búsqueda semántica se apoya en eso.

Entender (capa 1)

Texto: un modelo de embeddings toma un texto y devuelve una lista fija de números (por ejemplo 384 valores con un modelo pequeño como bge-small, 768 o 1024 con modelos más grandes). Ese vector es una coordenada en un espacio de cientos de dimensiones. La propiedad que lo hace útil: textos con significado parecido caen cerca, y la cercanía se mide con distancia coseno (el ángulo entre los dos vectores). “¿Cómo riego un cultivo de arroz?” y “técnicas de irrigación en arrozales” quedan cerca aunque no compartan palabras.

Un detalle que importa para el resto del curso: el modelo codifica la pregunta y cada chunk por separado. A eso se le llama bi-encoder. Es rápido (puedes pre-calcular los vectores de todos los chunks una vez), pero como nunca mira pregunta y chunk juntos, pierde matices finos. Por eso más adelante aparece el reranking (M3), que sí los mira juntos.

La dimensionalidad es un trade-off: más dimensiones capturan más matiz pero pesan más (memoria, latencia de comparación) y tardan más de indexar. Para un corpus de tesis, un modelo de 384-768 suele ser el punto dulce.

Visual:

flowchart LR
    T1["'riego de arroz'"] --> M[modelo de embeddings]
    T2["'irrigacion en arrozales'"] --> M
    T3["'mantenimiento de motores'"] --> M
    M --> V1["vector A"]
    M --> V2["vector B"]
    M --> V3["vector C"]
    V1 -.cerca.- V2
    V1 -.lejos.- V3

Ejemplo:

# services/embeddings.py — la forma, no el detalle
from fastembed import TextEmbedding

model = TextEmbedding("BAAI/bge-small-en-v1.5")   # 384 dimensiones
vecs = list(model.embed(["riego de arroz", "irrigacion en arrozales"]))
# vecs[0] y vecs[1] estan cerca en coseno; ese "cerca" es toda la busqueda semantica

Fijar (capa 2)

Nota atómica:

Feynman: “Un embedding es darle a cada texto una dirección en un mapa gigante. Dos textos que hablan de lo mismo apuntan casi al mismo lado, aunque usen palabras distintas. Buscar es preguntar ‘¿qué apunta hacia acá?’.”

Aplicar (capa 3)

En scholar-rag, identifica el modelo de embeddings que usa services/embeddings.py y su dimensionalidad. Anótala: ese número es el tamaño de cada vector en tu tabla chunk y define cuánta memoria ocupa tu índice. Guárdalo, lo necesitas en el Concepto 3.


Concepto 2 — El problema del full-scan (por qué sin índice todo es lento)

Idea núcleo: encontrar los vecinos más cercanos comparando contra todos los vectores es O(n). Con miles de chunks se nota; con millones, es inusable.

Entender (capa 1)

Texto: cuando llega una pregunta, su vector se compara con el de cada chunk para ordenar por distancia. Sin un índice, la base de datos hace exactamente eso: recorre los N vectores, calcula N distancias, ordena. Es correcto (encuentra el vecino exacto) pero lineal: duplicas el corpus, duplicas el tiempo. scholar-rag ya tiene un índice HNSW (chunk_embedding_idx), así que hoy no hace full-scan — pero entender el full-scan es lo que te deja apreciar por qué ese índice existe y qué te daría (o costaría) quitarlo. El ejercicio de profundidad no es crear el índice, es medir su efecto.

La salida no es hacer el recorrido más rápido, es no hacerlo: un índice ANN (Approximate Nearest Neighbors) organiza los vectores de antemano para saltar directo a la zona correcta del espacio, mirando solo una fracción. Cambias exactitud perfecta por velocidad enorme — y en RAG casi nunca necesitas el vecino exacto, te sirve “de los 5 más cercanos, casi seguro estos”.

Visual:

flowchart TB
    Q[vector de la pregunta] --> D{con indice?}
    D -->|no: full scan| A["compara contra los N chunks - O(n)"]
    D -->|si: ANN| B["salta a la zona correcta, mira una fraccion - casi O(log n)"]
    A --> R[top-k exacto, lento]
    B --> R2[top-k aproximado, rapido]

Ejemplo:

-- Sin indice: Postgres hace Seq Scan sobre chunk, calcula distancia en cada fila
EXPLAIN ANALYZE
SELECT id FROM chunk ORDER BY embedding <=> $1 LIMIT 5;
-- busca "Seq Scan on chunk" en el plan: esa es la señal del full-scan

Fijar (capa 2)

Nota atómica:

Feynman: “Buscar sin índice es leer la biblioteca entera libro por libro para hallar el que se parece al tuyo. Un índice ANN es el catálogo que te manda directo al pasillo correcto: quizá te saltas un libro bueno del pasillo de al lado, pero terminas en segundos, no en días.”

Aplicar (capa 3)

Corre el EXPLAIN ANALYZE de arriba contra tu tabla chunk real. Como scholar-rag ya tiene el índice HNSW, verás que el plan lo usa (no Seq Scan). Para ver el contraste, desactiva el índice en tu sesión (SET enable_indexscan = off;) y vuelve a correrlo: ahí aparece el full-scan. Anota los dos tiempos — esa diferencia es lo que el índice te está ahorrando, tu primer número de latencia medido.


Concepto 3 — HNSW: el índice ANN por defecto

Idea núcleo: HNSW arma un grafo en capas por el que “salta” hasta la vecindad correcta. Es rápido y preciso; su costo es memoria y tiempo de construcción.

Entender (capa 1)

Texto: HNSW (Hierarchical Navigable Small World) construye un grafo donde cada vector se conecta con sus vecinos cercanos, en varias capas. La capa de arriba tiene pocos nodos muy espaciados (saltos largos); las de abajo, cada vez más densas (saltos finos). Al buscar, entras por arriba, saltas hacia la zona correcta, y bajas afinando. Llegas a los vecinos aproximados sin recorrer todo.

Tres parámetros mandan:

La clave mental: m y ef_construction se pagan una vez al construir; ef_search es el que negocias recall-vs-latencia en cada búsqueda.

Visual:

flowchart TB
    E[entrada por capa alta] --> L2[capa 2: saltos largos, pocos nodos]
    L2 --> L1[capa 1: saltos medios]
    L1 --> L0[capa 0: todos los nodos, saltos finos]
    L0 --> R[vecinos aproximados top-k]

Ejemplo:

-- Crear el indice HNSW en pgvector (coseno)
CREATE INDEX ON chunk USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

-- El dial de consulta: mas ef_search = mas recall, mas latencia
SET hnsw.ef_search = 40;
SELECT id FROM chunk ORDER BY embedding <=> $1 LIMIT 5;

Fijar (capa 2)

Nota atómica:

Feynman: “HNSW es un mapa con niveles de zoom. Empiezas viendo el país (pocos saltos, lejos), bajas a la ciudad, luego a la cuadra. ef_search es cuántas cuadras revisas antes de decidir: más cuadras, más seguro aciertas, más tardas.”

Aplicar (capa 3)

Inspecciona el índice HNSW que ya tiene scholar-rag (\d chunk o SELECT indexdef FROM pg_indexes WHERE tablename='chunk') y anota sus parámetros. Luego varía ef_search (SET hnsw.ef_search = 20; vs 100) y mide recall@k (contra el gold de M1) y latencia en cada valor. Ese barrido es tu curva recall-vs-latencia real, la evidencia de que entiendes el dial, no solo que existe.


Concepto 4 — IVFFlat: la alternativa liviana

Idea núcleo: IVFFlat divide el espacio en celdas y al buscar solo mira unas pocas. Más liviano que HNSW, más rápido de construir, normalmente menos recall.

Entender (capa 1)

Texto: IVFFlat (Inverted File Flat) agrupa los vectores en lists celdas (clusters). Cada celda tiene un centroide. Al consultar, en vez de mirar todo, calcula a qué celdas está más cerca la pregunta y solo explora esas: cuántas, lo decide probes. Con probes = 1 mira una sola celda (rápido, arriesgado: si la respuesta cayó en la celda vecina, la pierdes); subir probes explora más celdas (más recall, más lento).

Frente a HNSW: IVFFlat usa menos memoria y se construye más rápido, pero suele dar menos recall al mismo nivel de latencia, y tiene un detalle práctico — necesita datos ya cargados para calcular buenos centroides (se construye después de insertar, no antes). Es una buena opción cuando la memoria es cara o el corpus cambia poco.

Visual:

flowchart TB
    Q[vector de la pregunta] --> C[calcula celdas mas cercanas]
    C --> P{probes = cuantas celdas mirar}
    P -->|probes=1| U[1 celda: rapido, puede fallar]
    P -->|probes=10| M[10 celdas: mas recall, mas lento]

Ejemplo:

-- IVFFlat: primero cargar datos, luego crear indice con nº de celdas
CREATE INDEX ON chunk USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);       -- regla de dedo: ~ sqrt(nº de filas)

SET ivfflat.probes = 10;    -- dial de consulta: recall vs latencia
SELECT id FROM chunk ORDER BY embedding <=> $1 LIMIT 5;

Fijar (capa 2)

Nota atómica:

Feynman: “IVFFlat divide la ciudad en barrios. En vez de tocar todas las puertas, calcula en qué barrios probablemente vives y solo toca ahí. probes es cuántos barrios visita: uno es rápido pero puede errar; varios, más seguro y más lento.”

Aplicar (capa 3)

Construye un IVFFlat sobre una copia de chunk y mide su latencia con probes en 1, 5 y 10. Anota los tres tiempos. Ya tienes datos de HNSW (Concepto 3) y de IVFFlat: en el Concepto 5 los enfrentas.


Concepto 5 — HNSW vs IVFFlat: elegir con tu propio benchmark

Idea núcleo: no hay ganador universal. El ganador es el que, sobre tu corpus y tu dataset gold, da el mejor recall a la latencia que aceptas. Eso se mide, no se opina.

Entender (capa 1)

Texto: los blogs dicen “HNSW es mejor” y suele ser cierto en recall/latencia, pero paga memoria y construcción lenta. IVFFlat gana en memoria y velocidad de build. La única respuesta que te hace ver senior es: “sobre mi corpus de N tesis, medí recall@5 vs p99 para ambos, y elegí X porque Y”. Usas el eval harness de M1 (recall@k contra el gold) y mides la latencia a la vez. Barres ef_search (HNSW) y probes (IVFFlat) para trazar la curva de cada uno, y comparas a igualdad de latencia cuál da más recall.

Esta es la mentalidad que el curso entero persigue: la decisión no viene de autoridad externa, viene de un número medido sobre tu realidad.

Visual:

quadrantChart
    title Recall vs Latencia sobre tu corpus
    x-axis "menor latencia" --> "mayor latencia"
    y-axis "menor recall" --> "mayor recall"
    quadrant-1 "ideal: rapido y preciso"
    quadrant-2 "preciso pero lento"
    quadrant-3 "evitar"
    quadrant-4 "rapido pero impreciso"
    "HNSW ef=40": [0.45, 0.85]
    "HNSW ef=80": [0.65, 0.92]
    "IVFFlat probes=5": [0.35, 0.70]
    "IVFFlat probes=10": [0.55, 0.80]

Ejemplo:

# eval/bench_index.py — reutiliza evaluate() de M1 y mide tiempo
import time
def bench(retrieve_fn, gold, k=5):
    t0 = time.perf_counter()
    metrics = evaluate(gold, retrieve_fn, k)     # de M1: recall@k, mrr
    metrics["latency_ms"] = (time.perf_counter() - t0) / len(gold) * 1000
    return metrics
# corre bench() para cada config (hnsw ef 40/80, ivfflat probes 5/10) y compara

Fijar (capa 2)

Nota atómica:

Feynman: “Preguntar ‘¿HNSW o IVFFlat?’ en abstracto es como preguntar ‘¿carro o moto?’ sin decir para qué. La respuesta sale de correr tu propia ruta con ambos y cronometrar; el ganador es el de tu ruta, no el del anuncio.”

Aplicar (capa 3)

Junta los números de los Conceptos 3 y 4 en una sola tabla: config → recall@5 → latencia. Elige una y escribe una frase justificándola con esos números. Esa frase es el germen del design doc del capstone y la respuesta a la pregunta 4 de las cinco.


Cierre del módulo

Pasaste de “hay una búsqueda vectorial” a “sé por qué era lenta, qué índice lo arregla, y cuál elegí con datos”. Tienes tu primer antes/después de latencia medido y una decisión de arquitectura defendida con números.

Lo que puedes responder ahora: “¿por qué ese índice y no otro, y qué recall pierdes por la latencia que ganas?” — con tu propia curva.


Siguiente: M3 — Chunking, retrieval híbrido y RRF, donde subes el recall sin tocar el índice, combinando búsqueda semántica y léxica.