Поиск с Django Haystack

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

Установка и настройка

1# Установка Haystack и Elasticsearch
2pip install django-haystack
3pip install elasticsearch
4pip install elasticsearch-dsl
5
6# Для Solr
7pip install django-haystack[elasticsearch]
8pip install django-haystack[solr]
 1# settings.py
 2INSTALLED_APPS = [
 3    'django.contrib.admin',
 4    'django.contrib.auth',
 5    'django.contrib.contenttypes',
 6    'django.contrib.sessions',
 7    'django.contrib.messages',
 8    'django.contrib.staticfiles',
 9    'haystack',
10    'your_app',
11]
12
13# Настройка Elasticsearch
14HAYSTACK_CONNECTIONS = {
15    'default': {
16        'ENGINE': 'haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine',
17        'URL': 'http://127.0.0.1:9200/',
18        'INDEX_NAME': 'haystack',
19        'TIMEOUT': 60,
20        'MAX_RETRIES': 3,
21    },
22    'solr': {
23        'ENGINE': 'haystack.backends.solr_backend.SolrEngine',
24        'URL': 'http://127.0.0.1:8983/solr/haystack',
25        'ADMIN_URL': 'http://127.0.0.1:8983/solr/admin/cores',
26        'TIMEOUT': 60,
27    },
28    'whoosh': {
29        'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine',
30        'PATH': os.path.join(BASE_DIR, 'whoosh_index'),
31    },
32}
33
34# Автоматическое обновление индекса при изменении моделей
35HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
36
37# Настройки для автокомплита
38HAYSTACK_AUTOCOMPLETE = True
39HAYSTACK_AUTOCOMPLETE_MIN_LENGTH = 2

Модели для поиска

 1# your_app/models.py
 2from django.db import models
 3from django.contrib.auth.models import User
 4
 5class Category(models.Model):
 6    name = models.CharField(max_length=100)
 7    slug = models.SlugField(unique=True)
 8    description = models.TextField(blank=True)
 9
10    def __str__(self):
11        return self.name
12
13class Author(models.Model):
14    name = models.CharField(max_length=200)
15    bio = models.TextField(blank=True)
16    email = models.EmailField(blank=True)
17
18    def __str__(self):
19        return self.name
20
21class Book(models.Model):
22    title = models.CharField(max_length=200)
23    author = models.ForeignKey(Author, on_delete=models.CASCADE)
24    category = models.ForeignKey(Category, on_delete=models.CASCADE)
25    description = models.TextField()
26    content = models.TextField()
27    published_date = models.DateField()
28    isbn = models.CharField(max_length=13, blank=True)
29    price = models.DecimalField(max_digits=10, decimal_places=2)
30    tags = models.ManyToManyField('Tag', blank=True)
31
32    def __str__(self):
33        return self.title
34
35class Tag(models.Model):
36    name = models.CharField(max_length=50)
37    slug = models.SlugField(unique=True)
38
39    def __str__(self):
40        return self.name
41
42class Article(models.Model):
43    title = models.CharField(max_length=200)
44    content = models.TextField()
45    author = models.ForeignKey(User, on_delete=models.CASCADE)
46    published_at = models.DateTimeField(auto_now_add=True)
47    category = models.ForeignKey(Category, on_delete=models.CASCADE)
48    tags = models.ManyToManyField(Tag, blank=True)
49
50    def __str__(self):
51        return self.title

Создание поисковых индексов

 1# your_app/search_indexes.py
 2from haystack import indexes
 3from haystack.fields import CharField, DateTimeField, IntegerField, DecimalField
 4from .models import Book, Article, Author, Category
 5
 6class BookIndex(indexes.SearchIndex, indexes.Indexable):
 7    # Основное текстовое поле для поиска
 8    text = indexes.CharField(document=True, use_template=True)
 9
