Полнотекстовый поиск в PostgreSQL: чек-лист перед продакшеном
Поиск по заголовкам и тексту может отлично работать на тестовой базе и тормозить на боевой. Проверьте конфигурацию языка, способ хранения tsvector, индекс, запросы и поведение при обновлении данных — до того, как пользователи начнут жаловаться.

На тестовой базе полнотекстовый поиск PostgreSQL может отвечать быстро, а в продакшене — пропускать слова, возвращать нерелевантные результаты или сканировать таблицу целиком. Перед запуском проверьте язык, формирование поискового вектора, индекс и реальный план запроса. Ниже — чек-лист для PostgreSQL 16; на другой версии сверьте синтаксис и поведение с документацией именно для неё.
Сначала зафиксируйте, что именно ищете
Полнотекстовый поиск PostgreSQL не сопоставляет запрос с исходной строкой символ за символом. Он разбирает текст на лексемы — нормализованные формы слов — и сравнивает их. Поэтому поиск учитывает особенности словаря языка, но не заменяет поиск подстроки и не обещает поведение поисковика в интернете.
До выбора индекса решите, какие поля участвуют в поиске, какой язык у документов и нужен ли заголовку больший вес, чем основному тексту. Для многоязычного каталога нельзя без проверки применить одну конфигурацию ко всем записям: русский словарь не подходит для английского текста.
Индекс ускоряет выбранную модель поиска, но не исправляет неверную модель. Если в вектор попал не тот текст или применён не тот словарь, быстрый ответ всё равно будет неправильным.
Выберите способ хранить поисковый вектор
Для продакшена обычно сравнивают вычисляемый при запросе вектор и отдельный столбец tsvector. Первый экономит место в таблице, но повторяет вычисление при поиске и требует, чтобы выражение запроса совпадало с выражением индекса. Отдельный столбец проще проверять и использовать в запросах, но его надо корректно обновлять.
В PostgreSQL 16 для таблицы с заголовком и текстом можно хранить вектор в генерируемом столбце. В этом примере заголовок получает вес A, а основной текст — D: это влияет на ранжирование, а не на сам факт совпадения.
ALTER TABLE articles
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
setweight(to_tsvector('russian', coalesce(body, '')), 'D')
) STORED;
Явно задавайте конфигурацию языка, а не полагайтесь на настройку сессии по умолчанию. coalesce нужен, чтобы NULL в одном из полей не превращал всё выражение в NULL. Учитывайте, что генерируемый столбец хранит результат вычисления: изменения словаря или логики нормализации сами по себе не означают, что старые векторы уже пересчитаны.
Сопоставьте индекс и способ хранения
| Подход | Что выигрываете | За что платите |
|---|---|---|
| Хранимый tsvector и GIN | Вектор легко проверить отдельно; GIN обычно подходит для полнотекстового поиска по большому набору документов. | Дополнительное место и обслуживание индекса; запись и обновление строк тоже требуют работы. |
| GIN по выражению | Не нужен отдельный столбец; индекс хранит результат выражения. | Выражение в запросе должно совпадать с индексируемым. Изменить набор полей или веса сложнее. |
| GiST по tsvector | Ещё один доступный тип индекса для полнотекстового поиска. | Индекс с потерями может давать ложные совпадения, которые PostgreSQL перепроверяет по строкам. Сравните его на своих данных с GIN. |
Для первого варианта индекс создаётся так:
CREATE INDEX articles_search_vector_gin
ON articles USING GIN (search_vector);
Для поиска пользовательского запроса используйте функцию разбора запроса, явно указывая конфигурацию. websearch_to_tsquery принимает привычный поисковый синтаксис с кавычками и операторами; не передавайте строку пользователя в SQL как часть текста запроса — используйте параметр.
SELECT id,
title,
ts_rank(search_vector, websearch_to_tsquery('russian', $1)) AS rank
FROM articles
WHERE search_vector @@ websearch_to_tsquery('russian', $1)
ORDER BY rank DESC
LIMIT 20;
ts_rank даёт оценку совпадения, а не гарантию идеального порядка результатов. Проверьте выдачу на запросах, которые действительно вводят ваши пользователи: на коротких запросах и частых словах ранжирование может удивить.
Чек-лист перед выкладкой
- Проверьте конфигурацию. Убедитесь, что выбран нужный словарь, а запрос и индекс используют одну и ту же конфигурацию. Проверка: сопоставьте результат
to_tsvector('russian', ...)на характерных фразах с ожидаемыми нормализованными словами. - Проверьте пустые и неполные записи. Прогоните документы с NULL, пустым заголовком, пустым текстом и текстом только из стоп-слов. Проверка: формирование вектора не должно ломаться, а отсутствие совпадений должно быть ожидаемым.
- Проверьте веса и ранжирование. Подготовьте несколько записей, где запрос встречается только в заголовке, только в тексте и в обоих полях. Проверка: результаты соответствуют принятому правилу приоритета.
- Проверьте индекс на реальном запросе. Запустите
EXPLAIN (ANALYZE, BUFFERS)с типичным поисковым запросом и оцените план, число прочитанных строк и буферов. Проверка: план не должен неожиданно читать всю большую таблицу для обычного селективного запроса. - Проверьте обновления. Измените заголовок и текст, затем выполните поиск по старому и новому содержимому. Проверка: старый текст больше не находится, новый находится. Для генерируемого столбца убедитесь, что изменения исходных полей отражаются в векторе.
- Проверьте эксплуатационную цену. Сравните размер таблицы и индекса, время поиска и скорость вставки или обновления на объёме, похожем на боевой. Проверка: зафиксируйте исходные данные теста и сравните с допустимыми для сервиса задержками.
Не считайте последовательное сканирование автоматической ошибкой: на маленькой таблице планировщик может выбрать его, потому что это дешевле. Важно проверить поведение на боевом масштабе и с характерной частотой запросов, а не добиться упоминания GIN в каждом плане.
Когда что выбирать
Хранимый вектор с GIN — практичный старт, если поля поиска фиксированы и важна простая диагностика. Индекс по выражению подходит, когда не хочется менять схему и набор полей невелик, но запрос должен повторять индексируемое выражение. GiST имеет смысл сравнить, если у вас есть конкретные требования к размеру и обновлениям: выбор делайте по измерениям на своих данных, а не по общему обещанию «быстрее».
Для больших таблиц создание индекса может повлиять на нагрузку и доступность записи. Способ миграции выбирайте по требованиям к блокировкам и окнам обслуживания, сверяясь с документацией PostgreSQL для вашей версии. Универсальный безопасный для любого продакшена сценарий здесь обещать нельзя.
Что ломается чаще всего
- Вектор и запрос построены по-разному. Например, индекс учитывает только заголовок, а ожидание — поиск ещё и по тексту. Сравните выражение индекса с запросом и проверьте тестовые документы.
- Язык не соответствует содержимому. Результат может быть пустым или неожиданным. Проверьте типичные слова и конфигурацию отдельно для каждого языка.
- Индекс есть, но запрос его не использует. Причиной может быть несовпадающее выражение, маленький объём таблицы или оценка стоимости планировщиком. Смотрите план и реальные объёмы чтения, не добавляйте индекс повторно вслепую.
- Вы поменяли правила обработки текста. После изменения словарей или логики формирования векторов проверьте, какие данные требуют пересчёта и как провести его без неприемлемой нагрузки.
- Ожидаете поиск подстроки. Полнотекстовый поиск работает с лексемами, а не с произвольной частью слова. Если продукту нужен поиск фрагментов, это отдельное требование и другой способ реализации.
Термин: tsvector — тип PostgreSQL для подготовленного поискового документа: в нём хранятся нормализованные лексемы и сведения, нужные для поиска и ранжирования. tsquery — представление поискового запроса, с которым такой документ сравнивают.
Что делать дальше
Соберите небольшой набор запросов из реальных сценариев: частые слова, фразы в кавычках, слова из заголовка и текста, пустые и многоязычные записи. Зафиксируйте ожидаемую выдачу, прогоните её после изменения словаря, индекса или схемы и сохраните планы запросов для типичной нагрузки. Следующий показатель для наблюдения — не только время ответа, но и рост индекса вместе с ценой записи и обновления документов.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: PostgreSQL 16: полнотекстовый поиск, PostgreSQL 16: типы индексов
Частые вопросы
GIN всегда лучше GiST для полнотекстового поиска?
Почему поиск не использует созданный индекс?
Можно ли искать часть слова, как в поиске по подстроке?
Что произойдёт, если изменить словарь или правила обработки текста?
Подойдёт ли этот пример для другой версии PostgreSQL?
Нужно ли добавлять GIN, если таблица пока маленькая?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.