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:
- Front: ¿qué significa que un embedding sea de un “bi-encoder”?
- Back: que codifica la pregunta y cada chunk por separado, sin mirarlos juntos. Es rápido y permite pre-calcular todos los vectores del corpus, pero pierde matiz fino de relevancia; ese matiz lo recupera un reranker (cross-encoder) después.
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:
- Front: ¿por qué una búsqueda vectorial “correcta” puede ser inaceptable en producción?
- Back: porque sin índice es O(n): compara el vector de la pregunta contra todos los del corpus. Es exacta pero escala lineal con el tamaño; la solución es un índice ANN que aproxima el resultado mirando solo una fracción.
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:
m: cuántas conexiones tiene cada nodo. Másm= grafo más navegable y mejor recall, pero más memoria. Típico: 16.ef_construction: cuánto esfuerzo pone al construir el índice (cuántos candidatos evalúa por nodo). Más = índice mejor, construcción más lenta. Típico: 64.ef_search: cuánto esfuerzo pone al consultar (cuántos candidatos explora). Este es el dial en vivo: subirlo mejora recall a costa de latencia. Es el que ajustas por query sin reconstruir.
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:
- Front: ¿cuál parámetro de HNSW ajustas para negociar recall vs latencia sin reconstruir el índice?
- Back:
ef_search.myef_constructionse fijan al construir (memoria y calidad del grafo);ef_searchse cambia por consulta: más candidatos explorados = más recall, más latencia.
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:
- Front: ¿qué hace
probesen IVFFlat y por quélistsse elige después de cargar datos? - Back:
probeses cuántas celdas explora la consulta (más = más recall, más latencia).listsdefine el número de celdas y necesita los datos cargados para calcular centroides representativos; por eso IVFFlat se construye después de insertar.
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:
- Front: ¿cómo decides entre HNSW e IVFFlat de forma defendible?
- Back: midiendo recall@k (contra tu dataset gold de M1) frente a latencia p99 sobre tu propio corpus, barriendo
ef_searchyprobespara trazar la curva de cada uno, y eligiendo el que da más recall a la latencia que aceptas. No por lo que diga un blog.
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.