Когда нужен поиск по тексту, первый порыв — поставить Elasticsearch. Но PostgreSQL умеет искать по тексту из коробки, без отдельного сервиса. Разберём, как это работает и когда этого достаточно.
База хранит не сам текст, а список корней: «Покупатели» и «покупателя» сводятся к одному корню покупател. 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 от большего к меньшему.
Слово встретилось по одному разу в обоих документах, но 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 хранит точный список документов на каждое слово: он большой и обновляется медленнее, зато ищет быстрее и без ложных срабатываний. Отсюда всё, что в таблице:
| GIN | GiST | |
|---|---|---|
| Размер на диске | больше | меньше |
| Скорость поиска | быстрее | медленнее |
| Скорость обновления | медленнее | быстрее |
| Ложные срабатывания | нет | есть (проверяет повторно) |
В подавляющем большинстве случаев выбирают 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. Это правило большого пальца, а не константа: на своих данных границу лучше померить.
Длинные поля отдают полнотекстовому поиску, короткие с опечатками триграммному индексу; ориентир границы примерно сотня символов.
Глубже: Пагинация через 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.
Что почитать дальше
- Веса в полнотекстовом поиске (setweight, ts_rank) — как ранжирование считается на самом деле.
- Поиск: PostgreSQL FTS или Elasticsearch — детальное сравнение, по каким критериям выбирать.
- Типы индексов в PostgreSQL — GIN, GiST и другие в деталях.
- JSONB в PostgreSQL — GIN-индекс работает и на jsonb.