Базовый и полнотекстовый поиск с Django и Postgres

В отличие от реляционных баз данных, полнотекстовый поиск не стандартизирован. Существует несколько вариантов с открытым исходным кодом, таких как ElasticSearch, Solr и Xapian. ElasticSearch, вероятно, является самым популярным решением, однако его сложно настроить и поддерживать. Кроме того, если вы не пользуетесь некоторыми расширенными функциями, которые предлагает ElasticSearch, вам следует придерживаться возможностей полнотекстового поиска, которые предлагают многие реляционные (например, Postgres, MySQL, SQLite) и нереляционные базы данных (например, MongoDB и CouchDB). Postgres, в частности, хорошо подходит для полнотекстового поиска. Django также поддерживает его "из коробки".

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

В этом руководстве вы узнаете, как добавить базовый и полнотекстовый поиск в приложение Django с помощью Postgres. Вы также оптимизируете полнотекстовый поиск, добавив векторное поле поиска и индекс базы данных.

Это руководство для среднего уровня. Предполагается, что вы знакомы как с Django, так и с Docker. Ознакомьтесь с Руководством по настройке Django с помощью Postgres, Gunicorn и Nginx для получения дополнительной информации.

Цели

К концу этого урока вы сможете:

  1. Настройка базовых функций поиска в приложении Django с помощью модуля Q object
  2. Добавить полнотекстовый поиск в приложение Django
  3. Сортируйте результаты полнотекстового поиска по релевантности, используя методы определения значимости, ранжирования и взвешивания
  4. Добавьте предварительный просмотр к результатам поиска
  5. Оптимизируйте полнотекстовый поиск с помощью векторного поля поиска и индекса базы данных

Настройка и обзор проекта

Клонировать базовую ветвь из django-search репозитория:

$ git clone https://github.com/testdrivenio/django-search --branch base --single-branch
$ cd django-search

Вы будете использовать Docker для упрощения настройки и запуска Postgres вместе с Django.

В корне проекта создайте изображения и разверните контейнеры Docker:

$ docker-compose up -d --build

Затем примените изменения и создайте суперпользователя:

$ docker-compose exec web python manage.py makemigrations
$ docker-compose exec web python manage.py migrate
$ docker-compose exec web python manage.py createsuperuser

После завершения перейдите к http://127.0.0.1:8011/quotes/, чтобы убедиться, что приложение работает должным образом. Вы должны увидеть следующее:

Quote Home Page

Хотите научиться работать с Django и Postgres? Ознакомьтесь со статьей О настройке Django с помощью Postgres, Gunicorn и Nginx.

Обратите внимание на модель Quote в quotes/models.py:

from django.db import models

class Quote(models.Model):
    name = models.CharField(max_length=250)
    quote = models.TextField(max_length=1000)

    def __str__(self):
        return self.quote

Затем выполните следующую команду управления, чтобы добавить 10 000 цитат в базу данных:

$ docker-compose exec web python manage.py add_quotes

Это займет пару минут. После завершения перейдите к http://127.0.0.1:8011/quotes/, чтобы просмотреть данные.

Вывод представления кэшируется в течение пяти минут, поэтому вы можете закомментировать @method_decorator в quotes/views.py , чтобы загрузить кавычки. После этого обязательно удалите комментарий.

Quote Home Page

В файле quotes/templates/quote.html у вас есть простая форма с полем для ввода поиска:

<form action="{% url 'search_results' %}" method="get">
  <input
    type="search"
    name="q"
    placeholder="Search by name or quote..."
    class="form-control"
  />
</form>

При отправке форма отправляет данные в серверную часть. Используется запрос GET, а не POST, таким образом, у нас есть доступ к строке запроса как в URL, так и в представлении Django, что позволяет пользователям обмениваться результатами поиска в виде ссылок.

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

Когда дело доходит до поиска, в Django вы обычно начинаете с выполнения поисковых запросов с contains или icontains для получения точных совпадений. Объект Q также можно использовать для добавления логических операторов AND (&) или OR (|).

Например, используя оператор OR, переопределите значение SearchResultsList по умолчанию QuerySet в quotes/views.py примерно так:

class SearchResultsList(ListView):
    model = Quote
    context_object_name = "quotes"
    template_name = "search.html"

    def get_queryset(self):
        query = self.request.GET.get("q")
        return Quote.objects.filter(
            Q(name__icontains=query) | Q(quote__icontains=query)
        )

