Перестройка HNSW-индекса с m = 16, ef_construction = 64 на m = 32, ef_construction = 256 подняла полноту фильтрованного векторного поиска с 0.56–0.78 до 0.80–0.86 при той же задержке ~20–30 мс. Удаление мёртвых строк и переиндексация сжали лексический RUM-индекс вдвое и вернули медиану лексического скана с 252 до 191 мс.

Всё живёт в одном PostgreSQL 17 с расширениями pgvector и RUM: около 925 тысяч живых векторов размерности 2000 и полнотекстовый индекс по тем же чанкам: лексика страхует векторы на редких терминах и вне домена эмбеддингов [5]. Отдельная векторная БД не понадобилась, но понадобилось понимать, как индексы ведут себя под фильтрами и частичными условиями.

Если коротко — для бизнеса

  • Векторный поиск перестал терять документы на фильтре. Поиск внутри одного источника находит 80–86% того, что нашёл бы точный перебор, против 15–31% на старте.
  • Отдельная векторная база не понадобилась. Векторы и полнотекстовый индекс живут в одном PostgreSQL. Векторный индекс сократился вдвое (14 → 7.2 ГБ) и впервые целиком поместился в память.
  • Без простоя. Все перестройки индексов шли рядом со старыми: поиск работал всё это время.
  • Урок: данные «храним на случай отката» без срока хранения замедлили лексический поиск. У такого правила должна быть дата окончания.

О цикле «RAG в проде»

Четыре статьи о том, как за две-три недели октября 2026 года мы перестроили поиск корпоративного RAG-ассистента. «Мы» — это автор и AI-агенты, которым он делегировал часть исследований и правок. Компания и системы обезличены, цифры настоящие.

  1. Гибридный поиск: как «ноги» усиливают друг друга
  2. Индексы под гибридный поиск в PostgreSQL ← вы здесь
  3. Структурный чанкинг и контекстные эмбеддинги
  4. Граф знаний в RAG
Термины за минуту
  • ANN (approximate nearest neighbors) — приближённый поиск ближайших векторов: быстро, но может пропустить часть настоящих соседей.
  • HNSW — многоуровневый граф «малого мира», по которому ANN-поиск идёт от вершины к вершине [1].
  • m — сколько связей хранит каждая вершина графа HNSW. Больше — плотнее граф и точнее поиск.
  • ef_construction — сколько кандидатов рассматривается при вставке вершины во время построения. Больше — качественнее граф, дольше сборка.
  • ef_search — сколько кандидатов держит поиск при обходе графа. Больше — глубже поиск, выше задержка.
  • halfvec — тип pgvector с половинной точностью: 16 бит на компоненту вместо 32 [3].
  • Частичный индекс — индекс только по строкам, удовлетворяющим условию WHERE. Планировщик использует его, только если может доказать, что условие запроса его покрывает.
  • Итеративный скан — режим pgvector, в котором обход HNSW продолжается, пока фильтр не пропустит нужное число строк.
  • RUM — полнотекстовый индекс PostgreSQL, который умеет ранжировать прямо внутри индекса [4].
  • Мёртвые строки — старые версии строк после UPDATE или DELETE. Они занимают место в таблице и индексах, пока их не уберёт VACUUM.
  • shared_buffers — память PostgreSQL под кэш страниц таблиц и индексов.
  • Recall@k против точного поиска — доля настоящих k ближайших соседей (найденных полным перебором), которую вернул ANN-индекс.
  • п.п. — процентные пункты: рост с 0.56 до 0.80 — это +24 п.п.

Исходные условия

Кому особенно полезен раздел:Бизнес и продуктИнженеры

Перф-аудит на старте показал, что база занята не поиском. 93% времени исполнения запросов уходило на фоновое обслуживание графа знаний, а весь пользовательский поиск (векторный и лексический) занимал 0.4%. С индексами поиска при этом было четыре проблемы.

  • HNSW по vector(2000) занимал 14 ГБ и не помещался в shared_buffers (12 ГБ).
  • Итеративный скан был выключен, фактический ef_search = 50. Запрос с LIMIT 50 и фильтром по источнику возвращал 12 строк для одной вики и 0 для реестра.
  • Частичные индексы не работали из-за ORM. SQLAlchemy рендерил фильтр как is_active IS TRUE, а PostgreSQL 17 не считает это эквивалентным условию индекса is_active = true. Итог — seq scan там, где должен быть индекс: загрузка чанков версии занимала 252 мс вместо 0.1 мс. Таких мест в коде было 69.
  • Таблицы раздуты мёртвыми строками: из 1.61 млн эмбеддингов живыми были 800 тысяч.
