Evgenii Legotckoi
31 березня 2023 р. 16:32

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

Нарешті, мені вдалося зробити досить швидкий повнотекстовий пошук за декількома моделями з урахуванням оптимізації запитів на сайті, який би мене влаштовував і відповідав моїм вимогам до якості проекту.

Якщо ви зараз скористаєтеся пошуком по сайту, то виявите, що пошук спрацює досить швидко, а також видасть декілька пошукових груп: Статті, Коментарі, Теми форуму, Відповіді на форумі, Тести.
З усіх груп пошуку будуть обрані по три результати, а також кожна група має лічильник загальної кількості знайдених результатів і запропоновано подивитися інші результати в окремих вкладках.

У результаті я зробив такий поділ з тієї причини, що створювати єдиний список з усіма результатами досить невигідно по ресурсах і недостатньо ефективно.

Тому підсумковий результат виглядає так:

Згодом подібний підхід дозволить мені гнучкіше і легко модифікувати систему пошуку, що дасть можливість додавати в пошук будь-який контент незалежно від інших частин сайту.

А тепер я розповім, як саме це зробив.

Повнотекстовий пошук у PostgreSQL

База даних PostgreSQL підтримує повнотекстовий пошук, а Django дозволяє засобами ORM його реалізувати.

Найпростіший спосіб, як запустити повнотекстовий пошук, це скористатися методом search по полю моделі, що описано в документації до Django.

Наприклад так

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

В даному випадку модель Entry має поле text, яким викликається повнотекстовий пошук за допомогою вбудованого функціоналу search.

Найбільш розвиненим способом виконання повнотекстового пошуку є використання пошукових векторів SearchVector по кількох полях. Тобто так

  1. from django.contrib.postgres.search import SearchVector
  2.  
  3. Entry.objects.annotate(search=SearchVector('text', 'tagline')).filter(search='Cheese')

На жаль, використання методу annotate є малоефективним, оскільки цей метод часом вимагає досить багато часу.
Для покращення продуктивності засобу Django пропонують використовувати спеціальне поле SearchVectorField, яке індексується базою даних PostgreSQL. Це дозволяє значно пришвидшити пошук на сайті.

Додавання SearchVectorField та індексу в модель

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

  1. from django.contrib.postgres.indexes import GinIndex
  2. from django.contrib.postgres.search import SearchVectorField
  3.  
  4. class Article(models.Model):
  5. title = models.CharField('Title', max_length=200)
  6. content = models.TextField(verbose_name='Content', blank=True)
  7.  
  8. # SearchVectorField for full-text search
  9. search_vector = SearchVectorField(null=True)
  10.  
  11. class Meta:
  12. indexes = [GinIndex(fields=["search_vector",]),]

Як бачите, у представленому коді є поле search_vector, яке індексується за допомогою вказівки GinIndex в Meta класі моделі Article.

Після того, як ви додали SearchVectorField та індексування даного поля, створіть нові міграції

  1. python manage.py makemigrations

У загальному вигляді нова міграція буде схожа на цю

  1. # Generated by Django 3.2 on 2023-03-27 21:03
  2.  
  3. import django.contrib.postgres.indexes
  4. import django.contrib.postgres.search
  5. from django.db import migrations
  6.  
  7.  
  8. class Migration(migrations.Migration):
  9.  
  10. dependencies = [
  11. # depends on
  12. ]
  13.  
  14. operations = [
  15. migrations.AddField(
  16. model_name='article',
  17. name='search_vector',
  18. field=django.contrib.postgres.search.SearchVectorField(null=True),
  19. ),
  20. migrations.AddIndex(
  21. model_name='article',
  22. index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='knowledge_a_search__682520_gin'),
  23. ),
  24. ]

