Как подружить Django и Sphinx?

Предыстория


Понадобилось мне добавить на сайт функцию поиска. Первой мыслью было — воспользоваться возможностями SQL-сервера, — но искать надо сразу по нескольким таблицам, слова и фразы, да ещё и со стеммингом. Понял, что изобретать свой велосипед будет накладно.

Решил поискать, а что же всё-таки есть из готовых решений? Оказалось, прямо скажем, не густо: django-haystack и django-sphinx. Ранее достоинства и недостатки обоих уже перечисляли, поэтому не буду повторяться.

Потратив какое-то время на чтение блогов и форумов, решил всё-таки попробовать django-sphinx, т. к. в django-haystack, насколько мне известно, с поддержкой Sphinx до сих пор не очень.

Автор же django-sphinx давно забросил свой проект, но есть множество форков, и, говорят, что пользоваться им вполне возможно. Я выбрал тот, что был, хм, посвежее и попытался подключить его к своему проекту.

История


Оказалось, что всё очень плохо там — множество ошибок, недоделок, проблемы с Python API Сфинкса.
По началу я пытался просто исправить ошибки в коде и заставить-таки его работать. У меня это даже получилось — я смог искать по одному слову (знатоки справедливо заметят, что SPH_MATCH_ANY решил бы и эту проблему), но об этом флаге я узнал чуть позднее. Да и еще много о чем узнал.

В комментариях к посту, на который я сослался ранее, ругали django-sphinx, что де то не умеет, это не поддерживает. Решил я добавить недостающие возможности — в результате родился очередной форк. Через какое-то время он уже умел индексировать MVA и поля из связанных моделей (документация Sphinx мне показалась местами запутанной — пришлось долго разбираться, что там к чему). Было исправлено множество ошибок и не меньше добавлено… а как иначе?

А затем я решил-таки прочитать раздел, посвященный SphinxQL. И почти полностью переписал django-sphinx.

На данный момент мой форк умеет работать со Sphinx повредством его диалекта SphinxQL и может похвастаться:

  • поддержкой sphinx 2.0.1-beta и выше
  • довольно большой гибкостью в настройке
  • автоматической генерацией конфигурации sphinx
  • возможностью искать как по одному индексу, так и по нескольким сразу
  • возможностью индексировать MVA и поля из связанных один-к-одному моделей в одном индексе
  • поддержкой создания сниппетов
  • привязкой документов из индекса к объектам соответствующих моделей
  • подобными Django ORM методами фильтрации поисковой выдачи (в том числе цепочки методов)


RealTime-индексы пока не поддерживаются, соответственно нет функций для работы с ними (INSERT, UPDATE, DELETE).
Не поддерживается поиск по связанным моделям. И не уверен, что оно вообще нужно. Комментаторы, кто знает, приведите примеры, где и как это можно использовать?

Часть кода уже покрыта тестами (да, попутно учусь писать юнит-тесты — раньше несколько раз пытался начать, но не понимал, с какой стороны вообще к этому занятию подходить)

Кроме того начал писать документацию — пока наброски, но в целом, надеюсь, всё понятно.

Ну и приведу несколько примеров, которые, на мой взгляд, могут показаться интересными.

За основу я возьму вот такие модели:

class Related(models.Model):
    name = models.CharField(max_length=10)

    def __unicode__(self):
        return self.name

class M2M(models.Model):
    name = models.CharField(max_length=10)

    def __unicode__(self):
        return self.name


class Search(models.Model):

    name = models.CharField(max_length=10)
    text = models.TextField()
    stored_string = models.CharField(max_length=100)

    datetime = models.DateTimeField()
    date = models.DateField()
    bool = models.BooleanField()
    uint = models.IntegerField()
    float = models.FloatField(default=1.0)

    related = models.ForeignKey(Related)
    m2m = models.ManyToManyField(M2M)

    search = SphinxSearch(
        index='test_index',
        options={
            'included_fields': [
                'text',

                'datetime',
                'bool',
                'uint',
            ],
            'stored_attributes': [
                'stored_string',
            ],
            'stored_fields': [
                'name',
            ],
            'related_fields': [
                'related',
            ],
            'mva_fields': [
                'm2m',
            ]
        },
    )


В первую очередь, на основе словаря options, переданного аргументом SphinxSearch будет сгенерирован конфиг, в котором:

  • все поля из included_fields будут помещены в индекс, при чем нестроковые поля — в качестве stored-атрибутов
  • все поля из stored_attributes, как вы поняли, тоже станут stored. Этот список может быть полезен, если надо сделать stored текстовое поле
  • поля из stored_fields станут stored, но при этом будут так же доступны для полнотекстового поиска
  • поля из related_fields, Вы уже догадались?, аналогичго будут объявлены как stored. Там будут храниться ключи из связанных моделей (чуть ниже я объясню, зачем)
  • наконец, назначение mva_fields, думаю Вам уже понятно. В этот список можно поместить только названия ManyToMany-полей


