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

Покупатель ищет «пара». В каталоге два товара с этим словом: «Гантели 5 кг (пара)», где оно стоит в названии, и «Кроссовки для бега», у которых в описании написано «вес пары 260 граммов». Поиск находит оба, и это правильно, но показывает их в случайном порядке, и кроссовки то и дело оказываются выше гантелей. Человек ждёт, что совпадение в названии весит больше совпадения в описании, а база об этом не знает: для неё оба поля слились в один набор слов. Веса и есть способ рассказать ей, из какого поля пришло слово.

Статья опирается на базовые вещи из статьи про полнотекстовый поиск: разобранный текст tsvector, запрос tsquery, оператор совпадения @@, словарь russian и GIN-индекс по вектору. Если эти слова незнакомы, начните с неё. Здесь они считаются известными, а разговор идёт о том, что происходит после того, как совпадение найдено.

Найти и упорядочить — две разные работы

Полнотекстовый поиск делает два разных дела, и их легко перепутать, потому что в одном запросе они стоят рядом. Первое — совпадение: оператор @@ отвечает «да» или «нет», есть ли в документе слова запроса. Ему безразлично, где слово нашлось, в заголовке или в последней строке. Второе — ранжирование: функция ts_rank смотрит на совпавший документ и выдаёт число, по которому строки сортируют. На это число веса влияют, а на ответ «да или нет» не влияют: помеченное весом слово ищется так же, как непомеченное.

В учебной песочнице у товаров есть колонка search, собранная из названия и описания без весов. Запросите по ней ранг для слова «пара»:

живой пример

SELECT id, title, ts_rank(search, q) AS rank
FROM products, websearch_to_tsquery('russian', 'пара') q
WHERE search @@ q
ORDER BY rank DESC;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Гантели получают 0,076, кроссовки 0,061: разница в полтора раза, которую перекроет любое второе слово запроса. А теперь тот же ранг по вектору, где название помечено весом A, а описание весом D:

живой пример

SELECT id, title,
       ts_rank(setweight(to_tsvector('russian', title), 'A')
            || setweight(to_tsvector('russian', coalesce(description, '')), 'D'),
               q) AS rank
FROM products, websearch_to_tsquery('russian', 'пара') q
WHERE search @@ q
ORDER BY rank DESC;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Гантели 0,62, кроссовки 0,06, разрыв в десять раз. Совпали те же две строки, условие WHERE не изменилось, изменился только порядок.

без весов search @@ «пара» гантели 0,076 кроссовки 0,061 порядок почти случайный с весами те же две строки название A, описание D гантели 0,62 · кроссовки 0,06 название всегда выше

Условие совпадения одно и то же, найдены те же две строки. Веса меняют только число, по которому строки сортируют: без них ранги почти равны, с ними совпадение в названии в десять раз тяжелее совпадения в описании.

Как метка веса живёт внутри tsvector

Вес это не число, а одна из четырёх букв, A, B, C или D, которую setweight приписывает к каждому слову вектора. Функция принимает один tsvector и одну букву и помечает ею все слова сразу, поэтому разные поля размечают по отдельности и склеивают оператором ||:

живой пример

SELECT setweight(to_tsvector('russian', coalesce(title, '')), 'A')
    || setweight(to_tsvector('russian', coalesce(array_to_string(tags, ' '), '')), 'B')
    || setweight(to_tsvector('russian', coalesce(description, '')), 'D')
FROM products
WHERE id = 'prod-01';
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

coalesce здесь не перестраховка: у товара без описания to_tsvector вернёт NULL, а NULL || что угодно тоже NULL, и товар выпадет из поиска целиком, вместе с названием. Результат для беспроводных наушников выглядит так:

'ауд':3B 'беспроводн':1A,5B,6 'наушник':2A,4B,8 'накладн':7 'шумоподавлен':10 …
'беспроводн' :1A — позиция 1, название ,5B — позиция 5, теги ,6 — позиция 6, описание (вес D) 'наушник' :2A — позиция 2, название ,4B — позиция 4, теги ,8 — позиция 8, описание (вес D)

