HNSW a escala: por qué su sistema RAG empeora a medida que crece la base de datos de vectores

una base de datos vectorial moderna (Neo4j, Milvus, Weaviate, Qdrant, Pinecone) existe una gran probabilidad de que Hierarchical Navigable Small World (HNSW) ya esté impulsando su capa de recuperación. Es muy probable que no lo haya elegido al crear la base de datos, ni lo haya ajustado o ni siquiera sepa que está ahí. Y, sin embargo, HNSW está decidiendo silenciosamente lo que su LLM considera verdad. Determina qué fragmentos de documentos se introducen en su proceso RAG, qué recuerdos recuerda su agente y, en última instancia, si el modelo responde correctamente o alucina con confianza.

A medida que su base de datos de vectores crece, la calidad de recuperación se degrada gradualmente:

No se generan excepciones No se registran errores La latencia a menudo se ve perfectamente bien

Pero la calidad del contexto se deteriora y su sistema RAG se vuelve menos confiable con el tiempo, aunque el modelo de integración y la métrica de distancia permanezcan sin cambios.

En este artículo, demuestro, utilizando experimentos controlados y datos reales, cómo HNSW afecta la calidad de recuperación a medida que crece el tamaño de la base de datos, por qué esta degradación es peor que la búsqueda plana y qué se puede hacer de manera realista al respecto en los sistemas RAG de producción.

Específicamente, haré:

Cree un caso de uso práctico y reproducible para medir el efecto de HNSW en la calidad de recuperación de RAG utilizando Recall@k. Demuestre que, para configuraciones HNSW fijas, la recuperación se degrada más rápido que la búsqueda plana a medida que crece el corpus. Analice estrategias de ajuste prácticas para equilibrar la recuperación y la latencia más allá de simplemente aumentar ef_search de HNSW.

¿Qué es HNSW?

HNSW es ​​un algoritmo basado en gráficos para la búsqueda aproximada del vecino más cercano (ANN). Organiza datos en múltiples capas de vecinos conectados y utiliza esta estructura gráfica para acelerar la búsqueda.

Representación de HNSW

Cada vector está conectado a un número limitado de vecinos en cada capa. Durante una búsqueda, realiza una búsqueda codiciosa a través de estas capas, y el número de vecinos verificados en cada capa es constante (controlado por M y ef_search), lo que hace que el proceso de búsqueda sea logarítmico con respecto al número de vectores. En comparación con la búsqueda plana, donde la complejidad temporal es O(N), la búsqueda HNSW tiene una complejidad temporal de O(log N), lo que significa que el tiempo necesario para una búsqueda crece muy lentamente (logarítmicamente) en comparación con la búsqueda lineal. Veremos esto en el resultado de nuestro caso de uso.

Parámetros del índice HNSW

1. Parámetros de tiempo de construcción: M y ef_construction. Solo se puede configurar antes de crear la base de datos.

M define el número máximo de conexiones (vecinos) que cada vector (nodo) puede tener en cada capa del gráfico. Una M más alta significa más conexiones, lo que hace que el gráfico sea más denso y potencialmente aumente la recuperación, pero a costa de más memoria y una indexación más lenta.

Ef_construction controla el tamaño del conjunto de candidatos utilizado durante la construcción del gráfico. Esencialmente, rige qué tan minuciosamente se construye el gráfico durante la indexación. Un valor más alto para ef_construction significa que el gráfico se construye de manera más exhaustiva y se consideran más candidatos antes de realizar cada conexión, lo que genera un gráfico de mayor calidad y una mejor recuperación a costa de una mayor memoria y una indexación más lenta.

Para una aplicación RAG de uso general, los valores típicos de M están dentro de un rango de 12 y 48 y ef_construction entre 64 y 200.

2. Parámetro de tiempo de consulta: ef_search

Esto define el número de nodos (o vectores) candidatos a explorar durante el proceso de consulta (es decir, durante la búsqueda de vecinos más cercanos). Controla qué tan exhaustivo es el proceso de búsqueda determinando cuántos candidatos se evalúan antes de que se devuelva el resultado de la búsqueda. Un valor más alto para ef_search significa que la búsqueda explorará más candidatos, lo que generará una mejor recuperación, pero consultas potencialmente más lentas.