Але цього буде недостатньо, оскільки ще потрібно заповнити поле SearchVectorField, що також можна зробити під час міграції. Тому модифікуємо міграцію в такий спосіб

  1. # Generated by Django 3.2 on 2023-03-27 21:03
  2.  
  3. import django.contrib.postgres.indexes
  4. import django.contrib.postgres.search
  5. from django.db import migrations
  6.  
  7.  
  8. def compute_search_vector(apps, schema_editor):
  9. Article = apps.get_model("knowledge", "Article")
  10. Article.objects.update(search_vector=django.contrib.postgres.search.SearchVector("title", "content"))
  11.  
  12.  
  13. class Migration(migrations.Migration):
  14.  
  15. dependencies = [
  16. # depends on
  17. ]
  18.  
  19. operations = [
  20. migrations.AddField(
  21. model_name='article',
  22. name='search_vector',
  23. field=django.contrib.postgres.search.SearchVectorField(null=True),
  24. ),
  25. migrations.AddIndex(
  26. model_name='article',
  27. index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='knowledge_a_search__682520_gin'),
  28. ),
  29. migrations.RunPython(
  30. compute_search_vector, reverse_code=migrations.RunPython.noop
  31. ),
  32. ]

У цьому коді додано додатковий крок, який додає виконання python коду, щоб відразу заповнити SearchVectorField , а саме останній крок migrations.RunPython , який запускає функцію compute_search_vector .
SearchVectorField має важливу особливість, у нього не можна безпосередньо додати SearchVector , але його можна заповнити, використовуючи метод update** у менеджера моделі. Тому цей код і виглядає таким чином

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

Після чого виконайте міграцію

  1. python manage.py migrate

Тепер усі статті мають проіндексоване пошукове поле. Але насправді цього недостатньо для повноцінної роботи пошукової системи, оскільки пошукове поле потрібно заповнювати як у разі створення нового об'єкта, і у разі редагування старого. Для цього документація Django пропонує звернутися до документації PostgreSQL та створити тригери. Це добре і правильно, але якщо ви не хочете з якоїсь причини зайвий раз лізти в PostgreSQL і вручну створювати всі необхідні тригери. Як бути тоді? В цьому випадку нас врятує сигнал/слотова система Django.

Для цього додамо до моделі Article наступний код

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

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

  1. from django.db.models.signals import post_save
  2. from django.dispatch import receiver
  3.  
  4. @receiver(post_save, sender=Article)
  5. def post_save_artcile(sender, instance, created, update_fields, **kwargs):
  6. instance.update_search_vector()

Таким чином, при кожному збереженні об'єкта статті буде відбуватися оновлення пошукового поля.

Клас SearchView для пошуку по всіх видах контенту

А тепер напишемо SearchView , який дозволив би виконати пошук за декількома видами контенту і видав би необхідний результат. У нашому випадку скажемо, що у нас на сайті є статті ("Article") та коментарі ("Comment")

  1. class SearchView(View):
  2. template_name = 'search/index.html'
  3.  
  4. def get(self, request, *args, **kwargs):
  5. query = request.GET.get('search', None)
  6. article_results = Article.objects.filter(search_vector=query)
  7. comment_results = Comment.objects.filter(search_vector=query)
  8.  
  9. return render(
  10. request=request,
  11. template_name=self.template_name,
  12. context={
  13. 'search': query or '',
  14. 'article_results': article_results[:3],
  15. 'article_results_count': article_results.count(),
  16. 'comment_results': comment_results[:3],
  17. 'comment_results_count': comment_results.count(),
  18. }
  19. )

Зверніть увагу, що у рядку сайту передається get параметр search , який відповідає за пошукову фразу. Також має кілька хитрощів для оптимізації запитів до бази даних.

Оскільки Django використовує ліниві запити до бази даних, то і реклами виконуються рівно тоді, коли це потрібно. Таким чином рядок

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

