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

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


Введение

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;
Запрос для GINSELECT 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 для RUMLimit (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 для GINLimit (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

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

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

EXPLAIN для RUMLimit (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 для GINLimit (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

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

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

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

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

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

Сравним:

Запрос с двумя join для GINSELECT 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-ов.

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

Запрос с пятью join для RUMSELECT 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.

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