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

Когда делаешь поиск по сайту, хочется, чтобы товар «Молоко» с совпадением прямо в названии стоял выше товара, где слово «молоко» встречается где-то в конце описания. Вот для этого в PostgreSQL и нужны веса.

Матчинг и ранжирование — две разные задачи

Полнотекстовый поиск в PostgreSQL работает в два шага.

Матчинг — это ответ на вопрос «есть ли совпадение вообще?». Оператор @@ просто возвращает true или false. Ему всё равно, где именно слово встретилось — в заголовке или в сноске.

Ранжирование — это сортировка результатов по качеству совпадения. Вот тут и появляются веса: они говорят функции ранжирования, что совпадение в name ценнее, чем в description.

Ключевое: веса не влияют на матчинг. Они влияют только на то, в каком порядке результаты появятся в выдаче.

Четыре уровня весов

PostgreSQL даёт ровно четыре уровня — A, B, C, D. Это не число, а буква-метка, которая хранится прямо внутри tsvector рядом с каждым словом. По умолчанию функция ts_rank переводит их в числа так:

БукваМножитель
A1.0
B0.4
C0.2
D0.1

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

Как проставить веса — функция setweight

Функция setweight() принимает tsvector и букву, и возвращает тот же tsvector, но со всеми словами, помеченными этой буквой. Важно: одним вызовом можно пометить только один tsvector, то есть — одно поле.

Чтобы разметить несколько полей с разными весами, для каждого делают отдельный tsvector, а потом склеивают оператором ||:

SELECT
    setweight(to_tsvector('russian', coalesce(name,        '')), 'A') ||
    setweight(to_tsvector('russian', coalesce(summary,     '')), 'B') ||
    setweight(to_tsvector('russian', coalesce(description, '')), 'D')
FROM product
WHERE id = 1;

coalesce здесь обязателен: если поле NULL, то to_tsvector вернёт NULL, и весь результат оператора || тоже станет NULL.

В выводе каждая лексема будет помечена позицией и буквой:

'молок':1A 'натурал':2B 'деревн':3D ...

Это и есть физическое содержимое tsvector — позиции и метки весов.

Хранить tsvector в таблице, а не считать на лету

Считать tsvector прямо в условии WHERE — это полный перебор таблицы с вычислением на каждой строке. При тысячах строк это работает, при миллионах — нет.

Правильный подход: завести generated column типа STORED. PostgreSQL будет пересчитывать её автоматически при каждом INSERT или UPDATE и хранить результат на диске. По этой колонке строят GIN-индекс, и поиск идёт уже по индексу.

ALTER TABLE product
    ADD COLUMN tsv tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('russian', coalesce(name,        '')), 'A') ||
        setweight(to_tsvector('russian', coalesce(summary,     '')), 'B') ||
        setweight(to_tsvector('russian', coalesce(description, '')), 'D')
    ) STORED;

CREATE INDEX product_tsv_idx ON product USING GIN (tsv);

После этого любой поиск будет использовать индекс, а обновлять tsv вручную не нужно.

Поиск и ранжирование результатов

Матчинг — оператором @@:

SELECT id, name
FROM product
WHERE tsv @@ websearch_to_tsquery('russian', 'молоко');

Это вернёт все строки, где «молоко» встречается хоть в каком-нибудь поле. Порядок пока произвольный.

Чтобы отсортировать по качеству совпадения — добавляем ts_rank и ORDER BY:

SELECT
    id,
    name,
    ts_rank(tsv, websearch_to_tsquery('russian', 'молоко')) AS rank
FROM product
WHERE tsv @@ websearch_to_tsquery('russian', 'молоко')
ORDER BY rank DESC
LIMIT 20;

Товар «Молоко» с совпадением в name (вес A) получит ранг около 0.6. Товар «Кефир», где слово «молоко» упомянуто в description (вес D) — около 0.06. В итоге нужный товар окажется выше.

ts_rank и ts_rank_cd — в чём разница

ts_rank учитывает веса лексем и частоту их встречаемости в документе.

ts_rank_cd (cover density) учитывает ещё и расстояние между совпавшими словами. Если ищешь «свежее молоко» и в тексте они стоят рядом — ранг выше, чем когда между ними абзац текста.

Для коротких полей (name, summary) разницы почти нет. Для длинных описаний ts_rank_cd даёт более естественный порядок.

Как усилить разрыв между весами

Дефолтные множители (A=1.0, D=0.1) иногда недостаточно разделяют хорошие и плохие совпадения. Тогда массивом в ts_rank переопределяют значения:

ts_rank(
    ARRAY[0.05, 0.1, 0.4, 1.0],   -- {D, C, B, A} — именно в этом порядке
    tsv,
    websearch_to_tsquery('russian', 'молоко')
)

Это полезно, когда «случайный» текст в description начинает конкурировать с точными совпадениями в name.

Подводные камни

Язык должен совпадать везде. Если индексировали с to_tsvector('russian', ...), а ищем с websearch_to_tsquery('english', ...) — словари приводят слова к разным основам. «Молоко» в русском словаре станет молок, в простом — останется молоко. Матчинг не сработает, и веса тут ни при чём. Зафиксируй язык в одном месте: параметром в конфиге или в функции.

setweight перезаписывает предыдущий вес. setweight(setweight(tsv, 'A'), 'B') оставит только B. Веса не суммируются — каждый вызов перезаписывает всё.

Стоп-слова в tsvector не попадают вообще. Слова вроде «и», «не», «для» словарь отфильтровывает на этапе разбора, и пометить их весом невозможно.

GIN-индекс не поддерживает INCLUDE. Это значит, что index-only scan по GIN недоступен — запрос всё равно обратится к таблице за дополнительными полями. Подробнее про covering index — в статье Covering Index.

На write-heavy таблице следи за GIN. Каждое обновление поля, входящего в tsv, обновляет GIN-индекс. По умолчанию fastupdate = on — это ускоряет вставку, накапливая изменения в pending list. При больших нагрузках следи, чтобы pending list не рос бесконтрольно.

Как выбрать веса для реального проекта

Стандартная схема для каталога товаров:

ПолеВесПочему
name (название)AТочное совпадение — самый сильный сигнал
brand, тегиBВажно, но не равноценно названию
summaryCКонтекстное упоминание
descriptionDПолезно для охвата, но может быть случайным

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

Коротко

  • PostgreSQL даёт четыре уровня весов: A / B / C / D (A — высший, D — базовый).
  • Веса не влияют на матчинг (@@) — только на ранжирование (ts_rank, ts_rank_cd).
  • Веса проставляются через setweight(to_tsvector(...), 'A') отдельно для каждого поля, потом склеиваются ||.
  • Готовый tsvector лучше хранить в generated column (STORED) с GIN-индексом — не пересчитывается на каждом запросе.
  • Язык словаря при индексации и при поиске обязан совпадать, иначе матчинг сломается.
  • ts_rank_cd дополнительно учитывает расстояние между словами — полезно для длинных текстов.
  • Выбор весов — продуктовое решение, которое стоит проверять на реальных запросах.

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