Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Полнотекстовый поиск в Postgres: tsvector и pg_trgm

Поиск по тексту в PostgreSQL: tsvector, pg_trgm и когда нужен внешний движок

Полнотекстовый поиск средствами Postgres: tsvector/tsquery с весами и ранжированием, триграммный pg_trgm для нечёткого поиска и LIKE, индексы GIN и границы применимости.

Прямой ответ

Полнотекстовый поиск в Postgres — встроенный механизм, которого хватает большинству продуктовых задач без Elasticsearch: текст приводится к tsvector (нормализованные лексемы: «бегу/бежал/бежит» → «бежа», стоп-слова отброшены), запрос — к tsquery, сопоставление индексируется GIN (индексы), а ранжирование (ts_rank_cd) учитывает частоту и близость. Базовая схема: генерируемая колонка search_vector tsvector GENERATED ALWAYS AS (to_tsvector('russian', coalesce(title,'') || ' ' || coalesce(body,''))) STORED + GIN-индекс — поиск WHERE search_vector @@ to_tsquery('russian', 'ремонт & ноутбук'). Второй инструмент — pg_trgm (триграммы): нечёткий поиск (similarity(), ILIKE '%опечатка%' с индексом) и «поиск как в адресной строке», где морфология не нужна. Граница, за которой смотрят на внешние движки: десятки миллионов документов, кастомные анализаторы, фасеты и агрегации по результатам, сложные релевантности; но до этого рубежа «Postgres + два индекса» дешевле в эксплуатации на порядок — и данные не надо синхронизировать во внешнюю систему. Для смыслового поиска («похожее по значению») — не лексемы, а эмбеддинги (pgvector), часто в связке: лексика + вектор (гибрид).

tsvector/tsquery: механика и схема данных

Как это работает: to_tsvector('russian', 'Ремонт ноутбуков в Москве') → 'москв':4 ноутбук:2 ремонт:1 — слова приведены к нормальным формам с позициями (конфигурация russian знает склонения и стоп-слова; для английского — english, мультиязычные данные — simple без морфологии или отдельная колонка на язык). Запрос to_tsquery('russian', 'ремонт & ноутбук') — булевы операторы & (и), | (или), ! (не); пользовательский ввод безопаснее подавать через plainto_tsquery (обычная фраза → AND-лексемы) или websearch_to_tsquery (синтаксис поисковой строки: кавычки, minus) — сырой to_tsquery с пользовательской строкой ломается на спецсимволах (!, & в вводе — синтаксическая ошибка запроса). Хранение: отдельная tsvector-колонка (generated STORED — считается при записи, не на каждый запрос) + GIN-индекс; ранжирование — ORDER BY ts_rank_cd(search_vector, query) DESC (плотность совпадений) + ts_headline для подсветки фрагментов в выдаче. Веса повышают точность: setweight(to_tsvector('russian', title), 'A') || setweight(to_tsvector('russian', body), 'B') — совпадение в заголовке ранжируется выше, чем в тексте (веса A–D учитываются в ts_rank).

pg_trgm: нечёткий поиск и LIKE с индексом

Триграммы — разбивка строки на трёхсимвольные фрагменты («кот» → « к», « ко», «кот», «от »); близость строк — доля общих триграмм, отсюда две возможности. Нечёткий поиск: ORDER BY similarity(name, 'ноутбук') DESC LIMIT 10 находит «нотбук»/«ноутбуки» (опечатки, словоформы без морфологии) — GIN-индекс USING gin (name gin_trgm_ops) ускоряет и similarity, и LIKE. LIKE/ILIKE с произвольной подстрокой: обычный b-tree бессилен для '%процессор%' (нужен префикс), триграммный индекс обслуживает LIKE '%процессор%' и ILIKE — поиск в адресной строке/фильтрах админки становится индексным (порог: pg_trgm.similarity_threshold для %-оператора). Подготовка: CREATE EXTENSION pg_trgm; (contrib — есть во всех дистрибутивах). Практика выбора: морфологический поиск по статьям/описаниям — tsvector; «начинается с/содержит» по именам, артикулам, email — pg_trgm; оба на одной таблице — нормально (два GIN-индекса на разные сценарии поиска). Цена: GIN медленнее при массовой записи (fastupdate сглаживает, но vacuum важен) и не помогает коротким строкам (<3 символов — триграмм мало, селективность низкая).