Здесь мы использовали метод filter для фильтрации по полям name или quote. Кроме того, мы использовали расширение icontains, чтобы проверить, присутствует ли запрос в полях name или quote (без учета регистра). При обнаружении совпадения будет возвращен положительный результат.

Не забудьте импортировать:

from django.db.models import Q

Попробуйте:

Search Page

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

Базовый поиск, который мы видели ранее, имеет ряд ограничений, особенно если вы хотите выполнять сложный поиск.

Как уже упоминалось, при обычном поиске вы можете выполнять только точные совпадения.

Другим ограничением является использование стоп-слов. Стоп-слова - это такие слова, как "a", "an" и "the". Эти слова являются обычными и недостаточно осмысленными, поэтому их следует игнорировать. Для проверки попробуйте выполнить поиск по слову, перед которым стоит "the". Допустим, вы искали "the middle". В этом случае вы увидите результаты только для "the middle", поэтому вы не увидите результатов, в которых есть слово "middle" без "the" перед ним.

Допустим, у вас есть эти два предложения:

  1. Я учусь в средней школе.
  2. Тебе не нравится средняя школа.

При каждом типе поиска вы получите следующие результаты:

Query Basic Search Full-text Search
"the middle" 1 1 and 2
"middle" 1 and 2 1 and 2

Другая проблема заключается в игнорировании похожих слов. При обычном поиске возвращаются только точные совпадения. Однако при полнотекстовом поиске учитываются похожие слова. Для проверки попробуйте найти несколько похожих слов, таких как "пони" и "ponies". При обычном поиске, если вы введете запрос "пони", вы не увидите результатов, содержащих "пони", и наоборот.

Допустим, у вас есть эти два предложения:

  1. Я пони.
  2. Тебе не нравятся пони

При каждом типе поиска вы получите следующие результаты:

Query Basic Search Full-text Search
"pony" 1 1 and 2
"ponies" 2 1 and 2

При использовании полнотекстового поиска обе эти проблемы устраняются. Однако имейте в виду, что в зависимости от вашей цели полнотекстовый поиск может на самом деле снизить точность (качество) и <3>>отзывчивость (количество релевантных результатов). Как правило, полнотекстовый поиск менее точен, чем обычный, поскольку он дает точные совпадения. Тем не менее, если вы ищете в больших наборах данных с большими блоками текста, предпочтительнее использовать полнотекстовый поиск, поскольку он обычно выполняется намного быстрее.

Полнотекстовый поиск - это продвинутый метод поиска, который проверяет все слова в каждом сохраненном документе на соответствие критериям поиска. Кроме того, с помощью полнотекстового поиска вы можете использовать зависящие от языка словоупотребления для индексируемых слов. Например, слова "drives", "drivened" и "ведомый" будут записываться как единое концептуальное слово "drive". Стемминг - это процесс приведения слов к их основному слову, основному основанию или корневой форме.

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

Чтобы воспользоваться преимуществами полнотекстового поиска в Postgres с помощью Django, добавьте django.contrib.postgres в свой список INSTALLED_APPS:

INSTALLED_APPS = [
    ...

    "django.contrib.postgres",  # new
]

Далее давайте рассмотрим два кратких примера полнотекстового поиска: по одному полю и по нескольким полям.

Обновите функцию get_queryset в SearchResultsList функции просмотра следующим образом:

class SearchResultsList(ListView):
    model = Quote
    context_object_name = "quotes"
    template_name = "search.html"

    def get_queryset(self):
        query = self.request.GET.get("q")
        return Quote.objects.filter(quote__search=query)

Здесь мы настраиваем полнотекстовый поиск по одному полю - полю цитаты.

Search Page

Как вы можете видеть, в нем учитываются похожие слова. В приведенном выше примере "пони" и "pony" рассматриваются как похожие слова.

Для поиска по нескольким полям и связанным моделям можно использовать класс SearchVector.

Еще раз обновите SearchResultsList:

class SearchResultsList(ListView):
    model = Quote
    context_object_name = "quotes"
    template_name = "search.html"

    def get_queryset(self):
        query = self.request.GET.get("q")
        return Quote.objects.annotate(search=SearchVector("name", "quote")).filter(
            search=query
        )

Для поиска по нескольким полям вы добавляетев набор запросов, используя SearchVector. Вектор - это данные, которые вы ищете и которые были преобразованы в форму, удобную для поиска. В приведенном выше примере этими данными являются поля name и quote в вашей базе данных.