10    # Поля для фильтрации и сортировки
11    title = indexes.CharField(model_attr='title', boost=2.0)
12    author_name = indexes.CharField(model_attr='author__name', boost=1.5)
13    category_name = indexes.CharField(model_attr='category__name')
14    description = indexes.CharField(model_attr='description', boost=1.2)
15    content = indexes.CharField(model_attr='content')
16
17    # Метаданные
18    published_date = indexes.DateTimeField(model_attr='published_date')
19    price = indexes.DecimalField(model_attr='price')
20    isbn = indexes.CharField(model_attr='isbn', null=True)
21
22    # Автокомплит поля
23    title_auto = indexes.EdgeNgramField(model_attr='title')
24    author_auto = indexes.EdgeNgramField(model_attr='author__name')
25
26    # Поля для фасетного поиска
27    category_id = indexes.IntegerField(model_attr='category__id')
28    author_id = indexes.IntegerField(model_attr='author__id')
29
30    def get_model(self):
31        return Book
32
33    def index_queryset(self, using=None):
34        return self.get_model().objects.select_related('author', 'category').prefetch_related('tags')
35
36class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
37    text = indexes.CharField(document=True, use_template=True)
38    title = indexes.CharField(model_attr='title', boost=2.0)
39    content = indexes.CharField(model_attr='content')
40    author_username = indexes.CharField(model_attr='author__username')
41    category_name = indexes.CharField(model_attr='category__name')
42    published_at = indexes.DateTimeField(model_attr='published_at')
43
44    # Автокомплит
45    title_auto = indexes.EdgeNgramField(model_attr='title')
46
47    def get_model(self):
48        return Article
49
50class AuthorIndex(indexes.SearchIndex, indexes.Indexable):
51    text = indexes.CharField(document=True, use_template=True)
52    name = indexes.CharField(model_attr='name', boost=2.0)
53    bio = indexes.CharField(model_attr='bio')
54
55    # Автокомплит
56    name_auto = indexes.EdgeNgramField(model_attr='name')
57
58    def get_model(self):
59        return Author

HTML шаблоны для индексации

 1# templates/search/indexes/your_app/book_text.txt
 2{{ object.title }}
 3{{ object.author.name }}
 4{{ object.category.name }}
 5{{ object.description }}
 6{{ object.content }}
 7{% for tag in object.tags.all %}
 8    {{ tag.name }}
 9{% endfor %}
10
11# templates/search/indexes/your_app/article_text.txt
12{{ object.title }}
13{{ object.content }}
14{{ object.author.username }}
15{{ object.category.name }}
16{% for tag in object.tags.all %}
17    {{ tag.name }}
18{% endfor %}
19
20# templates/search/indexes/your_app/author_text.txt
21{{ object.name }}
22{{ object.bio }}

