Хабрахабр

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

UPD. Часть 2

Мне пришлось недавно решать подобную задачу на работе — и я был очень удивлен отсутствию хоть сколько-нибудь вменяемых материалов по этому поводу. Эта статья — первая из небольшой серии статей о том, как оптимально настроить полнотекстовый поиск в PostgreSQL. Мой опыт борьбы под катом.

Завязка

В базе лежит ~500 тысяч документов общим объемом ~3,6 Гб. Я поддерживаю относительно большой проект, в котором есть публичный поиск по документам. Суть поиска такова: пользователь заполняет форму, в которой есть и полнотекстовый запрос, и фильтрация по множеству полей в БД, в том числе и с join-ами.

Основные проблемы были такими: Поиск работает (точнее, работал) через Sphinx, и работал не очень хорошо.

  1. Индексирование отъедало порядка 8 Гб оперативной памяти. На сервере с 8 Гб ОЗУ это проблема. Память свопилась, это приводило к ужасной производительности.
  2. Индекс строился примерно 40 минут. Ни о какой консистентности поисковых результатов речи не шло, индексирование запускалось раз в день.
  3. Поиск работал долго. Особенно долго осуществлялись запросы, которым соответствовало большое количество документов: огромное количестов id-шников приходилось передавать из сфинкса в базу, и сортировать по релевантности на бэкэнде.

У этой задачи есть два решения: Из-за этих проблем возникла задача — оптимизировать полнотекстовый поиск.

  1. Подтюнить Sphinx: настроить realtime-индекс, хранить в индексе атрибуты для фильтрации.
  2. Использовать встроенный FTS PostgreSQL.

Решено было реализовывать второе решение: так можно нативно обеспечить автообновление индекса, избавиться от долгого общения между двумя сервисами и мониторить один сервис вместо двух.

Но проблемы поджидали впереди. Казалось бы, хорошее решение.

Начнем с самого начала.

Наивно используем полнотекстовый поиск

Первый хранит текст документа в оптимизированном для поиска виде, второй — хранит полнотекстовый запрос. Как гласит документация, для полнотекстового поиска требуется использовать типы tsvector и tsquery.

Для ранжирования результатов есть ts_rank. Для поиска в PostgreSQL есть функции to_tsvector, plainto_tsquery, to_tsquery. Их использование интуитивно понятно и они хорошо описаны в документации, поэтому на подробностях их использования останавливаться не будем.

Традиционный поисковый запрос с помощью них будет выглядеть так:

SELECT id, ts_rank(to_tsvector("document_text"), plainto_tsquery('запрос'))
FROM documents_document
WHERE to_tsvector("document_text") @@ plainto_tsquery('запрос')
ORDER BY ts_rank(to_tsvector("document_text"), plainto_tsquery('запрос')) DESC;

Кажется, всё хорошо? Мы вывели id-ы документов, в тексте которых есть слово "запрос", и отсортировали их по убыванию релевантности. Нет.

У подхода выше есть много недостатков:

  1. Мы не используем индекс для поиска.
  2. Функция ts_vector вызывается для каждой строки таблицы.
  3. Функция ts_rank вызывается для каждой строки таблицы.

Результаты EXPLAIN на боевой базе: Это все приводит к тому, что поиск выполняется реально долго.

Gather Merge (actual time=420289.477..420313.969 rows=58742 loops=1) Workers Planned: 2 Workers Launched: 2 -> Sort (actual time=420266.150..420267.935 rows=19581 loops=3) Sort Key: (ts_rank(to_tsvector(document_text), plainto_tsquery('запрос'::text))) DESC Sort Method: quicksort Memory: 2278kB -> Parallel Seq Scan on documents_document (actual time=65.454..420235.446 rows=19581 loops=3) Filter: (to_tsvector(document_text) @@ plainto_tsquery('запрос'::text)) Rows Removed by Filter: 140636
Planning time: 3.706 ms
Execution time: 420315.895 ms

На один запрос! 420 секунд!

