Покупатель ищет «пара». В каталоге два товара с этим словом: «Гантели 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 не изменилось, изменился только порядок.
Условие совпадения одно и то же, найдены те же две строки. Веса меняют только число, по которому строки сортируют: без них ранги почти равны, с ними совпадение в названии в десять раз тяжелее совпадения в описании.
Как метка веса живёт внутри 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 …
Каждая основа слова хранит список вхождений: позицию в склеенном тексте и букву веса. Буква 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, то есть в названии. Это единственный случай, когда вес влияет на совпадение, а не на порядок, и он полезен для режима «искать только по названиям».
Каждое поле размечают своей буквой и склеивают в общий вектор; смотрите на узел справа: полей в нём уже нет, есть слова с метками, и метка решает вклад слова в ранг.
Где хранить веса: в колонке или в запросе
Считать вектор в каждом запросе для каждой строки таблицы нельзя: это полный перебор с разбором текста на лету. Поэтому вектор хранят в вычисляемой колонке и строят по ней 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 хранит, в каких документах слово есть, а не насколько документ хорош.
Индекс отвечает только на «где есть слово». Ранг считается для каждой совпавшей строки уже после индекса, и 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.
Что почитать дальше
- Полнотекстовый поиск в PostgreSQL — tsvector, tsquery, GIN и стемминг, на которые опирается эта статья.
- Типы индексов — что GIN хранит и почему не умеет отдавать ранг.
- EXPLAIN — как увидеть в плане, где ранжирование съедает время.
- Покрывающие индексы — почему
INCLUDEне работает с GIN.