← назад к разделу

Когда нужен поиск по тексту, первый порыв — поставить Elasticsearch. Но PostgreSQL умеет искать по тексту из коробки, без отдельного сервиса. Разберём, как это работает и когда этого достаточно.

документ 1 Покупатели выбирают товары to_tsvector('russian', …) покупател выбира товар что ввёл человек покупателя to_tsquery('russian', …) покупател GIN-индекс: корень → номера документов выбира → 1 каталог → 3 покупател → 1, 2 товар → 1, 2 что вернёт поиск Покупатели выбирают товарыпокупателвыбиратовар выбира→ 1покупател→ 1, 2товар→ 1, 2 покупателяпокупател документы 1 и 2, ранг считает ts_rank

База хранит не сам текст, а список корней: «Покупатели» и «покупателя» сводятся к одному корню покупател. GIN-индекс держит для каждого корня номера документов, где он встретился, поэтому поиск не перебирает строки таблицы, а сразу читает готовый список — и разница в окончании перестаёт мешать.

Обязательно

Почему простой LIKE не работает

Самый очевидный способ — WHERE body LIKE '%покупатель%'. У него два недостатка.

Первый — скорость. При поиске с % в начале PostgreSQL не может использовать обычный индекс и вынужден перебирать все строки. На тысяче записей это незаметно, на миллионе — катастрофа.

Второй — грамматика. Запрос LIKE '%покупатель%' не найдёт строку «покупателя выбирают товары», потому что там другое окончание. Пользователи пишут слова в разных формах, а LIKE это не учитывает.

Полнотекстовый поиск решает обе проблемы: он работает по индексу и понимает стемминг — сведение слов к корню.

tsvector и tsquery — два ключевых типа

PostgreSQL хранит обработанный текст в специальном типе tsvector. Это не просто текст, а список лексем — слов, приведённых к корневой форме с указанием позиций.

живой пример

SELECT to_tsvector('russian', 'Покупатели выбирают товары в каталоге');
-- 'выбира':2 'каталог':5 'покупател':1 'товар':3
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

Поисковый запрос хранится в типе tsquery:

живой пример

SELECT to_tsquery('russian', 'покупатель & каталог');
-- 'покупател' & 'каталог'
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Проверка совпадения — оператор @@:

живой пример

SELECT to_tsvector('russian', 'Покупатели выбирают товары в каталоге')
    @@ to_tsquery('russian', 'покупатель & каталог');
-- true
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

