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

Частая ситуация: в приложении появляются статьи, описания товаров, комментарии или база знаний, и пользователи ждут нормальный поиск по словам. Хочется, чтобы он был быстрым, достаточно понятным и при этом не тянул за собой отдельную инфраструктуру.
Поэтому выбор между полнотекстовым поиском PostgreSQL и отдельным поисковым движком напрямую влияет на сроки и бюджет. Отдельный движок почти всегда означает еще один сервис со своим деплоем, мониторингом, правами доступа, бэкапами, синхронизацией данных и дополнительной точкой отказа. Встроенный поиск в базе обычно запускается быстрее и проще поддерживается, особенно когда данные и так живут в PostgreSQL.
Частая ошибка - начинать с ILIKE '%слово%', получать медленно и нерелевантно и делать вывод, что «PostgreSQL не умеет поиск». Сравнивать надо не с ILIKE, а с полноценным FTS (tsvector/tsquery) и нормальной индексацией.
Обычно дискомфорт выглядит так: поиск «не находит очевидное» (формы слов, порядок слов, синонимы), результаты странно отсортированы, на популярных запросах растет задержка и нагрузка на базу. Со временем добавляются ожидания вроде подсветки фрагментов, подсказок и устойчивости к опечаткам, а также разные сценарии: фильтры, несколько полей, веса, разные типы документов.
Ниже - практичное правило выбора: когда встроенного поиска достаточно, а когда уже разумно думать про отдельный движок. Затем - стартовый пример запроса с ранжированием и минимальный чеклист индексов и настроек, чтобы поиск работал быстро.
Если вы собираете продукт быстро (прототип или внутренний сервис), полезно начать со встроенного решения и заранее заложить возможность переезда, не переписывая половину приложения. Такой подход легко поддерживать и в TakProsto: сначала поднимаете поиск в одном PostgreSQL, а усложняете только тогда, когда появляются реальные требования.
В большинстве продуктов людям нужен не «умный поиск», а быстрый и предсказуемый: найти страницу, документ или товар по нескольким словам. Для этого полнотекстовый поиск PostgreSQL часто закрывает большую часть задач без отдельного движка.
Типовые сценарии простые: поиск по заголовку и по телу, поиск по нескольким полям сразу (заголовок + описание + теги), приоритет полей (заголовок важнее тела), запросы из нескольких слов без строгого порядка.
Разница между LIKE/ILIKE и полнотекстовым поиском проста. LIKE/ILIKE ищет подстроку: это удобно для «начинается с» или точного фрагмента, но плохо масштабируется на больших текстах и часто приводит к чтению большого объема данных. Полнотекстовый поиск работает по словам и их формам и хорошо индексируется.
Внутри все держится на двух сущностях:
tsvector - подготовленный текст: слова приведены к нормальной форме, очищены от шума и представлены так, чтобы по ним быстро искать.tsquery - запрос пользователя, разложенный на термы и операторы (AND/OR, исключения, поиск по фразе).Обычно вы превращаете нужные поля (например, title и body) в один tsvector, храните его в колонке или рассчитываете на лету, и сравниваете с tsquery. При правильной схеме база использует индекс и не читает весь текст.
Важно понимать ограничения. FTS в PostgreSQL дает базовое ранжирование, но не заменяет персонализацию, поведенческие сигналы, богатые словари синонимов «из коробки» и смысловой поиск. Он отвечает на вопрос «что найти», а не «почему люди так ищут».
Для русского языка FTS особенно полезен: PostgreSQL умеет морфологию через конфигурации словаря, поэтому запрос «доставка» часто находит «доставкой» и «доставки». Качество зависит от конфигурации разбора текста и списка стоп-слов.
Если тексты и метаданные уже живут в PostgreSQL, а вам нужна адекватная (не идеальная) релевантность и быстрый запуск, начинайте с полнотекстового поиска PostgreSQL. По времени и поддержке это почти всегда дешевле, чем сразу тянуть отдельный поисковый движок.
Встроенный поиск хорошо подходит для задач формата «найти документ по словам»: база знаний, внутренний справочник, каталог статей, описания товаров, тикеты.
Обычно PostgreSQL достаточно на старте, если:
Отдельный движок чаще нужен не потому, что «PostgreSQL медленный», а потому что требования к продукту выходят за рамки простой выдачи. Обычно это комбинация нескольких пунктов: опечатки и подсказки по мере ввода, синонимы и «похожие документы», фасеты и агрегации по множеству полей в каждом запросе, несколько источников данных, очень высокая нагрузка или жесткие SLA, когда поиск должен жить отдельно и масштабироваться независимо.
Проверка на практике занимает один вечер и дает ясность.
Скорость. Выберите типичный запрос, прогоните несколько раз, посмотрите среднее время и план выполнения. Если вы стабильно укладываетесь в свой бюджет (например, 50-150 мс на warm cache), это хороший знак.
Качество. Возьмите 10-20 частых запросов и руками отметьте, попадает ли нужный документ в топ-3 или топ-10. Пример: база знаний для поддержки, запрос «не приходит код». Если вверху статьи про «код подтверждения» и «SMS», качество уже рабочее. Если сверху постоянно мусор, сначала попробуйте донастроить словарь, веса и запрос. Если это не помогает, значит, требования уже шире, чем простой FTS.
Если оба замера проходят, фиксируйте решение: запускайтесь на PostgreSQL и откладывайте отдельный движок до момента, когда появятся реальные признаки, а не предположения.
Полнотекстовый поиск PostgreSQL часто закрывает большую часть задач, особенно если документы понятные, ранжирование простое, а фильтры ограничены парой полей. Но есть признаки, что вы упираетесь уже в формат решения.
Если пользователи ждут поведения как в крупных поисковиках, «просто добавили индекс» быстро перестает работать. Отдельный движок обычно оправдан, когда обязательны автодополнение по мере ввода, устойчивость к опечаткам (fuzzy), раскладка и транслитерация, развитая система синонимов и нормализация доменной лексики, фасеты и агрегации с быстрым пересчетом, а также сложные правила ранжирования, которые нужно часто менять.
В PostgreSQL многое можно реализовать частично, но стоимость поддержки часто растет быстрее, чем польза.
Второй сигнал - нагрузка. Если параллельных поисковых запросов много, а вместе с поиском идут тяжелые фильтры (цена, статус, права доступа) и сортировки, база начинает конкурировать сама с собой: поиск отбирает ресурсы у транзакций. Это особенно заметно, когда поиск становится одной из главных функций продукта.
Третий сигнал - источники данных. Если нужно искать по нескольким базам, внешним системам или файлам (вложения, письма, выгрузки), PostgreSQL превращается в центр интеграции, и аккуратно поддерживать все потоки обновлений становится сложно.
При этом отдельный поисковый движок - это не «поставили и забыли». Появятся кластер, мониторинг, резервные копии, обновления и настройка индексации. Если вы не готовы поддерживать еще один сервис, логичнее сначала выжать максимум из FTS PostgreSQL.
Простой ориентир: если вы чаще спорите про «как поиск должен угадывать ожидания» и «как не положить базу на пике», чем про контент, пора планировать выделенный поиск.
Для русского языка почти всегда стоит начинать с конфигурации russian. Она включает стемминг и стоп-слова. Из-за этого запрос по «доставка» обычно находит и «доставкой», и «доставки». Иногда это удивляет: короткие слова и предлоги могут исчезнуть из запроса.
Ниже пример, где ищем по двум полям: заголовок важнее основного текста. Для этого собираем tsvector из нескольких колонок и задаем веса.
-- Поиск по опубликованным материалам за последний год
-- q: текст запроса пользователя
SELECT
id,
title,
published_at,
ts_rank_cd(
( setweight(to_tsvector('russian', coalesce(title, '')), 'A')
|| setweight(to_tsvector('russian', coalesce(body, '')), 'B')
),
plainto_tsquery('russian', :q)
) AS rank
FROM articles
WHERE status = 'published'
AND published_at >= now() - interval '1 year'
AND (
setweight(to_tsvector('russian', coalesce(title, '')), 'A')
|| setweight(to_tsvector('russian', coalesce(body, '')), 'B')
) @@ plainto_tsquery('russian', :q)
ORDER BY rank DESC, published_at DESC
LIMIT 20;
Что здесь важно:
plainto_tsquery подходит для старта: пользователь пишет обычную фразу, а PostgreSQL сам превращает ее в запрос.setweight(..., 'A') поднимает совпадения в заголовке относительно текста. Часто это сразу делает выдачу более «разумной».ts_rank_cd дает численный скор релевантности. Его удобно комбинировать с бизнес-сигналами, например со свежестью.Фильтры по статусу и дате можно добавлять прямо в WHERE, но есть нюанс скорости: если tsvector вычисляется на лету, база пересчитывает его для множества строк. Обычно это и становится узким местом. Типовое решение - хранить tsvector в отдельной колонке (или генерируемой), обновлять при изменениях и ставить индекс. Тогда план запроса становится более предсказуемым.
Чтобы полнотекстовый поиск PostgreSQL не тормозил, чаще всего важнее не «хитрый запрос», а дисциплина: где считается tsvector, какой индекс стоит и насколько аккуратно поддерживается статистика.
to_tsvector(...) можно вычислять прямо в запросе, но тогда индекс почти всегда бесполезен: функция считается для каждой строки. Для живого поиска лучше хранить tsvector в отдельном столбце и поддерживать его актуальным.
Простой шаблон: столбец search_vector + обновление при изменении текста (триггером или в коде приложения). Если у вас React фронт и Go бэкенд, удобно обновлять в базе, чтобы не дублировать логику в сервисах.
Для tsvector в большинстве случаев выбирают GIN: он дает быстрый поиск по словам и фразам. GiST обычно рассматривают реже: он может быть компактнее, но чаще медленнее на поиске, и его берут под конкретные причины.
Минимальный набор, который почти всегда дает заметный эффект:
russian) при построении tsvector и в plainto_tsquery/to_tsquery;tsvector в колонке и ставьте GIN индекс именно на него;tsvector при INSERT/UPDATE, иначе поиск будет «врать»;Проверяйте план запроса.
Запустите EXPLAIN (ANALYZE, BUFFERS) и посмотрите:
Bitmap Index Scan или Index Scan по вашему GIN индексу;Seq Scan по всей таблице (частая причина - to_tsvector считается на лету);rows) и сколько чтений по страницам видно в BUFFERS;LIMIT).Если индекс используется, а все равно медленно, чаще всего проблема в слишком широких запросах, плохой статистике или тяжелом ранжировании.
Если подключать FTS без плана, легко получить «странную выдачу» и медленные запросы. Быстрее работает подход в две итерации: сначала минимальная скорость и базовая релевантность, затем доводка по реальным запросам.
Соберите 30-100 примеров запросов: из логов, из чатов поддержки, из заметок команды. Важно сохранить не только слова, но и намерение (например, «найти статью про возврат», «ошибка при оплате»).
Дальше определите поля поиска и приоритеты. Чаще всего заголовок должен обгонять тело текста, а теги и краткое описание помогают уточнять.
На русском сразу решите вопросы нормализации: регистр, лишние пробелы, иногда «ё/е». И проверьте, что конфигурация языка выбрана осознанно, иначе «оплатил», «оплата», «оплатить» могут вести себя непредсказуемо.
Минимум, который стоит закрыть в первой итерации:
tsvector и GIN индекс;Когда минимум уже работает, вернитесь к собранным запросам и посмотрите, что раздражает: не те результаты вверху, слишком много «мусора», не находятся важные синонимы. Здесь чаще помогают точечные правки: поднять вес заголовка, добавить небольшой словарь синонимов для пары ключевых терминов, исключить технические поля.
Обновление tsvector лучше сделать надежным: генерируемая колонка или триггер. Если вы собираете продукт на TakProsto, удобно выделить это как отдельную задачу и проверить на двух кейсах: создание записи и редактирование.
Плохая выдача и медленные запросы чаще связаны не с тем, что «PostgreSQL не умеет поиск», а с тем, как его подключили.
Распространенная схема: часть условий через FTS, часть через ILIKE '%слово%' по тем же колонкам, еще и через OR. В итоге план запроса разваливается, индекс на tsvector может не использоваться, а результаты выглядят странно.
Если нужен «точный матч» по фрагменту строки (артикул, код), вынесите это в отдельное поле и отдельный фильтр. Текстовый поиск оставьте для длинных описаний, статей и комментариев.
Когда to_tsvector(...) стоит прямо в WHERE или ORDER BY, база пересчитывает представление текста для каждой строки. На небольших данных это незаметно, на проде начинает тормозить.
Правильный подход: хранить tsvector в отдельной колонке, поддерживать актуальность и индексировать.
Русский язык сложный, а часть слов («и», «в», «на») обычно выкидывается как стоп-слова. Отсюда типовые сюрпризы: запрос из коротких слов возвращает пусто, а ранжирование поднимает неожиданные результаты из-за морфологии.
Помогает простое правило для интерфейса: объяснить, что поиск работает по смысловым словам, и показывать подсветку найденных терминов. И не смешивать в одном поле русский текст с техническими идентификаторами.
Даже с индексированным tsvector ранжирование стоит денег. Ошибка выглядит так: запрос сортирует весь набор совпадений по rank, без ограничений по статусу, типу документа, правам доступа или хотя бы по свежести.
Хорошая привычка: сначала отфильтровать по простым условиям, затем ранжировать и применять LIMIT.
На десяти записях все выглядит идеально. На сотнях тысяч появляется другое распределение слов, длинные документы, повторы, смешение языков, опечатки. Проверяйте на данных, похожих на прод, и по реальным запросам.
Короткая самопроверка:
tsvector не строится в самом запросе;Два частых кейса похожи: база знаний поддержки (статьи, инструкции, FAQ) и каталог (товары, услуги, карточки с описанием). В обоих случаях обычно нужно одно и то же: фильтр по категории, поиск по заголовку и тексту, сортировка по релевантности.
Если объем контента умеренный и требования простые, полнотекстовый поиск PostgreSQL закрывает задачу без отдельного движка. Это особенно удобно, когда вы хотите меньше компонентов и проще операционку.
Для базы знаний рабочий минимум обычно сводится к одному tsvector (заголовок + тело, с повышенным весом заголовка), GIN индексу и запросу с ранжированием. Фильтр по категории делается обычным btree индексом.
Пример из жизни: оператор поддержки вводит «не приходит код» и выбирает категорию «Авторизация». Вы отдаете 10-20 результатов, где выше статьи с совпадением в заголовке и более плотным совпадением в тексте. Для такого сценария FTS PostgreSQL обычно воспринимается как «быстрый и нормальный».
В каталоге к тексту добавляются фильтры: категория, бренд, цена, наличие, атрибуты. Если фильтров немного, PostgreSQL справляется: фильтруете по колонкам, текст ищете по tsvector, сортируете по релевантности.
Каталог чаще упирается не в базовый поиск, а в ожидания UX. Подталкивают к отдельному движку подсказки на лету, толерантность к опечаткам и транслитерации («айфон» vs «iphone») без сложных костылей, тяжелые фасеты по десяткам атрибутов и очень высокий QPS.
Если у вас база знаний или небольшой каталог, начните с FTS PostgreSQL и простых фильтров. Когда подсказки, опечатки и сложные фасеты становятся обязательными, тогда уже логично выносить поиск в отдельный движок, а PostgreSQL оставить источником правды.
Чтобы понять, «нормальный» ли у вас полнотекстовый поиск PostgreSQL, не нужны долгие обсуждения. Нужны повторяемые проверки на реальных запросах.
Возьмите 20 самых частых запросов и прогоните по тестовой базе. Оцените качество (топ-10 выглядит разумно хотя бы для 15 из 20), стабильность (после обновлений данных выдача не «прыгает»), скорость (p95 в вашем бюджете, например до 200-300 мс для внутреннего сервиса), поведение с фильтрами и отсутствие резкого провала при параллельных запросах.
Если это проходит, обычно нет смысла спешить к отдельному поисковому движку: синхронизация данных и поддержка еще одной системы стоят дорого, а выигрыш может быть небольшим.
Без логов вы спорите о «вкусе поиска». Логи быстро показывают, где проблема: в данных, ранжировании, фильтрах или ожиданиях.
Полезный минимум: исходный текст запроса и нормализованный вариант, фильтры и контекст (раздел, язык, роль), количество результатов и размер топа, время выполнения на бэкенде и общее время ответа, клики по результатам (хотя бы факт клика и позиция).
Через неделю таких данных обычно видно, какие 5-10 запросов действительно болят и почему.
Оставайтесь на PostgreSQL, если поиск - это функция внутри приложения, а не отдельный продукт: база знаний, задачи, комментарии, небольшой каталог, где важны фильтры, права доступа и консистентность.
Если бизнес просит «как в больших маркетплейсах»: подсказки, сложные синонимы, агрегации, персонализацию и отдельную релевантность на уровне событий, тогда заранее закладывайте время на выделенный движок.
Следующий шаг простой: сформулируйте требования в 5-7 строк и соберите маленький прототип (например, «20 запросов, p95 до 300 мс, топ-10 приемлем для 80% запросов, учитываем фильтры X и права Y»). Дальше работайте итерациями: поменяли словарь, ранжирование или индексы, замерили снова.
Если удобнее вести разработку в формате чат-проекта, это можно сделать в TakProsto (takprosto.ai): зафиксировать требования, пройти 1-2 итерации прототипа и сравнить метрики до и после. Тогда решение, оставаться на PostgreSQL или выносить поиск в отдельный слой, будет основано на цифрах.
Если нужно быстро и предсказуемо искать по статьям, описаниям, тикетам или базе знаний, обычно хватает FTS PostgreSQL.
Хороший стартовый критерий: данные уже в PostgreSQL, нужна базовая релевантность (формы слов, веса полей), и нагрузка не требует отдельного масштабирования поиска.
Потому что ILIKE '%слово%' ищет подстроку и часто вынуждает читать много строк и текста, особенно на больших таблицах.
FTS работает по словам (и их формам), использует tsvector/tsquery и нормально индексируется, поэтому сравнивать нужно именно с ним, а не с ILIKE.
tsvector — это подготовленное представление текста: слова нормализованы и разложены так, чтобы по ним быстро искать.
tsquery — это поисковый запрос в формате PostgreSQL (слова + операторы). В типовом сценарии вы строите tsvector из полей документа и сравниваете с tsquery через оператор @@.
Для начала используйте plainto_tsquery: она превращает обычную фразу пользователя в запрос без операторов.
Чтобы ранжирование выглядело разумно:
setweight)На русском почти всегда начните с конфигурации russian: она учитывает морфологию, поэтому «оплата» часто найдет «оплатил/оплатить».
Учитывайте нюансы:
Потому что когда to_tsvector(...) стоит прямо в WHERE или ORDER BY, PostgreSQL вынужден вычислять его для множества строк, и индекс часто не помогает.
Практичный подход: хранить tsvector в отдельной колонке, обновлять при изменениях (триггером или в коде) и ставить индекс на эту колонку.
В большинстве случаев — GIN индекс на колонку tsvector.
Минимальный чеклист:
Сделайте измерение в два шага:
EXPLAIN (ANALYZE, BUFFERS) — убедиться, что используется индекс (обычно Bitmap Index Scan по GIN), а не Seq ScanЕсли время стабильно укладывается в ваш бюджет, а качество “нормальное”, обычно нет смысла усложнять архитектуру.
Чаще всего отдельный движок нужен, когда обязательны функции уровня «поисковика»:
Если при этом поиск начинает конкурировать с транзакциями в одной базе, выделение поиска становится оправданным.
Логируйте минимум, который помогает улучшать поиск по фактам:
С этими данными проще итеративно править веса, словарь и фильтры — и понять, хватает ли PostgreSQL или уже нужен отдельный слой.
ts_rank_cdLIMIT, чтобы не сортировать огромные выборкиto_tsvector и to_tsquery/plainto_tsquerysearch_vectorsearch_vector при INSERT/UPDATEVACUUM/ANALYZE и ANALYZE после массовых загрузок