¿Qué es Recall@k?

Recall@k es una métrica clave para medir la precisión de los sistemas RAG y de búsqueda de vectores. Mide la capacidad del recuperador para encontrar los fragmentos relevantes para una consulta de usuario dentro de los k resultados principales. Es importante porque si el recuperador omite los fragmentos que contienen la información necesaria para responder la pregunta (recuerdo bajo), el LLM no puede generar una respuesta precisa en el paso de síntesis de respuesta, independientemente de lo poderoso que sea.

[ text{Recall}@k = frac{text{elementos relevantes recuperados en la parte superior } k}{text{número total de elementos relevantes en el corpus}} ]

En la práctica, esta es una métrica difícil de medir porque el denominador (documentos reales) no se conoce fácilmente en un sistema de producción de la vida real. Lo que haremos en su lugar será diseñar un caso de uso donde la verdad fundamental (por ejemplo, índice vectorial) sea única y conocida, y Recall@k medirá el número promedio de veces que se recupera en los k resultados principales, en una gran cantidad de consultas de muestra.

Por ejemplo, Recall@5 medirá el número promedio de veces que el índice de verdad fundamental apareció en las 5 principales recuperaciones en más de 500 consultas.

Para un RAG, el rango aceptable de Recall@5 es 70-90% y Recall@10 es 80-95%, y veremos que nuestro caso de uso se adhiere a estos rangos para el índice Flat.

Caso de uso

Para probar HNSW, necesitamos una base de datos de vectores con una cantidad suficientemente grande de vectores (> 100.000). No parece haber un conjunto de datos público tan grande disponible que consista en fragmentos de documentos y consultas asociadas para las cuales el fragmento en particular se consideraría como verdad básica. E incluso si estuviera allí, el lenguaje natural puede ser ambiguo, por lo que es difícil decir con seguridad qué fragmentos del corpus podrían considerarse relevantes para una consulta (el denominador en la fórmula Recall@k). Desarrollar un conjunto de datos tan curado requeriría encontrar una gran cantidad de documentos, fragmentarlos e incrustarlos y luego desarrollar consultas para los fragmentos. Sería un proceso que requeriría muchos recursos.

En su lugar, volvamos a imaginar nuestro problema RAG como "dada una breve leyenda (consulta), nos gustaría recuperar las imágenes más relevantes del conjunto de datos".

Para este enfoque, utilicé el conjunto de datos LAION-Aesthetics disponible públicamente. Para acceder, deberá iniciar sesión en Hugging Face y aceptar los términos mencionados. Los detalles sobre el conjunto de datos están disponibles en el sitio de LAOIN aquí. Contiene una gran cantidad de filas que contienen URL de imágenes junto con un título de texto. Se parecen a lo siguiente:

LAOIN-Estética

Descargué un subconjunto de filas y generé 200.000 incrustaciones CLIP de las imágenes para construir la base de datos vectorial. Los títulos de texto de las imágenes se pueden utilizar cómodamente como consultas para RAG. Y cada título tiene solo un vector de imagen como verdad fundamental, por lo que el denominador de Recall@k se conoce exactamente para todas las consultas. Además, las incrustaciones CLIP de la imagen y su título nunca coinciden exactamente, por lo que hay suficiente "borrosidad" en las recuperaciones similares a un RAG puramente de documentos, donde se utiliza una consulta de texto para recuperar fragmentos de documentos relevantes utilizando una métrica de distancia. Esto será evidente cuando veamos el gráfico de Recall@k en las siguientes secciones.

Medición de Recall@k para Flat vs HNSW

Adoptamos el siguiente enfoque:

