Перестройка 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-агенты, которым он делегировал часть исследований и правок. Компания и системы обезличены, цифры настоящие.
- Гибридный поиск: как «ноги» усиливают друг друга
- Индексы под гибридный поиск в PostgreSQL ← вы здесь
- Структурный чанкинг и контекстные эмбеддинги
- Граф знаний в 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@40 | 0.15–0.31 | 0.80–0.86 |
| Эмбеддингов всего / живых | 1.61 млн / 0.80 млн | 1.29 млн / 0.93 млн |
| Лексический скан p50 / p95, мс | 175 / 530 | 191 / 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 задержку не спасут: всё упирается в случайное чтение с диска. Тип вектора стоит выбирать под этот бюджет с первого дня.
Фильтрованный ANN: упираемся в граф, а не в ef_search
Кому особенно полезен раздел:ИнженерыML и исследователи
Векторная нога ищет с фильтром по источнику, а фильтр — естественный враг HNSW. Граф отдаёт ef ближайших соседей, а условие WHERE отбрасывает чужие. У источника с 10% строк от 40 кандидатов остаётся в среднем 4.
Итеративный скан pgvector [3] продолжает обход графа, пока не наберёт нужное число строк или не упрётся в лимит просмотренных кортежей. На старом графе без него полнота на наших фильтрах была 0.15–0.31, с ним — 0.56–0.78. Режим relaxed_order допускает небольшие нарушения порядка, но их всё равно пересортирует реранкер.
| source | m16 · ef 100 | m16 · ef 200 | m32 · ef 100 |
|---|---|---|---|
| Вики A · 23% строк | 0.78 | 0.79 | 0.86 |
| Вики B · 10% строк | 0.78 | 0.79 | 0.85 |
| Реестр · 66% строк | 0.56 | 0.59 | 0.8 |
Поднятие 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-мерных векторах объём индекса составляют сами векторы, а не списки соседей.
Порядок, который мы зафиксировали в миграции:
CREATE INDEX CONCURRENTLYпод временным именем с новыми параметрами.DROP INDEX CONCURRENTLYстарого индекса.ALTER INDEX ... RENAMEв штатное имя, чтобы код и планы не заметили подмены.
Миграция идемпотентна. Индекс с целевыми параметрами (читаем pg_class.reloptions) оставляется как есть. Невалидный остаток прерванной сборки удаляется и собирается заново. Это позволило собрать индекс вручную, измерить полноту, а потом применить миграцию на проде как пустую операцию. Новое окружение получает тот же индекс с нуля.
Память сборки — главное условие скорости. Если граф не влезает в 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 / 530 | 252 / 836 | 191 / 653 |
| RUM-индекс, МБ | — | 806 | 368 |
| HNSW halfvec, ГБ | — | 8.4 | 7.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 и исследователи
- 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.
- Patel L. et al. ACORN: Performant and Predicate-Agnostic Search Over Vector Embeddings and Structured Data. arXiv:2403.04871, 2024 — почему фильтр после обхода графа теряет полноту и как учитывать предикат при обходе.
- pgvector — типы halfvec и bit, итеративный скан (
hnsw.iterative_scan,hnsw.max_scan_tuples), память при сборке. - RUM — полнотекстовый индекс с ранжированием внутри индекса.
- 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.