Обязательно добавьте импорт:

from django.contrib.postgres.search import SearchVector

Попробуйте выполнить поиск.

Выявление и ранжирование

В этом разделе вы объедините несколько методов, таких как Поисковый вектор, Поисковый запрос и Поисковый рейтинг для создания очень надежного поиска, использующего как поиск по источникам, так и ранжирование.

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

Обновить SearchResultsList:

class SearchResultsList(ListView):
    model = Quote
    context_object_name = "quotes"
    template_name = "search.html"

    def get_queryset(self):
        query = self.request.GET.get("q")
        search_vector = SearchVector("name", "quote")
        search_query = SearchQuery(query)
        return (
            Quote.objects.annotate(
                search=search_vector, rank=SearchRank(search_vector, search_query)
            )
            .filter(search=search_query)
            .order_by("-rank")
        )

Что здесь происходит?

  1. SearchVector - и снова вы использовали вектор поиска для поиска по нескольким полям. Данные преобразуются в другую форму, поскольку вы больше не выполняете поиск по исходному тексту, как это было при использовании icontains. Таким образом, вы сможете легко выполнять поиск во множественном числе. Например, поиск по "flask" и "фляжкам" приведет к одному и тому же результату поиска, потому что это, ну, в общем, в основном одно и то же.
  2. SearchQuery - переводит слова, предоставленные нам в виде запроса из формы, пропускает их через логический алгоритм, а затем ищет совпадения для всех полученных терминов.
  3. SearchRank - позволяет нам упорядочить результаты по релевантности. При этом учитывается, как часто термины запроса встречаются в документе, насколько близко они расположены в документе и насколько важна та часть документа, в которой они встречаются.

Добавьте импортные данные:

from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank

Search Page

Сравните результаты обычного поиска с результатами полнотекстового поиска. Разница очевидна. В полнотекстовом поиске запрос с наибольшими результатами отображается первым. В этом и заключается сила SearchRank. Объединение SearchVector, SearchQuery, и SearchRank - это быстрый способ создать гораздо более мощный и точный поиск, чем при обычном поиске.

Добавление весов

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

Значение должно быть одним из следующих значений: D, C, B, A. По умолчанию эти значения относятся к числам 0,1, 0,2, 0,4 и 1,0 соответственно.

Обновить SearchResultsList:

class SearchResultsList(ListView):
    model = Quote
    context_object_name = "quotes"
    template_name = "search.html"

    def get_queryset(self):
        query = self.request.GET.get("q")
        search_vector = SearchVector("name", weight="B") + SearchVector(
            "quote", weight="A"
        )
        search_query = SearchQuery(query)
        return (
            Quote.objects.annotate(rank=SearchRank(search_vector, search_query))
            .filter(rank__gte=0.3)
            .order_by("-rank")
        )

Здесь вы добавили значения к SearchVector, используя оба поля name и quote. Значения 0,4 и 1,0 были применены к полям "Имя" и "цитата" соответственно. Таким образом, совпадения с цитатами будут преобладать над совпадениями с названием и содержанием. Наконец, вы отфильтровали результаты, чтобы отобразить только те, которые превышают 0,3.

Добавление предварительного просмотра к результатам поиска

В этом разделе вы добавите небольшой предварительный просмотр результатов поиска с помощью метода SearchHeadline. При этом будет выделен запрос результата поиска.

Обновите SearchResultsList еще раз:

class SearchResultsList(ListView):
    model = Quote
    context_object_name = "quotes"
    template_name = "search.html"

    def get_queryset(self):
        query = self.request.GET.get("q")
        search_vector = SearchVector("name", "quote")
        search_query = SearchQuery(query)
        search_headline = SearchHeadline("quote", search_query)
        return Quote.objects.annotate(
            search=search_vector,
            rank=SearchRank(search_vector, search_query)
        ).annotate(headline=search_headline).filter(search=search_query).order_by("-rank")

Поле SearchHeadline содержит поле, которое вы хотите просмотреть. В данном случае это будет поле quote вместе с запросом, которое будет выделено жирным шрифтом.

Обязательно добавьте импорт:

from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank, SearchHeadline

Перед выполнением некоторых поисковых запросов обновите <li></li> в quotes/templates/search.html вот так:

<li>{{ quote.headline | safe }} - <b>By <i>{{ quote.name }}</i></b></li>

