Evgenii Legotckoi
Evgenii Legotckoi31 марта 2023 г. 6:32

Django - Урок 063. Полнотекстовый поиск на сайте для нескольких моделей с поддержкой мультиязычности

Наконец-то мне удалось сделать достаточно быстрый полнотекстовый поиск по нескольким моделям с учётом оптимизации запросов на сайте, который бы меня устраивал и отвечал моим требованиям к качеству проекта.

Если вы сейчас воспользуетесь поиском по сайту, то обнаружите, что поиск сработает достаточно быстро, а также выдаст несколько групп поиска: Статьи, Комментари, Темы форума, Ответы на форуме, Тесты.
Из всех групп поиска будут выбраны по три результата, а также будет у каждой группы есть счётчик общего количества найденных резульлатов и предложено посмотреть остальные результаты в отдельных вкладках.

В итоге я сделал такое разделение по той причине, что создавать единый список со всеми результатми довольно накладно по ресурсам и недостаточо эффективно.

Поэтому итоговый результат выгляди следующим образом:

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

А теперь я расскажу, как именно я это сделал.

Полнотекстовый поиск в PostgreSQL

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

Самый простой способ, как запустить полнотекстовый поиск, это воспользоваться методом search по полю модели, что и описано в документации к Django.

Например так

Entry.objects.filter(text__search='Cheese')

В данном случае модель Entry имеет поле text, по которому вызывается полнотекстовый поиск с помощью встроенного функционала search.

Более развитым способом выполнения полнотекстового поиска является использовния поисковых векторов SearchVector по нескольким полям. То есть так

from django.contrib.postgres.search import SearchVector

Entry.objects.annotate(search=SearchVector('text', 'tagline')).filter(search='Cheese')

К сожалению, использования метода annotate является малоэффективным, поскольку этот метод порой требует достаточно много времени.
Для улучшения производительности средства Django предлагают использовать специальное поле SearchVectorField , которое индексируется базой данных PostgreSQL. Это позволяет значительно ускорить поиск на сайте.

Добавление SearchVectorField и индекса в модель

Я покажу добавление SearchVectorField и индексирования на примере модели Article.

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

class Article(models.Model):
    title = models.CharField('Title', max_length=200)
    content = models.TextField(verbose_name='Content', blank=True)

    # SearchVectorField for full-text search
    search_vector = SearchVectorField(null=True)

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

Как видите, в представленном коде присутствует поле search_vector, которое индексируется с помощью указания GinIndex в Meta классе модели Article.

После того, как вы добавили SearchVectorField и индексирование данного поля, создайте новые миграции

python manage.py makemigrations

В общем виде новая миграция будет похожу на эту

# Generated by Django 3.2 on 2023-03-27 21:03

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


class Migration(migrations.Migration):

    dependencies = [
        # depends on 
    ]

    operations = [
        migrations.AddField(
            model_name='article',
            name='search_vector',
            field=django.contrib.postgres.search.SearchVectorField(null=True),
        ),
        migrations.AddIndex(
            model_name='article',
            index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='knowledge_a_search__682520_gin'),
        ),
    ]

Но этого будет недостаточно, поскольку нужно ещё заполнить поле SearchVectorField, что также можно сделать во время миграции. Поэтому модифичируем миграцию следующим образом

# Generated by Django 3.2 on 2023-03-27 21:03

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


def compute_search_vector(apps, schema_editor):
    Article = apps.get_model("knowledge", "Article")
    Article.objects.update(search_vector=django.contrib.postgres.search.SearchVector("title", "content"))


class Migration(migrations.Migration):

    dependencies = [
        # depends on 
    ]

    operations = [
        migrations.AddField(
            model_name='article',
            name='search_vector',
            field=django.contrib.postgres.search.SearchVectorField(null=True),
        ),
        migrations.AddIndex(
            model_name='article',
            index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='knowledge_a_search__682520_gin'),
        ),
        migrations.RunPython(
            compute_search_vector, reverse_code=migrations.RunPython.noop
        ),
    ]

В данном коде добавлен дополнительный шаг, который добавляет выполнение python кода, чтобы сразу заполнить SearchVectorField , а именно последний шаг migrations.RunPython , который запускает функцию compute_search_vector .
SearchVectorField имеет важную особенность, в него нельзя напрямую добавить SearchVector , но его можно заполнить используя метод update у менеджера модели. Поэтому-то данный код и выглядит таким образом