Las incrustaciones de imágenes de 200.000 se almacenan como archivos .npy. Del conjunto de datos de Laion, se seleccionan e integran aleatoriamente 500 subtítulos (consultas) mediante CLIP. Los índices de consulta seleccionados también forman la verdad fundamental, ya que corresponden a la imagen única de la consulta. La base de datos se construye en incrementos de 50.000 vectores, es decir, 4 iteraciones de vectores de tamaño 50k, 100k, 150k y 200k. Se construyen índices tanto planos como HNSW. HNSW se construye usando M=16 y ef_construction=100. Recall@k se calcula para k = 1, 5, 10, 15 y 20 en función de si los índices de verdad fundamental se incluyen en los resultados top-k. Primero, los valores de Recall@k se calculan para cada uno de los vectores de consulta y se promedian sobre el número de muestras (500). Luego, se calculan los valores promedio de Recall@k para los valores ef_search de HNSW de 10, 20, 40, 80 y 160. Finalmente, se dibujan 5 gráficos, uno para cada uno de los valores de Recall@k. Cada gráfico muestra la evolución de Recall@k a medida que crece el tamaño de la base de datos para el índice Flat y diferentes valores de ef_search de HNSW. El código se puede ver aquí importar pandas como pd importar numpy como np importar faiss importar antorcha importar open_clip importar os importar aleatorio importar matplotlib.pyplot como plt def evalua_subset(size, embeddings_all, df_all, query_vectors_all, eval_indices_all, ef_search_values): # Subconjunto incrustaciones incrustaciones = incrustaciones_all[:tamaño] dimensión = incrustaciones.forma[1]# Construir índices en memoria para este tamaño de subconjunto index_flat = faiss.IndexFlatL2(dimension) index_flat.add(embeddings) index_hnsw = faiss.IndexHNSWFlat(dimension, 16) index_hnsw.hnsw.efConstruction = 100 index_hnsw.add(embeddings) num_samples = len(eval_indices_all) resultados =[]ks = [1, 5, 10, 15, 20] # Evaluar Flat flat_recalls = {k: 0 para k en ks} para i, qv en enumerate(query_vectors_all): _, I = index_flat.search(qv, max(ks)) target = eval_indices_all[i] para k en ks: si el objetivo está en I[0][:k]: flat_recalls[k] += 1 flat_res = {"Setting": "Flat"} for k in ks: flat_res[f"R@{k}"] = flat_recalls[k]/num_samples results.append(flat_res) # Evaluar HNSW con diferentes efSearch para ef en ef_search_values: index_hnsw.hnsw.efSearch = ef hnsw_recalls = {k: 0 para k en ks} para i, qv en enumerate(query_vectors_all): _, I = index_hnsw.search(qv, max(ks)) target = eval_indices_all[i] para k en ks: si el objetivo está en I[0][:k]: hnsw_recalls[k] += 1 hnsw_res = {"Configuración": f"HNSW (ef={ef})", "ef": ef} para k en ks: hnsw_res[f"R@{k}"] = hnsw_recalls[k]/num_samples results.append(hnsw_res) devuelve resultados def format_table(size, results): ks = [1, 5, 10, 15, 20] líneas =[]lines.append(f"nTamaño de la base de datos: {tamaño}")lines.append("="*80) encabezado = f"{'Index/efSearch':<20}" para k en ks: encabezado += f" | {'R@'+str(k):<8}"lines.append(header)lines.append("-" * 80) para la fila en los resultados: línea = f"{fila['Configuración']:<20}" para k en ks: línea += f" | {fila[f'R@{k}']:<8.2f}" líneas.append(línea) líneas.append("="*80) return "n".join(lines) def main(n): dataset_path = r"C:databaselaion_final.parquet" embeddings_path = r"C:databaseembeddings.npy" results_dir = r"C:results" db_sizes = [50000, 100000, 150000, 200000] ef_search_values = [10, 20, 40, 80, 160] num_samples = n salida_txt = os.path.join(results_dir, f"eval_results_{num_samples}.txt") output_png = os.path.join(results_dir, f"recall_vs_dbsize_{num_samples}.png") si no es os.path.exists(dataset_path) o no os.path.exists(embeddings_path): print("Error: Conjunto de datos o incrustaciones no encontradas.") return os.makedirs(results_dir, exist_ok=True) # Cargar todos los datos una vez print("Cargando datos base…") df_all = pd.read_parquet(dataset_path) embeddings_all = np.load(embeddings_path).astype('float32') # Cargar el modelo CLIP una vez print("Cargando el modelo CLIP (ViT-B-32)…") model, _, preprocess = open_clip.create_model_and_transforms('ViT-B-32', pretrained='laion2b_s34b_b79k') tokenizer = open_clip.get_tokenizer('ViT-B-32') dispositivo = "cuda" if torch.cuda.is_available() else "cpu" model.to(device) model.eval() # Usar muestras válidas para todos los subconjuntos eval_indices = random.sample(range(min(db_sizes)), num_samples) print(f"Muestreo de {num_samples} consultas para una evaluación consistente…") # Generar vectores de consulta query_vectors =[]para idx en eval_indices: text = df_all.iloc[idx]['TEXT'] text_tokens = tokenizer([text]).to(dispositivo) con torch.no_grad(): text_features = model.encode_text(text_tokens) text_features /= text_features.norm(dim=-1, keepdim=True) query_vectors.append(text_features.cpu().numpy().astype('float32')) all_output_text =[]# Recopilar todos los resultados para trazar # estructura: { 'R@1': { 'Flat': [val1, val2…], 'ef=10': [val1, val2…] }, … } ks = [1, 5, 10, 15, 20] plot_data = {f"R@{k}": { "Flat":[]} para k en ks} para ef en ef_search_values: para k en ks: plot_data[f"R@{k}"][f"HNSW ef={ef}"] =[]para el tamaño en db_sizes: print(f"Evaluando con el tamaño de la base de datos: {tamaño}…") resultados = evalua_subset(tamaño, embeddings_all, df_all, query_vectors, eval_indices, ef_search_values) table_str = format_table(size, results) # Imprimir en pantalla print(table_str) all_output_text.append(table_str) # Recopilar para trazar la fila en los resultados: etiqueta = fila["Configuración"] if label == "Flat": para k en ks: plot_data[f"R@{k}"]["Flat"].append(row[f"R@{k}"]) else: ef = fila["ef"] para k en ks: plot_data[f"R@{k}"][f"HNSW ef={ef}"].append(row[f"R@{k}"]) # Guardar resultados de texto con open(output_txt, "w", encoding="utf-8") como f: f.write("n".join(all_output_text)) print(f"nResultados finales guardados en {output_txt}") # Crear gráficos individuales para cada K para k en ks: plt.figure(figsize=(10, 6)) k_key = f"R@{k}" para etiqueta, valores en plot_data[k_key].items(): estilo de línea = '–' if etiqueta == "Flat" else '-' marcador = 'o' if etiqueta == "Flat" else 's' plt.plot(db_sizes, valores, etiqueta=label, linestyle=linestyle, marcador=marcador) plt.title(f"Recall@{k} vs Tamaño de la base de datos") plt.xlabel("Tamaño de la base de datos") plt.ylabel("Recall") plt.grid(True) plt.legend() output_png = os.path.join(results_dir, f"recall_vs_dbsize_{k}.png") plt.tight_layout() plt.savefig(output_png) plt.close() print(f"Trazado guardado en {output_png}") if __name__ == "__main__": principal(500)