Каждая основа слова хранит список вхождений: позицию в склеенном тексте и букву веса. Буква D не печатается, это вес по умолчанию у всего, что не помечали.

Слово «беспроводные» встретилось трижды: на первой позиции с меткой A, потому что стоит в названии, на пятой с B, потому что есть в тегах, и на шестой без метки. Отсутствие буквы и есть вес D: он стоит по умолчанию у всего, что не помечали, и при печати опускается. При ранжировании ts_rank переводит буквы в множители: A это 1,0, B 0,4, C 0,2, D 0,1, и слово из названия даёт документу в десять раз больше, чем то же слово из описания.

Две вещи про метки, о которые спотыкаются. Первая: setweight не добавляет вес, а перезаписывает. setweight(setweight(v, 'A'), 'B') оставит только B, и если по ошибке пометить уже склеенный вектор, разница между названием и описанием исчезнет. Вторая: метку можно спросить в самом запросе. to_tsquery('russian', 'пара:A') найдёт только документы, где «пара» стоит с весом A, то есть в названии. Это единственный случай, когда вес влияет на совпадение, а не на порядок, и он полезен для режима «искать только по названиям».

один tsvector название вес A теги вес B описание вес D ранг строки A тяжелее D в 10 раз

Каждое поле размечают своей буквой и склеивают в общий вектор; смотрите на узел справа: полей в нём уже нет, есть слова с метками, и метка решает вклад слова в ранг.

Где хранить веса: в колонке или в запросе

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

CREATE FUNCTION tags_text(tags text[]) RETURNS text
    LANGUAGE sql IMMUTABLE PARALLEL SAFE
AS $$ SELECT array_to_string(tags, ' ') $$;

ALTER TABLE products
    ADD COLUMN tsv tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('russian', coalesce(tags_text(tags), '')), 'B') ||
        setweight(to_tsvector('russian', coalesce(description, '')), 'D')
    ) STORED;

CREATE INDEX products_tsv_idx ON products USING GIN (tsv);

Функция-обёртка вокруг array_to_string появилась не для красоты. В вычисляемой колонке разрешены только функции, про которые база уверена, что на одних аргументах они всегда дают один ответ, с пометкой IMMUTABLE. to_tsvector с явно названным словарём и setweight такие, а array_to_string помечена как STABLE: печать элементов массива может зависеть от настроек сессии, и что у нас массив обычного текста, база разбираться не станет, а ответит generation expression is not immutable. Своя функция с пометкой IMMUTABLE это обещание, которое мы даём сами.

У хранения весов в колонке есть цена, которую видно только потом: изменить веса нельзя. Вычисляемую колонку не переопределяют, её удаляют и создают заново, а это перезапись всей таблицы под блокировкой и перестройка GIN-индекса. Смена «теги важнее описания» на «описание важнее тегов» превращается в миграцию на часы для большой таблицы.

Есть второй путь, и в примере с гантелями он уже был: совпадение искать по хранимой колонке без весов, а ранг считать по вектору с весами, собранному на лету. Разбор текста при этом делается не для всей таблицы, а только для строк, прошедших WHERE, и веса живут в запросе: поменять их можно в следующем выпуске приложения без единой миграции. Веса в колонке нужны в двух случаях: когда по ним фильтруют, как в 'пара:A', и когда совпавших строк так много, что разбирать их тексты на каждом запросе дорого. Об этом следующий раздел.

Что стоит ранжирование

Запрос «найти, отсортировать по рангу и показать двадцать» выглядит дёшево из-за LIMIT 20, но индекс помогает только первой половине работы. GIN отдаёт все строки, где есть слова запроса. Дальше для каждой из них база читает вектор из таблицы, считает ts_rank, и лишь после этого сортирует и оставляет двадцать лучших. Ранг нельзя взять из индекса: GIN хранит, в каких документах слово есть, а не насколько документ хорош.

