Важно знать
- Веса в FTS — это 4 уровня A / B / C / D (A — высший). Они не влияют на матчинг (
@@), только на ранжирование (ts_rank,ts_rank_cd).- Веса проставляются через
setweight(to_tsvector(...), 'A')отдельно по каждому полю, потом склеиваются конкатенацией||.- Хранить готовый
tsvectorлучше в generated column (STORED) + GIN-индекс. Не пересчитываем на каждый запрос.- Числовые веса A=1.0, B=0.4, C=0.2, D=0.1 по умолчанию — переопределяются массивом в
ts_rank(weights, vec, query).- GIN не поддерживает
INCLUDE— covering-трюк здесь не работает; выбирай между размером индекса и охватом.- Язык словаря (
'russian','english') обязан совпадать при индексации и при поиске, иначе матчинг развалится.
Полнотекстовый поиск (FTS) в PostgreSQL — про две операции: матчинг (есть ли совпадение) и ранжирование (насколько хорошее). Веса нужны для второй: они говорят системе, что совпадение в title ценнее, чем в description, и должно идти выше в выдаче.
Базовые таблицы те же, что и в статье про ACID, но расширим product парой полей, которые в реальной витрине обычно есть:
ALTER TABLE product
ADD COLUMN summary TEXT,
ADD COLUMN description TEXT;
Сценарий: ищем «молоко». В выдаче должен сначала идти товар, у которого «Молоко» в name, потом — где «молоко» упомянуто в summary, потом — где про молоко рассказано в description. Это и есть работа весов.
Четыре уровня и что они значат
PostgreSQL даёт ровно четыре уровня — A, B, C, D. Это не настройка, это вшитая в tsvector метка на каждой лексеме. По умолчанию ts_rank использует множители:
| Вес | Множитель по умолчанию |
|---|---|
| A | 1.0 |
| B | 0.4 |
| C | 0.2 |
| D | 0.1 |
Без setweight все лексемы получают вес D — то есть «обычный текст».
Как проставлять веса
setweight() навешивает один вес на весь 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 = 6;
-- → 'kefir':1A 'molok':1A 'naturaln':2B 'svezh':3B 'derevn':4D ...
Смотри на выводе: после каждой лексемы стоит позиция и буква веса (1A, 2B, 4D). Эти метки лежат в tsvector физически — потом по ним считается ранг.
coalesce нужен, потому что to_tsvector падает на NULL (точнее, возвращает NULL, и весь ||-выражение становится NULL).
Не пересчитываем на каждый запрос — STORED-колонка
Считать tsvector в WHERE — это Seq Scan по таблице с применением to_tsvector к каждой строке. Это убийственно. Правильный способ — generated column, в которой tsvector пересчитывается при записи, лежит на диске, индексируется 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);
Теперь любой INSERT/UPDATE пересчитывает tsv автоматически, поиск идёт по GIN-индексу.
Поиск и ранжирование
Матчинг — оператор @@:
SELECT id, name
FROM product
WHERE tsv @@ websearch_to_tsquery('russian', 'молоко');
-- вернёт все товары, где «молоко» встречается в любом поле.
Ранжирование — ts_rank или ts_rank_cd. Они учитывают веса лексем, частоту и расстояние (для _cd).
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) получит rank примерно 0.6, товар «Кефир» с «молоко» в description (вес D) — 0.06. На выдаче «Молоко» окажется выше.
Переопределение множителей
Если хочешь усилить разрыв между A и D, передаёшь массив {D, C, B, A}:
ts_rank(
ARRAY[0.05, 0.1, 0.4, 1.0], -- {D, C, B, A}, порядок именно такой
tsv,
websearch_to_tsquery('russian', 'молоко')
)
Это уместно, когда дефолтный D=0.1 слишком высок и спам в description начинает забивать релевантные совпадения в name.
ts_rank vs ts_rank_cd
ts_rank— учитывает веса и частоту лексем.ts_rank_cd(cover density) — учитывает ещё и расстояние между совпавшими лексемами. Если ищем «свежее молоко» и в тексте они стоят рядом — ранг выше, чем когда между ними 200 слов.
Для коротких полей (name, summary) разницы почти нет; для длинных description — ts_rank_cd обычно даёт более «человеческий» порядок.
Подводные камни
Язык словаря должен совпадать. Если индексировали с to_tsvector('russian', ...), а ищем с to_tsvector('simple', ...) или websearch_to_tsquery('english', ...) — словари приводят слова к разным базам (молоко → молок в russian, молоко → молоко в simple), матчинг разваливается, и веса тут уже ни при чём. Жёстко фиксируй язык в одном месте — либо параметром в application.yml, либо в SQL-функции, чтобы не дрейфовало.
GIN не поддерживает INCLUDE. Если очень хочется отдавать name и price из индекса без обращения к таблице — это не про FTS. GIN-индекс хранит только лексемы и ctid. Index-only scan по GIN невозможен в принципе. См. Covering Index для деталей про INCLUDE.
setweight не накапливается. Повторный setweight(setweight(tsv, 'A'), 'B') перезапишет вес на B. Это нужно помнить, если строишь tsvector в несколько проходов (например, постфактум для legacy-данных).
Стоп-слова и веса. Стоп-слова, отфильтрованные словарём, в tsvector не попадают вовсе — никакого веса им не присвоить. Если ищешь «и», «не», «для» — они проигнорируются на этапе токенизации. Это не баг весов.
GIN-индекс дорог на запись. Каждое обновление поля, входящего в generated tsv, переписывает строки в GIN. На write-heavy таблице ставь fastupdate = on (по умолчанию включено) и следи за pending list через gin_clean_pending_list().
Веса как договор с продуктом
Главное — веса должны отражать продуктовое ранжирование, а не интуицию инженера. Стандартный профиль для каталога товаров:
| Поле | Вес | Почему |
|---|---|---|
name (название) | A | Точное совпадение с тем, что ищут — самое релевантное. |
brand, model, теги | B | Сильный сигнал, но не равноценный названию. |
summary, краткое описание | C | Контекстное упоминание, среднее качество совпадения. |
description, отзывы | D | Может быть случайным, но всё ещё полезно для recall. |
Если меняешь профиль весов — это продуктовое изменение, не чисто техническое. Соберите типичные поисковые сценарии (top-50 запросов), посмотрите выдачу до и после, договоритесь с продактом. Иначе ранжирование начнёт «дрожать» от релиза к релизу.
Что почитать дальше
- PostgreSQL: Full Text Search — официальная документация раздела.
- PostgreSQL: setweight, ts_rank — все варианты ранжирования и аргументы.
- Covering Index — почему
INCLUDEдля FTS не работает (GIN). - Elasticsearch — когда PG FTS перестаёт хватать и пора смотреть на отдельный поисковик.