Индексы поиска до и после программы
ПоказательНа стартеСейчас
HNSW-индексvector(2000), m = 16, 14 ГБhalfvec(2000), m = 32, 7.2 ГБ
Полнота фильтрованного поиска, Recall@400.15–0.310.80–0.86
Эмбеддингов всего / живых1.61 млн / 0.80 млн1.29 млн / 0.93 млн
Лексический скан p50 / p95, мс175 / 530191 / 653

Полнота «на старте» измерена на старом графе с выключенным итеративным сканом, то есть в режиме прода того времени. Живых эмбеддингов стало больше в первую очередь из-за роста корпуса: добавились тысячи статей. Лексический скан чуть медленнее исходного. Вероятные причины — рост корпуса и второй текстовый вектор у структурных чанков; по отдельности мы их не замеряли.

Проверьте у себя

Если ORM генерирует col IS TRUE, а частичный индекс объявлен с col = true, PostgreSQL 17 индекс не возьмёт. Прогоните EXPLAIN по запросам, которые формирует ORM, а не по написанным руками.

halfvec вместо vector: индекс должен влезать в память

Кому особенно полезен раздел:АрхитекторыИнженеры

Переход на половинную точность сократил HNSW-индекс с ~14 до ~7 ГБ, и он впервые целиком поместился в shared_buffers. Для HNSW это важнее, чем для B-дерева: обход графа — это случайные чтения страниц, и индекс, вылезающий из памяти, превращает каждый запрос в сотни обращений к диску.

Потеря точности для нашего случая невидима. Провайдер эмбеддингов и так отдаёт векторы, квантованные в int8, так что 16 бит на компоненту — уже запас.

Смена типа колонки на живой таблице в миллион строк — отдельная задача. Мы сделали теневую таблицу с триггерной синхронизацией и построили на ней все индексы. Потом подменили таблицы за секунды эксклюзивной блокировки, с проверками внутри одной транзакции: наборы id совпадают, каждый индекс валиден.

Все векторные индексы — частичные, только по живым строкам:

CREATE INDEX ... ON chunk_embeddings USING hnsw (embedding halfvec_cosine_ops)
  WITH (m = 32, ef_construction = 256)
  WHERE is_active = true AND is_deleted = false;

При переиндексации документа старые эмбеддинги не удаляются сразу, а помечаются неактивными. Из логического индекса они выпадают мгновенно, а физически доживают до VACUUM. У этого есть обратная сторона — см. «Грабли».

Архитектору

Размер индекса относительно памяти — архитектурный параметр, а не деталь настройки. Если HNSW не помещается в shared_buffers, ни ef_search, ни более быстрый CPU задержку не спасут: всё упирается в случайное чтение с диска. Тип вектора стоит выбирать под этот бюджет с первого дня.

Кому особенно полезен раздел:ИнженерыML и исследователи

Векторная нога ищет с фильтром по источнику, а фильтр — естественный враг HNSW. Граф отдаёт ef ближайших соседей, а условие WHERE отбрасывает чужие. У источника с 10% строк от 40 кандидатов остаётся в среднем 4.

Итеративный скан pgvector [3] продолжает обход графа, пока не наберёт нужное число строк или не упрётся в лимит просмотренных кортежей. На старом графе без него полнота на наших фильтрах была 0.15–0.31, с ним — 0.56–0.78. Режим relaxed_order допускает небольшие нарушения порядка, но их всё равно пересортирует реранкер.

sourcem16 · ef 100m16 · ef 200m32 · ef 100
Вики A · 23% строк0.780.790.86
Вики B · 10% строк0.780.790.85
Реестр · 66% строк0.560.590.8
Новый граф HNSW даёт больше, чем ef_search = 200 на старом. Recall@40 фильтрованного поиска против точного, 35 запросов, k = 40, iterative scan relaxed_order, октябрь 2026. m16 — m = 16, ef_construction = 64; m32 — m = 32, ef_construction = 256; ef — ef_search. Значения для m16 · ef 200 сняты с исходного графика приблизительно

Поднятие ef_search со 100 до 200 на старом графе давало единицы сотых: дело не в глубине поиска, а в качестве самого графа. На 2000-мерных векторах 16 связей на вершину и пул из 64 кандидатов при построении дают слишком редкий граф. С m = 32 и ef_construction = 256 полнота выросла на 7–24 п.п., а задержка осталась в пределах 20–30 мс.