Поисковые формы

  1# your_app/forms.py
  2from django import forms
  3from haystack.forms import SearchForm
  4from haystack.inputs import AutoQuery, Clean, Exact, Not, Raw
  5
  6class BookSearchForm(SearchForm):
  7    q = forms.CharField(
  8        required=False,
  9        label='Поиск',
 10        widget=forms.TextInput(attrs={
 11            'class': 'form-control',
 12            'placeholder': 'Введите название книги, автора или описание...'
 13        })
 14    )
 15
 16    category = forms.ModelChoiceField(
 17        queryset=Category.objects.all(),
 18        required=False,
 19        empty_label="Все категории",
 20        widget=forms.Select(attrs={'class': 'form-select'})
 21    )
 22
 23    author = forms.ModelChoiceField(
 24        queryset=Author.objects.all(),
 25        required=False,
 26        empty_label="Все авторы",
 27        widget=forms.Select(attrs={'class': 'form-select'})
 28    )
 29
 30    min_price = forms.DecimalField(
 31        required=False,
 32        min_value=0,
 33        decimal_places=2,
 34        widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'От'})
 35    )
 36
 37    max_price = forms.DecimalField(
 38        required=False,
 39        min_value=0,
 40        decimal_places=2,
 41        widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': 'До'})
 42    )
 43
 44    published_after = forms.DateField(
 45        required=False,
 46        widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
 47    )
 48
 49    published_before = forms.DateField(
 50        required=False,
 51        widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
 52    )
 53
 54    sort_by = forms.ChoiceField(
 55        choices=[
 56            ('relevance', 'По релевантности'),
 57            ('title', 'По названию'),
 58            ('author', 'По автору'),
 59            ('published_date', 'По дате публикации'),
 60            ('price', 'По цене'),
 61        ],
 62        required=False,
 63        initial='relevance',
 64        widget=forms.Select(attrs={'class': 'form-select'})
 65    )
 66
 67    def search(self):
 68        sqs = super().search()
 69
 70        if not self.is_valid():
 71            return self.no_query_found()
 72
 73        if self.cleaned_data.get('q'):
 74            sqs = sqs.filter(text=AutoQuery(self.cleaned_data['q']))
 75
 76        if self.cleaned_data.get('category'):
 77            sqs = sqs.filter(category_id=self.cleaned_data['category'].id)
 78
 79        if self.cleaned_data.get('author'):
 80            sqs = sqs.filter(author_id=self.cleaned_data['author'].id)
 81
 82        if self.cleaned_data.get('min_price'):
 83            sqs = sqs.filter(price__gte=self.cleaned_data['min_price'])
 84
 85        if self.cleaned_data.get('max_price'):
 86            sqs = sqs.filter(price__lte=self.cleaned_data['max_price'])
 87
 88        if self.cleaned_data.get('published_after'):
 89            sqs = sqs.filter(published_date__gte=self.cleaned_data['published_after'])
 90
 91        if self.cleaned_data.get('published_before'):
 92            sqs = sqs.filter(published_date__lte=self.cleaned_data['published_before'])
 93
 94        # Сортировка
 95        sort_by = self.cleaned_data.get('sort_by', 'relevance')
 96        if sort_by == 'title':
 97            sqs = sqs.order_by('title')
 98        elif sort_by == 'author':
 99            sqs = sqs.order_by('author_name')
100        elif sort_by == 'published_date':
101            sqs = sqs.order_by('-published_date')
102        elif sort_by == 'price':
103            sqs = sqs.order_by('price')
104
105        return sqs

Views для поиска

 1# your_app/views.py
 2from django.shortcuts import render
 3from django.core.paginator import Paginator
 4from django.views.generic import ListView
 5from haystack.generic_views import SearchView
 6from haystack.query import SearchQuerySet
 7from .forms import BookSearchForm
 8from .models import Book, Category, Author
 9
10class BookSearchView(SearchView):
11    template_name = 'search/book_search.html'
12    form_class = BookSearchForm
13    results_per_page = 20
14
15    def get_context_data(self, **kwargs):
16        context = super().get_context_data(**kwargs)
17
18        # Добавляем дополнительные данные для фильтров
19        context['categories'] = Category.objects.all()
20        context['authors'] = Author.objects.all()
21
22        # Фасетный поиск
23        if self.query:
24            sqs = self.form.search()
25            context['facets'] = {
26                'categories': sqs.facet('category_name'),
27                'authors': sqs.facet('author_name'),
28                'price_ranges': sqs.facet('price'),
29            }
30
31        return context
32
33def book_search(request):
34    form = BookSearchForm(request.GET or None)
35    results = []
36    facets = {}
37
38    if form.is_valid() and form.cleaned_data.get('q'):
39        sqs = form.search()
40        results = sqs
41
42        # Фасетный поиск
43        facets = {
44            'categories': sqs.facet('category_name'),
45            'authors': sqs.facet('author_name'),
46            'price_ranges': sqs.facet('price'),
47        }
48
49        # Пагинация
50        paginator = Paginator(results, 20)
51        page_number = request.GET.get('page')
52        page_obj = paginator.get_page(page_number)
53        results = page_obj
54
55    context = {
56        'form': form,
57        'results': results,
58        'facets': facets,
59        'categories': Category.objects.all(),
60        'authors': Author.objects.all(),
61    }
62
63    return render(request, 'search/book_search.html', context)
64
65# Автокомплит
66def autocomplete(request):
67    query = request.GET.get('q', '')
68    results = []
69
70    if len(query) >= 2:
71        sqs = SearchQuerySet().autocomplete(title_auto=query)
72        sqs = sqs.load_all()
73        results = sqs[:10]  # Ограничиваем 10 результатами
74
75    return render(request, 'search/autocomplete.html', {
76        'results': results,
77        'query': query
78    })

