Главная » Хабрахабр » Готовим полнотекстовый поиск в Postgres. Часть 2

Готовим полнотекстовый поиск в Postgres. Часть 2

В этой статье мы продолжим оптимизацию с помощью индекса RUM и проанализируем его плюсы и минусы в сравнении с GIN. В прошлой статье мы оптимизировали поиск в PostgreSQL стандартными средствами.

Введение

Он позволяет возвращать при проходе по индексу отсортированные по релевантности результаты. RUM — это extension для Postgres, новый индекс для полнотекстового поиска. На его установке я не буду сосредотачиваться — она описана в README в репозитории.

Пользуемся индексом

Весь список параметров можно найти в документации. Создается индекс аналогично индексу GIN, но с некоторыми параметрами.

CREATE INDEX idx_rum_document ON documents_documentvector USING rum ("text" rum_tsvector_ops);

Поисковый запрос для RUM:

SELECT document_id, "text" <=> plainto_tsquery('запрос') AS rank
FROM documents_documentvector
WHERE "text" @@ plainto_tsquery('запрос')
ORDER BY rank;

Запрос для GIN

SELECT document_id, ts_rank("text", plainto_tsquery('запрос')) AS rank
FROM documents_documentvector
WHERE "text" @@ plainto_tsquery('запрос')
ORDER BY rank DESC;

Такой запрос возвращает некоторую дистанцию между поисковым вектором и поисковым запросом. Отличие от GIN в том, что релевантность получается не с помощью функции ts_rank, а с помощью запроса c оператором <=>: "text" <=> plainto_tsquery('запрос'). Чем она меньше, тем лучше запрос соответствует вектору.

Сравнение с GIN

Здесь мы будем сравнивать на тестовой базе с ~500 тысячами документов, чтобы заметить отличия в результатах поиска.

Скорость выполнения запросов

Посмотрим, что выдаст на этой базе EXPLAIN для GIN:

Gather Merge (actual time=563.840..611.844 rows=119553 loops=1) Workers Planned: 2 Workers Launched: 2 -> Sort (actual time=553.427..557.857 rows=39851 loops=3) Sort Key: (ts_rank(text, plainto_tsquery('запрос'::text))) Sort Method: external sort Disk: 1248kB -> Parallel Bitmap Heap Scan on documents_documentvector (actual time=13.402..538.879 rows=39851 loops=3) Recheck Cond: (text @@ plainto_tsquery('запрос'::text)) Heap Blocks: exact=5616 -> Bitmap Index Scan on idx_gin_document (actual time=12.144..12.144 rows=119553 loops=1) Index Cond: (text @@ plainto_tsquery('запрос'::text)) Planning time: 4.573 ms Execution time: 617.534 ms

А для RUM?

Sort (actual time=1668.573..1676.168 rows=119553 loops=1) Sort Key: ((text <=> plainto_tsquery('запрос'::text))) Sort Method: external merge Disk: 3520kB -> Bitmap Heap Scan on documents_documentvector (actual time=16.706..1605.382 rows=119553 loops=1) Recheck Cond: (text @@ plainto_tsquery('запрос'::text)) Heap Blocks: exact=15599 -> Bitmap Index Scan on idx_rum_document (actual time=14.548..14.548 rows=119553 loops=1) Index Cond: (text @@ plainto_tsquery('запрос'::text)) Planning time: 0.650 ms Execution time: 1679.315 ms

Какой толк в этом хваленом RUM, спросите вы, если он работает в три раза медленнее, чем GIN? Что же это такое? И где пресловутая сортировка внутри индекса?

Спокойно: попробуем добавить в запрос LIMIT 1000.

EXPLAIN для RUM

Limit (actual time=115.568..137.313 rows=1000 loops=1) -> Index Scan using idx_rum_document on documents_documentvector (actual time=115.567..137.239 rows=1000 loops=1) Index Cond: (text @@ plainto_tsquery('запрос'::text)) Order By: (text <=> plainto_tsquery('запрос'::text)) Planning time: 0.481 ms Execution time: 137.678 ms

EXPLAIN для GIN

Limit (actual time=579.905..585.650 rows=1000 loops=1) -> Gather Merge (actual time=579.904..585.604 rows=1000 loops=1) Workers Planned: 2 Workers Launched: 2 -> Sort (actual time=574.061..574.171 rows=992 loops=3) Sort Key: (ts_rank(text, plainto_tsquery('запрос'::text))) DESC Sort Method: external merge Disk: 1224kB -> Parallel Bitmap Heap Scan on documents_documentvector (actual time=8.920..555.571 rows=39851 loops=3) Recheck Cond: (text @@ plainto_tsquery('запрос'::text)) Heap Blocks: exact=5422 -> Bitmap Index Scan on idx_gin_document (actual time=8.945..8.945 rows=119553 loops=1) Index Cond: (text @@ plainto_tsquery('запрос'::text)) Planning time: 0.223 ms Execution time: 585.948 ms

Уже не в пользу GIN, верно? ~150 мс против ~600 мс! И сортировка переместилась внутрь индекса!

А если посмотреть для LIMIT 100?

EXPLAIN для RUM

Limit (actual time=105.863..108.530 rows=100 loops=1) -> Index Scan using idx_rum_document on documents_documentvector (actual time=105.862..108.517 rows=100 loops=1) Index Cond: (text @@ plainto_tsquery('запрос'::text)) Order By: (text <=> plainto_tsquery('запрос'::text)) Planning time: 0.199 ms Execution time: 108.958 ms

EXPLAIN для GIN

Limit (actual time=582.924..588.351 rows=100 loops=1) -> Gather Merge (actual time=582.923..588.344 rows=100 loops=1) Workers Planned: 2 Workers Launched: 2 -> Sort (actual time=573.809..573.889 rows=806 loops=3) Sort Key: (ts_rank(text, plainto_tsquery('запрос'::text))) DESC Sort Method: external merge Disk: 1224kB -> Parallel Bitmap Heap Scan on documents_documentvector (actual time=18.038..552.827 rows=39851 loops=3) Recheck Cond: (text @@ plainto_tsquery('запрос'::text)) Heap Blocks: exact=5275 -> Bitmap Index Scan on idx_gin_document (actual time=16.541..16.541 rows=119553 loops=1) Index Cond: (text @@ plainto_tsquery('запрос'::text)) Planning time: 0.487 ms Execution time: 588.583 ms

Разница ещё немного заметнее.

RUM же делает это только для тех строк, которые нам действительно нужны. Всё дело в том, что GIN без разницы, сколько именно строк вы получаете в итоге — он должен пройтись по всем строкам, для которых запрос выполнился успешно, и проранжировать их. Его ts_rank эффективнее производит вычисления, чем оператор <=>. Если нам нужно очень много строк, GIN выигрывает. Но на маленьких запросах преимущество RUM неоспоримо.

Ему нужно только 10 постов на первой, второй, третьей странице etc. Чаще всего пользователю и не нужно выгружать сразу все 50 тысяч документов из базы. И именно под такие случаи заточен данный индекс, и он даст неплохой прирост производительности поиска на большой базе.

Терпимость к join-ам

Например, вывести в результатах вид документа, его владельца? Что, если в поиске требуется сделать join ещё одной или нескольких таблиц? Или, как в моем случае, отфильтровать по названиям связанных сущностей?

Сравним:

Запрос с двумя join для GIN

SELECT document_id, ts_rank("text", plainto_tsquery('запрос')) AS rank, case_number
FROM documents_documentvector RIGHT JOIN documents_document ON documents_documentvector.document_id = documents_document.id LEFT JOIN documents_case ON documents_document.case_id = documents_case.id
WHERE "text" @@ plainto_tsquery('запрос')
ORDER BY rank DESC
LIMIT 10;

Результат:

Limit (actual time=1637.902..1643.483 rows=10 loops=1) -> Gather Merge (actual time=1637.901..1643.479 rows=10 loops=1) Workers Planned: 2 Workers Launched: 2 -> Sort (actual time=1070.614..1070.687 rows=652 loops=3) Sort Key: (ts_rank(documents_documentvector.text, plainto_tsquery('запрос'::text))) DESC Sort Method: external merge Disk: 2968kB -> Hash Left Join (actual time=323.386..1049.092 rows=39851 loops=3) Hash Cond: (documents_document.case_id = documents_case.id) -> Hash Join (actual time=239.312..324.797 rows=39851 loops=3) Hash Cond: (documents_documentvector.document_id = documents_document.id) -> Parallel Bitmap Heap Scan on documents_documentvector (actual time=11.022..37.073 rows=39851 loops=3) Recheck Cond: (text @@ plainto_tsquery('запрос'::text)) Heap Blocks: exact=9362 -> Bitmap Index Scan on idx_gin_document (actual time=12.094..12.094 rows=119553 loops=1) Index Cond: (text @@ plainto_tsquery('запрос'::text)) -> Hash (actual time=227.856..227.856 rows=472089 loops=3) Buckets: 65536 Batches: 16 Memory Usage: 2264kB -> Seq Scan on documents_document (actual time=0.009..147.104 rows=472089 loops=3) -> Hash (actual time=83.338..83.338 rows=273695 loops=3) Buckets: 65536 Batches: 8 Memory Usage: 2602kB -> Seq Scan on documents_case (actual time=0.009..39.082 rows=273695 loops=3)
Planning time: 0.857 ms
Execution time: 1644.028 ms

На трех join-ах и больше время запроса достигает 2-3 секунд и растет с количеством join-ов.

Пусть запрос сразу будет с пятью join. А что же для RUM?

Запрос с пятью join для RUM

SELECT document_id, "text" <=> plainto_tsquery('запрос') AS rank, case_number, classifier_procedure.title, classifier_division.title, classifier_category.title
FROM documents_documentvector RIGHT JOIN documents_document ON documents_documentvector.document_id = documents_document.id LEFT JOIN documents_case ON documents_document.case_id = documents_case.id LEFT JOIN classifier_procedure ON documents_case.procedure_id = classifier_procedure.id LEFT JOIN classifier_division ON documents_case.division_id = classifier_division.id LEFT JOIN classifier_category ON documents_document.category_id = classifier_category.id
WHERE "text" @@ plainto_tsquery('запрос') AND documents_document.is_active IS TRUE
ORDER BY rank
LIMIT 10;

Результат:

Limit (actual time=70.524..72.292 rows=10 loops=1) -> Nested Loop Left Join (actual time=70.521..72.279 rows=10 loops=1) -> Nested Loop Left Join (actual time=70.104..70.406 rows=10 loops=1) -> Nested Loop Left Join (actual time=70.089..70.351 rows=10 loops=1) -> Nested Loop Left Join (actual time=70.073..70.302 rows=10 loops=1) -> Nested Loop (actual time=70.052..70.201 rows=10 loops=1) -> Index Scan using document_vector_rum_index on documents_documentvector (actual time=70.001..70.035 rows=10 loops=1) Index Cond: (text @@ plainto_tsquery('запрос'::text)) Order By: (text <=> plainto_tsquery('запрос'::text)) -> Index Scan using documents_document_pkey on documents_document (actual time=0.013..0.013 rows=1 loops=10) Index Cond: (id = documents_documentvector.document_id) Filter: (is_active IS TRUE) -> Index Scan using documents_case_pkey on documents_case (actual time=0.009..0.009 rows=1 loops=10) Index Cond: (documents_document.case_id = id) -> Index Scan using classifier_procedure_pkey on classifier_procedure (actual time=0.003..0.003 rows=1 loops=10) Index Cond: (documents_case.procedure_id = id) -> Index Scan using classifier_division_pkey on classifier_division (actual time=0.004..0.004 rows=1 loops=10) Index Cond: (documents_case.division_id = id) -> Index Scan using classifier_category_pkey on classifier_category (actual time=0.003..0.003 rows=1 loops=10) Index Cond: (documents_document.category_id = id)
Planning time: 2.861 ms
Execution time: 72.865 ms

Если вам при поиске не обойтись без join, то RUM вам явно подходит.

Место на диске

На тестовой базе в ~500 тысяч документов и 3,6 Гб индексы занимали очень разные объемы.

idx_rum_document | 1950 MB idx_gin_document | 418 MB

Но 2 Гб вместо 400 Мб не могут радовать. Да, диск — штука дешевая. Тут безоговорочно выигрывает GIN. Половина размера базы — многовато для индекса.

Выводы

Вам нужен RUM, если:

  • У вас очень много документов, но вы выдаете поисковые результаты постранично
  • Вам нужна сложная фильтрация результатов поиска
  • Вам не жалко места на диске

Вас вполне устроит GIN, если:

  • У вас маленькая база
  • У вас большая база, но выдавать результаты надо сразу и все
  • Вам не нужна фильтрация с join-ами
  • Вас интересует минимальный размер индекса на диске

Буду рад послушать советы от тех, кто знает, как всё настроить ещё лучше!) Надеюсь, эта статья снимет множество WTF?!, возникающих при работе и настройке поиска в Postgres.

В следующей частях я планирую рассказать подробнее о RUM в своем проекте: про использование дополнительных опций RUM, работу в связке Django + PostgreSQL.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Новости недели: ФСБ не указ операторам, ИИ обыгрывает чемпионов, Apple и Qualcomm мирятся

ФСБ разъяснила разницу между бытовыми и шпионскими устройствами, операторы связи тестируют eSim несмотря на возражения ФСБ, искусственный интеллект победил чемпионов мира по Dota 2, Марка Цукерберга предлагают снять с поста председателя совета директоров Facebook, Apple и Qualcomm помирились, складные смартфоны ...

[Из песочницы] Какой уровень востребованности PHP разработчиков на 1 квартал 2019 года?

На рынке труда программисты PHP являются одними из наиболее востребованных специалистов. За последние 3 года их популярность среди работодателей выросла на более чем на 12%. PHP — самый распространенный язык программирования общего назначения с открытым исходным кодом, многие наши клиенты ...