volver al blog

HNSW frente a IVF: afinar el índice antes de cambiar de base de datos vectorial

ragbases-de-datosembeddings

El índice se equivoca y no te avisa

Un RAG que recupera mal casi siempre acaba en la misma reunión: hay que cambiar el modelo de embeddings. A veces es cierto. Muchas veces el modelo estaba bien y el problema venía de una capa más abajo, en la estructura que decide qué vectores se comparan siquiera.

Porque cuando pides los 10 documentos más parecidos, tu base de datos vectorial no compara con el millón que tienes guardado. Recorre un índice aproximado, visita una fracción diminuta del espacio y te devuelve 10 resultados con su puntuación de similitud, tan convincentes como si fueran los verdaderos. No hay error, ni aviso, ni métrica en el panel. Solo faltan documentos.

A la fracción de los vecinos verdaderos que sí recupera se le llama recall, y es la métrica que separa una recuperación decente de una que parece embrujada. La buena noticia es que en los dos índices que vas a encontrarte se sube desde la consulta, sin reconstruir nada.

HNSW: un grafo por el que se baja

HNSW (hierarchical navigable small world) guarda los vectores como nodos de un grafo repartido en capas. La capa de arriba tiene pocos nodos y enlaces muy largos: sirve para cruzar el espacio de un salto. Cada capa inferior es más densa, con enlaces más cortos. La búsqueda entra por arriba, se acerca a saltos grandes y va bajando hasta afinar en la capa base.

Tres parámetros mandan:

  • M: cuántos vecinos guarda cada nodo. Más enlaces, mejor navegación y más memoria. Entre 16 y 64 cubre casi todo.
  • ef_construction: cuántos candidatos considera al insertar cada vector. Se paga una vez, al construir, y fija el techo de calidad del grafo.
  • ef_search: cuántos candidatos mantiene vivos durante la consulta. Este es el mando de verdad, porque actúa en tiempo de búsqueda.

Ese último punto es el que suele ignorarse. Si tu recall es malo con HNSW, antes de reindexar nada prueba a subir ef_search de 40 a 200 y mide otra vez. La latencia sube de forma aproximadamente lineal; el recall sube rápido al principio y luego se aplana. Ahí, en el codo de esa curva, está tu configuración. El nombre del parámetro cambia según la implementación: efSearch en Faiss, ef_search en hnswlib y pgvector.

El precio de HNSW es la memoria. Guarda los vectores completos más el grafo de enlaces, así que un millón de vectores de 1024 dimensiones en float32 te cuesta unos 4 GB solo en datos, más el grafo. Y los borrados son un problema conocido: la mayoría de implementaciones marcan el nodo como eliminado sin recomponer los enlaces, de modo que un índice con mucha rotación se degrada hasta que lo reconstruyes.

IVF: dividir el espacio en barrios

IVF (inverted file) es más viejo y más humilde. Ejecuta un k-means sobre una muestra de tus vectores, se queda con nlist centroides y asigna cada vector al suyo. Buscar consiste en mirar qué centroides caen cerca de la consulta y recorrer solo esas listas.

  • nlist: número de particiones. La heurística habitual en Faiss es entre 4·√N y 16·√N.
  • nprobe: cuántas particiones visitas en cada consulta. Otra vez, el mando en tiempo de búsqueda.

IVF tiene una limitación de diseño que conviene tener presente: un vector cerca de la frontera entre dos barrios se pierde si solo visitas uno. Por eso nprobe = 1 da un recall lamentable y nprobe = 32 suele arreglar la mitad de los problemas que se atribuyen al modelo de embeddings.

Su ventaja aparece al combinarlo con cuantización de producto (IVF-PQ): en vez de guardar el vector, guardas un código comprimido de unos pocos bytes. Un millón de vectores de 1024 dimensiones baja de 4 GB a unos 64 MB con 64 subcuantizadores de 8 bits, es decir, 64 bytes por vector. Pierdes precisión en la distancia y la recuperas reordenando los primeros candidatos con los vectores originales, que puedes guardar en disco o fuera del índice. El índice en memoria cabe entonces en una máquina que no cuesta una fortuna al mes.