Y los resultados son los siguientes:

Recuperación vs tamaño de base de datos para k = 5
Recuperación vs tamaño de base de datos para k = 1, 10, 15, 20

Observaciones

Para el índice plano (línea de puntos), Recall@5 y Recall@10 están en el rango de 0,70 a 0,85, como se puede esperar de las aplicaciones RAG de la vida real. El índice plano proporciona el mejor Recall@k en todos los tamaños de bases de datos y forma un límite superior de referencia para HNSW. En cualquier tamaño de base de datos dado, Recall@k aumenta para una k mayor. Entonces, para un tamaño de base de datos de 100k vectores, Recall@20 > Recall@15 > Recall@10 > Recall@5 > Recall@1. Esto es comprensible ya que con una k más alta, hay más probabilidad de que el índice de verdad fundamental esté presente en el conjunto recuperado. Tanto Flat como HNSW se deterioran constantemente a medida que crece el tamaño de la base de datos. Esto se debe a que los espacios vectoriales de alta dimensión se vuelven cada vez más saturados a medida que crece el número de vectores. El rendimiento mejora para HNSW con valores ef_search más altos. A medida que el tamaño de la base de datos se acerca a los 200k, HNSW parece degradarse más rápido que la búsqueda plana.

¿HNSW se degrada más rápido que Flat Search?

