Evgenii Legotckoi
Evgenii Legotckoi31 березня 2023 р. 06: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

А пошуковий 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
  • 06 квітня 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 р. 05:34
  • (відредаговано)

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

Evgenii Legotckoi
  • 24 квітня 2023 р. 05:43

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

Lissa
  • 24 квітня 2023 р. 07: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},
    }

Коментарі

Only authorized users can post comments.
Please, Log in or Sign up
d
  • dsfs
  • 26 квітня 2024 р. 14:56

C++ - Тест 004. Указатели, Массивы и Циклы

  • Результат:80бали,
  • Рейтинг балів4
d
  • dsfs
  • 26 квітня 2024 р. 14:45

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

  • Результат:50бали,
  • Рейтинг балів-4
d
  • dsfs
  • 26 квітня 2024 р. 14:35

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

  • Результат:73бали,
  • Рейтинг балів1
Останні коментарі
k
kmssr09 лютого 2024 р. 05:43
Qt Linux - Урок 001. Автозапуск програми Qt під Linux как сделать автозапуск для флэтпака, который не даёт создавать файлы в ~/.config - вот это вопрос ))
АК
Анатолий Кононенко05 лютого 2024 р. 12:50
Qt WinAPI - Урок 007. Робота з ICMP Ping в Qt Без строки #include <QRegularExpressionValidator> в заголовочном файле не работает валидатор.
EVA
EVA25 грудня 2023 р. 21:30
Boost - статичне зв&#39;язування в проекті CMake під Windows Ошибка LNK1104 часто возникает, когда компоновщик не может найти или открыть файл библиотеки. В вашем случае, это файл libboost_locale-vc142-mt-gd-x64-1_74.lib из библиотеки Boost для C+…
J
JonnyJo25 грудня 2023 р. 19:38
Boost - статичне зв&#39;язування в проекті CMake під Windows Сделал всё по-как у вас, но выдаёт ошибку [build] LINK : fatal error LNK1104: не удается открыть файл "libboost_locale-vc142-mt-gd-x64-1_74.lib" Хоть убей, не могу понять в чём дел…
G
Gvozdik19 грудня 2023 р. 08:01
Qt/C++ - Урок 056. Підключення бібліотеки Boost в Qt для компіляторів MinGW і MSVC Для решения твой проблемы добавь в файл .pro строчку "LIBS += -lws2_32" она решит проблему , лично мне помогло.
Тепер обговоріть на форумі
G
George1305 травня 2024 р. 02:13
добавить qlineseries в функции Всем доброго времени суток! Товарищи, помогите, юному падавану обуздать QChart, уже неделю пытаюсь сам решить проблему, в интернете подходящих статей не нашел:) Проблема в следующем:…
PS
Peter Son04 травня 2024 р. 03:57
Best Indian Food Restaurant In Cincinnati OH Ready to embark on a gastronomic journey like no other? Join us at App india restaurant and discover why we're renowned as the Best Indian Food Restaurant In Cincinnati OH . Whether y…
Evgenii Legotckoi
Evgenii Legotckoi03 травня 2024 р. 00:07
Мобильное приложение на C++Qt и бэкенд к нему на Django Rest Framework Добрый день. По моему мнению - да, но то, что будет касаться вызовов к функционалу Андроида, может создать огромные трудности.
IscanderChe
IscanderChe30 квітня 2024 р. 14:22
Во Flask рендер шаблона не передаётся в браузер Доброе утро! Имеется вот такой шаблон: <!doctype html><html> <head> <title>{{ title }}</title> <link rel="stylesheet" href="{{ url_…
G
Gar22 квітня 2024 р. 15:46
Clipboard Как скопировать окно целиком в clipb?

Слідкуйте за нами в соціальних мережах