- 1. Повнотекстовий пошук у PostgreSQL
- 2. Додавання SearchVectorField та індексу в модель
- 3. Клас SearchView для пошуку по всіх видах контенту
- 4. Рендеринг сторінки
- 5. Файл "urls.py"
- 6. Клас "SearchViewByContent"
- 7. Підтримка мельтимовності з використанням батарейки modeltranslation
- 1. Модель
- 2. SearchView
- 8. Висновок
Нарешті, мені вдалося зробити досить швидкий повнотекстовий пошук за декількома моделями з урахуванням оптимізації запитів на сайті, який би мене влаштовував і відповідав моїм вимогам до якості проекту.
Якщо ви зараз скористаєтеся пошуком по сайту, то виявите, що пошук спрацює досить швидко, а також видасть декілька пошукових груп: Статті, Коментарі, Теми форуму, Відповіді на форумі, Тести.
З усіх груп пошуку будуть обрані по три результати, а також кожна група має лічильник загальної кількості знайдених результатів і запропоновано подивитися інші результати в окремих вкладках.
У результаті я зробив такий поділ з тієї причини, що створювати єдиний список з усіма результатами досить невигідно по ресурсах і недостатньо ефективно.
Тому підсумковий результат виглядає так:
Згодом подібний підхід дозволить мені гнучкіше і легко модифікувати систему пошуку, що дасть можливість додавати в пошук будь-який контент незалежно від інших частин сайту.
А тепер я розповім, як саме це зробив.
Повнотекстовий пошук у 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.
Також тут є важливий момент. Я роблю пошук як за вектором із зазначенням мови, так і без вказівки мови. Це пов'язано з тим, що я можу не мати всіх перекладів для всіх мов у якоїсь конкретної статті, а видача результатів пошуку, на мою думку, завжди повинна проводитися, навіть якщо переклад відсутній.
Висновок
Таким чином можна зробити досить простий пошук за різними видами контенту, який до всього буде модифікованим і підтримуваним для майбутнього розширення пошуку іншими видами контенту на сайті.
Замечательная статья где давольно подробно расписан поиск. Когда то я искал что то именно вот такое. И на самом деле вариантов использования очень и очень много.
Спасибо Евгений за статью.
Заметила, что в русском варианте сложно со stemming поиска. Добавила во view SearchQuery c config, стало отлавиливать (кот - котЫ). Буду признательная за инфу, как этого добиться другим способом.
Пока не подскажу, не вставало такой задачи. Для меня Django/Python - это хобби, а не профессиональная область
В любом случае спасибо за ориентацию. Для моего сайта подойдёт такая логика.
К своему удивлению обнаружила, что украинский язык не включён в postgres-e в full text search. Есть какие-то кустарные варианты с прикручиванием файлов с stopwords и т.д.Я наверное сделаю "наивный" поиск, если выбран украинский.
А вы сталкивались с этой проблемой? Спасибо.
А в чём это выражается? У меня поиск срабатывает по все подключённым языкам, в том числе и по украинскому.
stackoverflow
Я сделала триггеры для update полей и вектора:
Ошибка выпадает на pg_catalog.ukrainian, жалуется, что нет такого.
p.s Сделала менеджер модели, к фильрует поле на украинском (без конфигураций, иначе тоже жалуется).
Может быть пригодится тем, кто использует django-ckeditor-5 for admin + modeltranslation package. Поле для редактирования in models.py нужно прописывать как обычное текстовое. А вот в админ классе обозначать