Руководство по текстовому поиску в Postgres 2026
Инструменты поиска, которые уже есть в вашей базе данных, и когда каждый из них окупается.
Большинство команд используют один инструмент поиска в Postgres. Команды, которые знают все три, выпускают лучший поиск с меньшей сложностью — и избегают дорогого обходного пути к выделенному поисковому сервису, который им ещё не нужен.
Это руководство охватывает полный набор нативных для Postgres вариантов: что делает каждый, когда он подходит, и как их комбинировать.
Три инструмента
Полнотекстовый поиск (tsvector / GIN-индекс) — лексический. Он разбивает текст на лексемы, стеммирует их и сопоставляет запросы с индексом. «Running» и «runs» сводятся к одной лексеме. То же самое с «dog» и «dogs». Функция ранжирования (ts_rank) учитывает частоту появления терминов запроса и их заметность.
Триграммы (pg_trgm) разбивают строки на перекрывающиеся трёхсимвольные срезы и измеряют, сколько срезов две строки имеют общих. «Dan» → " da", "dan", "an ". «Micheal» и «Michael» имеют большинство общих триграмм, поэтому сходство высокое. Это делает pg_trgm отличным инструментом для нечёткого поиска имён, устойчивости к опечаткам и автодополнения — той области, где FTS работает плохо.
Индексы точного совпадения (B-tree, hash) обрабатывают первичные ключи, адреса электронной почты, идентификаторы, артикулы и всё, где ответ бинарный: совпадает или нет. Это не ощущается как «поиск», но относится к разговору, потому что худший паттерн — использовать нечёткий или семантический поиск для задач, у которых есть правильные ответы.
Выбор не о продвинутости. Он о том, чтобы сопоставить инструмент с формой запроса.
Когда побеждает полнотекстовый поиск
Поиск ключевых слов в прозе. Блог-посты, документация, описания продуктов, тикеты поддержки, юридические документы. FTS создан именно для такого типа контента: индексированная, ранжированная выборка по тексту на естественном языке.
Пользовательские запросы на основе ключевых слов. Пользователи вводят поисковый термин, фильтруют по тегу или просматривают по ключевому слову. FTS обрабатывает это намерение нативно, без какой-либо инфраструктуры эмбеддингов.
Ранжированные результаты без внешних зависимостей. Индексы FTS быстры, детерминированы и не требуют вызовов API. Сигнал релевантности исходит из частоты терминов, взвешенной по позиции в поле.
Булева фильтрация вместе с поиском. FTS естественно комбинируется с вашей существующей логикой запросов:
SELECT * FROM postsWHERE search_vector @@ to_tsquery('english', 'postgres & performance') AND category = 'tutorial' AND published_at > NOW() - INTERVAL '6 months';Настройка FTS
-- Генерируемый столбец автоматически поддерживает индекс в актуальном состоянииALTER TABLE posts ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(body, '')), 'B') ) STORED;
CREATE INDEX posts_search_idx ON posts USING GIN (search_vector);
-- ЗапросSELECT title, ts_rank(search_vector, query) AS rankFROM posts, to_tsquery('english', 'postgres & performance') queryWHERE search_vector @@ queryORDER BY rank DESCLIMIT 10;setweight присваивает важность: A (заголовок) ранжируется выше B (тело). Это вся модель релевантности для большинства сценариев поиска по контенту.
Что FTS обрабатывает плохо
- Опечатки в запросах — «javascipt» не совпадёт с «javascript»
- Имена людей, адреса, собственные существительные, которые не стеммируются предсказуемо
- Префиксный поиск / автодополнение без специальной настройки
- Запросы, где пользователь описывает концепцию, а не называет её
Когда побеждают триграммы (pg_trgm)
pg_trgm охватывает неловкую середину, с которой FTS постоянно спотыкается.
FTS разбивает текст на лексемы и стеммирует их. Для прозы это правильно. Для имён и коротких идентификаторов — часто нет:
- Имена людей («Dan Levy» → стеммируется по-разному в зависимости от словаря и конфигурации языка)
- Названия компаний, адреса, названия продуктов, где важно точное написание
- Запросы с опечатками — «Micheal Jordan», «Amaon», «javascipt»
- Автодополнение / префиксный поиск
- Частичное совпадение строк («son» совпадает с «Johnson», «Anderson»)
pg_trgm также не зависит от языка, что важно для имён из разных лингвистических групп. FTS требует настройки словаря для каждого языка.
Нечёткий поиск имён
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX users_name_trgm_idx ON users USING GIN (name gin_trgm_ops);
-- Находит «Micheal Jordan» при поиске «Michael Jordan»SELECT id, name, similarity(name, $1) AS scoreFROM usersWHERE name % $1 -- оператор % = порог сходства (по умолчанию 0.3)ORDER BY score DESCLIMIT 10;Оператор % использует pg_trgm.similarity_threshold (по умолчанию 0.3, диапазон 0–1). Для поиска имён 0.3–0.4 ловит опечатки, сохраняя низкий уровень шума.
Автодополнение, префиксный поиск и поиск по содержимому
-- Префиксное совпадение для автодополнения. Триграммный GIN-индекс может помочь,-- но B-tree индекс по паттерну может быть лучше для чисто лево-якорных префиксов.SELECT name FROM usersWHERE name ILIKE $1 || '%'ORDER BY nameLIMIT 10;
-- word_similarity для частичных совпадений внутри более длинных строк-- («Johnson» внутри «Andrew Johnson III»)SELECT id, name, word_similarity($1, name) AS scoreFROM usersWHERE $1 <% nameORDER BY score DESCLIMIT 10;Триграммный GIN-индекс особенно полезен для запросов на содержание ILIKE '%pattern%' и устойчивого к опечаткам сопоставления — паттернов, которые без триграммного индекса обычно приводят к полному сканированию таблицы.
Когда обращаться к pg_trgm вместо FTS
| Сценарий | Используйте |
|---|---|
| Поиск имён людей/компаний с опечатками | pg_trgm |
| Автодополнение / префиксный поиск | pg_trgm (или FTS с префиксными запросами) |
| Короткие строки, идентификаторы, коды | pg_trgm |
| Проза-статьи, документация, тикеты | FTS |
| Лог-сообщения для ключевых слов | FTS |
| Многоязычный поиск имён | pg_trgm (не зависит от языка) |
Когда побеждает точное совпадение в SQL
Некоторые задачи «поиска» — это вообще не поиск.
«Найти пользователя с email dan@example.com» — это проверка на равенство. «Найти заказ ORD-12345» — это поиск по первичному ключу. «Список постов в категории tutorial, отсортированный по дате» — это фильтрованный запрос. Всё это относится к B-tree или hash-индексам.
Использование FTS или триграмм здесь добавляет сложность без улучшения корректности — а для точных идентификаторов приближённое совпадение хуже, чем его отсутствие.
CREATE INDEX users_email_idx ON users (email);
-- Точный поиск: быстрый и однозначныйSELECT id, name FROM users WHERE email = $1;Более широкий урок: приближённый поиск для задач с правильными ответами — это ошибка категории. Он вернёт что-то — что может быть уверенно неверным.
Комбинирование инструментов
Эти инструменты чисто комбинируются. Не обязательно выбирать ровно один.
FTS + pg_trgm для поисковой строки, устойчивой к опечаткам в ключевых словах:
-- Триграммное сходство по заголовку ловит опечатки; ts_rank обрабатывает релевантность телаSELECT id, title, ts_rank(search_vector, to_tsquery('simple', $1)) AS fts_rank, similarity(title, $1) AS trgm_scoreFROM postsWHERE search_vector @@ to_tsquery('simple', $1) OR title % $1ORDER BY (ts_rank(search_vector, to_tsquery('simple', $1)) + similarity(title, $1)) DESCLIMIT 10;FTS + unaccent для интернационального контента:
-- Убираем диакритические знаки, чтобы «José» совпадал с «Jose»CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE TEXT SEARCH CONFIGURATION public.simple_unaccent (COPY = pg_catalog.simple);
ALTER TEXT SEARCH CONFIGURATION public.simple_unaccent ALTER MAPPING FOR hword, hword_part, word WITH unaccent, simple;
ALTER TABLE posts ADD COLUMN search_vector tsvector;
CREATE TRIGGER posts_search_vector_refreshBEFORE INSERT OR UPDATE OF title, body ON postsFOR EACH ROW EXECUTE FUNCTION tsvector_update_trigger(search_vector, 'public.simple_unaccent', title, body);unaccent + pg_trgm для интернационального поиска имён:
ALTER TABLE users ADD COLUMN name_search text;
CREATE FUNCTION users_name_search_refresh()RETURNS trigger LANGUAGE plpgsql AS $$BEGIN NEW.name_search := unaccent(coalesce(NEW.name, '')); RETURN NEW;END;$$;
CREATE TRIGGER users_name_search_refreshBEFORE INSERT OR UPDATE OF name ON usersFOR EACH ROW EXECUTE FUNCTION users_name_search_refresh();
CREATE INDEX users_name_search_trgm_idx ON users USING GIN (name_search gin_trgm_ops);
SELECT id, nameFROM usersWHERE name_search % unaccent($1)ORDER BY similarity(name_search, unaccent($1)) DESCLIMIT 10;Примеры с триггерами избегают использования unaccent() внутри выражений генерируемых столбцов или индексов, где важны правила неизменяемости PostgreSQL. Если вы обернёте unaccent() в собственную неизменяемую функцию, документируйте, что принимаете риск при обновлении/конфигурации.
Примечательные расширения
pg_trgm поставляется с большинством дистрибутивов Postgres, но требует явного включения. Основа нечёткого строкового поиска в Postgres.
unaccent убирает диакритические знаки перед индексированием и запросами. Хорошо сочетается как с pg_trgm, так и с FTS для контента на европейских языках. Поставляется с Postgres.
pg_bigm расширяет триграммный подход до биграмм (двухсимвольные срезы), что значительно улучшает результаты для языков CJK (китайский, японский, корейский), где pg_trgm работает хуже. Устанавливается отдельно; не входит в комплект.
pg_search (от ParadeDB) заменяет стандартный стек GIN / tsvector на BM25-индекс на базе Tantivy. Это даёт BM25-скоринг (часто лучше, чем ts_rank), нечёткое сопоставление внутри FTS-запросов, фасетный поиск и значительно более быстрое индексирование на больших таблицах. Это путь обновления без переделки, когда стандартный FTS начинает показывать ограничения ранжирования или производительности.
-- pg_search: BM25 полнотекстовый поиск с нечётким сопоставлениемCREATE INDEX posts_bm25_idx ON posts USING bm25 (id, title, body) WITH (key_field = 'id', text_fields = '{"title": {}, "body": {}}');
-- Запрос с BM25-скорингом + нечётким сопоставлением (ловит «javascipt»)SELECT id, title, paradedb.score(id) AS rankFROM postsWHERE posts @@@ paradedb.fuzzy_phrase(field => 'title', value => 'postgres performnce')ORDER BY rank DESCLIMIT 10;pgvector добавляет хранение плотных векторов и поиск по сходству. Это правильный инструмент, когда пользователи описывают то, что хотят, а не называют это — семантический поиск, RAG, рекомендации похожего контента, многоязычные запросы. Подробно рассмотрено в Семантический векторный поиск и гибридные стратегии.
Таблица решений
| Что ищете | Рекомендация |
|---|---|
| Проза-статьи, документация, тикеты | FTS |
| Имена людей/компаний с опечатками | pg_trgm |
| Автодополнение, префиксный поиск | pg_trgm |
| Короткие коды, идентификаторы | pg_trgm |
| Лог-сообщения для ключевых слов | FTS |
| Международные имена | pg_trgm + unaccent |
| Большой контент, лучшее ранжирование | pg_search (ParadeDB BM25) |
| Первичные ключи, точные email, ID | B-tree индекс |
| Даты, диапазоны, отсортированные списки | B-tree индекс |
| Разрешения, категории, фильтры | Обычное предложение WHERE |
| Вопросы, парафразы, концепции | pgvector (см. следующую статью) |
Если сомневаетесь: короткие строки с вариациями написания → триграммы. Длинная проза для запросов по ключевым словам → FTS. Структурированные идентификаторы → обычные индексы. Концептуальные или естественно-языковые запросы → pgvector.
Гибридный поиск: два сигнала, один ранг
Когда запрос вроде "withRetry timeout errors" попадает в поисковую строку, он несёт два вида намерения: точные имена символов, которые пользователь знает (withRetry), и концептуальное описание (timeout errors). Ни один примитив не покрывает оба. Запуск FTS и векторного поиска параллельно — а затем объединение их ранжированных списков через Reciprocal Rank Fusion — делает это.
RRF оценивает каждый результат как 1 / (60 + rank) в каждом списке и суммирует по спискам. Константа 60 смягчает преимущество верхних рангов, так что результат, занявший второе место в обоих списках, может победить результат, выигравший один список и полностью пропустивший другой. Критически важно: RRF никогда не усредняет сырые оценки по методам — ранг FTS и косинусное расстояние — разные валюты и не могут быть объединены арифметически.
-- Гибридный поиск: FTS + pgvector, объединённые через RRFWITH fts AS ( SELECT id, ts_rank(search_vector, query) AS score, ROW_NUMBER() OVER (ORDER BY ts_rank(search_vector, query) DESC) AS rank FROM docs, to_tsquery('english', 'withRetry & timeout') query WHERE search_vector @@ query LIMIT 60),vec AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $embedding) AS rank FROM docs ORDER BY embedding <=> $embedding LIMIT 60)SELECT COALESCE(fts.id, vec.id) AS id, (COALESCE(1.0 / (60 + fts.rank), 0) + COALESCE(1.0 / (60 + vec.rank), 0)) AS rrf_scoreFROM fts FULL JOIN vec ON fts.id = vec.idORDER BY rrf_score DESCLIMIT 10;Пул из 60 документов-кандидатов на ветку (LIMIT 60) — распространённая стартовая точка. Расширьте, если полнота низкая; сузьте для скорости.
Что дальше
Текстовый поиск Postgres покрывает много задач, но у него есть потолок. Когда пользователи описывают то, что хотят, вместо того чтобы называть это — «что-то, чтобы спать в самолёте», «статьи об уверенности в отладке для нового инженера» — и лексический, и триграммный поиск оба проигрывают.
Это территория векторных эмбеддингов, семантического поиска и гибридных архитектур. Рассмотрено в Семантический векторный поиск и гибридные стратегии.