живой пример

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class SearchIndexDemo {

    private static final List<String> ENDINGS =
            List.of("ями", "ами", "ов", "ах", "ые", "ый", "и", "ы", "я", "ю", "е", "а", "у");

    static String stem(String word) {
        String w = word.toLowerCase().replaceAll("[^а-яё]", "");
        for (String end : ENDINGS) {
            if (w.endsWith(end) && w.length() - end.length() >= 5) {
                return w.substring(0, w.length() - end.length());
            }
        }
        return w;
    }

    public static void main(String[] args) {
        List<String> docs = List.of(
                "Покупатели выбрали товары в каталоге",
                "Покупателю вернули деньги за товар",
                "Каталог обновляется ночью");

        Map<String, List<Integer>> index = new LinkedHashMap<>();
        for (int id = 1; id <= docs.size(); id++) {
            for (String word : docs.get(id - 1).split(" ")) {
                index.computeIfAbsent(stem(word), k -> new ArrayList<>()).add(id);
            }
        }

        String query = "покупателя";
        long like = docs.stream().filter(d -> d.toLowerCase().contains(query)).count();
        System.out.println("LIKE '%" + query + "%' нашёл документов: " + like);

        String lexeme = stem(query);
        System.out.println("корень запроса: " + lexeme);
        System.out.println("в индексе: " + lexeme + " -> " + index.get(lexeme));
        for (int id : index.getOrDefault(lexeme, List.of())) {
            System.out.println("  найдено: " + docs.get(id - 1));
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

LIKE не находит ничего, поиск по корню — два документа из трёх. В PostgreSQL эту карту держит GIN-индекс, а роль stem играют словари конфигурации.

Как искать

Базовый запрос с ранжированием:

живой пример

SELECT id, title, ts_rank(search, q) AS rank
FROM products, to_tsquery('russian', 'наушники & беспроводной') q
WHERE search @@ q
ORDER BY rank DESC
LIMIT 20;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

ts_rank возвращает число — чем выше, тем лучше совпадение. При одинаковом количестве совпадений выше поднимаются документы, где слова встретились в полях с высоким весом (A > B > C > D).

plainto_tsquery — для пользовательского ввода

to_tsquery требует правильного синтаксиса с операторами &, |, !. Если туда попадёт произвольный ввод пользователя, запрос может упасть с ошибкой.

Для пользовательского ввода используйте plainto_tsquery — он интерпретирует слова через AND без специального синтаксиса:

живой пример

SELECT plainto_tsquery('russian', 'купить новый товар');
-- 'куп' & 'нов' & 'товар'
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

websearch_to_tsquery — Google-стиль

Доступен с PostgreSQL 11. Понимает минус для исключения слов и кавычки для фразового поиска:

живой пример

SELECT websearch_to_tsquery('russian', 'покупатель -ребёнок "новый каталог"');
-- 'покупател' & !'ребенок' & 'нов' <-> 'каталог'
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Минус стал отрицанием !, кавычки — оператором <->: слова должны стоять рядом в этом порядке.

Поиск по префиксу: подсказки при вводе

Пользователь набрал «покупат» — и ждёт подсказок, не дожидаясь конца слова. Для этого в tsquery есть звёздочка:

живой пример

SELECT title FROM products
WHERE search @@ to_tsquery('russian', 'кофемол:*');
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Двоеточие со звёздочкой означает «слово начинается на это». Работает оно по индексу — GIN умеет искать по префиксу лексемы, — и именно так делают подсказки при вводе штатными средствами полнотекстового поиска, без pg_trgm.

Две оговорки. Во-первых, префикс сравнивается уже с основой слова, а не с тем, что набрал человек: «кофемолки» превратится в основу «кофемолк», и префикс «кофемоло» ничего не найдёт. Во-вторых, для последнего слова запроса звёздочку ставят программно: удобнее собирать запрос через websearch_to_tsquery для полных слов и дописывать префикс к последнему токену вручную.

Стоп-слова: почему «в» пропало

Конфигурация выбрасывает из вектора служебные слова — предлоги, союзы, частицы. Это экономит место и обычно правильно: искать «в» бессмысленно. Но два следствия удивляют.

Первое: запрос, состоящий только из стоп-слов, превращается в пустой tsquery и не находит ничего. to_tsquery('russian', 'и') даёт пустоту, а пустой запрос не совпадает ни с чем — пользователь видит «ничего не найдено» вместо «уточните запрос». Ловят это в коде: если numnode(query) = 0, значит запрос выродился, и показывать надо подсказку, а не пустой список.

живой пример

SELECT numnode(websearch_to_tsquery('russian', 'и в на'));  -- 0
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Второе: фраза со стоп-словом внутри («война и мир») ищется как две лексемы подряд с пропуском, и точность падает. Для таких случаев есть операторы расстояния (<->), но проще принять, что точный поиск фразы — задача не для стоп-словной конфигурации.

Ранжирование: почему длинный документ обгоняет короткий

ts_rank без третьего аргумента считает «сырой» вес: чем больше совпадений, тем выше ранг — и документ на десять страниц с десятью упоминаниями обгоняет заголовок, где слово стоит один раз и по делу. Чинится это нормализацией по длине: третий аргумент — битовая маска, и обычно берут 32 («ранг делится на ранг плюс единица», то есть приводится к диапазону от нуля до единицы) или 2 (делить на длину документа).

живой пример

SELECT title, ts_rank(search, query, 32) AS rank
FROM products, websearch_to_tsquery('russian', 'кофемолка') AS query
WHERE search @@ query
ORDER BY rank DESC
LIMIT 20;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Рядом живёт ts_rank_cd — он учитывает не только частоту, но и близость найденных слов друг к другу: для запросов из нескольких слов он обычно даёт более осмысленный порядок, потому что документ, где слова стоят рядом, релевантнее документа, где они разбросаны по разным абзацам.

И главное ограничение, которое надо знать заранее: индекса по релевантности не существует. ORDER BY ts_rank(...) вычисляется для всех подошедших строк, и на выдаче в миллион строк сортировка съедает всё время. Обходят это двумя способами: сначала сужают выборку (по категории, по дате, по порогу совпадения) и только потом ранжируют; или берут расширение rum — индекс, который хранит позиции слов и умеет отдавать результаты уже упорядоченными по рангу, что делает ORDER BY rank LIMIT 20 дешёвым. Цена rum — индекс заметно больше GIN и медленнее на запись, поэтому его ставят под конкретную задачу ранжированного поиска, а не по умолчанию.

«ё», диакритика и несколько языков

Три практических вопроса, на которые рано или поздно натыкается любой русский поиск.

Буква «ё». Словарь русского языка в PostgreSQL приводит «ё» к «е» в основе, поэтому «ёлка» и «елка» обычно совпадают. Но если текст пришёл из внешнего источника с другой раскладкой или хранит редкие формы, проверить это стоит явным to_tsvector('russian', 'ёлка') — увидите основу своими глазами.

Диакритика. Для латиницы с надстрочными знаками (café, naïve) та же задача решается расширением unaccent: оно снимает знаки, и в вектор попадает «cafe». Ставится оно в конвейер обработки — отдельной конфигурацией, где unaccent применяется перед словарём.

Несколько языков в одной колонке. Самый частый реальный случай: каталог, где часть названий русские, часть английские, а колонка одна с конфигурацией russian. Английские слова при этом не приводятся к основе — «phones» и «phone» останутся разными лексемами. Три выхода: держать две колонки-вектора (search_ru, search_en) и искать в обеих через OR; хранить язык строки и строить вектор конфигурацией по этой колонке (тогда индекс один, а вектор строится выражением); или взять простую конфигурацию simple без словаря — она ничего не приводит к основе, зато одинаково работает с любым языком, и тогда поиск становится «по точным словоформам».

Конфигурация стемминга

PostgreSQL поставляется с несколькими конфигурациями полнотекстового поиска:

живой пример

SELECT cfgname FROM pg_ts_config;
-- simple, english, russian, german, ...
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

  • simple — только перевод в нижний регистр, стемминг не применяется. Полезен для кодов, идентификаторов, артикулов.
  • russian — стемминг по русскому языку. «Покупатель», «покупателя», «покупателям» — один токен, поиск находит все формы.

Для русскоязычного контента почти всегда нужна конфигурация russian.

Синонимы

Если нужно, чтобы поиск по «postgres» находил и «postgresql», и «pg», можно создать словарь синонимов:

CREATE TEXT SEARCH DICTIONARY my_synonyms (
    template = synonym,
    synonyms = 'my_synonyms'
);

CREATE TEXT SEARCH CONFIGURATION ru_extended (COPY = russian);
ALTER TEXT SEARCH CONFIGURATION ru_extended
    ALTER MAPPING FOR word, asciiword
    WITH my_synonyms, russian_stem;

В файле $SHAREDIR/tsearch_data/my_synonyms.syn:

postgresql postgres
pg postgres

Формат простой: в строке ровно два слова — что встретили и чем заменить. Здесь и postgresql, и pg приводятся к одному слову postgres, поэтому запрос по любому из трёх написаний найдёт все три.

В управляемой базе, облачном PostgreSQL, доступа к $SHAREDIR нет, и файл словаря туда не положить: там синонимы раскрывают на стороне приложения, расширяя сам запрос.

Когда PostgreSQL FTS достаточно, а когда нужен Elasticsearch

PostgreSQL FTS хорошо справляется с задачами поиска по статьям, товарам, комментариям, тикетам — при объёме до 10 миллионов документов и нагрузке до 100 запросов в секунду.

Elasticsearch стоит рассматривать, если нужны:

  • объёмы значительно больше 10 миллионов документов со сложным ранжированием,
  • автоматическое определение языка в многоязычном контенте,
  • фасеты, агрегации, аналитика по результатам поиска,
  • сложная толерантность к опечаткам.

Откуда берутся эти цифры, полезно понимать, чтобы не принимать их за закон. Первым упирается не поиск, а ранжирование: @@ по GIN отвечает быстро почти всегда, а ORDER BY ts_rank(...) вычисляется для каждой подошедшей строки, поэтому запрос по частому слову с сортировкой по релевантности — самая дорогая операция в этой схеме. Второй предел — размер GIN и память под него: когда индекс перестаёт помещаться в кеш, каждое обращение начинает читать диск. Третий — то, чего в PostgreSQL нет вовсе: фасетов (сводки «по брендам столько-то, по категориям столько-то») в одном запросе с поиском, синонимов и опечаток из коробки, готового распределённого шардирования. Как только нужны они, а не просто «найти по словам», разговор переходит к отдельной поисковой системе.

Дополнительно: при первом чтении можно пропустить

Глубже: Как хранить tsvector в таблицерасширенное

Вычислять to_tsvector() при каждом запросе — медленно и без индекса. Правильный путь — хранить вектор отдельно и индексировать его.

Вычисляемая колонка (PostgreSQL 12+)

CREATE TABLE products (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title       text NOT NULL,
    body        text NOT NULL,
    search  tsvector GENERATED ALWAYS AS (
        setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('russian', coalesce(body, '')), 'B')
    ) STORED
);

CREATE INDEX ix_products_search ON products USING gin (search);

GENERATED ALWAYS AS ... STORED означает, что PostgreSQL сам пересчитывает колонку при каждом INSERT и UPDATE. Делать это руками не нужно.

Одна деталь в примере не косметическая. У to_tsvector два варианта, и в вычисляемой колонке работает только двухаргументный — с явно названной конфигурацией 'russian'. Вариант из одного аргумента берёт конфигурацию из настройки default_text_search_config, а её можно поменять прямо на ходу; значит, на одном и том же тексте функция способна вернуть разный результат. В вычисляемое выражение PostgreSQL пускает только то, что всегда даёт один и тот же ответ, поэтому CREATE TABLE с одноаргументной формой не выполнится вовсе: generation expression is not immutable. На эту граблю наступают, повторяя пример по памяти.

setweight('A') помечает токены из заголовка как более важные — это влияет на ранжирование. Доступны веса A, B, C, D от большего к меньшему.

заголовок вес A множитель 1,0 выше в выдаче тело вес B множитель 0,4 ниже в выдаче

Слово встретилось по одному разу в обоих документах, но setweight пометил заголовок весом A, а тело весом B, и ts_rank умножает совпадение на 1,0 против 0,4.

Триггер (PostgreSQL до 12)

CREATE TRIGGER products_search_update
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE PROCEDURE
tsvector_update_trigger(search, 'pg_catalog.russian', title, body);

Здесь намеренно EXECUTE PROCEDURE, а не привычное EXECUTE FUNCTION: короткое написание появилось только в PostgreSQL 11, а раздел — как раз про версии постарше. С 11-й работают оба и означают одно и то же.

Глубже: GIN против GiSTрасширенное

Для полнотекстового поиска подходят два типа индексов, и различие у них одно. GiST хранит не список слов документа, а короткую сигнатуру: он маленький и быстро обновляется, но может ошибиться и вынужден перепроверять найденное по таблице. GIN хранит точный список документов на каждое слово: он большой и обновляется медленнее, зато ищет быстрее и без ложных срабатываний. Отсюда всё, что в таблице:

GINGiST
Размер на дискебольшеменьше
Скорость поискабыстреемедленнее
Скорость обновлениямедленнеебыстрее
Ложные срабатываниянетесть (проверяет повторно)

В подавляющем большинстве случаев выбирают GIN — он быстрее при поиске, а это обычно важнее.

GIN накапливает изменения в буфере и сбрасывает их пачкой (fastupdate = on по умолчанию). Для систем с редкими вставками и частым чтением буфер можно отключить:

ALTER INDEX ix_products_search SET (fastupdate = off);

Размер и обслуживание GIN стоит держать в голове с самого начала. Индекс по полнотекстовому вектору обычно составляет от четверти до половины размера самой колонки с текстом, а на каталоге с длинными описаниями бывает и больше. Запись он замедляет заметно: каждая вставка разбирает документ на лексемы и обновляет список ссылок по каждой из них, поэтому массовая загрузка в таблицу с GIN идёт в разы медленнее — и это тот случай, когда индекс создают после загрузки. У GIN есть буфер отложенных изменений (fastupdate), который переносит цену на случайную вставку: в интерактивном пути его обычно выключают, подробнее — в статье про типы индексов.

Перестраивают такой индекс REINDEX INDEX CONCURRENTLY, и повод к этому — рост размера без роста данных. Отдельно помните, что индекс по выражению (to_tsvector('russian', title) прямо в индексе) пересчитывает вектор при каждой записи и не даёт хранить его готовым; вариант с колонкой, о котором ниже, обычно и дешевле, и понятнее.

Глубже: Подсветка совпаденийрасширенное

ts_headline выделяет найденные слова прямо в тексте:

SELECT
    id,
    title,
    ts_headline('russian', body, q,
        'StartSel=<mark>, StopSel=</mark>, MaxFragments=2, MaxWords=20')
        AS snippet
FROM products, websearch_to_tsquery('russian', 'наушники') q
WHERE search @@ q
ORDER BY ts_rank(search, q) DESC
LIMIT 20;

MaxFragments=2 — показывать не более двух фрагментов, MaxWords=20 — длина каждого фрагмента в словах.

Глубже: pg_trgm — нечёткий поиск и подстрокирасширенное

Полнотекстовый поиск не поможет, если нужно:

  • найти товар по части артикула (LIKE '%ABC%'),
  • или исправить опечатку в имени (например, «Иванв» вместо «Иванов»).

Для этого есть расширение pg_trgm. Оно разбивает строку на трёхбуквенные группы (триграммы) и строит по ним индекс:

CREATE EXTENSION pg_trgm;

CREATE INDEX ix_customer_name_trgm
    ON customer USING gin (full_name gin_trgm_ops);

-- поиск по подстроке с индексом
SELECT * FROM customer WHERE full_name ILIKE '%иван%';

-- поиск с допуском на опечатки: % берёт порог из pg_trgm.similarity_threshold
SELECT * FROM customer
WHERE full_name % 'иванв'
ORDER BY similarity(full_name, 'иванв') DESC
LIMIT 10;

Тонкость: индекс работает с оператором %, а не с функцией. Фильтр WHERE similarity(full_name, 'иванв') > 0.4 посчитается для каждой строки таблицы — функция годится для сортировки, отбор оставляйте оператору.

Хорошее сочетание: FTS для длинного текста (тело статьи, описание), pg_trgm для коротких полей с опечатками (имена, бренды, коды). Граница между ними размытая, но ориентир такой: чем длиннее поле, тем хуже работает pg_trgm. Индекс растёт линейно по длине текста, а толку от него всё меньше — в длинной строке найдётся почти любая тройка символов, так что отсеять по индексу удаётся всё меньше строк. Где-то на сотне символов выигрыш обычно и заканчивается, и дальше выгоднее FTS. Это правило большого пальца, а не константа: на своих данных границу лучше померить.

FTS тело статьи описание товара комментарий pg_trgm имя бренд артикул

Длинные поля отдают полнотекстовому поиску, короткие с опечатками триграммному индексу; ориентир границы примерно сотня символов.

Глубже: Пагинация через keysetрасширенное

OFFSET + LIMIT работает медленно на глубоких страницах: база отбирает совпадения, считает для каждого релевантность, сортирует их все — и потом выбрасывает первые несколько сотен, чтобы отдать вам двадцатую страницу.

Keyset здесь помогает меньше, чем в обычных запросах, но помогает. Индекса по релевантности не существует, она вычисляется на лету, поэтому считать и сортировать всё равно придётся всё. Выигрыш в другом: база не тащит через себя отброшенные строки и не собирает их в память, и на глубоких страницах это заметно. Радикально проблему решает только ограничение глубины: не давать уходить дальше нескольких страниц, как это делают поисковики. Итог: keyset применять, глубину ограничивать.

Сама пагинация идёт по паре (rank, id):

SELECT id, title, ts_rank(search, q) AS rank
FROM products, websearch_to_tsquery('russian', 'коврик') q
WHERE search @@ q
  AND (ts_rank(search, q), id) < ($1, $2)
ORDER BY rank DESC, id DESC
LIMIT 20;

$1 и $2 — релевантность и id последней строки предыдущей страницы. Типы тут не формальность: id объявлен как bigint, а ts_rank возвращает real, так что подставить во второй параметр строку вроде 'prod-99' не выйдет — запрос просто не выполнится. Пара (rank, id) сравнивается целиком, и id в ней не для красоты: релевантность у десятков документов совпадает до последнего знака, и именно id решает, какая из них идёт раньше. Ровно тот же порядок стоит в ORDER BY — иначе страницы поедут.

Коротко

  • PostgreSQL FTS работает по индексу и понимает грамматику — в отличие от LIKE.
  • tsvector — индексируемое представление текста, tsquery — поисковый запрос, @@ — оператор совпадения; конфигурация russian сводит формы слова к одному корню.
  • Вектор хранят в вычисляемой колонке (GENERATED ALWAYS AS ... STORED) и индексируют через GIN, а setweight делает заголовок важнее тела.
  • Пользовательский ввод разбирают plainto_tsquery или websearch_to_tsquery, а не to_tsquery: он падает на спецсимволах.
  • ts_headline подсвечивает совпадения, pg_trgm добирает опечатки и подстроки на коротких полях.
  • До ~10M документов и ~100 запросов в секунду хватает PostgreSQL; дальше смотрят в сторону Elasticsearch.
  • Подсказки при вводе делает префиксный запрос to_tsquery('russian', 'кофемол:*'), но сравнивается он с основой слова, а не с набранным текстом.
  • Запрос из одних стоп-слов даёт пустой tsquery и ноль строк — это ловят в коде через numnode(...) = 0.
  • ts_rank без нормализации завышает длинные документы (третий аргумент 32 или 2, для нескольких слов — ts_rank_cd); индекса по релевантности нет, его заменяют сужением выборки или расширением rum.
  • Для латиницы с надстрочными знаками нужен unaccent, а каталог на двух языках требует двух векторов, вектора по колонке языка или конфигурации simple.

Что почитать дальше