Как приблизительный векторный поиск незаметно ухудшает показатель полноты (Recall) — и что с этим делать?
Делиться

Если вы используете современную векторную базу данных — Neo4j, Milvus, Weaviate, Qdrant, Pinecone — то с очень высокой вероятностью ваш слой поиска уже использует иерархическую навигационную модель малого мира ( HNSW) . Вполне вероятно, что вы не выбирали её при создании базы данных, не настраивали её и даже не знали о её наличии. И всё же HNSW незаметно определяет, что ваша модель LLM считает истиной . Она определяет, какие фрагменты документов поступают в ваш конвейер RAG, какие воспоминания воспроизводит ваш агент и, в конечном итоге, отвечает ли модель правильно — или уверенно выдаёт галлюцинации.
По мере роста вашей векторной базы данных качество поиска постепенно ухудшается:
- Никаких возражений не возникает.
- Ошибок в журнале не зарегистрировано.
- Задержка часто выглядит совершенно нормально.
Однако качество контекста ухудшается, и ваша система RAG со временем становится менее надежной — даже несмотря на то, что модель встраивания и метрика расстояния остаются неизменными.
В этой статье я, используя контролируемые эксперименты и реальные данные, демонстрирую, как HNSW влияет на качество поиска по мере роста размера базы данных, почему это ухудшение хуже, чем при обычном поиске, и что можно реально с этим сделать в производственных системах RAG.
В частности, я буду:
- Разработайте практичный, воспроизводимый сценарий использования для измерения влияния HNSW на качество поиска RAG с помощью Recall@k.
- Покажите, что при фиксированных настройках HNSW показатель полноты снижается быстрее, чем при плоском поиске , по мере роста корпуса.
- Обсудите практические стратегии настройки для балансировки полноты и задержки, выходящие за рамки простого увеличения параметра ef_search в HNSW.
Что такое HNSW?
HNSW — это алгоритм поиска приблизительного ближайшего соседа (ANN), основанный на графах. Он организует данные в несколько слоев связанных соседей и использует эту графовую структуру для ускорения поиска.