Хуже всего и до, и после — реестр, хотя его фильтр самый неселективный (66% строк). Наша гипотеза, пока не проверенная: карточки реестра почти одинаковы по тексту, их векторы образуют плотные сгустки, и среди почти равноудалённых соседей любой ANN ошибается чаще. Для RAG это часто не важно: путаются между собой карточки, которые и так почти неразличимы.

Следующий шаг, который мы ещё не делали, — частичные HNSW-индексы по источникам или алгоритмы, учитывающие предикат при обходе, такие как ACORN [2]. Тогда фильтр перестанет съедать кандидатов.

ML-исследователю

Полнота здесь измерена против точного поиска (полного перебора по тем же векторам), а не против судейских оценок релевантности. Она отвечает на вопрос «не теряет ли индекс соседей», а не «хорошо ли ищет модель». Эти две ошибки складываются, и их полезно мерить раздельно.

Перестройка без простоя

Кому особенно полезен раздел:АрхитекторыИнженеры

Новый индекс на 925 тысяч строк собрался за 15 минут рядом со старым, поиск всё это время работал на старом. Размер не вырос: 7.2 ГБ против 7.2 ГБ у свежеперестроенного индекса с m = 16. На 2000-мерных векторах объём индекса составляют сами векторы, а не списки соседей.

Порядок, который мы зафиксировали в миграции:

  1. CREATE INDEX CONCURRENTLY под временным именем с новыми параметрами.
  2. DROP INDEX CONCURRENTLY старого индекса.
  3. ALTER INDEX ... RENAME в штатное имя, чтобы код и планы не заметили подмены.

Миграция идемпотентна. Индекс с целевыми параметрами (читаем pg_class.reloptions) оставляется как есть. Невалидный остаток прерванной сборки удаляется и собирается заново. Это позволило собрать индекс вручную, измерить полноту, а потом применить миграцию на проде как пустую операцию. Новое окружение получает тот же индекс с нуля.

Идемпотентная миграция HNSW-индекса: повторный запуск ничего не ломает. Сборка — шаги 1–3 из списка выше и прогревИдемпотентная миграция HNSW-индекса: повторный запуск ничего не ломает. Сборка — шаги 1–3 из списка выше и прогревестьнетестьнетЦелевой индекс?Ничего не делатьОстаток сборки?Удалить остатокCREATE → DROP →RENAME → prewarm
Идемпотентная миграция HNSW-индекса: повторный запуск ничего не ломает. Сборка — шаги 1–3 из списка выше и прогрев

Память сборки — главное условие скорости. Если граф не влезает в maintenance_work_mem, pgvector достраивает его на диске в разы медленнее. Мы ставили в сессии:

SET maintenance_work_mem = '14GB';
SET max_parallel_maintenance_workers = 7;

Параллельная сборка держит граф в динамической разделяемой памяти, поэтому в контейнере /dev/shm должен быть не меньше maintenance_work_mem. Лимит памяти контейнера нужен с запасом на shared_buffers: у нас 16 + 14 + ~4 ГБ на соединения при лимите 40 ГБ.

После подмены индекс прогревается в буферы (pg_prewarm), иначе первые сотни запросов читают его с диска.

Внимание

В контейнере размер /dev/shm по умолчанию обычно мал. Если он меньше maintenance_work_mem, параллельная сборка HNSW упадёт или уйдёт на диск. Проверьте этот лимит до запуска сборки, а не после.

Лексический индекс RUM и цена мёртвых строк

Кому особенно полезен раздел:ИнженерыАрхитекторы

После перечанкинга корпуса лексический скан заметно замедлился: p50 со 175 до 252 мс (+44%), хотя живых данных стало не больше. Причина — 178 тысяч старых чанков, которые мы оставили рядом с новыми ради отката.

RUM строится по таблице чанков, а признак «живой» хранится в таблице эмбеддингов. Поэтому скан честно находил и ранжировал старые строки, а отбрасывал их уже на соединении. Лексическая нога работает в два яруса: сначала AND по терминам запроса, при нехватке результатов — OR по до 7 терминам, до 50 кандидатов на источник. Каждая мёртвая строка в топе OR-яруса отнимала место и время.

Удаление старых строк, VACUUM и REINDEX TABLE CONCURRENTLY дали:

Цена старых чанков, оставленных ради отката
ПоказательДо перечанкингаПосле, со старыми строкамиПосле чистки и REINDEX
Лексический скан p50 / p95, мс175 / 530252 / 836191 / 653
RUM-индекс, МБ—806368
HNSW halfvec, ГБ—8.47.2