Article.objects.update(search_vector=django.contrib.postgres.search.SearchVector("title", "content"))

После чего выполните миграцию

python manage.py migrate

Теперь все статьи имеют проиндексированное поисковое поле. Но на самом деле этого недостаточно для полноценной работы поисковой системы, поскольку поисковое поле нужно заполнять как в случае создания нового объекта, так и в случае редактирования старого. Для этого документация Django предлагает обратиться в документации PostgreSQL и создать триггеры. Это хорошо и правильно, но что если вы не хотите по какой-то причине лишний раз лезать в PostgreSQL и вручную создавать все необходимые триггеры. Как тогда быть? В этом случае нас спасёт сигнал/слотовая система Django.

Для этого добавим в модель Article следующий код

    def update_search_vector(self):
        qs = Article.objects.filter(pk=self.pk)
        qs.update(search_vector=SearchVector("title", "content"))

А потом подключимся к сигналу post_save

from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=Article)
def post_save_artcile(sender, instance, created, update_fields, **kwargs):
    instance.update_search_vector()

Таким образом при каждом сохранении объекта статьи будет происходить обновление поискового поля.

Класс SearchView для поиска по всем видам контента

А теперь напишем SearchView , который бы позволил выполнить поиск по нескольким видам контента и выдал бы необходимый результат. В нашем случае скажем, что у нас на сайте есть статьи ("Article") и комментарии ("Comment")

class SearchView(View):
    template_name = 'search/index.html'

    def get(self, request, *args, **kwargs):
        query = request.GET.get('search', None)
            article_results = Article.objects.filter(search_vector=query)
            comment_results = Comment.objects.filter(search_vector=query)

        return render(
            request=request,
            template_name=self.template_name,
            context={
                'search': query or '',
                'article_results': article_results[:3],
                'article_results_count': article_results.count(),
                'comment_results': comment_results[:3],
                'comment_results_count': comment_results.count(),
            }
        )

Обратите внимание, что в строке сайта передаётся get параметер search , который отвечает за поисковую фразу. Также имеет пара ухищрений для оптимизации запросов к базе данных.

Поскольку Django использует ленивые запросы к базе данных, то и реквесты выполняются ровно тогда, когда это требуется. Таким образом строка

article_results = Article.objects.filter(search_vector=query)

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

    'article_results': article_results[:3],
    'article_results_count': article_results.count(),

article_results[:3] выполняет операцию limit к найденным записям и возвращает только три объекта
article_results.count() выполняет подсчёт всех найденных записей. На самом деле подобный дополнительный код позволяет значительно сократить время выполнения запросов, что значительно ускоряет поиск на сайте.

Рендеринг страницы

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

У нас имеется главная поисковая страница, которая наследована от общего шаблона base.html (Данный шаблон не имеет смысла рассматривать).
А вот что касается кастомных тего, то здесь мы остановимся подольше. Их здесь имеется несколько:

  • append_query_to_url - тег для добавления query параметра в url страницы поиска по определённому контенту.
  • search_field - тег для рендеринга поля поиска
  • found_objects - тег для рендеринга вывода результатов по контенту
{% extends 'base.html' %}
{% block page %}
  {% load search %}
  {% url 'search:articles' as articles_search_url %}
  {% url 'search:comments' as comments_search_url %}

  {% append_query_to_url articles_search_url as articles_search_url_with_query %}
  {% append_query_to_url comments_search_url as comments_search_url_with_query %}

  {% search_field search %}

  {% block search_result %}
    {% found_objects article_results article_results_count 'Articles' 'Show all articles' articles_search_url_with_query  %}
    {% found_objects comment_results comment_results_count 'Comments' 'Show all comments' comments_search_url_with_query  %}
  {% endblock %}
{% endblock %}

Каталог "templatetags"

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

Итак, посмотрим на содержимое файла templatetags/search.py

# -*- coding: utf-8 -*-

from django import template
from django.template.defaultfilters import urlencode

register = template.Library()

@register.inclusion_tag('search/search_field.html', takes_context=True)
def search_field(context, value, **kwargs):
    context.update({'query_value': value})
    return context


@register.simple_tag(takes_context=True)
def append_query_to_url(context, url):
    return '{}?search={}'.format(url, urlencode(context.get('search', '')))