GIN: строки, где есть слово 200 000 совпадений чтение вектора каждой строки по одной из таблицы ts_rank для каждой строки индекс уже не помогает сортировка по рангу двадцать лучших LIMIT 20

Индекс отвечает только на «где есть слово». Ранг считается для каждой совпавшей строки уже после индекса, и LIMIT 20 эту работу не сокращает: чтобы выбрать двадцать лучших, нужно посчитать все.

Пока слово редкое, совпавших строк сотни, и всё летает. Частое слово, «чехол» в каталоге электроники, совпадает с двумястами тысячами товаров, и запрос считает двести тысяч рангов, чтобы показать двадцать. В плане это Bitmap Index Scan по GIN, затем Bitmap Heap Scan с чтением каждой строки, Sort и сверху Limit; время уходит во второй узел, и новые индексы его не меняют. Лечат это сужением совпадения, а не ускорением ранга: обязательный фильтр по категории или продавцу перед поиском, чтобы GIN отдавал тысячи строк, а не сотни тысяч; для запроса из одного частого слова замена ранга на готовый признак вроде популярности товара; расширение RUM, которое хранит позиции и веса прямо в индексе и умеет отдавать строки в порядке ранга, если такая нагрузка стала основной.

Длинное описание обыгрывает название

Даже с весами остаётся перекос, который веса не лечат. ts_rank складывает вклад каждого вхождения, поэтому длинное описание, где слово повторено двадцать раз, набирает больше короткого документа с одним вхождением. Проверить можно на голых строках, без таблицы:

SELECT round(ts_rank(to_tsvector('russian', 'Молоко'), q)::numeric, 3) AS короткий,
       round(ts_rank(to_tsvector('russian', repeat('Свежие овощи, хлеб, сыр и молоко доставляем каждое утро в удобное окно. ', 20)), q)::numeric, 3) AS длинный,
       round(ts_rank(to_tsvector('russian', repeat('Свежие овощи, хлеб, сыр и молоко доставляем каждое утро в удобное окно. ', 20)), q, 1)::numeric, 3) AS длинный_норм
FROM websearch_to_tsquery('russian', 'молоко') q;

Короткий документ получает 0,061, длинный 0,097, и описание с повторами обходит название. Третий аргумент ts_rank это нормализация, набор флагов, которые складывают через |. Флаг 1 делит ранг на 1 + log(длина документа): длинный документ падает до 0,013, короткий остаётся при своих, потому что логарифм единицы равен нулю. Флаг 2 делит на длину без логарифма и наказывает длинные тексты жёстче. А флаг 32, который часто советуют, длину не учитывает вовсе: он лишь сжимает ранг в диапазон от 0 до 1 через rank / (rank + 1), чтобы показывать его как проценты. Для каталога с описаниями обычно хватает ts_rank(vector, query, 1).

Если после нормализации разрыв между полями кажется малым, множители весов переопределяют массивом в порядке {D, C, B, A}: ts_rank('{0.05, 0.1, 0.4, 1.0}', vector, query) опускает описание ещё вдвое, и гантели с кроссовками расходятся как 0,62 и 0,03. И третья ручка, ts_rank_cd: она учитывает расстояние между словами запроса в тексте. Для «свежее молоко» документ, где слова стоят рядом, получает 0,10, а где между ними четыре других слова, 0,02. В коротких названиях слова и так рядом, разница видна на длинных описаниях.

Что делать со строкой, которую ввёл человек

