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

habr.png

В прошлой статье мы оптимизировали поиск в 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;


Запрос для 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

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

А если посмотреть для 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

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

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

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


Терпимость к 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-ов.

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


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

© Habrahabr.ru