---
title: "Индексы под гибридный поиск в PostgreSQL: halfvec, HNSW и RUM на практике"
date: 2026-10-25T10:00:00+03:00
type: post
lang: ru
domains: ["ai","architecture"]
tags: ["rag","search","postgresql","vector-search"]
series: rag-in-production
series_weight: 2
summary: "halfvec, перестройка HNSW с m = 32 и чистка мёртвых строк в одном PostgreSQL: полнота фильтрованного векторного поиска выросла с 0.56–0.78 до 0.80–0.86, RUM-индекс сжался вдвое."
llm_summary: "Вторая статья цикла «RAG в проде». Векторный и лексический поиск живут в одном PostgreSQL 17 с pgvector и RUM: около 925 тысяч векторов размерности 2000. Переход с vector на halfvec уменьшил HNSW-индекс с 14 до 7.2 ГБ, и он поместился в shared_buffers. Итеративный скан и перестройка графа HNSW с m = 16, ef_construction = 64 на m = 32, ef_construction = 256 подняли Recall@40 фильтрованного поиска с 0.15–0.31 до 0.80–0.86 при задержке 20–30 мс. Разобраны перестройка индекса без простоя, идемпотентная миграция, память сборки, цена мёртвых строк для RUM (скан p50 252 → 191 мс, индекс 806 → 368 МБ) и грабли с частичными индексами, которые не покрывает предикат запроса."
---


Перестройка 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]](#литература). Отдельная векторная БД не понадобилась, но понадобилось понимать, как индексы ведут себя под фильтрами и частичными условиями.

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

> [!NOTE] О цикле «RAG в проде»
> Четыре статьи о том, как за две-три недели октября 2026 года мы перестроили поиск корпоративного RAG-ассистента. «Мы» — это автор и AI-агенты, которым он делегировал часть исследований и правок. Компания и системы обезличены, цифры настоящие.
>
> 1. [Гибридный поиск: как «ноги» усиливают друг друга](/posts/hybrid-search-rag)
> 2. **Индексы под гибридный поиск в PostgreSQL** ← вы здесь
> 3. [Структурный чанкинг и контекстные эмбеддинги](/posts/structural-chunking)
> 4. [Граф знаний в RAG](/posts/knowledge-graph-rag)

> [!TERM]- Термины за минуту
>
> - **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 п.п.

## Исходные условия {audience="biz eng"}

Перф-аудит на старте показал, что база занята не поиском. 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 |
{caption="Индексы поиска до и после программы"}

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

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

## halfvec вместо vector: индекс должен влезать в память {audience="arch eng"}

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

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

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

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

```sql
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. У этого есть обратная сторона — см. [«Грабли»](#грабли).

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

## Фильтрованный ANN: упираемся в граф, а не в ef_search {audience="eng ml"}

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

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

```nivo
{
  "chart": "bar",
  "layout": "horizontal",
  "grouped": true,
  "legend": true,
  "labels": true,
  "valueFormat": ".2f",
  "valueScale": {"min": 0, "max": 1},
  "axisValueLegend": "Recall@40 против точного поиска",
  "indexBy": "source",
  "keys": ["m16 · ef 100", "m16 · ef 200", "m32 · ef 100"],
  "data": [
    {"source": "Вики A · 23% строк", "m16 · ef 100": 0.78, "m16 · ef 200": 0.79, "m32 · ef 100": 0.86},
    {"source": "Вики B · 10% строк", "m16 · ef 100": 0.78, "m16 · ef 200": 0.79, "m32 · ef 100": 0.85},
    {"source": "Реестр · 66% строк", "m16 · ef 100": 0.56, "m16 · ef 200": 0.59, "m32 · ef 100": 0.80}
  ],
  "height": 380,
  "caption": "Новый граф 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 сняты с исходного графика приблизительно",
  "fallback": {"kind": "table"}
}
```

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

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

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

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

## Перестройка без простоя {audience="arch eng"}

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

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

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

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

```mermaid {caption="Идемпотентная миграция HNSW-индекса: повторный запуск ничего не ломает. Сборка — шаги 1–3 из списка выше и прогрев"}
flowchart TD
  t{"Целевой индекс?"} -- есть --> n([Ничего не делать])
  t -- нет --> i{"Остаток сборки?"}
  i -- есть --> d0["Удалить остаток"] --> b
  i -- нет --> b["CREATE → DROP → RENAME → prewarm"]
```

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

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

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

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

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

## Лексический индекс RUM и цена мёртвых строк {audience="eng arch"}

После перечанкинга корпуса лексический скан заметно замедлился: 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 |
{caption="Цена старых чанков, оставленных ради отката"}

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

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

У новых чанков есть второй лексический вектор с весами: заголовок и извлечённые термины идут с весом A, тело — с весом C. Так совпадение с названием раздела или с кодом стандарта весит больше случайного упоминания в тексте. Подробнее — в статье про [структурный чанкинг](/posts/structural-chunking).

## Грабли {audience="eng"}

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

```sql
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 на векторном поиске. Если между прогонами индекс перестраивался, разница в несколько п.п. может быть шумом построения графа, а не эффектом изменения. Фиксируйте индекс или повторяйте сборку несколько раз и смотрите на разброс.

## Выводы {audience="biz arch eng ml"}

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

Дальше по циклу — [структурный чанкинг и контекстные эмбеддинги](/posts/structural-chunking): что именно лежит в этих индексах.

## Литература {audience="ml" .references}

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

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

