Рубрики
Без рубрики

Оптимизация Postgres Полный текстовый поиск в Джангу

Postgres предоставляет отличные возможности поиска из коробки. Для большинства приложений Django нет … Помечено с Джанго, Postgres, поиск, Python.

Postgres предоставляет отличные возможности поиска из коробки. Для большинства приложений Django не нужно управлять и поддерживать кластер Elasticsearch Iearch, если вам не понадобится расширенные функции Elasticsearch Actions. Django красиво интегрирует с поиском Postgres через встроенные Postgres модуль Отказ

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

Эта страница будет прогуляться по настройке Django и Postgres, индексацию данных образца и выполнения и оптимизации полного поиска текста.

Примеры проходят через настройку Django + Postgres, однако, рекомендация обычно применим к любому языку или структуре программирования, если она использует Postgres.

Если вы уже есть ветеран Django, вы можете пропустить первые шаги и прыгать прямо в оптимизацию.

  • Настройка проекта
  • Создание моделей и индексирование данных образца
  • Оптимизация поиска
    • Специализированный поисковой столбец и индексы джина
    • Postgres Triggers.
  • Измерение улучшения производительности
  • Недостатки
  • Вывод

Настройка проекта

Создайте каталоги и установить проект Django.

mkdir django_postgres
cd django_postgres
python -m venv venv
source venv/bin/activate
pip install django
django-admin startproject full_text_search
cd full_text_search
./manage.py startapp web

Теперь нам нужно будет установить 3 зависимости:

  • psycopg2: Библиотека клиента Postgres для Python
  • Wikipedia: клиентская библиотека для извлечения статей Wikipedia
  • Django-расширения: упростить отладку запросов SQL
pip install psycopg2 wikipedia django-extensions

Нам также нужно проводить Postgres локально. Я буду использовать докеренную версию Postgres здесь, так как она проще настроить, но не стесняйтесь устанавливать Bidgrees Binary, если вы хотите.

Открыть full_text_search/docker-compose.yml

---
version: '2.4'
services:
  postgres:
    image: postgres:11-alpine
    ports:
      - '5432:5432'
    environment:
      # Set the Postgres environment variables for bootstrapping the default
      # database and user.
      POSTGRES_DB: "my_db"
      POSTGRES_USER: "me"
      POSTGRES_PASSWORD: "password"

Структура проекта теперь должна выглядеть как вывод ниже. Мы проигнорируем каталог VINV, так как он упакован с файлами и неэлевантным.

$ tree -I venv
.
└── full_text_search
    ├── docker-compose.yml
    ├── full_text_search
    │   ├── __init__.py
    │   ├── __pycache__
    │   │   ├── __init__.cpython-37.pyc
    │   │   └── settings.cpython-37.pyc
    │   ├── settings.py
    │   ├── urls.py
    │   └── wsgi.py
    ├── manage.py
    └── web
        ├── admin.py
        ├── apps.py
        ├── __init__.py
        ├── migrations
        │   └── __init__.py
        ├── models.py
        ├── tests.py
        └── views.py

5 directories, 15 files

Мы изменим настройки базы данных по умолчанию для использования Postgres вместо SQLite. В Settings.py Изменить Базы данных атрибут:

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "my_db",
        "USER": "me",
        "PASSWORD": "password",
        "HOST": "localhost",
        "PORT": "5432",
        "OPTIONS": {"connect_timeout": 2},
    }
}

Мы также изменим наше Stall_apps Чтобы включить несколько приложений:

  • django.contrib.postgres Модуль Postgres для Django, который требуется для полного текстового поиска
  • django_extensions Чтобы распечатать журналы SQL при выполнении запросов в Python
  • наше Веб приложение

Открыть full_text_search/settings.py и изменить:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # Added apps below
    'django.contrib.postgres',
    'django_extensions',
    'web',
]

Начать postgres и django.

cd full_text_search
docker-compose up -d
./manage.py runserver

Если мы открываем наш браузер и введите http://localhost: 8000 Мы должны увидеть успешную установку.

Создание моделей и индексирование данных образца

Предположим, у нас есть модель, которая представляет собой страницу Википедии. Для простоты мы будем использовать два поля: заголовок и контент.

Открыть full_text_search/web/models.py

from django.db import models