Каждый вектор связан с ограниченным числом соседей в каждом слое. Во время поиска выполняется жадный поиск по этим слоям, и количество проверяемых соседей в каждом слое постоянно (контролируется M и ef_search), что делает процесс поиска логарифмическим по отношению к числу векторов. По сравнению с плоским поиском, где временная сложность составляет O(N), поиск HNSW имеет временную сложность O(log N), что означает, что время, необходимое для поиска, растет очень медленно (логарифмически) по сравнению с линейным поиском. Мы увидим это в результатах нашего примера использования.
Параметры индекса HNSW
1. Параметры сборки : M и ef_construction. Могут быть установлены только до сборки базы данных.
M определяет максимальное количество связей (соседей), которые может иметь каждый вектор (узел) в каждом слое графа. Более высокое значение M означает больше связей, что делает граф более плотным и потенциально увеличивает полноту, но за счет большего объема памяти и более медленной индексации .
Параметр ef_construction управляет размером набора кандидатов, используемых при построении графа. По сути, он определяет, насколько тщательно строится граф во время индексирования. Более высокое значение ef_construction означает, что граф строится более тщательно, с рассмотрением большего количества кандидатов перед установлением каждого соединения, что приводит к более высокому качеству графа и лучшей полноте за счет увеличения объема памяти и замедления индексирования .
Для общего применения RAG типичные значения M находятся в диапазоне от 12 до 48, а ef_construction — от 64 до 200.
2. Параметр времени выполнения запроса : ef_search
Этот параметр определяет количество узлов-кандидатов (или векторов), которые будут исследованы в процессе запроса (т.е. во время поиска ближайших соседей). Он контролирует тщательность процесса поиска , определяя, сколько кандидатов оценивается до возврата результата поиска. Более высокое значение ef_search означает, что поиск будет исследовать больше кандидатов , что приведет к лучшей полноте, но потенциально к более медленным запросам .
Что такое Recall@k?
Recall@k — это ключевой показатель для измерения точности векторного поиска и систем RAG. Он измеряет способность системы поиска находить релевантные фрагменты для запроса пользователя среди k лучших результатов. Это важно, потому что если система поиска пропускает фрагменты, содержащие информацию, необходимую для ответа на вопрос (низкая полнота), то LLM не сможет сгенерировать точный ответ на этапе синтеза ответа, независимо от того, насколько она мощна.
[ text{Recall}@k = frac{text{количество релевантных элементов, найденных в топе } k}{text{общее количество релевантных элементов в корпусе}} ]
На практике измерить этот показатель сложно, поскольку знаменатель (документы с эталонными данными) не всегда легко определить в реальной производственной системе. Вместо этого мы разработаем сценарий использования, в котором эталонные данные (например, векторный индекс) будут уникальными и известными, а Recall@k измерит среднее количество раз, когда они будут найдены в k лучших результатах, на основе большого количества тестовых запросов.
Например, Recall@5 будет измерять среднее количество раз, когда эталонный индекс появлялся в топ-5 результатов поиска по 500 запросам.
Для RAG допустимый диапазон значений Recall@5 составляет 70-90%, а Recall@10 — 80-95%, и мы увидим, что наш вариант использования соответствует этим диапазонам для индекса Flat.
Вариант использования
Для тестирования HNSW нам необходима база данных векторов с достаточно большим количеством векторов (> 100 000). По всей видимости, такого большого общедоступного набора данных, состоящего из фрагментов документов и связанных с ними запросов, для которых конкретный фрагмент считался бы эталонным, не существует. Даже если бы такой набор существовал, естественный язык может быть неоднозначным, поэтому трудно с уверенностью сказать, какие именно фрагменты в корпусе можно считать релевантными для запроса (знаменатель в формуле Recall@k). Разработка такого тщательно подобранного набора данных потребовала бы поиска большого количества документов, их разбиения на фрагменты и встраивания, а затем разработки запросов к этим фрагментам. Это был бы ресурсоемкий процесс.
Вместо этого давайте переосмыслим нашу задачу RAG следующим образом: «имея короткую подпись (запрос), мы хотим извлечь наиболее релевантные изображения из набора данных».
Для этого подхода я использовал общедоступный набор данных LAION-Aesthetics. Для доступа к нему необходимо войти в систему Hugging Face и согласиться с указанными условиями. Подробная информация о наборе данных доступна на сайте LAOIN здесь. Он содержит огромное количество строк, содержащих URL-адреса изображений вместе с текстовыми подписями. Они выглядят следующим образом:

Я загрузил подмножество строк и сгенерировал 200 000 CLIP-встраиваний изображений для создания векторной базы данных. Текстовые подписи к изображениям можно удобно использовать в качестве запросов для RAG. И каждая подпись имеет только один вектор изображения в качестве эталонного значения, поэтому знаменатель Recall@k точно известен для всех запросов. Кроме того, CLIP-встраивания изображения и его подписи никогда не совпадают точно, поэтому в результатах поиска достаточно «нечеткости», аналогичной чисто документальному RAG, где текстовый запрос используется для извлечения релевантных фрагментов документа с использованием метрики расстояния. Это станет очевидным, когда мы увидим график Recall@k в следующих разделах.
Измерение показателя Recall@k для Flat vs HNSW
Мы придерживаемся следующего подхода:
- Встраивания 200 000 изображений хранятся в виде файлов .npy.
- Из набора данных laion случайным образом выбираются 500 подписей (запросов), которые затем встраиваются с помощью CLIP. Выбранные индексы запросов также формируют эталонные данные, поскольку соответствуют уникальному изображению для запроса.
- База данных создается с шагом в 50 000 векторов, то есть выполняется 4 итерации размером 50 000, 100 000, 150 000 и 200 000 векторов. Создаются как плоские, так и HNSW-индексы. HNSW-индекс создается с использованием M=16 и ef_construction=100.
- Показатель Recall@k рассчитывается для k = 1, 5, 10, 15 и 20 в зависимости от того, включены ли эталонные индексы в k лучших результатов.
- Сначала для каждого из векторов запроса вычисляются значения Recall@k, которые затем усредняются по количеству выборок (500).
- Затем рассчитываются средние значения Recall@k для значений ef_search HNSW, равных 10, 20, 40, 80 и 160.
- В заключение, построены 5 диаграмм, по одной для каждого значения Recall@k. Каждая диаграмма отображает изменение значения Recall@k по мере роста размера базы данных для плоского индекса и различных значений ef_search для HNSW.
Код можно посмотреть здесь: import pandas as pd import numpy as np import faiss import torch import open_clip import os import random import matplotlib.pyplot as plt def evaluate_subset(size, embeddings_all, df_all, query_vectors_all, eval_indices_all, ef_search_values): # Подмножество эмбеддингов embeddings = embeddings_all[:size] dimension = embeddings.shape[1] # Создание индексов в памяти для этого размера подмножества 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) results = [] ks = [1, 5, 10, 15, 20] # Оценка Flat flat_recalls = {k: 0 for k in ks} for i, qv in enumerate(query_vectors_all): _, I = index_flat.search(qv, max(ks)) target = eval_indices_all[i] for k in ks: if target in 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) # Оценка HNSW с разными efSearch for ef in ef_search_values: index_hnsw.hnsw.efSearch = ef hnsw_recalls = {k: 0 for k in ks} for i, qv in enumerate(query_vectors_all): _, I = index_hnsw.search(qv, max(ks)) target = eval_indices_all[i] for k in ks: if target in I[0][:k]: hnsw_recalls[k] += 1 hnsw_res = {«Setting»: f»HNSW (ef={ef})», «ef»: ef} for k in ks: hnsw_res[f»R@{k}»] = hnsw_recalls[k]/num_samples results.append(hnsw_res) return results def format_table(size, results): ks = [1, 5, 10, 15, 20] lines = [] lines.append(f»nРазмер базы данных: {size}») lines.append(«=»*80) header = f»{'Index/efSearch':<20}" for k in ks: header += f" | {'R@'+str(k):<8}" lines.append(header) lines.append("-" * 80) for row in results: line = f"{row['Setting']:<20}" for k in ks: line += f" | {row[f'R@{k}']:<8.2f}" lines.append(line) lines.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 output_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") if not os.path.exists(dataset_path) or not os.path.exists(embeddings_path): print("Ошибка: Набор данных или эмбеддинги не найдены.") return os.makedirs(results_dir, exist_ok=True) # Загрузка всех данных один раз print("Загрузка базовых данных...") df_all = pd.read_parquet(dataset_path) embeddings_all = np.load(embeddings_path).astype('float32') # Загрузка модели CLIP один раз print("Загрузка модели 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') device = "cuda" if torch.cuda.is_available() else "cpu" model.to(device) model.eval() # Использование выборок, действительных для всех подмножеств eval_indices = random.sample(range(min(db_sizes)), num_samples) print(f"Выборка {num_samples} запросов для согласованной оценки...") # Генерация векторов запросов query_vectors = [] for idx in eval_indices: text = df_all.iloc[idx]['TEXT'] text_tokens = tokenizer([text]).to(device) with 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 = [] # Собираем все результаты для построения графика # structure: { 'R@1': { 'Flat': [val1, val2...], 'ef=10': [val1, val2...] }, ... } ks = [1, 5, 10, 15, 20] plot_data = {f"R@{k}": { "Flat": [] } for k in ks} for ef in ef_search_values: for k in ks: plot_data[f"R@{k}"][f"HNSW ef={ef}"] = [] for size in db_sizes: print(f"Оценка с размером базы данных: {size}...") results = evaluate_subset(size, embeddings_all, df_all, query_vectors, eval_indices, ef_search_values) table_str = format_table(size, results) # Вывод на экран print(table_str) all_output_text.append(table_str) # Сбор данных для построения графика for row in results: label = row["Setting"] if label == "Flat": for k in ks: plot_data[f"R@{k}"]["Flat"].append(row[f"R@{k}"]) else: ef = row["ef"] for k in ks: plot_data[f"R@{k}"][f"HNSW ef={ef}"].append(row[f"R@{k}"]) # Сохранение текстовых результатов с помощью open(output_txt, "w", encoding="utf-8") as f: f.write("n".join(all_output_text)) print(f"nОкончательные результаты сохранены в {output_txt}") # Создание отдельных графиков для каждого K for k in ks: plt.figure(figsize=(10, 6)) k_key = f"R@{k}" for label, values in plot_data[k_key].items(): linestyle = '--' if label == "Flat" else '-' marker = 'o' if label == "Flat" else 's' plt.plot(db_sizes, values, label=label, linestyle=linestyle, marker=marker) plt.title(f"Recall@{k} vs Database Size") plt.xlabel("Database Size") 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"Plot saved to {output_png}") if __name__ == "__main__": main(500)
И вот результаты:


Наблюдения
- Для индекса Flat (пунктирная линия) значения Recall@5 и Recall@10 находятся в диапазоне 0,70–0,85, что соответствует реальным условиям применения RAG.
- Плоский индекс обеспечивает наилучшую полноту (Recall@k) для баз данных любого размера и формирует эталонную верхнюю границу для HNSW.
- При любом заданном размере базы данных показатель Recall@k увеличивается с ростом k . Таким образом, для базы данных размером 100 000 векторов Recall@20 > Recall@15 > Recall@10 > Recall@5 > Recall@1. Это объяснимо, поскольку с увеличением k возрастает вероятность того, что истинный индекс присутствует в полученном наборе данных.
- Как Flat, так и HNSW демонстрируют устойчивое ухудшение качества по мере роста размера базы данных. Это происходит потому, что многомерные векторные пространства становятся все более переполненными по мере увеличения числа векторов.
- Производительность для HNSW повышается при более высоких значениях ef_search.
- По мере приближения размера базы данных к 200 000 записей, HNSW, по-видимому, деградирует быстрее, чем Flat search .
Разлагается ли HNSW быстрее, чем Flat Search?
Для оценки сравнительной эффективности индексов Flat и HNSW по мере роста размера базы данных используется несколько иной подход:
- Процесс построения индексов базы данных и выбора параметров запроса остается прежним.
- Вместо того чтобы учитывать эталонные данные, мы вычисляем перекрытие между индексом Flat и каждым из результатов поиска HNSW ef_search для заданного количества запросов (k).
- Для каждого значения k построено пять графиков, отображающих изменение степени перекрытия по мере роста размера базы данных. При идеальном совпадении с индексом Flat линия HNSW будет иметь значение 1. Что еще более важно, если ухудшение результатов HNSW превышает значение индекса Flat, линия будет иметь отрицательный наклон, в противном случае — горизонтальный или положительный наклон.
Код можно посмотреть здесь: import pandas as pd import numpy as np import faiss import torch import open_clip import os import random import matplotlib.pyplot as plt import time def evaluate_subset_compare(size, embeddings_all, df_all, query_vectors_all, ef_search_values): # Встраивание подмножества embeddings = embeddings_all[:size] dimension = embeddings.shape[1] # Создание индексов в памяти для этого размера подмножества 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) results = [] ks = [1, 5, 10, 15, 20] max_k = max(ks) # 1. Оценить Flat один раз для этого подмножества flat_times = [] flat_results_all = [] for qv in 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. Оценить HNSW относительно Flat for ef in ef_search_values: index_hnsw.hnsw.efSearch = ef hnsw_times = [] # Отслеживание количества пересечений для каждого k overlap_counts = {k: 0 for k in ks} for i, qv in 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) # Плоский результат уже был предварительно рассчитан I_flat_all = flat_results_all[i] for k in ks: set_flat = set(I_flat_all[:k]) set_hnsw = set(I_hnsw_all[0][:k]) intersection = set_flat.intersection(set_hnsw) overlap_counts[k] += len(intersection) / k avg_hnsw_time_ms = (sum(hnsw_times) / num_samples) * 1000 hnsw_res = { «Setting»: f»HNSW (ef={ef})», «ef»: ef, «FlatTime_ms»: avg_flat_time_ms, «HNSWTime_ms»: avg_hnsw_time_ms } for k in ks: # Среднее значение по всем запросам hnsw_res[f»R@{k}»] = overlap_counts[k] / num_samples results.append(hnsw_res) return results def format_all_tables(db_sizes, ef_search_values, all_results): ks = [1, 5, 10, 15, 20] lines = [] # 1. Создать одну таблицу для каждого Recall@k for k in ks: k_label = f»R@{k}» lines.append(f»nTable: {k_label} (HNSW Overlap with Flat)») lines.append(«=» * (20 + 12 * len(db_sizes))) # Заголовок header = f»{'ef_search':<18}" for size in db_sizes: header += f" | {size:<9}" lines.append(header) lines.append("-" * (20 + 12 * len(db_sizes))) # Строки (значения ef) for ef in ef_search_values: row_str = f"{ef:<18}" for size in db_sizes: # Найти результат для этого размера и ef val = 0 for r in 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. Создание таблицы времени поиска lines.append("nТаблица: Среднее время поиска (мс)") lines.append("=" * (20 + 12 * len(db_sizes))) header = f"{'Настройка индекса':<18}" for size in db_sizes: header += f" | {size:<9}" lines.append(header) lines.append("-" * (20 + 12 * len(db_sizes))) # Плоская строка row_flat = f"{'Плоский индекс':<18}" for size in db_sizes: # Плоское время одинаково для всех ef в пределах размера, поэтому просто берите любое t = all_results[size][0]['FlatTime_ms'] row_flat += f" | {t:<9.4f}" lines.append(row_flat) # Строки HNSW for ef in ef_search_values: row_str = f"HNSW (ef={ef:<3})" for size in db_sizes: t = 0 for r in all_results[size]: 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 output_txt = os.path.join(results_dir, f"compare_results_{num_samples}.txt") output_png_prefix = "compare_vs_dbsize" if not os.path.exists(dataset_path) or not os.path.exists(embeddings_path): print("Ошибка: Набор данных или эмбеддинги не найдены.") return os.makedirs(results_dir, exist_ok=True) # Загрузка всех данных один раз print("Загрузка базовых данных...") df_all = pd.read_parquet(dataset_path) embeddings_all = np.load(embeddings_path).astype('float32') # Загрузка модели CLIP один раз print("Загрузка модели 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') device = "cuda" if torch.cuda.is_available() else "cpu" model.to(device) model.eval() # Использовать запросы из первых 50 тыс. строк eval_indices = random.sample(range(min(db_sizes)), num_samples) print(f"Выборка {num_samples} запросов...") # Генерация векторов запросов query_vectors = [] for idx in eval_indices: text = df_all.iloc[idx]['TEXT'] text_tokens = tokenizer([text]).to(device) with 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}": {} for k in ks} for ef in ef_search_values: for k in ks: plot_data[f"R@{k}"][f"ef={ef}"] = [] for size in db_sizes: print(f"Оценка с размером базы данных: {size}...") results = evaluate_subset_compare(size, embeddings_all, df_all, query_vectors, ef_search_values) all_results_data[size] = results # Собираем данные для построения графика for row in results: ef = row["ef"] for k in ks: plot_data[f"R@{k}"][f"ef={ef}"].append(row[f"R@{k}"]) # Форматируем сводные таблицы final_output_text = format_all_tables(db_sizes, ef_search_values, all_results_data) print(final_output_text) # Сохраняем текстовые результаты with open(output_txt, "w", encoding="utf-8") as f: f.write(final_output_text) print(f"nОкончательные результаты сохранены в {output_txt}") # Создаем отдельные графики для каждого K for k in ks: plt.figure(figsize=(10, 6)) k_key = f"R@{k}" for label, values in plot_data[k_key].items(): plt.plot(db_sizes, values, label=label, marker='s') plt.title(f"HNSW vs Flat Overlap Recall@{k} vs Database Size") plt.xlabel("Database Size") plt.ylabel("Overlap Ratio") 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"Plot saved to {output_png}") if __name__ == "__main__": main(500)
И вот результаты:


Наблюдения
- Во всех случаях линии имеют отрицательный наклон, что указывает на то, что показатель HNSW снижается быстрее, чем индекс Flat, по мере роста базы данных.
- Более высокие значения ef_search ухудшаются медленнее, чем более низкие, которые падают довольно резко.
- Более высокие значения ef_search демонстрируют значительное совпадение (>90%) с эталонным плоским поиском по сравнению с более низкими значениями.
Компромисс между временем запоминания и временем отклика
Мы знаем, что HNSW работает быстрее, чем плоский поиск. Чтобы увидеть это в действии, я также измерил среднюю задержку в коде из предыдущего раздела. Вот среднее время поиска (в миллисекундах):
| Размер базы данных | 50 000 | 100,000 | 150 000 | 200 000 |
| Плоский индекс | 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 |
Наблюдения
- Алгоритм HNSW на порядки быстрее, чем плоский поиск, и это главная причина, по которой он является предпочтительным алгоритмом поиска практически для всех векторных баз данных.
- Время, затрачиваемое на плоский поиск, увеличивается почти линейно с размером базы данных (сложность O(N)).
- Для заданного значения ef_search (строки) время HNSW остается практически постоянным. В этом масштабе (200 тыс. векторов) задержка HNSW также остается практически постоянной.
- По мере увеличения значения ef_search в столбце время выполнения HNSW значительно возрастает. Например, время, затраченное на ef=160, в 3 раза больше, чем на ef=40.
Настройка конвейера RAG
Приведенный выше анализ показывает, что, хотя HNSW, безусловно, является подходящим вариантом для использования в производственной среде по причинам снижения задержки, существует необходимость периодической настройки ef_search для поддержания баланса между задержкой и полнотой по мере роста базы данных. Ниже приведены некоторые рекомендуемые практики:
- Учитывая сложность измерения показателя Recall@k в производственной базе данных, следует вести репозиторий тестовых примеров с эталонными фрагментами документов и запросами, которые можно запускать через регулярные интервалы для проверки качества поиска. Можно начать с наиболее часто задаваемых пользователем запросов и фрагментов, необходимых для хорошего показателя Recall.
- Еще один косвенный способ определить качество полноты поиска — использовать мощный LLM для оценки качества полученного контекста. Вместо вопроса «Получили ли мы лучшие документы для запроса пользователя?», на который сложно дать точный ответ в большой базе данных, мы можем задать несколько более слабый вопрос: «Действительно ли полученный контекст содержит ответ на вопрос пользователя?» и позволить LLM-оценщику ответить на него.
- Собирайте отзывы пользователей в процессе работы. Оценка пользователями ответа, а также любые внесенные вручную корректировки, могут использоваться в качестве триггера для оптимизации производительности.
- При настройке ef_search начните с умеренно высокого значения, измерьте Recall@k, а затем уменьшайте его до тех пор, пока задержка не станет приемлемой.
- Измерьте полноту ответа (Recall) при значении top_k, используемом RAG, обычно от 3 до 10. Рассмотрите возможность снижения значения top_k до 15 или 20 и позвольте LLM самостоятельно определять, какие фрагменты в заданном контексте использовать для ответа на этапе синтеза. Предполагая, что контекст не станет слишком большим, чтобы поместиться в контекстное окно LLM, такой подход позволит достичь высокой полноты ответа при умеренном значении ef_search, тем самым сохраняя низкую задержку.
Гибридный трубопровод RAG
Настройка HNSW с использованием ef_search не может решить проблему снижения полноты поиска при увеличении размера базы данных сверх определенного предела. Это связано с тем, что векторный поиск, даже с использованием плоского индекса, становится шумным, когда слишком много векторов плотно упакованы в N-мерном пространстве (где N — количество измерений, выдаваемых моделью встраивания). Как показывают диаграммы в разделе выше, полнота поиска падает более чем на 10% при росте базы данных с 50 000 до 200 000. Надежный способ поддержания полноты поиска — использование фильтрации метаданных (например, с помощью графа знаний) для идентификации потенциальных идентификаторов документов и выполнения поиска только для них. Я подробно обсуждаю это в своей статье «GraphRAG на практике: как создавать экономически эффективные системы поиска с высокой полнотой поиска».
Основные выводы
- HNSW — это алгоритм поиска по умолчанию в большинстве векторных баз данных, но в производственных системах RAG он редко настраивается или контролируется.
- Качество поиска незаметно ухудшается по мере роста векторной базы данных, даже при стабильной задержке.
- При одинаковом размере корпуса поиск по плоскому шаблону неизменно демонстрирует более высокий показатель Recall@k, чем HNSW, что служит полезной верхней границей для оценки.
- По мере увеличения размера базы данных точность поиска HNSW снижается быстрее, чем точность поиска Flat для фиксированных значений ef_search.
- Увеличение параметра ef_search улучшает полноту поиска, но при этом быстро возрастает задержка, создавая резкий компромисс между полнотой поиска и задержкой.
- Простая настройка параметров HNSW недостаточна в больших масштабах — сам векторный поиск становится шумным в плотных пространствах вложений.
- Гибридные RAG-конвейеры с использованием метаданных-фильтров (SQL, графы, инвертированные индексы) — это наиболее надежный способ поддержания полноты выборки в масштабе.
Заключение
HNSW заслужила свое место в качестве основы современных векторных баз данных — не потому, что она идеально точна, а потому, что она достаточно быстра, чтобы сделать практически осуществимым крупномасштабный семантический поиск.
Однако в системах RAG скорость без учета запоминания является ложной оптимизацией.
В этой статье показано, что по мере роста векторных баз данных качество поиска незаметно ухудшается — особенно при приблизительном поиске — в то время как показатели задержки остаются обманчиво стабильными. В результате система, которая кажется здоровой с точки зрения инфраструктуры, постепенно передает в LLM более слабый контекст, усиливая иллюзии и снижая качество ответов.
Решение заключается не в том, чтобы отказаться от HNSW, и не в том, чтобы произвольно увеличивать ef_search.
Вместо этого, системы RAG производственного класса должны:
- Регулярно и четко измеряйте качество поиска.
- Рассматривайте результаты поиска по запросу «Flat search» как базовый показатель полноты памяти.
- Постоянно корректируйте баланс между запоминанием и временем реакции.
- И в конечном итоге, переход к гибридным архитектурам поиска, которые сужают пространство поиска до применения векторного сходства.
Если ответы вашей системы RAG ухудшаются по мере роста объема данных, проблема может заключаться не в вашем LLM, ваших подсказках или ваших векторных представлениях, а в алгоритме поиска, на который вы даже не подозревали, что полагаетесь.
Свяжитесь со мной и поделитесь своими комментариями на сайте www.linkedin.com/in/partha-sarkar-lets-talk-AI
Изображения, использованные в этой статье, сгенерированы синтетическим путем. Используется набор данных LAOIN-Aesthetics под лицензией CC-BY 4.0. Рисунки и код созданы мной.
Источник: towardsdatascience.com



























