Сегодня в этот погожий весенний денек хочется написать не только о поиске видео, но и о технической
реализации работы со Sphinxsearch в нагруженном Django-проекте.
Начать стоит, наверно, с постановки бизнес-задачи:
- Необходимо искать релевантные видео по названию, описанию и другим текстовым данным
- К каждому видео надо искать похожие видео
- Надо чтобы нужные ролики показывались в выдаче нужных запросов на нужных местах.
А еще нефункциональные требования:
- Django-проект с дофига просмотрами и постоянными обновлениями описаний видео
- Инкапсуляция работы с поисковым движком в библиотеке и совместимость с остальными библиотеками на сайте (в первую очередь, Django REST Framework)
Про то, как в Rutube используется sphinxsearch и будет данный рассказ.
Про релевантность видео и математику
Когда говорят про поиск в интернете, обычно имеется ввиду поиск текстовой информации. В случае с видео все гораздо хуже. Обычно человек имеет ввиду вполне конкретный зрительный образ, который сам транслирует в текст запроса. Другие люди, залившие ролики на сайт, транслировали содержимое видео в название и описание ролика, и хорошо если это не "test", "sdfsdf" или "111". В любом случае, в наличии лишь минимум текстовой информации, и иногда некоторые метаданные, проставленные редакцией и пользователями-партнерами. Так что если вы программист поиска, вопросы "почему по запросу "рп" не ищутся "Реальные Пацаны" будут преследовать вас по ночам. На такие вопросы нам помогает отвечать специальная тестовая утилита.
Это страница, которая по поисковому запросу возвращает не только видео со всеми полями, которые есть в индексе, но и информацию от ранкера со значениями всех характеристик для каждого документа. Для этого запрашивается PACKEDFACTORS()
, SNIPPETS()
и CALL KEYWORDS
. Данных с этой страницы обычно достаточно для того, чтобы математически обосновать, что названный нерелевантным (вот оно, это магическое слово, которое только программисты понимают в математическом смысле, а все остальные — в духовном!)… Так вот, обосновать, почему нерелевантный ролик оказался выше релевантного.
Django-бэкенд поисковой базы
Раз уж Sphinxsearch поддерживает mysql-клиент, то почему бы взять и не запилить бэкенд для Django, который бы строил нужные нам запросы к поиску, а затем возвращал бы результаты в виде моделей Django? Не предлагаю всем пробовать заниматься этим, но это действительно не так страшно. А тем, кому все-таки страшно, или просто неинтересно, предлагаем переходить сразу к следующему разделу.
Как обычно, на гитхабе что-нибудь полезное, да найдется. Тот же django-sphinx-db, к примеру, помог начать работу с движком прямо из моделей django. В Django-1.8 была сильно изменена приватная часть реализации бекендов баз данных, из-за чего перенос django-sphinx-db
стал проблематичен. В результате, появился проект django-sphinxsearch, которому немного недостает внимания со стороны разработки, но который уже используется у нас в продакшне. Вот, кстати, пример сложностей с поддержкой бекендов: выходит новая версия Django, и всё разваливается, потому что "мои кишки, что хочу, то и меняю". Так что приходится начинать с начала.
Выглядит это примерно так:
- Ищется PEP-0249-совместимый коннектор к БД. MySQL-python, psycopg, python_monetdb — зависит от того, к чему прикручивается Django.
- Берется наиболее близкий по духу бекенд и наследуется. В случае sphinxsearch это MySQL, он же
django.db.backends.mysql
. - Самое сложное, это научить
SQLCompiler
генерировать код, совместимый с базой, под которую пишется бекенд. Это касается использования кавычек, возможности указывать имена таблиц без указания схемы, синтаксиса LIMIT/OFFSET, и тому подобных вещей. Тут хочется сказать отдельное "фе" разработчикам Django за то, что методSQLCompiler.as_sql
, собирающий строку из QuerySet.query — это монолит почти на 100 строк; в результате, чтобы поменятьLIMIT OFFSET
наLIMIT start, end
приходится регуляркой проходиться по результату вызова метода базового класса . - Добавляются методы QuerySet, обеспечивающие специфичный для поиска функционал. Например,
SphinxQuerySet.match
добавляет в self.query структуруself.match
, содержащую данные, необходимые для построения SphinxQL-выражения. Поле match клонируется, модифицируется в QuerySet, и, наконец, используется вSphinxWhereNode.make_atom
для генерации части строки запроса. Ничего сложного, просто надо писать тесты и иметь под рукой хороший отладчик.
Ранжирование результатов поиска
Результаты поиска обычно сортируются по тому, насколько они соответствуют поисковому запросу. Как это посчитать? Например, можно взять число слов, которые одновременно присутствуют в документе и запросе. Чем больше слов в пересечении — тем точнее результат подходит к этому запросу. Можно не просто брать число совпадающих слов, но еще учитывать их последовательность. А если для каждого слова учитывать его “редкость”, то вообще здорово: на выдачу перестанет влиять наличие предлогов и союзов в запросе и документе. Таких характеристик придумано много разных, полезных и не очень, так что в общем случае разумно использовать взвешенную сумму значений всех характеристик, которые считает движок.
Помимо характеристик, связывающих конкретный документ с поисковым запросом, можно еще независимо от запроса добавлять дополнительный вес документам, обладающим определенными признаками. Например, повышать в выдаче ролики, загруженные в хорошем качестве. Или добавлять весу более свежим или чаще просматриваемым роликам.
Так что добавляем в запрос
SELECT weight() + a * view_count + b * age as my_weight, ... OPTION ranker=expr('...') ORDER BY my_weight DESC;
и порядок сортировки выдачи у вас под полным контролем.
Так нехитрым образом накручиваются:
- общий вид формулы ранжирования
- веса отдельных полей (title — в 10 раз важнее description)
- веса отдельных характеристик
- дополнительные бонусы тем результатам, которые "матчатся" на поисковый запрос
QuerySet.iterator()
(редакция намекает, что этих "крутилок" им мало)
Если тюнинга поискового запроса мало, можно "прибить" результаты гвоздями. Для некоторых запросов это вообще критичный функционал, поэтому хочешь не хочешь, а приходится реализовывать механизмы манипулирования результатами поиска.
- Ищем, есть ли для текущего запроса ролики, которые редакция хотела бы видеть в результатах поиска; получаем позиции, которые ими заняты.
- Запрашиваем поиск, за исключением "гвозде"-результатов.
- Меняем метод
QuerySet.iterator()
так, чтобы тот в "нормальном" состоянии выдавал результаты от sphinxsearch, а на некоторых местах — те самые "гвоздями" прибитые ролики (к примеру, у нас так по запросу "пасадобль" возвращается эпизод из “Реальных Пацанов”. No comments). - Если уж совсем поиск нерелевантные результаты выдает, можно вообще, к примеру, вместо похожих роликов выдавать что-то из БД, например, список серий того же сериала. Для этого достаточно, чтобы основная модель Video совпадала по полям с моделью результата поиска SearchVideo.
Технические ограничения
Немного расскажу про то, что в sphinxsearch делать нельзя. Самое странное, и в то же время объяснимое "нельзя": нельзя выдать всю выдачу кроме одного или нескольких документов. Просто fullscan сделать можно, а fullscan WHERE MATCH('~document_id')
— нельзя. Софт запрещает, мол, неэффективно.
Есть два ограничения на лимиты: первое, SELECT *
без явного указания LIMIT возвращает 20 результатов, примерно как repr(queryset)
; второе, чтобы найти 100500й элемент, надо добавить в запрос OPTION max_matches=100500
. Внутри движка частичная сортировка, размер окна которой по-умолчанию равен 1000. В результате, запрос большего смещения — ошибка.
Есть много странных ограничений на числовые операции с атрибутами. Например, можно писать float_field <> 3.1415
в SELECT
, но нельзя в WHERE
. Что поделаешь, особенности парсера. Борется через QuerySet.extra()
.
Самое неприятное "нельзя": нельзя полагаться, что поиск не крашнется в самый неприятный момент. У нас был случай, когда searchd рестартовал сразу после получения запроса, содержащего число "13". Особенно это неприятно на странице, где результаты поиска не являются основным контентом. Мы обошлись генератором, который в случае получения OperationalError тихо и мирно возвращает пустой ответ.
Под нагрузкой
В ситуации, когда данных на сайте много и они меняются очень часто, нельзя просто так взять и индексировать весь сайт раз в 5 минут. Надо быть умнее. Для тех кто "собаку съел" в поиске, этот раздел будет не очень интересен, так как вещи-то в основном известные, но все же опишу кратко и по существу:
-
main + delta + killlist. main — основной индекс, содержит сайт целиком, обновляется раз в день. delta — содержит только документы, которые обновились со времени последней индексации main-индекса. killlist — список документов, которые надо исключить из предыдущего индекса.
# получаем IP индексирующего сервера sql_query_pre = set @ip = substring_index(user(), '@', -1); # для delta-индекса наполняем KILL-лист всеми # документами, которые входят в delta-индекса + # удаленными, их тоже надо из main убрать sql_query_killlist = select id from ... where ... and last_updated_ts > @last_index_ts
- global_idf. При наличии нескольких локальных индексов стоит указывать параметр global_idf=1; в противном случае Inverse Document Frequency будет считаться отдельно по каждому индексу, вследствие чего “редкие” слова из “дельты” будут толкать наверх результаты, которые там не должны быть.
- Несколько поисковых серверов. Не стали мудрить с репликацией индексных файлов поискового движка, просто каждый сервер индексирует БД отдельно. Рассинхронизация данных случается, и даже более стабильное решение придумано, но пока руки не дошли. Решение: RT-index, который обновляется одновременно на всех поисковых серверах при получении сообщения об изменении какого-либо документа. Плюсы: мгновенная индексация, отсутствие рассинхрона данных в нормальном состоянии; минусы: дико сложный код инициации отправки сообщений, т.к. документ в поиске содержит данные примерно 15 таблиц БД, необходимость обработчиков сообщений на каждом поисковом сервере.
- Нагрузка на сервер. Конечно, до 100% CPU Load лучше не доводить, но если это стало нормой, то есть опция
max_predicted_time
, которая ограничивает теоретическое время выполнения запроса. Релевантность страдает, но срезать часть проблем можно. Можно, но не нужно, так как "Бог всё видит". Бог — редакция, а всё — это появление, к примеру, ну очень странных "похожих" на странице. Для борьбы со временными перегрузками имеет смысл поставитьCONN_TIMEOUT
в коннекте Django, чтобы поиск не тормозил всё остальное. - Администрирование списков синонимов и исключений. Раскладываем по WebDAV и применяем при ближайшей индексации (при ротации индексов).
Про изменение RT-индексов
Все-таки sphinxsearch — это не база данных. Но возможность изменения данных в нем есть.
- В
UPDATE
позволяется производить обновление атрибутов, имеющих фиксированную длину. Кстати, даже для "on-disk"-индексов. - В остальных случаях используется
REPLACE
, помещающий старую версию документа удаленной, и добавляющий в конец новую. - Отсюда сложности для разработчиков бекенда для Django: во-первых,
queryset.update(field=value)
работает только для числовых атрибутов,REPLACE
надо форматировать как bulk insert; во-вторых,REPLACE
все-таки больше похож по синтаксису наINSERT
, а значит формировать его надо с помощьюSQLInsertCompiler
. В общем, есть над чем подумать.
После трехлетнего использования sphinxsearch в продакшне вся команда поиска его горячо и всем сердцем полюбила. Пожалуй, это единственный проект, в котором все проблемы настолько странные и занимательные 🙂
- DSPH-146 Ребенок и подоконник
- DSPH-115 Хомяк сломался (поисковая выдача)
- DSPH-129 Договаривались убирать из похожих такие же ролики
- DSPH-118 Плохие похожие, очень плохие
- DSPH-131 Опять похожие и опять это отвратительно
А еще sphinxsearch у нас в Rutube используется для хранения и обработки логов в велосипедном аналоге Kibana — кстати, довольно шустро работает. Будет время — расскажем и о нем.
ссылка на оригинал статьи https://habrahabr.ru/post/281653/
Добавить комментарий