Готовим полнотекстовый поиск в 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;
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
.
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
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
?
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
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 ещё одной или нескольких таблиц? Например, вывести в результатах вид документа, его владельца? Или, как в моем случае, отфильтровать по названиям связанных сущностей?
Сравним:
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.
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.