Когда делаешь поиск по сайту, хочется, чтобы товар «Молоко» с совпадением прямо в названии стоял выше товара, где слово «молоко» встречается где-то в конце описания. Вот для этого в PostgreSQL и нужны веса.
Матчинг и ранжирование — две разные задачи
Полнотекстовый поиск в PostgreSQL работает в два шага.
Матчинг — это ответ на вопрос «есть ли совпадение вообще?». Оператор @@ просто возвращает true или false. Ему всё равно, где именно слово встретилось — в заголовке или в сноске.
Ранжирование — это сортировка результатов по качеству совпадения. Вот тут и появляются веса: они говорят функции ранжирования, что совпадение в name ценнее, чем в description.
Ключевое: веса не влияют на матчинг. Они влияют только на то, в каком порядке результаты появятся в выдаче.
Четыре уровня весов
PostgreSQL даёт ровно четыре уровня — A, B, C, D. Это не число, а буква-метка, которая хранится прямо внутри tsvector рядом с каждым словом. По умолчанию функция ts_rank переводит их в числа так:
| Буква | Множитель |
|---|---|
| A | 1.0 |
| B | 0.4 |
| C | 0.2 |
| D | 0.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 | Важно, но не равноценно названию |
summary | C | Контекстное упоминание |
description | D | Полезно для охвата, но может быть случайным |
Смена весов — это продуктовое решение, не техническое. Перед изменением стоит собрать типичные поисковые запросы и сравнить выдачу до и после. Иначе ранжирование будет меняться от релиза к релизу без видимой причины.
Коротко
- PostgreSQL даёт четыре уровня весов: A / B / C / D (A — высший, D — базовый).
- Веса не влияют на матчинг (
@@) — только на ранжирование (ts_rank,ts_rank_cd). - Веса проставляются через
setweight(to_tsvector(...), 'A')отдельно для каждого поля, потом склеиваются||. - Готовый
tsvectorлучше хранить в generated column (STORED) с GIN-индексом — не пересчитывается на каждом запросе. - Язык словаря при индексации и при поиске обязан совпадать, иначе матчинг сломается.
ts_rank_cdдополнительно учитывает расстояние между словами — полезно для длинных текстов.- Выбор весов — продуктовое решение, которое стоит проверять на реальных запросах.
Что почитать дальше
- Covering Index — почему
INCLUDEне работает с GIN. - PostgreSQL: Full Text Search — официальная документация.
- PostgreSQL: ts_rank и setweight — все варианты ранжирования.