Переиндексация обеих таблиц заняла около 11 минут без блокировки записи.

Архитектору

Правило, которое мы из этого вынесли: «неактивно, но хранится ради отката» должно иметь срок. Особенно для индексов, где фильтр живости применяется после скана, а не внутри индекса.

У новых чанков есть второй лексический вектор с весами: заголовок и извлечённые термины идут с весом A, тело — с весом C. Так совпадение с названием раздела или с кодом стандарта весит больше случайного упоминания в тексте. Подробнее — в статье про структурный чанкинг.

Грабли

Кому особенно полезен раздел:Инженеры

Частичный индекс не работает, если предикат запроса его не покрывает. При переиндексации старые эмбеддинги документа выключались запросом:

UPDATE chunk_embeddings SET is_active = false, ...
 WHERE source_document_id = $1 AND is_active = true;

Индекс по source_document_id частичный, с условием is_active AND NOT is_deleted. Предикат UPDATE не содержит NOT is_deleted. Планировщик не может доказать, что индекс покрывает все нужные строки, и берёт параллельный seq scan. Итог — около 27 тысяч полных просмотров таблицы за ночь переиндексации, по ~133 мс на документ. Лечится одним условием в WHERE. Мораль: для частичных индексов проверять EXPLAIN каждого запроса, а не только поисковых.

Память под массовую запись. На время переиндексации корпуса индекс растёт: новые векторы уже в нём, старые ещё физически не удалены. Мы заранее подняли shared_buffers с 12 до 16 ГБ, max_wal_size до 16 ГБ, checkpoint_timeout до 15 минут и включили сжатие WAL lz4. Вставки не уходили на диск, а Postgres занимал около одного ядра, пока шесть процессов записывали ~100 чанков в секунду. Узким местом оставался внешний API эмбеддингов, а не база.

Инструмент замера должен мерить прод, а не свою историю. В нашей утилите замера полноты вариант «как на проде» остался от тех времён, когда итеративный скан был выключен, и показывал 0.43 вместо реальных 0.77. Параметры поиска в замере надо читать из тех же настроек, что и в проде.

Случайность построения графа видна в метриках. После обычного REINDEX с теми же параметрами у 69 из 111 эталонных запросов поменялся топ-10, и key-doc R@10 колебался на несколько п.п. Слабый граф делает качество зависимым от порядка вставки. Поэтому при сравнении двух версий конвейера индекс должен быть один и тот же.

ML-исследователю

Последний пункт касается любого A/B на векторном поиске. Если между прогонами индекс перестраивался, разница в несколько п.п. может быть шумом построения графа, а не эффектом изменения. Фиксируйте индекс или повторяйте сборку несколько раз и смотрите на разброс.

Выводы

Кому особенно полезен раздел:Бизнес и продуктАрхитекторыИнженерыML и исследователи

  • Индекс должен влезать в память. halfvec вдвое сократил HNSW и убрал случайные чтения с диска без видимой потери точности.
  • Фильтр съедает кандидатов. Итеративный скан и более плотный граф (m = 32, ef_construction = 256) подняли полноту до 0.80–0.86 при задержке 20–30 мс; ef_search упирался в качество графа.
  • Перестройка без простоя — это CONCURRENTLY, временное имя, RENAME и идемпотентная миграция.
  • Мёртвые строки стоят дорого. У данных «ради отката» должен быть срок, а у частичных индексов — EXPLAIN на каждый запрос.

Дальше по циклу — структурный чанкинг и контекстные эмбеддинги: что именно лежит в этих индексах.

Литература

Кому особенно полезен раздел:ML и исследователи

  1. Malkov Yu. A., Yashunin D. A. Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs. arXiv:1603.09320, 2016 — смысл параметров m и ef_construction.
  2. Patel L. et al. ACORN: Performant and Predicate-Agnostic Search Over Vector Embeddings and Structured Data. arXiv:2403.04871, 2024 — почему фильтр после обхода графа теряет полноту и как учитывать предикат при обходе.
  3. pgvector — типы halfvec и bit, итеративный скан (hnsw.iterative_scan, hnsw.max_scan_tuples), память при сборке.
  4. RUM — полнотекстовый индекс с ранжированием внутри индекса.
  5. Thakur N. et al. BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models. arXiv:2104.08663, 2021 — зачем держать лексический индекс рядом с векторным.

Идентификаторы arXiv сверены с API arXiv 25 октября 2026.