Сегодня в этот погожий весенний денек хочется написать не только о поиске видео, но и о технической
реализации работы со 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 — кстати, довольно шустро работает. Будет время — расскажем и о нем.
Комментарии (10)
calorie
20.04.2016 17:20+1А еще sphinxsearch у нас в Rutube используется для хранения и обработки логов в велосипедном аналоге Kibana — кстати, довольно шустро работает. Будет время — расскажем и о нем.
а меня очень радует использование sphinx в сборке всяких разнообразных топов и рейтингов — вспоминаю как в зеленые времена проект регулярно и по расписанию почти умирал, когда высчитывал подборку «Популярное» для главной
Cloudo58
23.04.2016 09:56А еще sphinxsearch у нас в Rutube используется для хранения и обработки логов в велосипедном аналоге Kibana — кстати, довольно шустро работает. Будет время — расскажем и о нем.
было бы очень интересно почитать…
у меня как раз сейчас стоит задача анализа логов с использованием языка запросов вроде lucene'совского (как в kibana). Очень хотелось заюзать сфинкс, но не придумал как сделать поиск по запроcу вроде `signature:«sql inj» OR src_port>80`. Сфинкс не позволяет смешивать полнотекстовый поиск и фильтрацию по OR.tumbler
23.04.2016 10:22Сфинкс не позволяет смешивать полнотекстовый поиск и фильтрацию по OR, но мне почему-то кажется, что «sql inj» можно вставить spx_attr_string и сравнивать на равенство
SELECT attr='sql inj' or src_port>80 as where_condition WHERE where_condition=1
При этом sphinxsearch будет делать фулскан по всем данным, так что скорость будет не ахти.Cloudo58
23.04.2016 11:10Согласен. Поиск на равенство строковой константе прокатит, но хочется искать и по подстроке.
Мы в итоге выбрали elasticsearch, хотя и очень не хотели java-based решение.
Есть похожая система — ELSA. Она использует именно сфинкс с нехитрым алгоритмом принятия решения о том, к кому направлять запрос — sphinx / mysql, но она выдает неадекватные результаты по количеству найденных записей (если запрос в итоге выполняется к mysql, то количество результатов <=100).tumbler
25.04.2016 10:04И правильно сделали, все-таки sphinxsearch — он гораздо больше просто про поиск, чем про фильтрацию. Есть еще безумный вариант подключить sphinxsearch к postgresql через dblink ради выполнения UNION-запросов :) С vertica и mysql это прокатывает на ура.
Fortop
24.04.2016 16:10А в чем сложность «репликации» индексов?
Индексирует одна машина. Затем rsync раскидывает файлы по серверам, и по факту завершения делаем rotate
На видеопортале с 7.5 млн уников в сутки эта схема работала на ура.
Более того она лучше для производительности поскольку индексация непосредственно на раздающем сервере просаживает время ответа практически на все время индексации, в нашем случае это было от 15 минут до 2-4 часов в зависимости от нагрузки.tumbler
25.04.2016 10:01Да собственно сложности никакой нет, было «предубеждение» со стороны админов еще со времен, когда СХД через drbd работало.
У нас почему-то индексация на графиках вообще не видна, в связи с этим вопрос: что же такое может делать indexer, чтобы не осталось процессорного времени на обычную работу? Данные-то подготавливаются на MySQL-реплике.Fortop
25.04.2016 10:23Данные, конечно на mysql, но в моем случае шла предобработка скриптами и использовался xmlpipe.
Соответственно скрипты запускались на машине где шла индексация.
Oceinic
> В любом случае, в наличии лишь минимум текстовой информации, и иногда некоторые метаданные, проставленные редакцией и пользователями-партнерами.
На самом деле информации у вас больше, чем когда нибудь может понадобиться. Посмотрите этот TED talk: www.ted.com/talks/fei_fei_li_how_we_re_teaching_computers_to_understand_pictures
tumbler
Компьютерное зрение это конечно круто, но, пожалуй, только для гигантов вроде facebook и youtube.