class Page(models.Model):
    title = models.CharField(max_length=100, unique=True)
    content = models.TextField()

Теперь запустите миграцию, чтобы создать модель.

cd full_text_search
./manage.py makemigrations && ./manage.py migrate
No changes detected
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  # truncated the other output for brievity

Мы будем использовать сценарий для индекса случайных статей Wikipedia и сохранить содержимое в Postgres.

Редактировать Web/index_wikipedia.py.

import logging
import wikipedia
from .models import Page

logger = logging.getLogger("django")

def index_wikipedia(num_pages):
    for _ in range(0, num_pages):
        p = wikipedia.random()
        try:
            wiki_page = wikipedia.page(p)
            Page.objects.update_or_create(title=wiki_page.title, defaults={
                "content": wiki_page.content
            })
        except Exception:
            logger.exception("Failed to index %s", p)

Теперь давайте запустим наш сценарий для индекса Wikipedia. При запуске скрипта будут ошибки, но не беспокойтесь о том, пока нам удается хранить несколько сотен статей. Скрипт займет некоторое время, чтобы запустить, так что возьмите чашку кофе и вернемся через несколько минут.

./manage.py shell_plus

>>> from web.index_wikipedia import index_wikipedia
>>> index_wikipedia(200)

#
## A bunch of errors will be show here, ignore them.
#

>>> Page.objects.count()
183

Оптимизация поиска

Теперь предположим, что мы хотим позволить пользователям выполнить полный поиск текста по контенту. Мы впитете в интерактивное запросу наш набор набора данных, чтобы проверить полный текстовый поиск. Откройте сеанс Shell Django:

$ ./manage.py shell_plus --print-sql

>>> Page.objects.filter(content__search='football')
SELECT t.oid,
       typarray
  FROM pg_type t
  JOIN pg_namespace ns
    ON typnamespace = ns.oid
 WHERE typname = 'hstore'

Execution time: 0.001440s [Database: default]

SELECT typarray
  FROM pg_type
 WHERE typname = 'citext'

Execution time: 0.000260s [Database: default]

SELECT "web_page"."id",
       "web_page"."title",
       "web_page"."content"
  FROM "web_page"
 WHERE to_tsvector(COALESCE("web_page"."content", '')) @@ (plainto_tsquery('football')) = true
 LIMIT 21

Execution time: 0.222619s [Database: default]

, ...]>

Django выполняет две подготовительные запросы и, наконец, выполняет наш поисковый запрос. Глядя на последний запрос, мы можем на первый взгляд видеть, что исполнение составляло ~ 315 мс для выполнения запроса и только сериализации. Это слишком медленно, когда мы хотим сохранить скорость нагрузки на странице в двойных цифрах в миллисекундах.

Давайте посмотрим, почему этот запрос выполняет так медленно. Откройте второй терминал, где мы будем использовать отличные Postgres Query Analyzer Отказ Скопируйте запрос сверху и запустите Объяснить анализировать :

$ ./manage.py dbshell
psql (10.8 (Ubuntu 10.8-0ubuntu0.18.10.1), server 11.2)

Type "help" for help.

my_db=# explain analyze SELECT "web_page"."id",
my_db-#        "web_page"."title",
my_db-#        "web_page"."content"
my_db-#   FROM "web_page"
my_db-#  WHERE to_tsvector(COALESCE("web_page"."content", '')) @@ (plainto_tsquery('football')) = true
my_db-#  LIMIT 21
my_db-# ;
                                                  QUERY PLAN
---------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.00..106.71 rows=1 width=643) (actual time=5.001..220.212 rows=18 loops=1)
   ->  Seq Scan on web_page  (cost=0.00..106.71 rows=1 width=643) (actual time=4.999..220.206 rows=18 loops=1)
         Filter: (to_tsvector(COALESCE(content, ''::text)) @@ plainto_tsquery('football'::text))
         Rows Removed by Filter: 165
 Planning Time: 3.336 ms
 Execution Time: 220.292 ms
(6 rows)

Мы видим, что, хотя время планирования довольно быстро (~ 3 мс) время выполнения очень медленно при ~ 220 мс.

->  Seq Scan on web_page  (cost=0.00..106.71 rows=1 width=643) (actual time=4.999..220.206 rows=18 loops=1)