URLs для поиска

1# your_app/urls.py
2from django.urls import path
3from . import views
4
5urlpatterns = [
6    path('search/', views.book_search, name='book_search'),
7    path('search/autocomplete/', views.autocomplete, name='search_autocomplete'),
8    path('search/advanced/', views.BookSearchView.as_view(), name='advanced_search'),
9]

HTML шаблоны для поиска

  1# templates/search/book_search.html
  2{% extends 'base.html' %}
  3{% load static %}
  4
  5{% block content %}
  6<div class="container mt-4">
  7    <h1>Поиск книг</h1>
  8
  9    <!-- Форма поиска -->
 10    <div class="card mb-4">
 11        <div class="card-body">
 12            <form method="get" class="row g-3">
 13                <div class="col-md-6">
 14                    {{ form.q }}
 15                </div>
 16                <div class="col-md-2">
 17                    {{ form.category }}
 18                </div>
 19                <div class="col-md-2">
 20                    {{ form.author }}
 21                </div>
 22                <div class="col-md-2">
 23                    <button type="submit" class="btn btn-primary w-100">Поиск</button>
 24                </div>
 25
 26                <!-- Расширенные фильтры -->
 27                <div class="col-12">
 28                    <div class="row g-3">
 29                        <div class="col-md-2">
 30                            <label>Цена от:</label>
 31                            {{ form.min_price }}
 32                        </div>
 33                        <div class="col-md-2">
 34                            <label>до:</label>
 35                            {{ form.max_price }}
 36                        </div>
 37                        <div class="col-md-2">
 38                            <label>Дата от:</label>
 39                            {{ form.published_after }}
 40                        </div>
 41                        <div class="col-md-2">
 42                            <label>до:</label>
 43                            {{ form.published_before }}
 44                        </div>
 45                        <div class="col-md-2">
 46                            <label>Сортировка:</label>
 47                            {{ form.sort_by }}
 48                        </div>
 49                    </div>
 50                </div>
 51            </form>
 52        </div>
 53    </div>
 54
 55    <!-- Результаты поиска -->
 56    {% if results %}
 57        <div class="row">
 58            <div class="col-md-3">
 59                <!-- Фасеты -->
 60                {% if facets %}
 61                    <div class="card">
 62                        <div class="card-header">
 63                            <h5>Фильтры</h5>
 64                        </div>
 65                        <div class="card-body">
 66                            {% if facets.categories %}
 67                                <h6>Категории</h6>
 68                                {% for category in facets.categories %}
 69                                    <div class="form-check">
 70                                        <input class="form-check-input" type="checkbox"
 71                                               id="cat_{{ forloop.counter }}">
 72                                        <label class="form-check-label" for="cat_{{ forloop.counter }}">
 73                                            {{ category.0 }} ({{ category.1 }})
 74                                        </label>
 75                                    </div>
 76                                {% endfor %}
 77                            {% endif %}
 78
 79                            {% if facets.authors %}
 80                                <h6>Авторы</h6>
 81                                {% for author in facets.authors %}
 82                                    <div class="form-check">
 83                                        <input class="form-check-input" type="checkbox"
 84                                               id="auth_{{ forloop.counter }}">
 85                                        <label class="form-check-label" for="auth_{{ forloop.counter }}">
 86                                            {{ author.0 }} ({{ author.1 }})
 87                                        </label>
 88                                    </div>
 89                                {% endfor %}
 90                            {% endif %}
 91                        </div>
 92                    </div>
 93                {% endif %}
 94            </div>
 95
 96            <div class="col-md-9">
 97                <div class="row">
 98                    {% for result in results %}
 99                        <div class="col-md-6 mb-3">