Ранжирование, гибриды и границы Postgres-поиска

Простое ранжирование из коробки — ts_rank_cd (частота/близость/веса) — для каталога/доков достаточно; продуктовые улучшения без внешних систем: свои множители весов, учёт свежести (rank * exp(-age)), фильтрация до ранжирования (категория/наличие — частичные индексы). Гибрид с векторным: лексический поиск точен для терминов («ORA-12154»), векторный — для смысла («не подключается база») — сумма/RRF двух выдач в одном SQL (tsvector + pgvector в одной БД — RAG-пайплайн использует именно гибрид). Когда Postgres тесен: >50–100M документов с частыми обновлениями (GIN-запись становится узким местом), требования фасетов/мгновенных агрегаций по выдаче (Elasticsearch-территория), кастомные токенизаторы/синонимы сложной формы, поиск «по клику» с персонализацией; но переезд на внешний движок — это ETL-синхронизация, отдельный кластер и его отказоустойчивость — решение по факту упора, не заранее. Промежуточная ступень — вынести тяжёлый поиск на реплику (поисковые запросы не мешают OLTP).

Практический сценарий: поиск по базе знаний поддержки

Задача: 40k статей внутренней базы знаний, поиск с опечатками и морфологией, ответ < 100 мс. Реализация: (1) колонка search_vector — generated из title (вес A) + body (вес B), конфигурация russian; GIN-индекс; (2) поиск: websearch_to_tsquery на ввод пользователя (кавычки-фразы и минус-слова работают «как в Google» из коробки) || plainto_tsquery fallback; (3) выдача: ts_rank_cd + ts_headline подсветка, фильтр по разделу до ранжирования; (4) тест на реальных запросах: «не грузится виндовс» — морфология ловит «грузится/загрузка», но «виндовс» vs «windows» — словарь не знает: добавлен словарь синонимов (thesaurus-конфиг Postgres: виндовс → windows) — типовой длинный хвост поисковых задач; (5) опечатки: трек «поиск ничего не нашёл» показывает similarity(title, ввод)-подсказки («возможно, вы имели в виду») через pg_trgm; (6) метрики: доля нулевых результатов (главная поисковая метрика — дашборд) упала с 22% до 7% после синонимов и подсказок. Время на всё — три дня; внешний поисковый движок не понадобился: 40k документов, p95 = 35 мс на GIN.

FAQ

Как искать по нескольким языкам в одной таблице? Варианты: отдельная tsvector-колонка на язык + выбор по языку документа (чистая морфология каждого); simple-конфигурация для всех (без приведения форм — ищет точные лексемы, работает сносно для коротких запросов); мультиязычный поиск через pg_trgm (морфологии нет вовсе, зато опечатки); идеала нет — выбирают по доле языков и требованиям.

Почему поиск «всего текста» медленный без индекса и куда GIN не поможет? LIKE '%x%' без триграммного индекса — Seq Scan всегда; GIN(tsvector) не обслуживает LIKE вовсе (разные механизмы) — для подстрок нужен именно gin_trgm_ops; и оба не помогут, если строка короче триграмм или предикат обёрнут в функцию, не совпадающую с индексным выражением.

Обновление search_vector — дорого? Generated STORED пересчитывается на каждый UPDATE строки (полный to_tsvector по тексту — на больших body ощутимо); массовые перезаписи не меняющихся статей — избегать (обновлять только изменённые), частые мелкие правки — нормально; альтернатива — триггерный расчёт только при изменении полей текста.

Подсказки «как в Google» (автокомплит) — тоже Postgres? Да: префиксный поиск по словарю терминов (отдельная таблица частых запросов/терминов с b-tree — LIKE 'терм%' индексный), + pg_trgm для опечаток в подсказках; частоты пополняются из поисковых логов — типовой замкнутый цикл улучшения поиска.

Источники: документация PostgreSQL (Full Text Search, pgtrgm) — ссылки в шапке.