Важно знать

  • Веса в 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 использует множители:

ВесМножитель по умолчанию
A1.0
B0.4
C0.2
D0.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) разницы почти нет; для длинных descriptionts_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 перестаёт хватать и пора смотреть на отдельный поисковик.