Para ver el rendimiento relativo de los índices Flat frente a HNSW a medida que crece el tamaño de la base de datos, se adopta un enfoque ligeramente diferente:

El proceso de construcción de índices de la base de datos y selección de consultas sigue siendo el mismo que antes. En lugar de considerar la verdad fundamental, calculamos la superposición entre el índice Flat y cada uno de los resultados de HNSW ef_search para un recuento de recuperación determinado (k). Se dibujan cinco gráficos para cada uno de los valores k, que denotan la evolución de la superposición a medida que crece el tamaño de la base de datos. Para una coincidencia perfecta con el índice Flat, la línea HNSW mostrará una puntuación de 1. Más importante aún, si la degradación de los resultados de HNSW es ​​mayor que el índice Flat, la línea tendrá una pendiente negativa; de lo contrario, tendrá una pendiente horizontal o positiva. El código se puede ver aquí importar pandas como pd importar numpy como np importar faiss importar antorcha importar open_clip importar os importar aleatorio importar matplotlib.pyplot como plt importar tiempo def evalua_subset_compare(size, embeddings_all, df_all, query_vectors_all, ef_search_values): # Subconjunto incrustaciones incrustaciones = incrustaciones_all[:tamaño] dimensión = incrustaciones.forma[1]# Construir índices en memoria para este tamaño de subconjunto index_flat = faiss.IndexFlatL2(dimension) index_flat.add(embeddings) index_hnsw = faiss.IndexHNSWFlat(dimension, 16) index_hnsw.hnsw.efConstruction = 100 index_hnsw.add(embeddings) num_samples = len(query_vectors_all) resultados =[]ks = [1, 5, 10, 15, 20] max_k = max(ks) # 1. Evalúe Flat una vez para este subconjunto flat_times =[]resultados_planos_todos =[]para qv en query_vectors_all: start_t = time.perf_counter() _, I_flat_all = index_flat.search(qv, max_k) flat_times.append(time.perf_counter() – start_t) flat_results_all.append(I_flat_all[0]) avg_flat_time_ms = (sum(flat_times) / num_samples) * 1000 # 2. Evalúe HNSW en relación con Flat para ef en ef_search_values: index_hnsw.hnsw.efSearch = ef hnsw_times =[]# Seguimiento del recuento de intersecciones para cada k solapa_cuentas = {k: 0 para k en ks} para i, qv en enumerate(query_vectors_all): # HNSW top-max_k start_t = time.perf_counter() _, I_hnsw_all = index_hnsw.search(qv, max_k) hnsw_times.append(time.perf_counter() – start_t) # El resultado plano ya estaba I_flat_all precalculado = flat_results_all[i] para k en ks: set_flat = set(I_flat_all[:k]) set_hnsw = set(I_hnsw_all[0][:k]) intersección = set_flat.intersection(set_hnsw) superposición_counts[k] += len(intersección) / k avg_hnsw_time_ms = (suma(hnsw_times) / num_samples) * 1000 hnsw_res = { "Configuración": f"HNSW (ef={ef})", "ef": ef, "FlatTime_ms": avg_flat_time_ms, "HNSWTime_ms": avg_hnsw_time_ms } para k en ks: # Promedio de todas las consultas hnsw_res[f"R@{k}"] = solapamiento_counts[k] / num_samples results.append(hnsw_res) devuelve resultados def format_all_tables(db_sizes, ef_search_values, all_results): ks = [1, 5, 10, 15, 20] líneas =[]# 1. Cree una tabla para cada Recall@k para k en ks: k_label = f"R@{k}"lines.append(f"nTable: {k_label} (HNSW Overlap with Flat)")lines.append("=" * (20 + 12 * len(db_sizes))) # Header header = f"{'ef_search':<18}" for size in db_sizes: header += f" | {size:<9}"lines.append(header)lines.append("-" * (20 + 12 * len(db_sizes))) # Filas (valores ef) para ef en ef_search_values: row_str = f"{ef:<18}" para tamaño en db_sizes: # Encuentra el resultado para este tamaño y ef val = 0 para r en all_results[size]: if r.get('ef') == ef: val = r.get(k_label, 0) break row_str += f" | {val:<9.2f}"lines.append(row_str)lines.append("=" * (20 + 12 * len(db_sizes))) # 2. Crear tabla de tiempos de búsqueda lines.append("nTable: Tiempo promedio de búsqueda (ms)") lines.append("=" * (20 + 12 * len(db_sizes))) header = f"{'Configuración de índice':<18}" para el tamaño en db_sizes: header += f" | {size:<9}"lines.append(header)lines.append("-" * (20 + 12 * len(db_sizes))) # Fila plana row_flat = f"{'Flat Index':<18}" para tamaño en db_sizes: # El tiempo fijo es el mismo para todos los ef en un tamaño, así que tome cualquier t = all_results[size][0]['FlatTime_ms'] fila_plana += f" | {t:<9.4f}" líneas.append(row_flat) # Filas HNSW para ef en ef_search_values: fila_str = f"HNSW (ef={ef:<3})" para tamaño en db_sizes: t = 0 para r en all_results[tamaño]: if r.get('ef') == ef: t = r.get('HNSWTime_ms', 0) break row_str += f" | {t:<9.4f}"lines.append(row_str)lines.append("=" * (20 + 12 * len(db_sizes))) return "n".join(lines) def main(n): dataset_path = r"C:databaselaion_final.parquet" embeddings_path = r"C:databaseembeddings.npy" results_dir = r"C:results" db_sizes = [50000, 100000, 150000, 200000] ef_search_values = [10, 20, 40, 80, 160] num_samples = n salida_txt = os.path.join(results_dir, f"compare_results_{num_samples}.txt") output_png_prefix = "compare_vs_dbsize" si no es os.path.exists(dataset_path) o no os.path.exists(embeddings_path): print("Error: Conjunto de datos o incrustaciones no encontradas.") return os.makedirs(results_dir, exist_ok=True) # Cargar todos los datos una vez print("Cargando datos base…") df_all = pd.read_parquet(dataset_path) embeddings_all = np.load(embeddings_path).astype('float32') # Cargar el modelo CLIP una vez print("Cargando el modelo CLIP (ViT-B-32)…") model, _, preprocess = open_clip.create_model_and_transforms('ViT-B-32', pretrained='laion2b_s34b_b79k') tokenizer = open_clip.get_tokenizer('ViT-B-32') dispositivo = "cuda" if torch.cuda.is_available() else "cpu" model.to(device) model.eval() # Usar consultas de las primeras 50k filas eval_indices = random.sample(range(min(db_sizes)), num_samples) print(f"Muestreo de {num_samples} consultas…") # Generar vectores de consulta query_vectors =[]para idx en eval_indices: text = df_all.iloc[idx]['TEXT'] text_tokens = tokenizer([text]).to(dispositivo) con torch.no_grad(): text_features = model.encode_text(text_tokens) text_features /= text_features.norm(dim=-1, keepdim=True) query_vectors.append(text_features.cpu().numpy().astype('float32')) all_results_data = {} ks = [1, 5, 10, 15, 20] plot_data = {f"R@{k}": {} para k en ks} para ef en ef_search_values: para k en ks: plot_data[f"R@{k}"][f"ef={ef}"] =[]para el tamaño en db_sizes: print(f"Evaluando con el tamaño de la base de datos: {tamaño}…") resultados = evaluar_subset_compare(tamaño, embeddings_all, df_all, query_vectors, ef_search_values) all_results_data[tamaño] = resultados # Recopilar para trazar la fila en los resultados: ef = fila["ef"] para k en ks: plot_data[f"R@{k}"][f"ef={ef}"].append(row[f"R@{k}"]) # Formatear tablas dinámicas final_output_text = format_all_tables(db_sizes, ef_search_values, all_results_data) print(final_output_text) # Guardar resultados de texto con open(output_txt, "w", encoding="utf-8") como f: f.write(final_output_text) print(f"nResultados finales guardados en {output_txt}") # Crear gráficos individuales para cada K para k en ks: plt.figure(figsize=(10, 6)) k_key = f"R@{k}" para etiqueta, valores en plot_data[k_key].items(): plt.plot(db_sizes, valores, etiqueta=etiqueta, marcador='s') plt.title(f"HNSW vs recuperación de superposición plana @{k} vs tamaño de la base de datos") plt.xlabel("Tamaño de la base de datos") plt.ylabel("Relación de superposición") plt.grid(True) plt.legend() output_png = os.path.join(results_dir, f"{output_png_prefix}_{k}.png") plt.tight_layout() plt.savefig(output_png) plt.close() print(f"Trazado guardado en {output_png}") if __name__ == "__main__": main(500)