100                            <div class="card h-100">
101                                <div class="card-body">
102                                    <h5 class="card-title">{{ result.title }}</h5>
103                                    <p class="card-text">
104                                        <strong>Автор:</strong> {{ result.author_name }}<br>
105                                        <strong>Категория:</strong> {{ result.category_name }}<br>
106                                        <strong>Цена:</strong> {{ result.price }} ₽
107                                    </p>
108                                    <p class="card-text">{{ result.description|truncatewords:20 }}</p>
109                                    <a href="{{ result.object.get_absolute_url }}"
110                                       class="btn btn-primary btn-sm">Подробнее</a>
111                                </div>
112                            </div>
113                        </div>
114                    {% endfor %}
115                </div>
116
117                <!-- Пагинация -->
118                {% if results.has_other_pages %}
119                    <nav aria-label="Навигация по страницам">
120                        <ul class="pagination justify-content-center">
121                            {% if results.has_previous %}
122                                <li class="page-item">
123                                    <a class="page-link" href="?page={{ results.previous_page_number }}">Предыдущая</a>
124                                </li>
125                            {% endif %}
126
127                            {% for num in results.paginator.page_range %}
128                                {% if results.number == num %}
129                                    <li class="page-item active">
130                                        <span class="page-link">{{ num }}</span>
131                                    </li>
132                                {% elif num > results.number|add:'-3' and num < results.number|add:'3' %}
133                                    <li class="page-item">
134                                        <a class="page-link" href="?page={{ num }}">{{ num }}</a>
135                                    </li>
136                                {% endif %}
137                            {% endfor %}
138
139                            {% if results.has_next %}
140                                <li class="page-item">
141                                    <a class="page-link" href="?page={{ results.next_page_number }}">Следующая</a>
142                                </li>
143                            {% endif %}
144                        </ul>
145                    </nav>
146                {% endif %}
147            </div>
148        </div>
149    {% elif form.cleaned_data.get('q') %}
150        <div class="alert alert-info">
151            По вашему запросу ничего не найдено. Попробуйте изменить параметры поиска.
152        </div>
153    {% endif %}
154</div>
155{% endblock %}
156
157# templates/search/autocomplete.html
158{% if results %}
159    <ul class="list-group">
160        {% for result in results %}
161            <li class="list-group-item">
162                <a href="{{ result.object.get_absolute_url }}" class="text-decoration-none">
163                    <strong>{{ result.title }}</strong><br>
164                    <small class="text-muted">{{ result.author_name }}</small>
165                </a>
166            </li>
167        {% endfor %}
168    </ul>
169{% else %}
170    <div class="text-muted p-2">Ничего не найдено</div>
171{% endif %}

Команды управления

 1# Создание индекса
 2python manage.py build_index
 3
 4# Обновление индекса
 5python manage.py update_index
 6
 7# Пересоздание индекса
 8python manage.py rebuild_index
 9
10# Очистка индекса
11python manage.py clear_index
12
13# Проверка статуса индекса
14python manage.py haystack_info
15
16# Создание индекса для конкретного приложения
17python manage.py build_index --app your_app
18
19# Обновление индекса в фоне
20python manage.py update_index --workers=4

Тестирование поиска

 1# your_app/tests/test_search.py
 2from django.test import TestCase, override_settings
 3from django.contrib.auth.models import User
 4from haystack.test.utils import get_solr_backend
 5from .models import Book, Author, Category
 6from .search_indexes import BookIndex
 7from .forms import BookSearchForm
 8
 9@override_settings(HAYSTACK_CONNECTIONS={'default': {'ENGINE': 'haystack.backends.simple_backend.SimpleEngine'}})