Ранг считается против tsquery, а его нужно из чего-то собрать, и здесь четыре функции с похожими именами делают разное. to_tsquery ждёт готовое выражение с операторами и на обычную фразу «беспроводные наушники» отвечает ошибкой syntax error in tsquery. plainto_tsquery берёт любую строку, приводит слова к основам и соединяет через «И»; минусы и кавычки она считает мусором и выбрасывает. phraseto_tsquery соединяет слова оператором <->, «следом друг за другом», и годится для поиска точной фразы. websearch_to_tsquery понимает то, к чему люди привыкли в поисковиках: кавычки как фразу, минус как исключение, or как «ИЛИ»:

живой пример

SELECT websearch_to_tsquery('russian', 'беспроводные наушники -bluetooth "с шумоподавлением"');
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Составной запрос с исключением выглядит так:

'беспроводн' & 'наушник' & !'bluetooth' & 'шумоподавлен'

Для строки из поля поиска на сайте это ответ по умолчанию; to_tsquery оставляют для запросов, которые собирает код. Две вещи одинаковы для всех четырёх. Словарь обязан совпадать с тем, которым строили вектор: «молоко» через russian превращается в основу молок, а словарь simple оставит молоко как есть, и совпадения не будет, хотя слово то же. И стоп-слова, «и», «для», «не», «только», выбрасываются из вектора ещё при разборе: пометить их весом невозможно, а запрос из одних стоп-слов пуст и не находит ничего.

GIN под потоком записи

Каждое изменение поля, входящего в вектор, это изменение GIN-индекса, а GIN обновляется дорого: одно слово это одна запись в дереве, а в описании их сотни. Поэтому у GIN включён режим fastupdate: новые записи складываются в список ожидания, pending list, и переносятся в дерево пачкой, когда список дорастает до gin_pending_list_limit, по умолчанию 4 МБ, или когда до него доходит VACUUM. Обратная сторона в том, что поиск обязан просмотреть и список, а список читается последовательно: при 4 МБ это заметно, а если лимит подняли ради быстрой вставки, поиск начинает тормозить на ровном месте. На таблицах с плотной записью список чистят по расписанию функцией gin_clean_pending_list('products_tsv_idx') или выключают режим через ALTER INDEX products_tsv_idx SET (fastupdate = off): тогда каждая вставка платит сразу, а поиск не платит никогда.

И последнее про GIN: он не поддерживает INCLUDE, поэтому доступ только по индексу с ним невозможен, за title и price запрос всё равно пойдёт в таблицу. Почему так и что с этим делать, разобрано в статье про покрывающие индексы.

Как разложить поля каталога

Раскладка по буквам это продуктовое решение, а не техническое, но отправная точка у каталогов одна:

ПолеВесПочему
названиеAТочное совпадение, самый сильный сигнал
бренд, тегиBВажно, но не равноценно названию
краткое описаниеCКонтекстное упоминание
полное описаниеDНужно для охвата, но слово там может быть случайным

Перед тем как менять раскладку или множители, собирают два-три десятка настоящих запросов из журнала поиска и сравнивают выдачу до и после. Без этого ранжирование меняется от выпуска к выпуску, и никто не может сказать, стало ли лучше.

Коротко

  • Веса меняют порядок выдачи, а не состав: @@ их не видит, ts_rank переводит A/B/C/D в 1,0/0,4/0,2/0,1.
  • setweight помечает весь вектор одной буквой и перезаписывает прежнюю; поля размечают по отдельности и склеивают через ||, coalesce обязателен.
  • Веса в хранимой колонке нельзя поменять без перезаписи таблицы; ранг по вектору с весами, собранному на лету только для совпавших строк, меняется правкой запроса.
  • Ранг считается для каждой совпавшей строки после индекса, LIMIT этого не сокращает; частое слово лечат сужением совпадения.
  • Длинные документы с повторами обходят короткие; лечит нормализация, флаг 1, а флаг 32 только сжимает ранг в 0..1.
  • Для строки от человека websearch_to_tsquery; словарь при разборе и при поиске один и тот же.
  • GIN копит изменения в списке ожидания до 4 МБ; при плотной записи список чистят по расписанию или выключают fastupdate.

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