Requiere entrenamiento previo, eso sí. Y si tu corpus cambia de distribución (añades un idioma nuevo, un sector nuevo), los centroides dejan de representar los datos y toca reentrenar.

Medir el recall real: 20 líneas

Nada de esto sirve sin un número. Y el número es fácil: construye un índice exacto sobre el mismo corpus, úsalo como verdad de referencia y compara.

import numpy as np
import faiss

d, k = 768, 10
vectores = np.load("corpus.npy").astype("float32")   # (N, 768) ya normalizados
consultas = np.load("consultas.npy").astype("float32")  # (500, 768) reales, no sintéticas

# 1. Verdad de referencia: fuerza bruta. Lento, pero es lo correcto por definición.
exacto = faiss.IndexFlatIP(d)
exacto.add(vectores)
_, ideal = exacto.search(consultas, k)

# 2. El índice que vas a llevar a producción.
indice = faiss.IndexHNSWFlat(d, 32, faiss.METRIC_INNER_PRODUCT)  # M = 32
indice.hnsw.efConstruction = 200
indice.add(vectores)

def recall_en_k(ef):
    indice.hnsw.efSearch = ef            # se cambia en caliente, sin reconstruir
    _, obtenido = indice.search(consultas, k)
    # Fracción de los k verdaderos vecinos que el índice sí ha devuelto.
    aciertos = [len(set(a) & set(b)) for a, b in zip(ideal, obtenido)]
    return sum(aciertos) / (len(consultas) * k)

for ef in (16, 32, 64, 128, 256, 512):
    print(f"efSearch={ef:4d}  recall@{k}={recall_en_k(ef):.3f}")

Dos avisos sobre este script. Las consultas tienen que ser reales, sacadas de tus registros; con consultas sintéticas medirás una geometría que ningún usuario visita. Y mide también la latencia en la misma tirada: un recall de 0,99 a 400 ms puede ser peor negocio que 0,94 a 20 ms, según lo que haya después en la cadena.

Qué eliges al elegir

Plano (exacto)HNSWIVF-PQ
Recall1,0 por definición0,95–0,99 bien afinado0,85–0,95, mejora con reordenado
Latencia con un millón de vectoresCientos de ms1–10 ms5–20 ms
Memoria (un millón × 1024 dim)~4 GB~4,5 GB con el grafo~64 MB + centroides
ConstrucciónInstantáneaLenta, minutos u horasRequiere entrenar
Añadir vectoresTrivialBien, salvo mucho borradoBien, hasta que la distribución cambia
Mando de recallef_searchnprobe
Filtros por metadatosPerfectosSe degradan si el filtro es selectivoAceptables

Esa última fila merece un párrafo. Filtrar por metadatos con HNSW es más traicionero de lo que parece: si el filtro deja fuera al 99 % del corpus, el grafo pierde su conectividad efectiva y la búsqueda se atasca visitando nodos descartados. Con filtros muy selectivos suele salir más a cuenta invertir el orden, filtrar primero en la base relacional y hacer búsqueda exacta sobre el subconjunto resultante.

La regla corta

Por debajo de 100.000 vectores no montes nada aproximado: la búsqueda exacta responde en decenas de milisegundos y tienes recall perfecto gratis. Ese umbral se ha movido mucho hacia arriba en los últimos años y casi nadie lo ha actualizado en su cabeza.

Entre 100.000 y varios millones, HNSW con M = 32 y ef_construction = 200 es un punto de partida sensato. Afina ef_search contra tu propia curva de recall y latencia, no contra el valor por defecto de la biblioteca.

Por encima, o cuando la factura de memoria empiece a doler, IVF-PQ y reordenado de los primeros 100 candidatos contra los vectores originales. Comprimes el índice unas sesenta veces y recuperas casi todo el recall en el reordenado.

Y antes de cualquiera de las tres: mide el recall que tienes hoy. He visto sistemas con un modelo de embeddings excelente que perdían un tercio de los documentos relevantes por un nprobe a 1 heredado de un tutorial. Cambiar el modelo no habría arreglado nada, y habría costado una semana averiguarlo.

Fuentes