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

UPD. Часть 2

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


Завязка

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

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

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

  • Подтюнить Sphinx: настроить realtime-индекс, хранить в индексе атрибуты для фильтрации.
  • Использовать встроенный FTS PostgreSQL.
  • Решено было реализовывать второе решение: так можно нативно обеспечить автообновление индекса, избавиться от долгого общения между двумя сервисами и мониторить один сервис вместо двух.

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

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

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

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

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

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

    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-ы документов, в тексте которых есть слово «запрос», и отсортировали их по убыванию релевантности. Кажется, всё хорошо? Нет.

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

  • Мы не используем индекс для поиска.
  • Функция ts_vector вызывается для каждой строки таблицы.
  • Функция 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. В этом ничего страшного нет. Причина в том, что в моей базе лежат документы, созданные в WYSIWYG-редакторе. Он вставляет множество   всюду, где можно, и их бывает по 54 тысячи штук подряд. Postgres слова такой длины игнорирует и пишет ворнинг, который нельзя отключить.

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

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

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

    Результаты EXPLAINSort (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»));

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

    Результаты EXPLAINSort (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 в базе. При изменении данных в таблице с документами, конечно, надо обновлять его — через триггеры в БД, с помощью бэкэнда.

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

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

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

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

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

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

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

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

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

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

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