В этом ничего страшного нет. Ещё база генерит множество ворнингов вида [54000] word is too long to be indexed. Он вставляет множество   всюду, где можно, и их бывает по 54 тысячи штук подряд. Причина в том, что в моей базе лежат документы, созданные в WYSIWYG-редакторе. Postgres слова такой длины игнорирует и пишет ворнинг, который нельзя отключить.

Попробуем исправить все замеченные проблемы и ускорить поиск.

Наивно оптимизируем поиск

В ней ~12 тысяч документов. Играться с боевой базой мы не будем, конечно — создадим тестовую базу. Непростительно долго! Запрос из примера там выполняется ~35 секунд.

Результаты EXPLAIN

Sort (actual time=35431.874..35432.208 rows=3593 loops=1) Sort Key: (ts_rank(to_tsvector(document_text), plainto_tsquery('запрос'::text))) DESC Sort Method: quicksort Memory: 377kB -> Seq Scan on documents_document (actual time=8.470..35429.261 rows=3593 loops=1) Filter: (to_tsvector(document_text) @@ plainto_tsquery('запрос'::text)) Rows Removed by Filter: 9190
Planning time: 0.200 ms
Execution time: 35432.294 ms

Индекс

Самый простой способ: функциональный индекс. В первую очередь, конечно, надо добавить индекс.

CREATE INDEX idx_gin_document ON documents_document USING gin (to_tsvector('russian', "document_text"));

Ему надо пройтись по базе и вызвать функцию to_tsvector для каждой записи. Создаваться такой индекс будет долго — на тестовой базе ему понадобилось ~26 секунд. Хотя поиск он всё же ускоряет до 12 секунд, это всё ещё непростительно долго!

Результаты EXPLAIN

Sort (actual time=12213.943..12214.327 rows=3593 loops=1) Sort Key: (ts_rank(to_tsvector('russian'::regconfig, document_text), plainto_tsquery('запрос'::text))) DESC Sort Method: quicksort Memory: 377kB -> Bitmap Heap Scan on documents_document (actual time=3.849..12212.248 rows=3593 loops=1) Recheck Cond: (to_tsvector('russian'::regconfig, document_text) @@ plainto_tsquery('запрос'::text)) Heap Blocks: exact=946 -> Bitmap Index Scan on idx_gin_document (actual time=0.427..0.427 rows=3593 loops=1) Index Cond: (to_tsvector('russian'::regconfig, document_text) @@ plainto_tsquery('запрос'::text))
Planning time: 0.109 ms
Execution time: 12214.452 ms

Многократный вызов to_tsvector

При изменении данных в таблице с документами, конечно, надо обновлять его — через триггеры в БД, с помощью бэкэнда. Для решения этой проблемы нужно хранить tsvector в базе.

Сделать это можно двумя способами:

  1. Добавить колонку типа tsvector в таблицу с документами.
  2. Создать отдельную таблицу с one-to-one связью с таблицей документов, и хранить там вектора.

При бэкапе не придется тратить время и место на tsvector, которые бэкапить вообще не нужно. Плюсы первого подхода: отсутствие join-ов при поиске.
Плюсы второго подхода: отсутствие лишних данных в таблице с документами, она остается такого же размера, как и раньше.

Оба похода ведут к тому, что данных на диске становится вдвое больше: хранятся тексты документов и их вектора.

Я для себя выбрал второй подход, его преимущества для меня весомей.

Создание индекса

CREATE INDEX idx_gin_document ON documents_documentvector USING gin ("document_text");

Новый поисковый запрос

SELECT documents_document.id, ts_rank("text", plainto_tsquery('запрос'))
FROM documents_document LEFT JOIN documents_documentvector ON documents_document.id = documents_documentvector.document_id
WHERE "text" @@ plainto_tsquery('запрос')
ORDER BY ts_rank("text", plainto_tsquery('запрос')) DESC;

Добавление данных заняло 24 секунды на тестовой базе, а создание индекса — всего 2,7 секунды. Добавим данные в связанную таблицу и создадим индекс. Обновление индекса и данных, как видим, существенно не ускорилось, но сам индекс теперь можно обновить очень быстро.

А во сколько раз ускорился сам поиск?

