В реальных приложениях рано или поздно появляется задача: «дайте поиск по описаниям товаров / статьям / постам — чтобы по слову ноутбук находилось всё, где это слово встречается». Первая мысль — WHERE description ILIKE '%ноутбук%'. Это работает на маленьких данных и не работает на больших — ни по производительности, ни по качеству результата.
Для этой задачи в PostgreSQL встроен полнотекстовый поиск (full-text search, FTS). Он отдельный мини-движок внутри СУБД, со своими типами, операторами и индексами.
Что плохо в LIKE и ILIKE
- Нет морфологии.
ILIKE '%ноутбук%'не найдёт «ноутбука» в форме другого падежа без отдельного запроса. Английский'%book%'найдёт «booking» и «booklet», даже если ищут именно «book». - Нет ранжирования. Результат — просто список строк, без оценки релевантности.
- Индексируется плохо. Только
LIKE 'prefix%'(с якорем в начале) использует обычный B-tree.%anywhere%идёт по seq scan на каждый запрос. - Нет учёта стоп-слов и приоритетов. «И» в русском или «the» в английском перетягивают результат на себя.
FTS решает все четыре пункта.
tsvector — разобранный документ
tsvector — это результат лексической обработки текста: документ разбит на токены, токены приведены к канонической форме (лемматизированы), стоп-слова отброшены.
Посмотри, во что превращается обычный текст:
Видишь? ноутбук и ноутбуки свернулись в одну лексему (ноутбук), числа в конце — это позиции этой лексемы в исходном тексте. Стоп-слова (a, are, the, и подобные) выкинуты. В английской версии laptop и laptops тоже стали одним токеном.
Текст проходит через цепочку преобразований и становится разреженным индексом «лексема → позиции».
tsquery — запрос к tsvector
tsquery — это запрос на том же языке: лексема, возможно с булевыми связками (&, |, !).
tsvector @@ tsquery — оператор match:
Обрати внимание на средний случай: tsvector содержит лексему ноутбук, tsquery про ноутбукам тоже сворачивается в ноутбук — match есть. Это и есть «морфологический поиск», который LIKE дать не может.
Plainto, phraseto и websearch
to_tsquery требует «правильного» синтаксиса с амперсандами и кавычками — это удобно для машинного запроса, но не для ввода пользователя. Для пользовательского ввода есть три удобных функции:
plainto_tsquery— превращает простой текст в запрос с&между словами.phraseto_tsquery— то же, но с порядком слов (фразовый поиск).websearch_to_tsquery— синтаксис, похожий на Google: кавычки, минус для исключения.
Сравни, как три функции превратят один и тот же пользовательский ввод:
В проде с пользовательским вводом обычно используют websearch_to_tsquery — это самое толерантное к синтаксису, не падает на скобках и кавычках.
Применим к нашим продуктам
В нашей вселенной нет жирных описаний, но имена у нас есть. Сделаем поиск по products.name:
Найди продукты по слову — морфология должна работать:
В наших данных «ноутбук» в имени продукта прямо не встречается — но если бы было «Игровой ноутбук» или «ноутбуки для дизайнеров», такой запрос всё нашёл бы. Попробуй с английским словом:
Поиск по английским словам — laptop, laptops, laptopping — всё сведётся к одной лексеме:
«Framework Laptop 13» найдётся через запрос laptops — английский dictionary привёл оба к laptop.
ts_rank — релевантность
Когда документов много, важно сортировать по «насколько хорошо подходит». ts_rank даёт вещественную оценку — чем больше, тем релевантнее. Алгоритм учитывает частоту совпадений и (опционально) близость к началу документа.
Сортировка по релевантности с использованием ts_rank:
ts_rank относительный — у него нет фиксированного диапазона. Сравнивать абсолютные значения между разными запросами бессмысленно; полезно только внутри одного ORDER BY.
Веса полей: setweight и приоритеты
В реальных продуктах документ — это не один кусок текста, а несколько полей: заголовок, описание, теги, тело. Совпадение в заголовке должно весить больше, чем в теле. Это решается через setweight:
Каждый кусок получает вес A, B, C или D — A самый важный:
ts_rank тогда умножает вклад каждого попадания на вес, и заголовочные совпадения автоматически идут выше. В production-схеме это обычно выглядит как:
ALTER TABLE products
ADD COLUMN search_vec TSVECTOR
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name,'')), 'A') ||
setweight(to_tsvector('english', coalesce(description,'')), 'B')
) STORED;
Подсветка совпадений: ts_headline
Когда нужно показать пользователю кусок документа с подсвеченными совпадениями (как в Google), используется ts_headline. Она возвращает сниппет с обёрнутыми тегами:
Сниппет с обозначенными совпадениями:
Это удобно для поисковых страниц с превью. Цена — ts_headline тяжеловата (она прогоняет текст через парсер заново), поэтому её обычно вызывают только на финальную страницу результатов, не на сотни строк.
Хранить или вычислять
Главный архитектурный выбор FTS: вычислять tsvector каждый раз или хранить его в отдельной колонке.
- Вычислять:
WHERE to_tsvector('english', name) @@ .... Простой, не требует миграции, но без индекса — медленно на больших таблицах. - Хранить: добавить
search_vec TSVECTOR(сGENERATED ALWAYS AS (...) STOREDв современном Postgres) и поставить GIN-индекс. Поиск по миллионам строк — миллисекунды.
Для прод-системы обычно хранят. Для разовых исследований — вычисляют на лету.
ALTER TABLE products
ADD COLUMN search_vec TSVECTOR
GENERATED ALWAYS AS (to_tsvector('english', name)) STORED;
CREATE INDEX products_search_idx ON products USING GIN (search_vec);
Этот паттерн — стандарт в современном Postgres. Колонка пересчитывается СУБД при каждом UPDATE, не нужно поддерживать триггер.
Когда FTS — это перебор
FTS не серебряная пуля:
- Поиск по точному ID, email, артикулу — используй обычный B-tree индекс. FTS свернёт
'LP-001'непредсказуемо, и поиск не сработает. - Префиксный поиск (
prefix%) — обычный B-tree быстрее. Для подстановочного%anywhere%— есть pg_trgm с GIN, который умеет это. - Многоязычный контент с автоопределением языка — FTS требует одного словаря на колонку. Для много-языкового нужна отдельная архитектура.
- Очень специфический поиск (например, по синонимам конкретного домена) — нужен внешний движок: Elasticsearch, Meilisearch, OpenSearch.
Граница: до 10М документов с простой морфологией — FTS Postgres решает почти всё. После — стоит сравнить с внешним движком.
Чек-лист
tsvector— разобранный документ (токены, лемматизированы, без стоп-слов).tsquery— запрос на том же языке; матч через оператор@@.- Для пользовательского ввода предпочитай
websearch_to_tsquery. ts_rankдаёт относительную оценку релевантности, годится только внутри одного запроса.- В проде обычно хранят
tsvectorвGENERATED ALWAYS AS (...) STOREDколонке +GIN-индекс. - FTS делает то, что не умеет
LIKE: морфологию, стоп-слова, ранжирование, индексирование подстрок. - Не пытайся искать через FTS точные идентификаторы, артикулы — это для B-tree.