Y los resultados son los siguientes:

Superposición del índice plano vs HNSW para k = 5
Superposición del índice plano vs HNSW para k = 1, 10, 15, 20

Observaciones

En todos los casos, las líneas tienen una pendiente negativa, lo que indica que HNSW se degrada más rápido que el índice Flat a medida que crece la base de datos. Los valores más altos de ef_search se degradan más lentamente que los valores más bajos, que caen de manera bastante pronunciada. Los valores más altos de ef_search tienen una superposición significativa (>90 %) con la búsqueda plana de referencia en comparación con los valores más bajos.

Compensación entre recuperación y latencia

Sabemos que HNSW es ​​más rápido que la búsqueda plana. Para verlo en acción, también he medido la latencia media en el código del apartado anterior. Estos son los tiempos de búsqueda promedio (en ms):

Tamaño de la base de datos50,000100,000150,000200,000Índice plano 5,1440 9,3850 14,8843 18,4100 HNSW (ef=10) 0,0851 0,0742 0,0763 0,0768 HNSW (ef=20) 0,1159 0,0876 0,0959 0,0983 HNSW (ef=40 )0,1585 0,1366 0,1415 0,1493 HNSW (ef=80 )0,2508 0,2262 0,2398 0,2417 HNSW (ef=160 )0,4613 0,3992 0,4140 0,4064