Что же нам всё это даёт? А даёт это достаточно большие возможности для поиска.

Получим QuerySet для нашей модели. Это можно сделать двумя способами:

    qs = Search.search.query('query')


либо:

    qs = SphinxQuerySet(model=Search).query('query')


Оба способа дадут похожий результат, но во втором случае не будут учтены параметры, переданные SphinxSearch в описании модели (за исключением списков полей).

Теперь мы можем что-нибудь поискать:

    qs1 = qs.filter(bool=True, uint__gt=100, float__range=(1.0, 15.4)).group_by('date').order_by('-pk').group_order_by('-datetime')


Поясню, что делает этот запрос:
  • ищет в индексе модели Search слово 'query'
  • при этом в выдачу будут включены лишь результаты в которых поле bool содержит Истину, поле uint больше 100, а содержимое поля float находится в диапазоне от 1.0 до 15.4
  • группирует все результаты по дате
  • сортируя их по идентификатору документа в обратном порядке ('pk' приводится к 'id' автоматически)
  • внутри каждой группы сортирует результаты по полю datetime тоже в обратном порядке


Что еще можно сделать?

Например, предположим, что в переменной r хранится QuerySet с несколькими объектами Related, а в m — с M2M (см. модели выше). Тогда можно сделать что-то такое:

    qs2 = qs.filter(related__in=r, m2m__in=m)

    # или
    qs3 = qs.filter(related=r[0])


То есть не требуется самостоятельно подготавливать списки идентификаторов — django-sphinx сделает это за вас!

Ну и напоследок скажу, что SphinxQuerySet ведёт себя как массив.

    # можно взять любой результат по индексу
    doc = qs[5]

    # или срез
    docs = qs[3:20]
    docs = qs[:50]
    docs = qs[100:]


Наконец, чтобы получить значения stored-атрибутов (если они понадобятся по каким-то причинам) или вычисленным выражениями, необходимо обратиться к атрибуту sphinx объекта, полученного из SphinxQuerySet.

Да. Немного о выражениях.
Sphinx умеет вычислять различные формулы на лету для каждого документа (по этому же принципу работает и ранжирование) и позволяет составлять собственные:

    qs4 = qs.fields(expr1='uint*(float+100)')


Результат вычисления Вы сможете найти внутри атрибута sphinx полученных объектов.
Кроме того Sphinx позволяет сортировать выдачу не только по определённому полю, но и по этим выражениям, так что такой код тоже возможен:

    qs4 = qs.fields(expr1='uint*(float+100)').order_by('expr1')


Так о чём это я?



Я надеюсь, что обитатели хабра дадут мне полезные советы (или закидают какашками, если заслужил...) и укажут, куда бы мне стоило дальше развивать django-sphinx.

