Поиск с Django Haystack
Haystack предоставляет унифицированный API для различных поисковых движков, позволяя легко переключаться между Elasticsearch, Solr, Whoosh и другими.
Установка и настройка
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.