лише задає загальний запит до бази даних, але не виконує його, оскільки для початкової сторінки пошуку нам потрібна загальна кількість знайдених матеріалів і лише три перші об'єкти зі всіх знайдених матеріалів.
Тому як контекст для рендерингу передаються лише два QuerySet.

  1. 'article_results': article_results[:3],
  2. '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 - тег для рендерингу виведення результатів за контентом
  1. {% extends 'base.html' %}
  2. {% block page %}
  3. {% load search %}
  4. {% url 'search:articles' as articles_search_url %}
  5. {% url 'search:comments' as comments_search_url %}
  6.  
  7. {% append_query_to_url articles_search_url as articles_search_url_with_query %}
  8. {% append_query_to_url comments_search_url as comments_search_url_with_query %}
  9.  
  10. {% search_field search %}
  11.  
  12. {% block search_result %}
  13. {% found_objects article_results article_results_count 'Articles' 'Show all articles' articles_search_url_with_query %}
  14. {% found_objects comment_results comment_results_count 'Comments' 'Show all comments' comments_search_url_with_query %}
  15. {% endblock %}
  16. {% endblock %}

Каталог "templatetags"

У деяких статтях, наприклад в Як написати блоковий шаблонний тег tabbar на кшталт тега blocktranslate , я вже описував структуру цього каталогу і що там має бути для реєстрації шаблонних тегів, тому не зупинятимуся на цьому зайвий раз.

Отже, подивимося на вміст файлу templatetags/search.py

  1. # -*- coding: utf-8 -*-
  2.  
  3. from django import template
  4. from django.template.defaultfilters import urlencode
  5.  
  6. register = template.Library()
  7.  
  8. @register.inclusion_tag('search/search_field.html', takes_context=True)
  9. def search_field(context, value, **kwargs):
  10. context.update({'query_value': value})
  11. return context
  12.  
  13.  
  14. @register.simple_tag(takes_context=True)
  15. def append_query_to_url(context, url):
  16. return '{}?search={}'.format(url, urlencode(context.get('search', '')))
  17.  
  18. @register.inclusion_tag('search/found_objects.html', takes_context=True)
  19. def found_objects(context, results, results_count, objects_title, all_search_message, search_url):
  20. context.update({
  21. 'results': results,
  22. 'results_count': results_count,
  23. 'objects_title': objects_title,
  24. 'all_search_message': all_search_message,
  25. 'search_url': search_url
  26. })
  27. return context

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

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

Файл "search/found_objects.html"