10class BookSearchTest(TestCase):
11    def setUp(self):
12        self.user = User.objects.create_user(
13            username='testuser',
14            password='testpass123'
15        )
16        self.category = Category.objects.create(
17            name='Техническая литература',
18            slug='tech'
19        )
20        self.author = Author.objects.create(
21            name='Тестовый автор',
22            bio='Биография тестового автора'
23        )
24        self.book = Book.objects.create(
25            title='Python для начинающих',
26            author=self.author,
27            category=self.category,
28            description='Книга о Python для новичков',
29            content='Подробное содержание книги о Python',
30            published_date='2024-01-01',
31            price='29.99'
32        )
33
34        # Обновляем индекс
35        BookIndex().update()
36
37    def test_search_form(self):
38        form_data = {
39            'q': 'Python',
40            'category': self.category.id,
41            'author': self.author.id,
42        }
43        form = BookSearchForm(data=form_data)
44        self.assertTrue(form.is_valid())
45
46    def test_search_results(self):
47        from haystack.query import SearchQuerySet
48
49        sqs = SearchQuerySet().filter(text='Python')
50        self.assertEqual(len(sqs), 1)
51        self.assertEqual(sqs[0].title, 'Python для начинающих')
52
53    def test_search_by_author(self):
54        from haystack.query import SearchQuerySet
55
56        sqs = SearchQuerySet().filter(author_name='Тестовый автор')
57        self.assertEqual(len(sqs), 1)
58        self.assertEqual(sqs[0].title, 'Python для начинающих')
59
60    def test_search_by_category(self):
61        from haystack.query import SearchQuerySet
62
63        sqs = SearchQuerySet().filter(category_name='Техническая литература')
64        self.assertEqual(len(sqs), 1)
65        self.assertEqual(sqs[0].title, 'Python для начинающих')

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

 1# your_app/search_indexes.py
 2from haystack import indexes
 3from django.db.models import Prefetch
 4
 5class BookIndex(indexes.SearchIndex, indexes.Indexable):
 6    # ... существующие поля ...
 7
 8    def index_queryset(self, using=None):
 9        # Оптимизируем запросы к базе данных
10        return self.get_model().objects.select_related(
11            'author', 'category'
12        ).prefetch_related(
13            'tags'
14        ).only(
15            'title', 'description', 'content', 'published_date', 'price', 'isbn',
16            'author__name', 'category__name'
17        )
18
19# Кэширование результатов поиска
20from django.core.cache import cache
21from django.conf import settings
22
23def cached_search(query, filters=None, timeout=300):
24    cache_key = f"search_{hash(query)}_{hash(str(filters))}"
25    results = cache.get(cache_key)
26
27    if results is None:
28        sqs = SearchQuerySet().filter(text=query)
29        if filters:
30            for key, value in filters.items():
31                sqs = sqs.filter(**{key: value})
32        results = list(sqs)
33        cache.set(cache_key, results, timeout)
34
35    return results

Лучшие практики

  • Используй boost для приоритизации важных полей
  • Оптимизируй индексы с помощью select_related и prefetch_related
  • Кэшируй результаты для часто используемых запросов
  • Ограничивай размер результатов для предотвращения медленных запросов
  • Используй фасеты для улучшения пользовательского опыта
  • Логируй медленные запросы для оптимизации
  • Настраивай релевантность с помощью различных типов полей
  • Тестируй производительность с большими объемами данных

FAQ

Q: Как обновить поисковый индекс?
A: Используй python manage.py rebuild_index для полного обновления или update_index для инкрементального. Для продакшена настрой cron задачи.

Q: Какой поисковый движок выбрать?
A: Elasticsearch для больших проектов и сложных запросов, Solr для enterprise решений, Whoosh для простых проектов без внешних зависимостей.

Q: Как настроить автокомплит?
A: Используй EdgeNgramField в индексах и настрой HAYSTACK_AUTOCOMPLETE в settings. Создай отдельный endpoint для автокомплита.

Q: Как улучшить релевантность результатов?
A: Используй boost для важных полей, настрой веса для различных типов контента, применяй фильтры для уточнения результатов.

Q: Как обрабатывать большие объемы данных?
A: Используй пагинацию, кэширование, фоновое обновление индексов, оптимизируй запросы к базе данных.

Q: Как настроить поиск по нескольким языкам?
A: Создай отдельные поля для каждого языка в индексе, используй анализаторы для конкретных языков в Elasticsearch.