@register.inclusion_tag('search/found_objects.html', takes_context=True)
def found_objects(context, results, results_count, objects_title, all_search_message, search_url):
    context.update({
        'results': results,
        'results_count': results_count,
        'objects_title': objects_title,
        'all_search_message': all_search_message,
        'search_url': search_url
    })
    return context

Каталог "шаблоны/поиск"

Далее рассмотрим Что из себя представляют шаблоны inclusion тегов

Файл "search/found_objects.html"

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

{% load i18n %}
<div class="card box-shadow m-2">
  <div class="card-header">{{ objects_title }} <span class="badge badge-primary">{{ results_count }}</span>
  </div>
    {% if results|length > 0 %}
      {% for object in results %}
        {# You custom render of object #}
      {% endfor %}
      <div class="card-footer border-0"><a href="{{ search_url }}" class="btn btn-sm btn-outline-secondary">{{ all_search_message }}</a></div>
    {% else %}
      <div class="card-body">
        {% trans 'Nothing found' %}
      </div>
    {% endif %}
</div>

Файл "search/search_field.html"

<form method="get" class="my-3 px-3">
  <div class="input-group bmd-form-group pt-0">
  {% load i18n %}
    <input class="form-control" name="search" placeholder="{% trans 'Search' %}" value="{{ query_value }}" title="" type="text">
    <div class="input-group-append">
      <button type="submit" class="btn btn-outline-secondary mdi mdi-magnify mdi-0"></button>
    </div>
  </div>
</form>

Файл "urls.py"

Далее добавим SearchView в маршрутизатор Django .

# -*- coding: utf-8 -*-

from django.urls import path

from search import views

app_name = 'search'
urlpatterns = [
    path('', views.SearchView.as_view(), name='index'),
]

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

Класс "SearchViewByContent"

А теперь напишем более обощённый класс для поиска по разным видам контента. Для этого можно воспользоваться Generic классом ListView . Поиск будет осуществляться по полю поискового вектора. Также для правильной пагинации нам понадобится pagination url. Это всё присутствует в данном коде.

class SearchViewByContent(ListView):
    template_name = 'search/search_objects.html'
    paginate_by = 10

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context.update({
            'search': self.request.GET.get('search', None) or '',
            'last_question': self.get_pagination_url()
        })
        return context

    def get_pagination_url(self):
        return self.request.get_full_path().replace(self.request.path, '')

    def get_queryset(self):
        qs = super().get_queryset()
        query = self.request.GET.get('search', None)
        return qs.filter(search_vector=query)

Рендеринг страницы

Воспользуемся кодом уже существующего шаблона и расширим его поведение. Как видите, здесь используется шаблонный тег из батарейки django bootstrap 4 . В одной из предыдущих статей я уже описывал, как подключить эту батарейку более старой версии. С тех времён ничего не изменилось и статья также актульна и для последней версии django bootstrap 5.

{% extends 'search/index.html' %}
{% block search_result %}
  <div id="object-list">
  {% load bootstrap_pagination from bootstrap4 %}
  {% for object in object_list %}
    {# Render your object here #}
  {% empty %}
    <div class="card card-body mb-3">{{ not_found_message|default:_("Nothing found") }}</div>
  {% endfor %}
  {% if object_list %}
    <div class="mt-3">{% bootstrap_pagination object_list pages_to_show="3" url=last_question justify_content='center' %}</div>
  {% endif %}
  </div>
{% endblock %}

Добавим маршруты в файл "urls.py"

# -*- coding: utf-8 -*-

from django.urls import path

from search import views
from articles.models import Article, Comment

app_name = 'earch'
urlpatterns = [
    path('', views.IndexView.as_view(), name='index'),
    path('articles/', views.SearchView.as_view(queryset=Article.objects.all()), name='articles'),
    path('comments/', views.SearchView.as_view(queryset=Comment.objects.all()), name='comments'),
]

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

Поддержка мельтиязычности с использованием батарейки modeltranslation

Django modeltranslation является замечательным пакетом, который добавляет мультиязычность в ваши модели, но к сожалению он не совместим с полем SearchVectorField . Для этого нужно вручную добавлять новые поля SearchVectorField для каждого языка, который поддерживается на сайте. Но на самом деле это не так много работы. А выглядеть это может так

Модель

class Article(models.Model):

    # Another code

    # Search vectors
    search_vector = SearchVectorField(null=True)
    search_vector_ru = SearchVectorField(null=True)
    search_vector_en = SearchVectorField(null=True)

    objects = ArticleManager()

    class Meta:
        ordering = ['-pub_date']
        verbose_name = _('Article')
        verbose_name_plural = _('Articles')
        indexes = [
            GinIndex(fields=[
                "search_vector", "search_vector_ru", "search_vector_en",
            ]),
        ]

Соответственно новую миграцию нужно будет исправлять для каждого нового поля SearchVectorField

Просмотр поиска

А поисковый SearchView можно исправить следующим образом

class IndexView(View):
    template_name = 'evileg_search/index.html'

    def get(self, request, *args, **kwargs):
        query = request.GET.get('search', None)
        current_language = get_language()
        article_results = Article.objects.filter(
            Q(**{'search_vector_{}'.format(current_language): query}) |
            Q(search_vector=query)
        )
        comment_results = Comment.objects.filter(search_vector=query)

        return render(
            request=request,
            template_name=self.template_name,
            context={
                'search': query or '',
                'article_results': article_results[:3],
                'article_results_count': article_results.count(),
                'comment_results': comment_results[:3],
                'comment_results_count': comment_results.count(),
            }
        )

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

Также здесь есть важный момент. Я делаю поиск как по вектору с указанием языка, так и без указания языка. Это связано с тем, что я могу не иметь всех переводов для всех языков у какой-то конкретной статьи, а выдача результатов поиска, по моему мнению, всегда должна производиться.ю даже если перевод отсутствует.

Заключение

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

Рекомендуем хостинг TIMEWEB
Рекомендуем хостинг TIMEWEB
Стабильный хостинг, на котором располагается социальная сеть EVILEG. Для проектов на Django рекомендуем VDS хостинг.

Вам это нравится? Поделитесь в социальных сетях!

NSProject
  • 6 апреля 2023 г. 12:25

Замечательная статья где давольно подробно расписан поиск. Когда то я искал что то именно вот такое. И на самом деле вариантов использования очень и очень много.
Спасибо Евгений за статью.

Lissa
  • 19 апреля 2023 г. 11:02

Заметила, что в русском варианте сложно со stemming поиска. Добавила во view SearchQuery c config, стало отлавиливать (кот - котЫ). Буду признательная за инфу, как этого добиться другим способом.

Evgenii Legotckoi
  • 19 апреля 2023 г. 14:35

Пока не подскажу, не вставало такой задачи. Для меня Django/Python - это хобби, а не профессиональная область

Lissa
  • 19 апреля 2023 г. 15:47

В любом случае спасибо за ориентацию. Для моего сайта подойдёт такая логика.

Lissa
  • 24 апреля 2023 г. 5:34
  • (ред.)

К своему удивлению обнаружила, что украинский язык не включён в postgres-e в full text search. Есть какие-то кустарные варианты с прикручиванием файлов с stopwords и т.д.Я наверное сделаю "наивный" поиск, если выбран украинский.
А вы сталкивались с этой проблемой? Спасибо.

Evgenii Legotckoi
  • 24 апреля 2023 г. 5:43

А в чём это выражается? У меня поиск срабатывает по все подключённым языкам, в том числе и по украинскому.

Lissa
  • 24 апреля 2023 г. 7:08
  • (ред.)

stackoverflow
Я сделала триггеры для update полей и вектора:

# Generated by Django 4.1 on 2023-04-23 20:45

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


def compute_search_vector_uk(apps, schema_editor):
    Post = apps.get_model("posts", "Post")
    vector = SearchVector("title_uk", weight="A", config="ukrainian") + SearchVector(
        "content_uk",
        weight="B",
        config="ukrainian"

    )
    Post.objects.update(vector_uk=vector)


class Migration(migrations.Migration):

    dependencies = [
        ("posts", "0005_post_vector"),
    ]

    operations = [
        migrations.AddField(
            model_name="post",
            name="vector_uk",
            field=django.contrib.postgres.search.SearchVectorField(
                blank=True, null=True
            ),
        ),
        migrations.RunSQL(
            sql="""
            CREATE TRIGGER vector_uk_trigger
            BEFORE INSERT OR UPDATE OF title_uk, content_uk, vector_uk
            ON posts_post
            FOR EACH ROW EXECUTE PROCEDURE
            tsvector_update_trigger(
                vector_uk, 'pg_catalog.ukrainian', title_uk, content_uk
            );
            UPDATE posts_post SET vector_uk = NULL;
            """,
            reverse_sql="""
            DROP TRIGGER IF EXISTS vector_uk_trigger
            ON posts_post;
            """,
        ),
        migrations.RunPython(
            compute_search_vector_uk, reverse_code=migrations.RunPython.noop
        ),
    ]

Ошибка выпадает на pg_catalog.ukrainian, жалуется, что нет такого.
p.s Сделала менеджер модели, к фильрует поле на украинском (без конфигураций, иначе тоже жалуется).

Lissa
  • 28 апреля 2023 г. 17:01
  • (ред.)

Может быть пригодится тем, кто использует django-ckeditor-5 for admin + modeltranslation package. Поле для редактирования in models.py нужно прописывать как обычное текстовое. А вот в админ классе обозначать

formfield_overrides = {
        models.TextField: {"widget": CKEditor5Widget},
    }

Комментарии

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

C++ - Тест 002. Константы

  • Результат:16баллов,
  • Очки рейтинга-10
B

C++ - Тест 001. Первая программа и типы данных

  • Результат:46баллов,
  • Очки рейтинга-6
FL

C++ - Тест 006. Перечисления

  • Результат:80баллов,
  • Очки рейтинга4
Последние комментарии
k
kmssr8 февраля 2024 г. 18:43
Qt Linux - Урок 001. Автозапуск Qt приложения под Linux как сделать автозапуск для флэтпака, который не даёт создавать файлы в ~/.config - вот это вопрос ))
АК
Анатолий Кононенко5 февраля 2024 г. 1:50
Qt WinAPI - Урок 007. Работаем с ICMP Ping в Qt Без строки #include <QRegularExpressionValidator> в заголовочном файле не работает валидатор.
EVA
EVA25 декабря 2023 г. 10:30
Boost - статическая линковка в CMake проекте под Windows Ошибка LNK1104 часто возникает, когда компоновщик не может найти или открыть файл библиотеки. В вашем случае, это файл libboost_locale-vc142-mt-gd-x64-1_74.lib из библиотеки Boost для C+…
J
JonnyJo25 декабря 2023 г. 8:38
Boost - статическая линковка в CMake проекте под Windows Сделал всё по-как у вас, но выдаёт ошибку [build] LINK : fatal error LNK1104: не удается открыть файл "libboost_locale-vc142-mt-gd-x64-1_74.lib" Хоть убей, не могу понять в чём дел…
G
Gvozdik18 декабря 2023 г. 21:01
Qt/C++ - Урок 056. Подключение библиотеки Boost в Qt для компиляторов MinGW и MSVC Для решения твой проблемы добавь в файл .pro строчку "LIBS += -lws2_32" она решит проблему , лично мне помогло.
Сейчас обсуждают на форуме
P
Pisych27 февраля 2023 г. 4:04
Как получить в массив значения из связанной модели? Спасибо, разобрался:))
AC
Alexandru Codreanu19 января 2024 г. 11:57
QML Обнулить значения SpinBox Доброго времени суток, не могу разобраться с обнулением значение SpinBox находящего в делегате. import QtQuickimport QtQuick.ControlsWindow { width: 640 height: 480 visible: tr…
BlinCT
BlinCT27 декабря 2023 г. 8:57
Растягивать Image на парент по высоте Ну и само собою дял включения scrollbar надо чтобы был Flickable. Так что выходит как то так Flickable{ id: root anchors.fill: parent clip: true property url linkFile p…
Дмитрий
Дмитрий10 января 2024 г. 4:18
Qt Creator загружает всю оперативную память Проблема решена. Удалось разобраться с помощью утилиты strace. Запустил ее: strace ./qtcreator Начал выводиться весь лог работы креатора. В один момент он начал считывать фай…
Evgenii Legotckoi
Evgenii Legotckoi12 декабря 2023 г. 6:48
Побуквенное сравнение двух строк Добрый день. Там случайно не высылается этот сигнал textChanged ещё и при форматировани текста? Если решиать в лоб, то можно просто отключать сигнал/слотовое соединение внутри слота и …

Следите за нами в социальных сетях