Загалом рендеринг шаблону для кількох ваших об'єктів з пошуку буде виглядати так, як показано нижче.
Як бачите, тут використовується розмітка з bootstrap, а також немає прикладу рендерингу самого об'єкта. Я впевнений, що якщо ви читаєте цю статтю, то можете самостійно написати те, що потрібно вам, оскільки ця стаття є опис мого досвіду, а не прямим керівництвом до бездумного копіювання.

  1. {% load i18n %}
  2. <div class="card box-shadow m-2">
  3. <div class="card-header">{{ objects_title }} <span class="badge badge-primary">{{ results_count }}</span>
  4. </div>
  5. {% if results|length > 0 %}
  6. {% for object in results %}
  7. {# You custom render of object #}
  8. {% endfor %}
  9. <div class="card-footer border-0"><a href="{{ search_url }}" class="btn btn-sm btn-outline-secondary">{{ all_search_message }}</a></div>
  10. {% else %}
  11. <div class="card-body">
  12. {% trans 'Nothing found' %}
  13. </div>
  14. {% endif %}
  15. </div>

Файл "search/search_field.html"

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

Файл "urls.py"

Далі додамо SearchView у маршрутизатор Django .

  1. # -*- coding: utf-8 -*-
  2.  
  3. from django.urls import path
  4.  
  5. from search import views
  6.  
  7. app_name = 'search'
  8. urlpatterns = [
  9. path('', views.SearchView.as_view(), name='index'),
  10. ]

Ось так буде виглядати налаштування для сторінки повнотекстового пошуку на Django, принаймні так буде виглядати головна сторінка пошуку. Але я також додав сторінки пошуку по окремих частинах контенту, що також важливо. На головній сторінці пошуку користувач може подивитися, чи можна знайти хоч щось на сайті на його запит, а на детальних сторінках користувач зможе посморіти всі знайдені на сайті записи.

Клас "SearchViewByContent"

А тепер напишемо більш узагальнений клас для пошуку з різних видів контенту. Для цього можна скористатися Generic класом ListView . Пошук здійснюватиметься полем пошукового вектора. Також для правильної пагінації нам знадобиться pagination url. Це все є в даному коді.

  1. class SearchViewByContent(ListView):
  2. template_name = 'search/search_objects.html'
  3. paginate_by = 10
  4.  
  5. def get_context_data(self, **kwargs):
  6. context = super().get_context_data(**kwargs)
  7. context.update({
  8. 'search': self.request.GET.get('search', None) or '',
  9. 'last_question': self.get_pagination_url()
  10. })
  11. return context
  12.  
  13. def get_pagination_url(self):
  14. return self.request.get_full_path().replace(self.request.path, '')
  15.  
  16. def get_queryset(self):
  17. qs = super().get_queryset()
  18. query = self.request.GET.get('search', None)
  19. return qs.filter(search_vector=query)

Рендеринг сторінки

Скористайтеся кодом вже існуючого шаблону та розширимо його поведінку. Як бачите, тут використовується шаблонний тег із батареї django bootstrap 4 . В одній із попередніх статей я вже описував, як підключити цю батарейку старішої версії. З того часу нічого не змінилося і стаття також актуальна і для останньої версії django bootstrap 5.

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

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

  1. # -*- coding: utf-8 -*-
  2.  
  3. from django.urls import path
  4.  
  5. from search import views
  6. from articles.models import Article, Comment
  7.  
  8. app_name = 'earch'
  9. urlpatterns = [
  10. path('', views.IndexView.as_view(), name='index'),
  11. path('articles/', views.SearchView.as_view(queryset=Article.objects.all()), name='articles'),
  12. path('comments/', views.SearchView.as_view(queryset=Comment.objects.all()), name='comments'),
  13. ]

Таким чином, пошук буде реалізований вже за окремими видами контенту на сайті.

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

Django modeltranslation є чудовим пакетом, який додає мультимовність у ваші моделі, але на жаль він не сумісний із полем SearchVectorField . Для цього потрібно вручну додавати нові поля SearchVectorField для кожної мови, яка підтримується на сайті. Але насправді, це не так багато роботи. А виглядати це може так

Модель

  1. class Article(models.Model):
  2.  
  3. # Another code
  4.  
  5. # Search vectors
  6. search_vector = SearchVectorField(null=True)
  7. search_vector_ru = SearchVectorField(null=True)
  8. search_vector_en = SearchVectorField(null=True)
  9.  
  10. objects = ArticleManager()
  11.  
  12. class Meta:
  13. ordering = ['-pub_date']
  14. verbose_name = _('Article')
  15. verbose_name_plural = _('Articles')
  16. indexes = [
  17. GinIndex(fields=[
  18. "search_vector", "search_vector_ru", "search_vector_en",
  19. ]),
  20. ]

Відповідно, нову міграцію потрібно буде виправляти для кожного нового поля SearchVectorField

SearchView

А пошуковий SearchView можна виправити так

  1. class IndexView(View):
  2. template_name = 'evileg_search/index.html'
  3.  
  4. def get(self, request, *args, **kwargs):
  5. query = request.GET.get('search', None)
  6. current_language = get_language()
  7. article_results = Article.objects.filter(
  8. Q(**{'search_vector_{}'.format(current_language): query}) |
  9. Q(search_vector=query)
  10. )
  11. comment_results = Comment.objects.filter(search_vector=query)
  12.  
  13. return render(
  14. request=request,
  15. template_name=self.template_name,
  16. context={
  17. 'search': query or '',
  18. 'article_results': article_results[:3],
  19. 'article_results_count': article_results.count(),
  20. 'comment_results': comment_results[:3],
  21. 'comment_results_count': comment_results.count(),
  22. }
  23. )

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

Також тут є важливий момент. Я роблю пошук як за вектором із зазначенням мови, так і без вказівки мови. Це пов'язано з тим, що я можу не мати всіх перекладів для всіх мов у якоїсь конкретної статті, а видача результатів пошуку, на мою думку, завжди повинна проводитися, навіть якщо переклад відсутній.

Висновок

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

Вам це подобається? Поділіться в соціальних мережах!

NSProject
  • 06 квітня 2023 р. 22:25

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

Lissa
  • 19 квітня 2023 р. 21:02

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

Evgenii Legotckoi
  • 20 квітня 2023 р. 00:35

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

Lissa
  • 20 квітня 2023 р. 01:47

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

Lissa
  • 24 квітня 2023 р. 15:34
  • (відредаговано)

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

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

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

Lissa
  • 24 квітня 2023 р. 17:08
  • (відредаговано)

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

  1. # Generated by Django 4.1 on 2023-04-23 20:45
  2.  
  3. import django.contrib.postgres.search
  4. from django.contrib.postgres.search import SearchVector
  5. from django.db import migrations
  6.  
  7.  
  8. def compute_search_vector_uk(apps, schema_editor):
  9. Post = apps.get_model("posts", "Post")
  10. vector = SearchVector("title_uk", weight="A", config="ukrainian") + SearchVector(
  11. "content_uk",
  12. weight="B",
  13. config="ukrainian"
  14.  
  15. )
  16. Post.objects.update(vector_uk=vector)
  17.  
  18.  
  19. class Migration(migrations.Migration):
  20.  
  21. dependencies = [
  22. ("posts", "0005_post_vector"),
  23. ]
  24.  
  25. operations = [
  26. migrations.AddField(
  27. model_name="post",
  28. name="vector_uk",
  29. field=django.contrib.postgres.search.SearchVectorField(
  30. blank=True, null=True
  31. ),
  32. ),
  33. migrations.RunSQL(
  34. sql="""
  35. CREATE TRIGGER vector_uk_trigger
  36. BEFORE INSERT OR UPDATE OF title_uk, content_uk, vector_uk
  37. ON posts_post
  38. FOR EACH ROW EXECUTE PROCEDURE
  39. tsvector_update_trigger(
  40. vector_uk, 'pg_catalog.ukrainian', title_uk, content_uk
  41. );
  42. UPDATE posts_post SET vector_uk = NULL;
  43. """,
  44. reverse_sql="""
  45. DROP TRIGGER IF EXISTS vector_uk_trigger
  46. ON posts_post;
  47. """,
  48. ),
  49. migrations.RunPython(
  50. compute_search_vector_uk, reverse_code=migrations.RunPython.noop
  51. ),
  52. ]
  53.  

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

Lissa
  • 29 квітня 2023 р. 03:01
  • (відредаговано)

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

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

Коментарі

Only authorized users can post comments.
Please, Log in or Sign up
  • Останні коментарі
  • Evgenii Legotckoi
    16 квітня 2025 р. 17:08
    Благодарю за отзыв. И вам желаю всяческих успехов!
  • IscanderChe
    12 квітня 2025 р. 17:12
    Добрый день. Спасибо Вам за этот проект и отдельно за ответы на форуме, которые мне очень помогли в некоммерческих пет-проектах. Профессиональным программистом я так и не стал, но узнал мно…
  • AK
    01 квітня 2025 р. 11:41
    Добрый день. В данный момент работаю над проектом, где необходимо выводить звук из программы в определенное аудиоустройство (колонки, наушники, виртуальный кабель и т.д). Пишу на Qt5.12.12 поско…
  • Evgenii Legotckoi
    09 березня 2025 р. 21:02
    К сожалению, я этого подсказать не могу, поскольку у меня нет необходимости в обходе блокировок и т.д. Поэтому я и не задавался решением этой проблемы. Ну выглядит так, что вам действитель…
  • VP
    09 березня 2025 р. 16:14
    Здравствуйте! Я устанавливал Qt6 из исходников а также Qt Creator по отдельности. Все компоненты, связанные с разработкой для Android, установлены. Кроме одного... Когда пытаюсь скомпилиров…