almdev Технический блог

Полнотекстовый поиск в PostgreSQL без внешнего движка
Где заканчивается встроенный поиск и почему до этой границы ещё далеко.

Колонка tsvector, русская морфология, веса полей и ранжирование, индекс GIN и его цена, переиндексация, подсветка фрагментов и признаки того, что пора выносить поиск наружу.

Подкатегория: PostgreSQL

Чтение
6 мин
Технологии / версии
PostgreSQL · Поиск · tsvector · Индексы
15 сен 2026 · 6 мин · 1 просмотр · Ops
Поисковые линзы находят связанные карточки в механическом каталоге
PostgreSQL 09/2026

Поиск по статьям нужен в первый же месяц. Отдельный поисковый движок — это ещё один сервис в compose, ещё один процесс на сервере, ещё одна вещь, которую надо обновлять, и синхронизация данных, которая рано или поздно разъедется.

PostgreSQL умеет полнотекстовый поиск сам. Опишу, докуда его хватает, потому что граница проходит гораздо дальше, чем принято думать.

Как это устроено

Текст превращается в tsvector — набор нормализованных лексем с позициями. Запрос превращается в tsquery — дерево условий. Оператор сопоставления проверяет одно против другого.

SELECT to_tsvector('russian', 'Установка и настройка Redis для Laravel-проектов');

В выводе слова приведены к основам, предлоги и союзы выброшены как стоп-слова, позиции сохранены. Дефисное Laravel-проектов парсер разбирает и как составной токен, и на части; точный набор лексем зависит от конфигурации и версии словарей. Поэтому при отладке я смотрю не на переписанный вручную результат, а на вывод to_tsvector() и ts_debug() в той базе, где будет работать поиск.

Хранимая колонка вместо вычисления на лету

Считать to_tsvector при каждом запросе без подходящего индекса дорого: база прочитает всю таблицу. Expression index тоже решает задачу, если выражение в запросе в точности совпадает с индексированным. Я выбрал хранимую колонку — её проще использовать в ранжировании и проверять отдельно.

В этом проекте готовый вектор хранится отдельно. Для этого есть два способа.

Генерируемая колонка. Начиная с PostgreSQL 12 колонка вычисляется базой автоматически при записи:

ALTER TABLE posts ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('russian', coalesce(excerpt, '')), 'B') ||
        setweight(to_tsvector('russian', coalesce(content_text, '')), 'C')
    ) STORED;

Триггер. Старый способ, нужен, если выражение недетерминированное или источники лежат в других таблицах.

Я выбрал генерируемую колонку. Она не может рассинхронизироваться в принципе: база пересчитывает её сама при любом изменении, включая прямые запросы в обход приложения. С триггером такое тоже работает, но триггер можно отключить, а генерируемую колонку — нет.

Ограничение: выражение обязано быть неизменяемым. to_tsvector с явным указанием конфигурации подходит, без указания — нет, потому что зависит от настройки сессии. Отсюда 'russian' первым аргументом обязательно.

Веса полей

setweight расставляет метки от A до D. Заголовок весит больше, чем тело.

Само по себе это ничего не даёт — веса учитываются только при ранжировании:

SELECT id, title,
       ts_rank(search_vector, query) AS rank
FROM posts, websearch_to_tsquery('russian', 'настройка redis') AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;

Коэффициенты по умолчанию: A — 1.0, B — 0.4, C — 0.2, D — 0.1. Их можно передать массивом, и я один раз это делал, когда заголовки перевешивали слишком сильно и статья с нужным словом в заголовке, но не по теме, вылезала первой.

Отдельно про разбор запроса. Функций три, и разница между ними существенная.

to_tsquery требует строгого синтаксиса с операторами и падает на произвольном вводе пользователя. Использовать её напрямую с пользовательским текстом нельзя.

plainto_tsquery соединяет все слова через И. Безопасно, но не понимает кавычек и минусов.

websearch_to_tsquery понимает синтаксис, к которому люди привыкли: кавычки для точной фразы, минус для исключения, or для альтернативы. Это то, что нужно, и именно её я использую.

Русская морфология

Конфигурация russian идёт в поставке и делает стемминг по алгоритму Snowball. Она отрезает окончания по правилам, не заглядывая в словарь.

Работает достаточно хорошо для 90 процентов случаев. Где ломается:

  • «стали» — это форма глагола «стать» и множественное число «стали» как металла. Стеммер даст одну основу для обоих.
  • Составные слова и аббревиатуры обрабатываются как есть.
  • Опечатки не прощаются вовсе: «редиc» с латинской буквой не найдёт ничего.

Для качественного результата ставят словарные модули с полноценной морфологией. Я не ставил: это внешнее расширение, которое надо собирать под каждую версию PostgreSQL и тащить в образ. При объёме в несколько сотен статей выигрыш не окупает этой возни.

Технический текст на русском полон английских слов. Конфигурация russian обрабатывает их как есть, что для названий инструментов обычно нормально, но английские стоп-слова не отсекаются: the становится значимой лексемой. Если это заметно на реальных запросах, нужна своя конфигурация с отдельным mapping для ASCII-слов. Я её не добавлял: на моём объёме проблема не оправдала ещё одну словарную настройку.

Индекс GIN и его цена

CREATE INDEX idx_posts_search ON posts USING GIN (search_vector);

GIN — обратный индекс: для каждой лексемы список документов. Именно то, что нужно для поиска.

Цифры на моей таблице статей, 2400 записей со средним размером текста в 12 килобайт:

ЧтоРазмер
Таблица41 МБ
Колонка вектора6 МБ
Индекс GIN4 МБ

Поиск без индекса — 380 мс, с индексом — 4 мс.

Цена — запись. Обновление статьи с индексом идёт примерно на 15 процентов медленнее. При соотношении «пишем раз в неделю, читаем тысячи раз» это несущественно.

Альтернатива — GiST, он меньше и быстрее пишется, но медленнее ищет. Для поиска по тексту берут GIN, GiST остаётся для случаев с очень интенсивной записью.

Переиндексация

Генерируемая колонка не требует переиндексации в обычном смысле — она всегда актуальна. Пересчёт нужен в двух случаях: поменялось выражение (добавили поле в вектор, изменили веса) и изменилась конфигурация словаря.

Начиная с PostgreSQL 17 выражение можно заменить через ALTER COLUMN ... SET EXPRESSION; для хранимой колонки это переписывает существующие данные и перестраивает зависимые объекты. В более ранних версиях generation expression напрямую не меняется — колонку приходится пересоздавать. Оба пути на большой таблице требуют отдельного плана выкладки.

В проекте с задачами вектор собирается приложением, а не базой — там в него входят данные из связанных таблиц. Для него есть команда:

foreach ($this->issues->iterateForReindex() as $batch) {
    foreach ($batch as $issue) {
        $issue->setSearchVector($this->builder->build($issue));
    }

    $this->em->flush();
    $this->em->clear();
}

Порциями по тысяче, с очисткой единицы работы после каждой. Без очистки процесс на ста тысячах задач съедает всю память.

Подсветка совпадений

WITH matches AS (
    SELECT id, content_text, query
    FROM posts, websearch_to_tsquery('russian', :q) AS query
    WHERE search_vector @@ query
    ORDER BY ts_rank(search_vector, query) DESC
    LIMIT 20
)
SELECT id,
       ts_headline('russian', content_text, query,
           'MaxWords=30, MinWords=15, StartSel=<mark>, StopSel=</mark>')
FROM matches;

Функция возвращает фрагмент текста вокруг совпадения с обёрнутыми словами.

Два предупреждения из практики.

Она работает по исходному тексту, а не по вектору, и на длинных документах это заметно дорого. Во фрагменте выше сначала выбираются двадцать результатов, и только потом для них строится подсветка.

И она вставляет теги в текст. Если этот текст потом экранируется при выводе, разметка подсветки превратится в видимые угловые скобки. Нужен либо вывод без экранирования с предварительной чисткой, либо разбор результата и сборка разметки в шаблоне.

Когда пора выносить поиск наружу

Четыре признака, при которых встроенного не хватит.

Нужны опечатки и нечёткий поиск. В PostgreSQL это делается через триграммы отдельным индексом, но совмещать их с полнотекстовым поиском в одном ранжировании неудобно.

Нужны фасеты с подсчётом — «найдено 340, из них 120 в разделе Backend». Считать это агрегатами по каждому фильтру можно, и на больших объёмах это дорого.

Объём перевалил за миллионы документов с активной записью. GIN на такой нагрузке начинает требовать внимания к настройкам обновления индекса.

Нужны подсказки при вводе с ранжированием по популярности, синонимы, поиск по разным языкам в одном индексе.

Ни один из четырёх признаков в моих проектах не наступил. Самая большая таблица — двести тысяч задач, поиск отрабатывает за 12 мс.

Итог

Ноль дополнительных сервисов, одна колонка, один индекс, три функции. Настройка заняла вечер, включая разбор разницы между тремя способами превратить запрос в дерево условий.

Единственное, о чём иногда думаю, — про опечатки. Пользователи их делают, и «ничего не найдено» на запрос с одной перепутанной буквой выглядит глупо. Расширение с триграммами закрывает это в один индекс, и я всё собираюсь его добавить.

#postgresql #poisk #tsvector #indeksy