Мы можем отметить, что запрос выполняет последовательное сканирование по всей таблице, чтобы найти соответствующие записи. Мы, вероятно, можем оптимизировать запрос, используя индекс.

Filter: (to_tsvector(COALESCE(content, ''::text)) @@ plainto_tsquery('football'::text))

Кроме того, запрос нормализует Содержание Столбец из текста на ЦВВЕКТ, используя to_tsvector Для того, чтобы выполнить полный текстовый поиск.

WHERE to_tsvector(COALESCE("web_page"."content", '')) @@ (plainto_tsquery('football')) = true

Tsvector Тип – это токеризованная версия нашего текста, который нормализует столбец поиска (больше на токенизации здесь ). Postgres необходимо выполнить эту нормализацию для каждой строки, а каждая строка содержит целую страницу Wikipedia. Это как интенсивная и медленная операция CPU.

Специализированный поисковой столбец и индексы джина

Во избежание промежуточного литья текста в Tsvectors мы создадим специализированный столбец, который используется только для поиска. Столбец должен быть заполнен на вставках или обновлениях. При выполнении запроса мы избегаем штрафа производительности типов литья.

Поскольку теперь мы можем иметь тип Tsvector, мы также можем добавить индекс джина для ускорения запроса. Индекс GIN гарантирует, что поиск будет выполнен с индексированным сканированием вместо последовательного сканирования по всем записям.

Откройте наш web/models.py Файл и внесите модификации модели страницы.

from django.db import models
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex

class Page(models.Model):
    title = models.CharField(max_length=100, unique=True)
    content = models.TextField()

    # New modifications. A field and an index
    content_search = SearchVectorField(null=True)

    class Meta:
        indexes = [GinIndex(fields=["content_search"])]

Запустить миграцию

Migrations for 'web':
  web/migrations/0002_auto_20190525_0647.py
    - Add field content_search to page
    - Create index web_page_content_505071_gin on field(s) content_search of model page
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, web
Running migrations:
  Applying web.0002_auto_20190525_0647... OK

Postgres Triggers.

Теоретически наша проблема теперь решена. У нас есть индексированный столб в джине, который должен хорошо работать, когда мы ищем на нем, но, поступив, чтобы мы ввели еще одну проблему: оптимизированные Content_Search Колонна должна быть сохранена вручную в синхронизации и обновлена всякий раз, когда Содержание Обновления столбцов.

К счастью для нас Postgres предоставляет дополнительную функцию, которая решает эту проблему, а именно триггеры Отказ Триггеры – это Postgres функционирует, что огонь, когда удельное действие выполняется подряд. Мы создадим триггер, который заполняет Content_Search всякий раз, когда Содержание ряд создан или обновляется. Таким образом, Postgres сохранит два столбца синхронизации без нас, чтобы написать любой код Python.

Чтобы добавить триггер, нам нужно создать ручную миграцию Django. Это добавит функцию триггера и обновлять все наши строки страниц, чтобы убедиться, что триггер уволен, а Content_Search населен при миграционном времени для наших существующих записей. Если у вас чрезвычайно большой набор данных, вы можете не захотеть сделать это в производстве.

Добавить новую миграцию в Веб/миграции/0003_crate_text_search_trigger.py Отказ Обязательно изменить предыдущую миграцию в зависимости Потому что ранее аутогенеративная миграция, вероятно, отличается для вас.

from django.db import migrations

class Migration(migrations.Migration):

    dependencies = [
        # NOTE: The previous migration probably looks different for you, so
        # modify this.
        ('web', '0002_auto_20190524_0957'),
    ]

    migration = '''
        CREATE TRIGGER content_search_update BEFORE INSERT OR UPDATE
        ON web_page FOR EACH ROW EXECUTE FUNCTION
        tsvector_update_trigger(content_search, 'pg_catalog.english', content);

        -- Force triggers to run and populate the text_search column.
        UPDATE web_page set ID = ID;
    '''

    reverse_migration = '''
        DROP TRIGGER content_search ON web_page;
    '''

    operations = [
        migrations.RunSQL(migration, reverse_migration)
    ]

Запустить миграцию

$ ./manage.py makemigrations && ./manage.py migrate
No changes detected
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, web
Running migrations:
  Applying web.0003_create_text_search_trigger... OK

Измерение улучшения производительности

