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

Большинство людей не просят «полнотекстовый поиск». Они хотят поле поиска, которое кажется быстрым и на первой странице показывает то, что имелось в виду. Если результаты медленные, шумные или странно отсортированы, пользователям всё равно — использовали ли вы полнотекстовый поиск PostgreSQL или отдельный движок. Они просто перестают доверять поиску.
Это один выбор: держать поиск внутри Postgres или добавить выделенный поисковый движок. Цель не в идеальной релевантности. Цель — надёжная отправная точка, которую быстро выпустить, легко эксплуатировать и которая достаточно хороша для реального использования вашего приложения.
Для многих приложений полнотекстовый поиск PostgreSQL долгое время оказывается достаточным. Если у вас несколько текстовых полей (title, description, notes), базовое ранжирование и пара фильтров (status, category, tenant), Postgres справится без дополнительной инфраструктуры. Вы получаете меньше компонентов, проще бэкапы и меньше инцидентов типа «почему поиск упал, а приложение — нет?»
«Достаточный» обычно означает, что вы одновременно достигаете трёх целей:
Конкретный пример: SaaS-панель, где пользователи ищут проекты по имени и заметкам. Если запрос вроде «onboarding checklist» возвращает нужный проект в топ-5 за меньше чем секунду и вы не постоянно правите анализаторы или реиндексируете, это «достаточно». Когда эти цели не достигаются без наращивания сложности — тогда вопрос «встроенный поиск против поискового движка» становится реальным.
Команды часто описывают поиск через фичи, а не через ожидаемые результаты. Полезный шаг — перевод каждой фичи в то, что она будет стоить в разработке, настройке и поддержке.
Ранние запросы обычно звучат как: толерантность к опечаткам, фасеты и фильтры, подсветка, «умное» ранжирование и автодополнение. Для первой версии отделите действительно обязательные вещи от приятных дополнений. Базовое поле поиска обычно должно находить релевантные элементы, обрабатывать распространённые формы слов (множественное число, времена), уважать простые фильтры и оставаться быстрым по мере роста таблицы. Именно сюда чаще всего и подходит полнотекстовый поиск Postgres.
Postgres хорош, когда ваш контент хранится в обычных текстовых полях и вы хотите поиск рядом с данными: статьи справки, посты в блоге, тикеты поддержки, внутренние доки, названия и описания продуктов или заметки в карточках клиентов. Это в основном задачи «найти нужную запись», а не «построить продукт для поиска».
Приятные дополнения — там, где появляется сложность. Толерантность к опечаткам и богатое автодополнение обычно тянут вас к дополнительным инструментам. Фасеты возможны в Postgres, но если вам нужно много фасетов, глубокая аналитика и мгновенные подсчёты по огромным данным, выделенный движок начинает выглядеть привлекательнее.
Скрытая стоимость редко связана с лицензией. Она вторая система. Как только вы добавили движок поиска, появляются синхронизация данных и бэктриллы (и баги, которые они приносят), мониторинг и апгрейды, поддержка «почему поиск показывает старые данные?» и две независимые системы настроек релевантности.
Если вы не уверены — начните с Postgres, выпустите простое решение и добавляйте движок только тогда, когда появится ясное требование, которое Postgres не покрывает.
Используйте правило из трёх проверок. Если вы проходите все три — оставайтесь с полнотекстовым поиском PostgreSQL. Если хотя бы одна провалена сильно — рассмотрите выделенный поисковый движок.
Потребности в релевантности: устраивают ли вас «достаточно хорошие» результаты, или вам нужен почти идеальный порядок по множеству крайних случаев (опечатки, синонимы, «пользователи также искали», персонализация)? Если вы можете терпеть случайные несовершенства в порядке, Postgres обычно подходит.
Объём запросов и задержки: сколько поисковых запросов в секунду вы ожидаете в пике и какой у вас реальный бюджет по задержке? Если поиск — небольшая часть трафика и вы можете держать запросы быстрыми с правильными индексами, Postgres в порядке. Если же поиск становится ключевой нагрузкой и начинает конкурировать с основными операциями чтения/записи — это предупреждение.
Сложность: ищете ли вы по одному-двум текстовым полям или комбинируете множество сигналов (теги, фильтры, временное затухание, популярность, права доступа) и несколько языков? Чем сложнее логика, тем сильнее вы почувствуете трения внутри SQL.
Безопасный старт: выпустите базовую версию в Postgres, логируйте медленные запросы и «поиски без результатов», и только затем принимайте решение. Многие приложения никогда не перерастают Postgres и вы избегаете раннего запуска и синхронизации второй системы.
Признаки, которые обычно указывают на выделенный движок:
Зелёные флаги для оставания в Postgres:
PostgreSQL полнотекстовый поиск — встроенный способ превратить текст в форму, которую база быстро ищет, без сканирования каждой строки. Он лучше всего работает, когда ваш контент уже в Postgres и вы хотите быстрый, приличный поиск с предсказуемой эксплуатацией.
Есть три элемента, которые стоит знать:
ts_rank (или ts_rank_cd), чтобы более релевантные строки шли первыми.Языковая конфигурация важна, потому что она меняет, как Postgres обрабатывает слова. С правильной конфигурацией «running» и «run» могут совпадать (стемминг), а обычные служебные слова игнорироваться (stop words). С неверной конфигурацией поиск может казаться сломанным, потому что обычная формулировка пользователя больше не совпадает с индексом.
Префиксный матчинг — функция, к которой люди тянутся, когда хотят поведение, похожее на автодополнение, например сопоставление «dev» с «developer». В Postgres FTS это обычно делается через префиксный оператор (например, term:*). Это может улучшить восприятие качества, но часто увеличивает стоимость запроса, поэтому рассматривайте его как опциональное улучшение, а не по умолчанию.
Чего Postgres не претендует на роль: полноценной платформы поиска со всеми функциями. Если вам нужна нечёткая коррекция орфографии, продвинутое автодополнение, learning-to-rank, сложные анализаторы по полям или распределённое индексирование по множеству узлов — вы вышли за пределы встроенной зоны комфорта. Для многих приложений же PostgreSQL FTS даёт большую часть ожидаемого пользователями функционала при гораздо меньшем числе компонентов.
Вот небольшой реалистичный пример для контента, который вы хотите искать:
-- Minimal example table
CREATE TABLE articles (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
Хорошая отправная точка для PostgreSQL FTS: собрать запрос из того, что ввёл пользователь, сначала отфильтровать строки (когда можно), затем ранжировать оставшиеся совпадения.
-- $1 = user search text, $2 = limit, $3 = offset
WITH q AS (
SELECT websearch_to_tsquery('english', $1) AS query
)
SELECT
a.id,
a.title,
a.updated_at,
ts_rank_cd(
setweight(to_tsvector('english', coalesce(a.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(a.body, '')), 'B'),
q.query
) AS rank
FROM articles a
CROSS JOIN q
WHERE
a.updated_at >= now() - interval '2 years' -- example safe filter
AND (
setweight(to_tsvector('english', coalesce(a.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(a.body, '')), 'B')
) @@ q.query
ORDER BY rank DESC, a.updated_at DESC, a.id DESC
LIMIT $2 OFFSET $3;
Несколько деталей, которые сэкономят время позже:
WHERE перед ранжированием (status, tenant_id, диапазоны дат). Вы ранжируете меньше строк, так что будет быстрее.ORDER BY (например, updated_at, затем id). Это делает пагинацию стабильной, когда у многих результатов одинаковый ранг.websearch_to_tsquery для ввода пользователя. Он обрабатывает кавычки и простые операторы так, как люди ожидают.Когда эта база заработает, переместите выражение to_tsvector(...) в сохранённую колонку. Это избавит от пересчёта на каждый запрос и упростит индексирование.
Большинство историй «PostgreSQL полнотекстовый поиск медленный» сводятся к одному: база строит документ поиска на каждом запросе. Исправьте это сначала, сохранив предсобранный tsvector и индексируя его.
tsvector: сгенерированная колонка или триггер?Сгенерированная колонка — самый простой вариант, когда документ поиска строится из колонок одной строки. Она остаётся корректной автоматически и её трудно забыть при обновлениях.
Используйте tsvector, поддерживаемый триггером, когда документ зависит от связанных таблиц (например, вы комбинируете продукт с именем категории), или когда нужна кастомная логика, которую трудно выразить одним сгенерированным выражением. Триггеры добавляют движущиеся части, так что держите их маленькими и тестируйте.
Создайте GIN-индекс на колонке tsvector. Это базовый набор, который делает полнотекстовый поиск PostgreSQL мгновенным для типичного поиска в приложениях.
Схема, которая работает для многих приложений:
tsvector в той же таблице, где строки, которые вы чаще всего ищете.tsvector.@@ против сохранённого tsvector, а не to_tsvector(...), вычисляемый на лету.VACUUM (ANALYZE) после больших бэкофиллов, чтобы планировщик видел новый индекс.Держать вектор в той же таблице обычно быстрее и проще. Отдельная таблица поиска может иметь смысл, если базовая таблица сильно нагружена записью, или если вы индексируете объединённый документ из многих таблиц и хотите обновлять его по собственному расписанию.
Частичные индексы помогают, когда вы ищете только подмножество строк, например status = 'active', один тенант в мульти-тенантном приложении или конкретный язык. Они уменьшают размер индекса и могут ускорить поиск, но только если ваши запросы всегда включают тот же фильтр.
Вы можете получить удивительно хорошие результаты с PostgreSQL FTS, если правила релевантности просты и предсказуемы.
Самая простая победа — взвешивание полей: совпадения в заголовке должны важить больше, чем совпадения в теле. Постройте комбинированный tsvector, где title имеет больший вес, чем body, затем ранжируйте с помощью ts_rank или ts_rank_cd.
Если нужно, чтобы «свежие» или «популярные» элементы поднимались, делайте это аккуратно. Небольшой буст — нормально, но не позволяйте ему побеждать текстовую релевантность. Практичный шаблон: ранжировать по тексту сначала, затем решать ничьи по свежести, или добавлять ограниченную премию, чтобы нерелевантная новая запись не перебила старое идеальное совпадение.
Синонимы и фразовый матчинг — там, где ожидания часто расходятся. Синонимы не наступают автоматически: их нужно добавить через тезаурус или кастомный словарь, либо расширять запрос вручную (например, трактовать «auth» как «authentication»). Фразовый матчинг тоже не по умолчанию: обычные запросы ищут слова в любом месте, а не «точную фразу». Если пользователи вводят фразы в кавычках или длинные вопросы, рассмотрите phraseto_tsquery или websearch_to_tsquery для лучшего соответствия пользовательским ожиданиям.
Смешанный многоязычный контент требует решения. Если вы знаете язык для каждого документа, храните его и генерируйте tsvector с нужной конфигурацией (English, Russian и т.д.). Если язык неизвестен, безопасный запас — индексировать с конфигурацией simple (без стемминга), или хранить два вектора: один специфичный для языка, когда он известен, и один simple для всех.
Чтобы валидировать релевантность, держите процесс маленьким и конкретным:
Обычно этого достаточно для полнотекстового поиска в поисковых полях приложений вроде «шаблоны», «доки» или «проекты».
Большинство историй «Postgres полнотекстовый поиск медленный или нерелевантный» происходят из нескольких исправимых ошибок. Исправление их обычно проще, чем добавление новой системы поиска.
Одна ловушка — считать tsvector вычисляемым значением, которое само по себе остаётся корректным. Если вы храните tsvector в колонке, но не обновляете его при каждом insert и update, результаты будут выглядеть случайными, потому что индекс больше не соответствует тексту. Если вы вычисляете to_tsvector(...) на лету в запросе, результаты могут быть корректными, но медленнее, и вы теряете преимущество индекса.
Ещё один путь к ухудшению производительности — ранжировать до того, как вы сузили набор кандидатов. ts_rank полезен, но обычно его следует вызывать после того, как Postgres использовал индекс, чтобы найти совпадения. Если вы считаете ранг для огромной части таблицы (или сначала делаете join), вы можете превратить быстрый поиск в полный скан таблицы.
Люди также ожидают, что «contains» будет как LIKE '%term%'. Ведущие wildcard-символы плохо сочетаются с FTS, потому что FTS основан на словах (лексемах), а не на произвольных подстроках. Если нужен поиск подстрок для кодов или частичных идентификаторов, используйте другой инструмент (например, триграммный индекс).
Проблемы с производительностью часто появляются из обработки результатов, а не из поиска совпадений. Два шаблона, на которые стоит обратить внимание:
OFFSET для пагинации, из-за которого Postgres пропускает всё больше строк при перелистывании.Операционные вопросы тоже важны. После большого числа обновлений индекс может раздуться, и реиндексирование дорого, если ждать до критической точки. Измеряйте реальные времена запросов (и смотрите EXPLAIN ANALYZE) до и после изменений. Без цифр легко «починить» поиск, сделав что-то хуже в другом месте.
Прежде чем винить полнотекстовый поиск PostgreSQL, прогоните эти проверки. Большинство багов «Postgres поиск медленный или нерелевантный» происходит из отсутствия базиса, а не из недостатков самой функции.
Постройте реальный tsvector: храните его в сгенерированной или поддерживаемой колонке (не вычисляйте в каждом запросе), используйте правильную языковую конфигурацию (english, simple и т.д.) и применяйте веса, если смешиваете поля (title > subtitle > body).
Нормализуйте то, что индексируете: держите шумные поля (ID, boilerplate, навигационный текст) вне tsvector, и обрезайте большие блобы, если пользователи их не ищут.
Создайте правильный индекс: добавьте GIN-индекс на колонку tsvector и подтвердите через EXPLAIN, что он используется. Если ищется только подмножество (например, status = 'published'), частичный индекс может уменьшить размер и ускорить чтение.
Поддерживайте таблицы: мёртвые кортежи замедляют индексное сканирование. Регулярный VACUUM важен, особенно для часто обновляемого контента.
Имейте план реиндексации: большие миграции или раздутые индексы иногда требуют контролируемого окна для reindex.
Когда данные и индекс в порядке, переключитесь на форму запроса. PostgreSQL FTS быстрый, когда может рано сузить набор кандидатов.
Фильтруйте сначала, затем ранжируйте: применяйте строгие фильтры (tenant, language, published, category) перед ранжированием. Ранжирование тысяч строк, которые вы потом откидываете, — напрасная работа.
Используйте стабильную сортировку: сортируйте по рангу, а затем по tie-breaker вроде updated_at или id, чтобы результаты не прыгали между обновлениями.
Избегайте «запрос делает всё»: если нужен нечёткий матч или толерантность к опечаткам, делайте это осознанно (и измеряйте). Не форсируйте последовательные сканы по ошибке.
Тестируйте реальные запросы: соберите топ-20 запросов, проверьте релевантность вручную и держите небольшой список ожидаемых результатов, чтобы ловить регрессии.
Следите за медленными путями: логируйте медленные запросы, проверяйте EXPLAIN (ANALYZE, BUFFERS) и мониторьте размер индекса и кеш-хиты, чтобы заметить, когда рост меняет поведение.
Центр помощи SaaS — хороший старт, потому что цель проста: помочь людям найти статью, отвечающую на их вопрос. У вас несколько тысяч статей, у каждой — заголовок, краткое описание и тело. Большинство посетителей вводят 2–5 слов вроде «reset password» или «billing invoice».
С PostgreSQL FTS это можно довести до рабочего состояния очень быстро. Вы храните tsvector для комбинированных полей, добавляете GIN-индекс и ранжируете по релевантности. Успех выглядит так: результаты появляются за <100 мс, топ-3 обычно корректны, и систему не нужно постоянно «кормить».
Потом продукт растёт. Поддержка хочет фильтровать по области продукта, платформе (web, iOS, Android) и плану (free, pro, business). Авторы документации хотят синонимы, подсказки «вы имели в виду» и лучшую обработку опечаток. Маркетинг хочет аналитику «топ запросов без результатов». Трафик растёт, и поиск становится одним из самых загруженных endpoint'ов.
Это сигналы, что выделенный движок может окупиться:
Практичный путь миграции: оставьте Postgres источником истины даже после добавления движка. Сначала логируйте запросы и случаи без результатов, затем запустите асинхронный синк, который копирует только индексируемые поля в новый индекс. Запускайте обе системы параллельно некоторое время и переключайтесь постепенно, а не ставьте всё на карту в один день.
Если ваш поиск в основном «найти документы, содержащие эти слова», и объём данных не огромен, PostgreSQL FTS обычно достаточен. Начните с него, заставьте работать и добавляйте движок лишь тогда, когда сможете назвать недостающую функцию или боль масштабирования.
Краткое резюме, которое стоит помнить:
tsvector, добавить GIN-индекс и ваши потребности в ранжировании базовые.Практический следующий шаг: реализуйте стартовый запрос и индекс из предыдущих разделов, затем логируйте несколько простых метрик неделю. Отслеживайте p95 времени запроса, медленные запросы и грубый сигнал успеха вроде «поиск -> клик -> отсутствие мгновенного отказа» (даже простой счётчик событий поможет). Вы быстро увидите, нужно ли улучшать ранжирование или достаточно улучшить UX (фильтры, подсветка, лучшие сниппеты).
Если хотите быстро двигаться на стороне приложения, Koder.ai может помочь прототипировать интерфейс поиска и API через чат, а потом итеративно тестировать с использованием снимков и отката, пока вы измеряете поведение пользователей.
PostgreSQL полнотекстовый поиск считается «достаточным», когда одновременно выполняются три условия:
Если вы достигаете этого с хранением tsvector + индексом GIN, вы обычно в очень хорошем положении.
По умолчанию начинайте с полнотекстового поиска PostgreSQL. Он разворачивается быстрее, хранит данные и поиск в одном месте и избавляет от необходимости строить и поддерживать отдельный pipeline индексирования.
Переходите на отдельный движок, когда у вас появляется явное требование, с которым Postgres справляется плохо (высококачественная толерантность к опечаткам, богатая автодополняемость, серьёзная фасетная аналитика или нагрузка на поиск, конкурирующая с основной базой данных).
Простое правило: оставайтесь в Postgres, если вы проходите три проверки:
Если одно из этих требований проваливается (особенно опечатки/автодополнение или высокая нагрузка), стоит подумать о выделенном движке.
Используйте Postgres FTS, когда поиск преимущественно «найти нужную запись» по нескольким полям (title/body/notes) с простыми фильтрами (tenant, status, category).
Это хорошо подходит для центров помощи, внутренних документов, тикетов, поиска по статьям/блогам и SaaS-панелей, где пользователи ищут по названиям проектов и заметкам.
Хорошая базовая форма запроса обычно:
websearch_to_tsquery.Держите предкомпилированный tsvector и добавьте индекс GIN. Это избавляет от пересчёта to_tsvector(...) на каждый запрос.
Практический набор:
Используйте сгенерированную колонку, когда документ поиска строится из колонок той же строки (просто и надёжно).
Используйте триггер, если текст для поиска зависит от связанных таблиц или требует кастомной логики.
По умолчанию: сначала выбирайте сгенерированную колонку, триггеры — только когда действительно нужна композиция по таблицам.
Начните с предсказуемой релевантности:
title должны важить больше, чем в body.Проверяйте изменения на небольшом наборе реальных запросов и ожидаемых результатов.
FTS в Postgres основан на словах, а не на подстроках, поэтому он не ведёт себя как LIKE '%term%' для произвольных фрагментов.
Если нужен поиск по подстроке (коды продуктов, частичные ID), используйте другой инструмент (например, триграммный индекс), а не пытайтесь заставить FTS делать то, для чего он не предназначен.
Сигналы, что вы перерастаете Postgres FTS:
Практичный путь: оставляйте Postgres источником истины и добавляйте асинхронную индексацию, когда требование станет очевидным.
tsvector через @@.ts_rank/ts_rank_cd и стабильному tie-breaker вроде updated_at, id.Это делает результаты релевантными, быстрыми и стабильными для пагинации.
tsvector в ту же таблицу, что и строки для поиска.tsvector_column @@ tsquery.Это самое распространённое исправление, когда поиск кажется медленным.