Learning Platform
Урок 11.05 · 18 мин
Средний
full-text searchtsvectortsqueryts_rankILIKE

В реальных приложениях рано или поздно появляется задача: «дайте поиск по описаниям товаров / статьям / постам — чтобы по слову ноутбук находилось всё, где это слово встречается». Первая мысль — 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 — это результат лексической обработки текста: документ разбит на токены, токены приведены к канонической форме (лемматизированы), стоп-слова отброшены.

Посмотри, во что превращается обычный текст:

PostgreSQL

Видишь? ноутбук и ноутбуки свернулись в одну лексему (ноутбук), числа в конце — это позиции этой лексемы в исходном тексте. Стоп-слова (a, are, the, и подобные) выкинуты. В английской версии laptop и laptops тоже стали одним токеном.

Пайплайн to_tsvector

Текст проходит через цепочку преобразований и становится разреженным индексом «лексема → позиции».

raw«Бесплатные ноутбуки»
parserтокенизация
dictionaryлемматизация + стоп-словаСловарь зависит от языка: russian, english, и т.д.
tsvector'бесплатн':1 'ноутбук':2

tsquery — запрос к tsvector

tsquery — это запрос на том же языке: лексема, возможно с булевыми связками (&, |, !).

tsvector @@ tsquery — оператор match:

PostgreSQL

Обрати внимание на средний случай: tsvector содержит лексему ноутбук, tsquery про ноутбукам тоже сворачивается в ноутбук — match есть. Это и есть «морфологический поиск», который LIKE дать не может.

Plainto, phraseto и websearch

to_tsquery требует «правильного» синтаксиса с амперсандами и кавычками — это удобно для машинного запроса, но не для ввода пользователя. Для пользовательского ввода есть три удобных функции:

  • plainto_tsquery — превращает простой текст в запрос с & между словами.
  • phraseto_tsquery — то же, но с порядком слов (фразовый поиск).
  • websearch_to_tsquery — синтаксис, похожий на Google: кавычки, минус для исключения.

Сравни, как три функции превратят один и тот же пользовательский ввод:

PostgreSQL

В проде с пользовательским вводом обычно используют websearch_to_tsquery — это самое толерантное к синтаксису, не падает на скобках и кавычках.

Применим к нашим продуктам

В нашей вселенной нет жирных описаний, но имена у нас есть. Сделаем поиск по products.name:

Найди продукты по слову — морфология должна работать:

PostgreSQL

В наших данных «ноутбук» в имени продукта прямо не встречается — но если бы было «Игровой ноутбук» или «ноутбуки для дизайнеров», такой запрос всё нашёл бы. Попробуй с английским словом:

Поиск по английским словам — laptop, laptops, laptopping — всё сведётся к одной лексеме:

PostgreSQL

«Framework Laptop 13» найдётся через запрос laptops — английский dictionary привёл оба к laptop.

ts_rank — релевантность

Когда документов много, важно сортировать по «насколько хорошо подходит». ts_rank даёт вещественную оценку — чем больше, тем релевантнее. Алгоритм учитывает частоту совпадений и (опционально) близость к началу документа.

Сортировка по релевантности с использованием ts_rank:

PostgreSQL

ts_rank относительный — у него нет фиксированного диапазона. Сравнивать абсолютные значения между разными запросами бессмысленно; полезно только внутри одного ORDER BY.

Веса полей: setweight и приоритеты

В реальных продуктах документ — это не один кусок текста, а несколько полей: заголовок, описание, теги, тело. Совпадение в заголовке должно весить больше, чем в теле. Это решается через setweight:

Каждый кусок получает вес A, B, C или D — A самый важный:

PostgreSQL

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. Она возвращает сниппет с обёрнутыми тегами:

Сниппет с обозначенными совпадениями:

PostgreSQL

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

Хранить или вычислять

Главный архитектурный выбор FTS: вычислять tsvector каждый раз или хранить его в отдельной колонке.

  • Вычислять: WHERE to_tsvector('english', name) @@ .... Простой, не требует миграции, но без индекса — медленно на больших таблицах.
  • Хранить: добавить search_vec TSVECTORGENERATED 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 решает почти всё. После — стоит сравнить с внешним движком.

Проверка знанийKnowledge check
У вас в таблице articles 5М строк, колонка body TEXT. Сейчас поиск идёт через body ILIKE '%ключевое слово%'. Запросы стали медленными. С чего начать — переход на FTS, индекс pg_trgm, внешний движок?
ОтветAnswer
Начать всегда с самого простого: добавить GENERATED колонку search_vec TSVECTOR из body и GIN-индекс. Это даст морфологический поиск + ранжирование + миллисекунды на запросе. Если этого недостаточно (например, нужны фасеты, суггесты, нечёткий поиск с опечатками) — рассмотреть pg_trgm для нечёткого поиска как дополнение, либо внешний движок типа Elasticsearch. Но на 5М строк с обычным текстовым поиском Postgres FTS почти всегда хватает, и это сильно дешевле в эксплуатации, чем отдельный сервис.
GIN-индекс: inverted index для FTS, JSONB и arrays

Чек-лист

  • tsvector — разобранный документ (токены, лемматизированы, без стоп-слов).
  • tsquery — запрос на том же языке; матч через оператор @@.
  • Для пользовательского ввода предпочитай websearch_to_tsquery.
  • ts_rank даёт относительную оценку релевантности, годится только внутри одного запроса.
  • В проде обычно хранят tsvector в GENERATED ALWAYS AS (...) STORED колонке + GIN-индекс.
  • FTS делает то, что не умеет LIKE: морфологию, стоп-слова, ранжирование, индексирование подстрок.
  • Не пытайся искать через FTS точные идентификаторы, артикулы — это для B-tree.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Какое утверждение про tsvector корректно?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7