Теперь вместо отображения котировок, как вы делали раньше, отображается только предварительный просмотр полного поля котировок вместе с выделенным поисковым запросом.

Повышение производительности

Полнотекстовый поиск - это трудоемкий процесс. Для борьбы с низкой производительностью вы можете:

  1. Сохраните векторы поиска в базе данных с помощью поля SearchVectorField. Другими словами, вместо того, чтобы преобразовывать строки в векторы поиска "на лету", мы создадим отдельное поле базы данных, содержащее обработанные векторы поиска, и будем обновлять это поле при каждой вставке или обновлении полей quote или name.
  2. Создайте индекс базы данных, который представляет собой структуру данных, повышающую скорость процессов поиска данных в базе данных. Таким образом, это ускоряет выполнение запроса. Postgres предоставляет вам несколько индексов для работы, которые могут быть применимы в разных ситуациях. Гининдекс, пожалуй, самый популярный из них.

Чтобы узнать больше о производительности полнотекстового поиска, ознакомьтесь с разделом Производительность из документации Django.

Векторное поле поиска

Начните с добавления нового поля SearchVectorField в модель Quote в quotes/models.py:

from django.contrib.postgres.search import SearchVectorField  # new
from django.db import models


class Quote(models.Model):
    name = models.CharField(max_length=250)
    quote = models.TextField(max_length=1000)
    search_vector = SearchVectorField(null=True)  # new

    def __str__(self):
        return self.quote

Создайте файл переноса:

$ docker-compose exec web python manage.py makemigrations

Теперь вы можете заполнить это поле только в том случае, если объекты quote или name уже существуют в базе данных. Таким образом, нам нужно добавить триггер для обновления поля search_vector всякий раз, когда обновляются поля quote или name. Чтобы добиться этого, создайте пользовательский файл миграции в разделе "цитаты/миграции" с именем 0003_search_vector_trigger.py:

from django.contrib.postgres.search import SearchVector
from django.db import migrations


def compute_search_vector(apps, schema_editor):
    Quote = apps.get_model("quotes", "Quote")
    Quote.objects.update(search_vector=SearchVector("name", "quote"))


class Migration(migrations.Migration):

    dependencies = [
        ("quotes", "0002_quote_search_vector"),
    ]

    operations = [
        migrations.RunSQL(
            sql="""
            CREATE TRIGGER search_vector_trigger
            BEFORE INSERT OR UPDATE OF name, quote, search_vector
            ON quotes_quote
            FOR EACH ROW EXECUTE PROCEDURE
            tsvector_update_trigger(
                search_vector, 'pg_catalog.english', name, quote
            );
            UPDATE quotes_quote SET search_vector = NULL;
            """,
            reverse_sql="""
            DROP TRIGGER IF EXISTS search_vector_trigger
            ON quotes_quote;
            """,
        ),
        migrations.RunPython(
            compute_search_vector, reverse_code=migrations.RunPython.noop
        ),
    ]

В зависимости от структуры вашего проекта вам может потребоваться обновить имя предыдущего файла переноса в dependencies.

Примените изменения:

$ docker-compose exec web python manage.py migrate

Чтобы использовать новое поле для поиска, обновите SearchResultsList следующим образом:

class SearchResultsList(ListView):
    model = Quote
    context_object_name = "quotes"
    template_name = "search.html"

    def get_queryset(self):
        query = self.request.GET.get("q")
        return Quote.objects.filter(search_vector=query)

Обновите <li></li> в quotes/templates/search.html еще раз:

<li>{{ quote.quote | safe }} - <b>By <i>{{ quote.name }}</i></b></li>

Индекс

Наконец, давайте создадим функциональный индекс, GinIndex.

Обновите модель Quote:

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


class Quote(models.Model):
    name = models.CharField(max_length=250)
    quote = models.TextField(max_length=1000)
    search_vector = SearchVectorField(null=True)

    def __str__(self):
        return self.quote

    # new
    class Meta:
        indexes = [
            GinIndex(fields=["search_vector"]),
        ]

Создайте и примените миграции в последний раз:

$ docker-compose exec web python manage.py makemigrations
$ docker-compose exec web python manage.py migrate

Протестируйте это.

Заключение

В этом руководстве мы рассказали вам о том, как добавить базовый и полнотекстовый поиск в приложение Django. Мы также рассмотрели, как оптимизировать функциональность полнотекстового поиска, добавив векторное поле поиска и индекс базы данных.

Возьмите полный код из django-search репозитория.

Вернуться на верх