Observaciones

HNSW es ​​mucho más rápido que la búsqueda plana, lo cual es la razón principal por la que es el algoritmo de búsqueda elegido para casi todas las bases de datos vectoriales. El tiempo necesario para la búsqueda plana aumenta casi linealmente con el tamaño de la base de datos (complejidad O(N)). Para un valor de ef_search dado (una fila), el tiempo HNSW es ​​casi constante. A esta escala (200.000 vectores), la latencia de HNSW permanece casi constante. A medida que ef_search aumenta en una columna, el tiempo HNSW aumenta de manera muy significativa. Por ejemplo, el tiempo necesario para ef=160 es 3 veces mayor que el de ef=40

Ajuste del oleoducto RAG

El análisis anterior muestra que, si bien HNSW es ​​definitivamente la opción a adoptar en un escenario de producción por razones de latencia, existe la necesidad de ajustar periódicamente ef_search para mantener el equilibrio entre latencia y recuperación a medida que crece la base de datos. Algunas mejores prácticas que deberían adoptarse son las siguientes:

Dada la dificultad de medir Recall@k en una base de datos de producción, mantenga un repositorio de casos de prueba de consultas y fragmentos de documentos reales, que se puedan ejecutar a intervalos regulares para verificar la calidad de la recuperación. Podríamos comenzar con las consultas más frecuentes realizadas por el usuario y los fragmentos necesarios para una buena recuperación. Otra forma indirecta de determinar la calidad del recuerdo sería utilizar un LLM potente para juzgar la calidad del contexto recuperado. En lugar de preguntar "¿Obtuvimos los mejores documentos para la consulta del usuario?", lo cual es difícil de decir con precisión para una base de datos grande, podemos hacer una pregunta un poco más débil: "¿El contexto recuperado realmente contiene la respuesta a la pregunta del usuario?" y deje que el juez LLM responda a eso. Recopile comentarios de los usuarios en producción. La calificación del usuario de una respuesta junto con cualquier corrección manual se puede utilizar como activador para ajustar el rendimiento. Mientras ajusta ef_search, comience con un valor conservadoramente alto, mida Recall@k y luego reduzca hasta que la latencia sea aceptable. Medida de recuperación en top_k que usa el RAG, generalmente entre 3 y 10. Considere relajar top_k a 15 o 20 y deje que el LLM decida qué fragmentos en el contexto dado usar para la respuesta durante el paso de síntesis. Suponiendo que el contexto no sea demasiado grande para caber en la ventana de contexto del LLM, dicho enfoque permitiría una recuperación alta y al mismo tiempo tendría un valor ef_search moderado, manteniendo así la latencia baja.