Наконец, на забавную часть, давайте проверим, что запросы выполняются быстрее, чем раньше. Откройте оболочку Django снова, но при фильтрации строк используйте индексированные Content_Search столбец, а не нормальный Содержание столбец.

./manage.py shell_plus --print-sql

>>> from django.contrib.postgres.search import SearchQuery
>>> Page.objects.filter(content_search=SearchQuery('football', config='english'))
SELECT t.oid,
       typarray
  FROM pg_type t
  JOIN pg_namespace ns
    ON typnamespace = ns.oid
 WHERE typname = 'hstore'

Execution time: 0.000829s [Database: default]

SELECT typarray
  FROM pg_type
 WHERE typname = 'citext'

Execution time: 0.000310s [Database: default]

SELECT "web_page"."id",
       "web_page"."title",
       "web_page"."content",
       "web_page"."content_search"
  FROM "web_page"
 WHERE "web_page"."content_search" @@ (plainto_tsquery('english'::regconfig, 'football')) = true
 LIMIT 21

Execution time: 0.001359s [Database: default]


Время выполнения запроса от 0,220 до 0,001с!

Давайте снова проанализируем запрос, чтобы увидеть, как выполняет Postgres. Скопируйте запрос сверху и запустите через Объяснить анализировать Отказ

./manage.py dbshell

my_db=# explain analyze SELECT "web_page"."id",
my_db-#        "web_page"."title",
my_db-#        "web_page"."content",
my_db-#        "web_page"."content_search"
my_db-#   FROM "web_page"
my_db-#  WHERE "web_page"."content_search" @@ (plainto_tsquery('english'::regconfig, 'football')) = true
my_db-#  LIMIT 21
my_db-# ;
                                                                QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=8.01..12.02 rows=1 width=675) (actual time=0.022..0.022 rows=0 loops=1)
   ->  Bitmap Heap Scan on web_page  (cost=8.01..12.02 rows=1 width=675) (actual time=0.020..0.020 rows=0 loops=1)
         Recheck Cond: (content_search @@ '''football'''::tsquery)
         ->  Bitmap Index Scan on web_page_content_505071_gin  (cost=0.00..8.01 rows=1 width=0) (actual time=0.017..0.017 rows=0 loops=1)
               Index Cond: (content_search @@ '''football'''::tsquery)
 Planning Time: 3.061 ms
 Execution Time: 0.165 ms
(7 rows)

Вот интересные биты:

->  Bitmap Index Scan on web_page_content_505071_gin  (cost=0.00..8.01 rows=1 width=0) (actual time=0.017..0.017 rows=0 loops=1)

Вместо последовательного сканирования Postgres использует индекс на Content_Search столбец.

Index Cond: (content_search @@ '''football'''::tsquery)

Мы также больше не совершаем дорогих to_tsquery Операция для каждой строки и вместо этого используйте Content_Search колонка как есть.

Недостатки

К сожалению, есть компромиссы при использовании этой методики оптимизации.

  • Поскольку мы поддерживаем другой столбец нашего текста для единственной цели скорости поиска, наш размер таблицы занимает значительно больше места. Кроме того, индекс джина на Content_Search Колонна занимает место на его собственном.

  • Поскольку столбец поиска обновляется на каждом обновлении или вставьте его, также замедляет запись в базу данных.

Если вы ограничены памятью и диском или необходимости быстрых пишетов, эта техника не может быть подходящей для вашего случая использования. Однако я подозреваю, что большинство приложений Crud out есть в порядке с жертвующим диском и скоростью записи для молнии быстрого поиска.

Вывод

Postgres предлагает отличный полную возможность поиска текста, но немного замедляется из коробки. Чтобы ускорить текстовые поиски, мы добавляем вторичный столбец типа Tsvector, который является поиском оптимизированной версией нашего текста.

Мы добавляем индекс джина в столбце поиска, чтобы убедиться, что Postgres выполняет индексное сканирование, а не последовательное сканирование. Это уменьшает время выполнения запроса по порядку величины.

Чтобы поддерживать текстовый столбец и столбец поиска в Sync, мы используем триггер Postgres, который заполняет столбец поиска в любых модификациях на нашему текстовому столбцу.

Полный пример кода можно найти в Github Отказ

Оригинал: “https://dev.to/danihodovic/optimizing-postgres-full-text-search-with-django-42hg”