Sort (actual time=48.147..48.432 rows=3593 loops=1) Sort Key: (ts_rank(documents_documentvector.text, plainto_tsquery('запрос'::text))) DESC Sort Method: quicksort Memory: 377kB -> Hash Join (actual time=2.281..47.389 rows=3593 loops=1) Hash Cond: (documents_document.id = documents_documentvector.document_id) -> Seq Scan on documents_document (actual time=0.003..2.190 rows=12783 loops=1) -> Hash (actual time=2.252..2.252 rows=3593 loops=1) Buckets: 4096 Batches: 1 Memory Usage: 543kB -> Bitmap Heap Scan on documents_documentvector (actual time=0.465..1.641 rows=3593 loops=1) Recheck Cond: (text @@ plainto_tsquery('запрос'::text)) Heap Blocks: exact=577 -> Bitmap Index Scan on idx_gin_document (actual time=0.404..0.404 rows=3593 loops=1) Index Cond: (text @@ plainto_tsquery('запрос'::text))
Planning time: 0.410 ms
Execution time: 48.573 ms

Метрики без join

Запрос:

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

Результат:

Sort (actual time=44.339..44.487 rows=3593 loops=1) Sort Key: (ts_rank(text, plainto_tsquery('запрос'::text))) Sort Method: quicksort Memory: 265kB -> Bitmap Heap Scan on documents_documentvector (actual time=0.692..43.682 rows=3593 loops=1) Recheck Cond: (text @@ plainto_tsquery('запрос'::text)) Heap Blocks: exact=577 -> Bitmap Index Scan on idx_gin_document (actual time=0.577..0.577 rows=3593 loops=1) Index Cond: (text @@ plainto_tsquery('запрос'::text))
Planning time: 0.182 ms
Execution time: 44.610 ms

И это несмотря на join и ts_rank. Невероятно! Уже вполне приемлемый результат, большую часть времени отнимет не поиск, а вычисление ts_rank для каждой из строк.

Многократный вызов ts_rank

44 миллисекунды — вполне достойное время выполнения. Кажется, мы успешно решили все наши проблемы, кроме этой. Не тут-то было! Кажется, хэппи-энд близок?

Запустим тот же самый запрос без ts_rank и сравним результаты.

Без ts_rank

Запрос:

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

Результат:

Bitmap Heap Scan on documents_documentvector (actual time=0.503..1.609 rows=3593 loops=1) Recheck Cond: (text @@ plainto_tsquery('запрос'::text)) Heap Blocks: exact=577 -> Bitmap Index Scan on idx_gin_document (actual time=0.439..0.439 rows=3593 loops=1) Index Cond: (text @@ plainto_tsquery('запрос'::text))
Planning time: 0.147 ms
Execution time: 1.715 ms

В тридцать раз быстрее! 1,7 мс! Разница в любом случае на порядок, и 1,5 секунды — не то время, которое хочется ждать ответа от базы. Для боевой базы результаты ~150 мс и 1,5 секунды. Что же делать?

Выключить сортировку по релевантности нельзя, сократить количество строк для подсчета — нельзя (база должна вычислить ts_rank для всех совпавших документов, иначе их нельзя отсортировать).

Но мне подобный подход не нравится: правильно отобрать нужные запросы довольно сложно, и поиск все равно будет тормозить на запросах неправильных. Кое-где в интернете рекомендуют кэшировать наиболее частые запросы (и, соответственно, вызов ts_rank).

К сожалению, из коробки в PostgreSQL ничего такого сделать не получится. Очень хотелось бы, чтобы после прохода по индексу данные приходили в уже отсортированном виде, как это делает тот же Sphinx.

Подробно о нём можно почитать, например, в презентации его авторов. Но нам повезло — так умеет делать индекс RUM. "расстояние" между tsvector и tsquery и выдавать сортированный результат сразу после сканирования индекса. Он хранит дополнительную информацию о запросе, которая позволяет прямо в индексе оценивать т.н.

У него есть минусы, плюсы и границы применения — об этом я напишу в следующей статье. Но выкидывать GIN и устанавливать RUM сразу не стоит.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть