Полнотекстовый и фасетный поиск в Django проектах

Сделал для себя очень хорошее открытие - Haystack. Этот пакет для Django позволяет легко и быстро сделать поддержку полнотекстового поиска. Haystack может работать без поисковых движков стороннего производства, а так же поддерживает Solr, Whoosh и Xapian.

Рассматривать работу поиска будем на примере простейшего книжного магазина. У нас есть три модели -это авторы, категории и сами книги.

class Author(models.Model):
    first_name = models.CharField(max_length=20)
    last_name = models.CharField(max_length=40)
    birthday = models.DateField(null=True, blank=True)

    @property
    def full_name(self):
        return u' '.join((self.first_name, self.last_name))

    def __unicode__(self):
        return self.full_name


class Book(models.Model):
    authors = models.ManyToManyField(Author, related_name='books')
    title = models.CharField(max_length=300)
    publish_year = models.PositiveIntegerField()
    isbn = models.CharField(max_length=13)
    description = models.TextField(null=True, blank=True)
    categories = models.ManyToManyField('Category',
        related_name='books')

    def __unicode__(self):
        return self.title


class Category(models.Model):
    title = models.CharField(max_length=100)

    def __unicode__(self):
        return self.title

Для подключения Haystack к нашему магазину нам нужно добавить 'haystack' в INSTALLED_APPS и описать настройки поискового механизма.

Если вам не нужен фасетный поиск - то можно использовать Whoosh, для которого из всех настроек нужно указать только путь к каталогу для хранения индекса.

Теперь нужно описать поисковый индекс в файле search_index.py нашего приложения(не проекта).

from haystack import indexes

from bookshop.apps.book.models import Book


class BookIndex(indexes.SearchIndex, indexes.Indexable):
    authors = indexes.MultiValueField(faceted=True)
    title = indexes.CharField(model_attr='title')
    year = indexes.IntegerField(model_attr='publish_year',
        faceted=True)
    text = indexes.CharField(document=True)
    categories = indexes.MultiValueField(faceted=True)

    def get_model(self):
        return Book

    def prepare_authors(self, book):
        return [a.id for a in book.authors.all()]

    def prepare_text(self, book):
        return u'\n'.join((
            book.title,
            u', '.join([a.full_name for a in book.authors.all()]),
            book.description,
        ))

    def prepare_categories(self, book):
        return [c.id for c in book.categories.all()]

Каждый индексный класс должен содержать поле text с параметром document=True. Параметры model_attr указывают с какого поля модели брать данные, а параметр faceted указывает что по этому параметру мы хотим строить фасет. Значение для каждого поля можно задавать объявив метод prepare_<имя поля> который будет возвращать значение.

Теперь нам нужно создать конфигурационную схему для Solr’а, для этого нужно выполнить ./manage build_solr_schema и xml который нам вернется нужно записать в cхему настройки solr’а. После нужно запустить ./manage.py rebuild_index что бы пересоздать инндекс. Для обновления индекса нужно выполнять ./manage.py update_index.

Осталось только написать view для поиска.

from django.shortcuts import render
from haystack.forms import FacetedSearchForm
from haystack.query import SearchQuerySet


def search(request):
    results = SearchQuerySet().all().facet('authors').facet('categories')\
        .facet('year')

    if request.GET.get('q'):
        form = FacetedSearchForm(
            request.GET,
            searchqueryset=results,
            selected_facets=request.GET.getlist('selected_facets')
        )
        if form.is_valid():
            results = form.search()
    else:
        form = FacetedSearchForm()
        for facet in request.GET.getlist('selected_facets'):
            if not ':' in facet:
                continue
            field, value = facet.split(':', 1)
            if value:
                results = results.narrow('%s:"%s"' % (field, value))


    full_path = request.get_full_path()
    if not '?' in full_path:
        full_path += '?'

    return render(request, 'search.html', {
        'results': results,
        'form': form,
        'facets': results.facet_counts(),
        'full_path': full_path,
    })

Стандартная форма к сожалению не рассчитана на отсутствие поискового запроса, поэтому когда у нас его нету мы сами обработаем фасеты (строки 21-26). Также нам нужен шаблон для вывода наших результатов.

{% extends 'base.html' %}
{% load book_extra %}

{% block col_left %}
    <div>
<form action="." method="get">
{{ form.as_table }}
            <input type="submit" value="Search" />
        </form>
</div>
<div>
<h2>
Авторы</h2>
<ul>
            {% for author in facets.fields.authors %}
                {% if author.1 > 0 %}
<li><a href="https://draft.blogger.com/{{ full_path }}selected_facets=authors_exact:{{ author.0 }}">
                        {{ author.0|get_author_full_name }} ({{ author.1 }})
                    </a></li>
{% endif %}
            {% endfor %}
        </ul>
<h2>
Год издания</h2>
<ul>
            {% for year in facets.fields.year %}
                {% if year.1 > 0 %}
<li><a href="https://draft.blogger.com/{{ full_path }}&selected_facets=year_exact:{{ year.0 }}">
                        {{ year.0 }} ({{ year.1 }})
                    </a></li>
{% endif %}
            {% endfor %}
        </ul>
<h2>
Категории</h2>
<ul>
            {% for category in facets.fields.categories %}
                {% if category.1 > 0 %}
<li><a href="https://draft.blogger.com/{{ full_path }}&selected_facets=categories_exact:{{ category.0 }}">
                        {{ category.0|get_category_title }} ({{ category.1 }})
                    </a></li>
{% endif %}
            {% endfor %}
        </ul>
</div>
{% endblock %}

{% block col_right %}
    {% for result in results %}
        <div class="search_result">
<h3>
<a href="javascript:void(0);">{{ result.object.title }}</a></h3>
<div>
<div>
{% for author in result.object.authors.all %}
                        {{ author.full_name}}{% if not forloop.last %}, {% endif %}
                    {% endfor %}
                </div>
<div>
{% for category in result.object.categories.all %}
                        {{ category.title %}{% if not forloop.last %}, {% endif %}
                    {% endfor %}
                </div>
</div>
{{ result.object.description|truncatewords:80 }}</div>
{% empty %}
        Sorry, no results found.<br />

    {% endfor %}
{% endblock %}