Tubería RAG híbrida

El ajuste de HNSW mediante ef_search no puede solucionar el problema de la caída de la recuperación al aumentar el tamaño de la base de datos más allá de un punto. Esto se debe a que la búsqueda de vectores, incluso utilizando un índice plano, se vuelve ruidosa cuando se empaquetan demasiados vectores juntos en el espacio de N dimensiones (siendo N el número de dimensiones generadas por el modelo de incrustación). Como muestran los gráficos de la sección anterior, el recuerdo cae más del 10% a medida que la base de datos crece de 50k a 200k. La forma confiable de mantener la recuperación es utilizar el filtrado de metadatos (por ejemplo, usando un gráfico de conocimiento) para identificar posibles identificadores de documentos y ejecutar la recuperación solo para ellos. Hablo de esto en detalle en mi artículo GraphRAG en la práctica: cómo construir sistemas de recuperación rentables y de alta recuperación

Conclusiones clave

HNSW es ​​el algoritmo de recuperación predeterminado en la mayoría de las bases de datos vectoriales, pero rara vez se ajusta o monitorea en los sistemas RAG de producción. La calidad de la recuperación se degrada silenciosamente a medida que crece la base de datos de vectores, incluso cuando la latencia permanece estable. Para el mismo tamaño de corpus, la búsqueda plana logra consistentemente un Recall@k mayor que HNSW, lo que sirve como un límite superior útil para la evaluación. La recuperación de HNSW se degrada más rápido que la búsqueda plana para valores fijos de ef_search a medida que aumenta el tamaño de la base de datos. Aumentar ef_search mejora la recuperación, pero la latencia aumenta rápidamente, lo que crea un fuerte equilibrio entre recuperación y latencia. Simplemente ajustar los parámetros HNSW es ​​insuficiente a escala: la búsqueda de vectores en sí misma se vuelve ruidosa en espacios de incrustación densos. Las canalizaciones RAG híbridas que utilizan filtros de metadatos (SQL, gráficos, índices invertidos) son la forma más confiable de mantener la recuperación a escala.

Conclusión

HNSW se ha ganado su lugar como la columna vertebral de las bases de datos vectoriales modernas, no porque sea perfectamente precisa, sino porque es lo suficientemente rápida como para hacer práctica la búsqueda semántica a gran escala.

Sin embargo, en los sistemas RAG, la velocidad sin recuperación es una optimización falsa.

Este artículo muestra que a medida que las bases de datos vectoriales crecen, la calidad de la recuperación se deteriora silenciosamente (especialmente en búsquedas aproximadas), mientras que las métricas de latencia permanecen engañosamente estables. El resultado es un sistema que parece saludable desde una perspectiva de infraestructura, pero que gradualmente alimenta un contexto más débil al LLM, lo que aumenta las alucinaciones y reduce la calidad de las respuestas.

La solución no es abandonar HNSW ni aumentar arbitrariamente ef_search.

En cambio, los sistemas RAG de producción deben:

Mida la calidad de la recuperación de forma explícita y periódica. Trate la búsqueda plana como una base de recuperación. Reequilibra continuamente la recuperación y la latencia. Y, en última instancia, avanzar hacia arquitecturas de recuperación híbridas que reduzcan el espacio de búsqueda antes de aplicar la similitud de vectores.

Si las respuestas de su sistema RAG empeoran a medida que crecen sus datos, es posible que el problema no sea su LLM, sus indicaciones o sus incrustaciones, sino el algoritmo de recuperación en el que nunca se dio cuenta de que confiaba.

Conéctese conmigo y comparta sus comentarios en www.linkedin.com/in/partha-sarkar-lets-talk-AI

Las imágenes utilizadas en este artículo se generan sintéticamente. Conjunto de datos de LAOIN-Aesthetics utilizado bajo licencia CC-BY 4.0. Figuras y código creados por mi.