Всем спасибо за внимание! Думал написать небольшую статейку, а получилось… то, что получилось.
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 21

    +3
    А я просто указал в DATABASES коннект к сфинксу с бекендом для MySQL и пользоватлся просто курсором. Не очень понял зачем нужны заморочки с ОРМ, там где использовать её не удобно.
      0
      Бесспорно, можно и ручками запросы писать, и результаты разбирать самостоятельно — всё от потребностей зависит.

      ORM избавляет от рутины, упрощает поддержку, ускоряет разработку. На мой взгляд, от ORM стоит отказываться только если оно неспособно решить конкретную задачу, либо в целях оптимизации кода, когда необходимо выжать из кода всё.
      0
      Я правильно понял: наполнение индекса (если данные собираются по нескольким моделям сразу) лежит на плечах разработчика?
      Что мне нравится в django-haystack, так это умение не только искать в бакэндах, но и удобный инструментарий для наполнения индексов.
        0
        Смотря, что вы подразумеваете под наполнением. Sphinx принципиально не имеет средств по наполнению индексов (что по одной, что по нескольким таблицам/моделям) на лету. RT-индексы — это всё-таки немножко другое. Поэтому их обновление — это полная индексация с нуля. Например по крону.

        А вот работать с RT-индексами пока что нельзя. Но функционал будет реализован в скором будущем.

        Если Вы про несколько моделей в одном индексе, то:

        а) для разнородных моделей это занятие бессмысленное
        б) если вы храните данные в нескольких связанных моделях (как это, например, делается для модели Users и профилей), то эти модели можно поместить в один индекс уже сейчас (при условии, что вы не храните данные более чем в двух последовательно связанных моделях). Хотя я что-то затрудняюсь ответить, как на автомате проиндексировать ту же модель Users — внести правки в неё проблематично. Разве что манкипатчить.
        в) если несколько моделей имеют схожую структуру, то опять же их индексировать вместе не обязательно — сфинкс умеет искать по нескольким индексам и отдавать данные скопом. единственное требование тут — одинаковые поля у всех моделей (как SQL UNION)
          0
          Sphinx принципиально не имеет средств по наполнению индексов (что по одной, что по нескольким таблицам/моделям) на лету.

          Да, я понял. Но функционал по полной переиндексации нужно реализовывать самому?

          в) если несколько моделей имеют схожую структуру, то опять же их индексировать вместе не обязательно — сфинкс умеет искать по нескольким индексам и отдавать данные скопом. единственное требование тут — одинаковые поля у всех моделей (как SQL UNION)

          Спасибо! Не знал об этом. С помощью ORM Вашего приложения это возможно?
            0
            Да, можно искать сразу по нескольким индексам — django-sphinx сам определит к какой модели относится каждый документ и вернёт объекты в том же порядке, в котором получил от sphinx.

            Да, я понял. Но функционал по полной переиндексации нужно реализовывать самому?


            Да. В данный момент — самому.
            Но это выполняется единственной командой, которую можно поместить, например, в крон.
      • UFO just landed and posted this here
          0
          О, брат :) github.com/semirook/sphinxit. Дописать документацию всё никак руки не дойдут, но в коде много комментариев, можно разобраться (вообще это был внутренний проект). Поддержка свежего SphinxQL, Python 2 и 3, есть тесты. Не решаем ли мы одну и ту же задачу? Правда я изначально не хотел привязываться к фреймворку, поэтому Sphinxit можно использовать в любом python-проекте. Интеграцию лучше каким-то addon`ом уже выпустить, типа интроспекция моделей и обновление дельта-индексов по post-save сигналу.
            0
            Хотя ваша библиотека не поддерживает работу с RealTime-индексами, думаю, не сложно будет и её добавить.

            Вы решили одну задачу из тех, что стоят перед django-sphinx, но решили уж точно лучше, чем я. Уже задумываюсь над тем, а не возложить ли эту задачу на sphinxit.

            Кстати, сразу бросилось в глаза:

                def Search(self, *args):
                    ....
            
                def Snippets(self, index, data, query):
                    ....
            


            Почему с заглавной?
              0
              RT-индексы будут, просто не было повода добавить их поддержку раньше, нигде не использовал. Основной индекс + дельта хорошо себя ведут, хотя несколько раз хотелось «бросить всё к чёрту и уехать в Кисловодск», то есть переписать реализацию на RT-индексах.

              С заглавной, так как это функция-фабрика и я захотел подчеркнуть таким образом точку входа. В реальном проекте выглядит как:

              Sphinxit = SphinxConnector(**settings.SPHINXIT_CONNECTION)
              SphinxSearch = Sphinxit.Search
              ...
              def get_sphinx_query(self):
                  query = (SphinxSearch(*self.search_indexes).match(self.query)
                                                             .limit(self.get_offset(), self.paginate_by))
               
                  self.apply_filters_to_sphinx_query(query)
                  self.apply_sortings_to_sphinx_query(query)
              
                  return query
              ...
              


              Ну а потом где надо:

              ...
              search_result = self.get_sphinx_query().process()
              self.sphinx_result = search_result['result']
              self.total_found = int(search_result['meta']['total_found'])
              ...
              


              Есть места, которые мне не нравятся и ещё массу всего нужно доделать и переделать. Но даже в текущем состоянии вполне неплохо зарекомендовавший себя в продакшене продукт получился. Надо как-то взять себя в кулак и таки дописать документацию с примерами :)
                0
                У вас в коде комментариев больше, чем самого кода :)
                Куда уж больше примеров.
            0
            Кстати, вы решили как-то проблему фильтрации выдачи через OR? Вот из доки Сфинкса фрагмент:
            WHERE clause. This clause will map both to fulltext query and filters. Comparison operators (=, !=, <, >, <=, >=), IN, AND, NOT, and BETWEEN are all supported and map directly to filters. OR is not supported yet but will be in the future.

            Меня нехило это пробесило, пришлось долго гуглить, чтобы найти хак, который в итоге стал Q-объектом в Sphinxit. Но соединять таким образом MVA-атрибуты всё равно нельзя. Вот я по сей день думаю, как же так?!
              0
              Нет, даже не брался ещё за её решение.

              Что-то мне подсказывает, что в самом Sphinx не было изначально предусмотрено такой возможности. Ни в API, ни даже в SphinxSE я не увидел упоминаний об OR.

              Похоже, остаётся ждать нативной поддержки.
                0
                Если кроме OR никаких других преобразований не предвидится (т.е. по сути нужен банальный union матчей по разным фильтрам) — можно кинуть пачку запросов — с одинаковой полнотекстовой частью, но разными фильтрами. Запросы разделяем точкой с запятой и кидаем через интерфейс, который умеет работать с мультизапросами (mysqli например). Результаты потом объединяем ручками.
                Возможно, в конфиге сфинкса также стОит подкрутить опции, отвечающие за кэширование результатов во время мультизапросов.

                В идеальном случае сработает оптимизация: сфинкс увидит одинаковую полнотекстовую часть и вычислит её единственный раз. А потом для каждого запроса прогонит закэшированный результат через фильтры.
                  0
                  Там есть более просто решение:

                  SELECT id, (id=1) OR (id>=5) AS cnd FROM index WHERE MATCH('Hello') AND cnd>0;
                  


                  Но соединять через OR MVA-атрибуты нельзя, вот в этом случае иначе как ручками я способа не знаю.
                    0
                    Оптимизация в сфинксе работает как-то странно. Я пробовал экспериментировать. Если я правильно понял, сфинкс оптимизирует запросы только если в списке полей указана звёздочка (*). Соответственно способ @artiflex из комментария ниже не будет оптимизироваться.

                    Поправьте меня, если ошибаюсь.

                    Поскольку речь идёт от python, mysqli — не вариант. А вот MySQLdb, похоже, мультизапросы не умеет. По крайней мере я не смог найти, как через него выполнить такой запрос.

                    @artiflex, а oursql умеет такое?

                    PS, а как имя пользователя вставить в коммент? Вроде правильный тег поставил — а что-то не то получилось.
                      0
                      Не совсем так. Условий гораздо больше.
                      Нужно, чтобы
                      1. Совпадали строки поиска (очевидно)
                      2. Если указаны веса — совпадали веса.
                      3. Совпадал режим поиска.
                      4. Совпадал режим ранкера
                      5. Совпадала отсечка (cutoff)
                      6. Совпадали фильтры
                      7. Совпадали выражения сортировки (если используются)
                      8. Совпадали гео-якори (если используются)
                      9. В select-листах не было выражений.

                      (10. В секции searchd в конфиге должны быть заданы размеры кэшей. Например:
                      subtree_docs_cache = 1M
                      subtree_hits_cache = 1M
                      )

                      В этом случае будет работать оптимизация (кэширование результатов) на уровне пачки запросов. (пачка — строка запросов, разделённых ;. В пачке допустимы select и show meta вперемешку).

                      (Та же оптимизация может работать и на кусочках единственного сложного запроса — для этого нужно только размеры кэшей).
                        0
                        Спасибо, что разжевали. Кстати, было бы неплохо, если б это присутствовало в документации.

                        Я пробовал даже совсем простые запросы к индексу с ну очень небольшим количеством документов (до 1000).

                        Запросы вида:

                        SELECT * FROM index; SELECT * FROM index ORDER BY field;
                        

                        и

                        SELECT some_field FROM index; SELECT some_field FROM index ORDER BY field;
                        

                        Значения кешей дефолтные. Параметры запросов — дефолтные. В первом случает оптимизирует, во втором — нет. От набора полей в SELECT не зависит никак. Любой список (даже идентичный тому, что возвращает "*") сразу же отключает оптимизацию. То есть про сложные выражения, кучи различных условий вообще речи не идёт.

                        Можете на данном примере объяснить, почему так происходит?
                          0
                          Попробую глянуть на выходных (или даже завтра).
                          Если удастся — либо откомментирую, либо поправлю :D

                          (сейчас в работе более забавные фичи — вроде HA (внутренняя замена HAproxy и LoadBalancing) и поддержка JSON (уже есть) ).
                            0
                            В общем — разбирались и отрыли древний код.
                            Очень давно, когда работали только по api и на каждый запрос слали каждый раз полный пакет всех атрибутов из схемы, какие-либо спец. выражения шли в select-лист. Собственно, наличие этого листа и позволяло сразу сказать, есть в запросе выражения или нет. И если есть — вырубить оптимизацию. Сейчас пока что лишь пришли к мнению, что «с этим надо что-то делать». Но насколько быстро — непонятно. На нынешней архитектуре — это слишком поздний и сложный анализ выходит.
                              0
                              Как говорится, лучше поздно, чем никогда.

                  Only users with full accounts can post comments. Log in, please.