О поиске видео замолвите слово
- четверг, 21 апреля 2016 г. в 03:16:19
Сегодня в этот погожий весенний денек хочется написать не только о поиске видео, но и о технической
реализации работы со Sphinxsearch в нагруженном Django-проекте.
Начать стоит, наверно, с постановки бизнес-задачи:
А еще нефункциональные требования:
Про то, как в Rutube используется sphinxsearch и будет данный рассказ.
Когда говорят про поиск в интернете, обычно имеется ввиду поиск текстовой информации. В случае с видео все гораздо хуже. Обычно человек имеет ввиду вполне конкретный зрительный образ, который сам транслирует в текст запроса. Другие люди, залившие ролики на сайт, транслировали содержимое видео в название и описание ролика, и хорошо если это не "test", "sdfsdf" или "111". В любом случае, в наличии лишь минимум текстовой информации, и иногда некоторые метаданные, проставленные редакцией и пользователями-партнерами. Так что если вы программист поиска, вопросы "почему по запросу "рп" не ищутся "Реальные Пацаны" будут преследовать вас по ночам. На такие вопросы нам помогает отвечать специальная тестовая утилита.
Это страница, которая по поисковому запросу возвращает не только видео со всеми полями, которые есть в индексе, но и информацию от ранкера со значениями всех характеристик для каждого документа. Для этого запрашивается PACKEDFACTORS()
, SNIPPETS()
и CALL KEYWORDS
. Данных с этой страницы обычно достаточно для того, чтобы математически обосновать, что названный нерелевантным (вот оно, это магическое слово, которое только программисты понимают в математическом смысле, а все остальные — в духовном!)… Так вот, обосновать, почему нерелевантный ролик оказался выше релевантного.
Раз уж Sphinxsearch поддерживает mysql-клиент, то почему бы взять и не запилить бэкенд для Django, который бы строил нужные нам запросы к поиску, а затем возвращал бы результаты в виде моделей Django? Не предлагаю всем пробовать заниматься этим, но это действительно не так страшно. А тем, кому все-таки страшно, или просто неинтересно, предлагаем переходить сразу к следующему разделу.
Как обычно, на гитхабе что-нибудь полезное, да найдется. Тот же django-sphinx-db, к примеру, помог начать работу с движком прямо из моделей django. В Django-1.8 была сильно изменена приватная часть реализации бекендов баз данных, из-за чего перенос django-sphinx-db
стал проблематичен. В результате, появился проект django-sphinxsearch, которому немного недостает внимания со стороны разработки, но который уже используется у нас в продакшне. Вот, кстати, пример сложностей с поддержкой бекендов: выходит новая версия Django, и всё разваливается, потому что "мои кишки, что хочу, то и меняю". Так что приходится начинать с начала.
Выглядит это примерно так:
django.db.backends.mysql
.SQLCompiler
генерировать код, совместимый с базой, под которую пишется бекенд. Это касается использования кавычек, возможности указывать имена таблиц без указания схемы, синтаксиса LIMIT/OFFSET, и тому подобных вещей. Тут хочется сказать отдельное "фе" разработчикам Django за то, что метод SQLCompiler.as_sql
, собирающий строку из QuerySet.query — это монолит почти на 100 строк; в результате, чтобы поменять LIMIT OFFSET
на LIMIT start, end
приходится регуляркой проходиться по результату вызова метода базового класса .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;
и порядок сортировки выдачи у вас под полным контролем.
Так нехитрым образом накручиваются:
(редакция намекает, что этих "крутилок" им мало)
Если тюнинга поискового запроса мало, можно "прибить" результаты гвоздями. Для некоторых запросов это вообще критичный функционал, поэтому хочешь не хочешь, а приходится реализовывать механизмы манипулирования результатами поиска.
QuerySet.iterator()
так, чтобы тот в "нормальном" состоянии выдавал результаты от sphinxsearch, а на некоторых местах — те самые "гвоздями" прибитые ролики (к примеру, у нас так по запросу "пасадобль" возвращается эпизод из “Реальных Пацанов”. No comments).Немного расскажу про то, что в 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
max_predicted_time
, которая ограничивает теоретическое время выполнения запроса. Релевантность страдает, но срезать часть проблем можно. Можно, но не нужно, так как "Бог всё видит". Бог — редакция, а всё — это появление, к примеру, ну очень странных "похожих" на странице. Для борьбы со временными перегрузками имеет смысл поставить CONN_TIMEOUT
в коннекте Django, чтобы поиск не тормозил всё остальное.Все-таки sphinxsearch — это не база данных. Но возможность изменения данных в нем есть.
UPDATE
позволяется производить обновление атрибутов, имеющих фиксированную длину. Кстати, даже для "on-disk"-индексов.REPLACE
, помещающий старую версию документа удаленной, и добавляющий в конец новую.queryset.update(field=value)
работает только для числовых атрибутов, REPLACE
надо форматировать как bulk insert; во-вторых, REPLACE
все-таки больше похож по синтаксису на INSERT
, а значит формировать его надо с помощью SQLInsertCompiler
. В общем, есть над чем подумать.После трехлетнего использования sphinxsearch в продакшне вся команда поиска его горячо и всем сердцем полюбила. Пожалуй, это единственный проект, в котором все проблемы настолько странные и занимательные :)
А еще sphinxsearch у нас в Rutube используется для хранения и обработки логов в велосипедном аналоге Kibana — кстати, довольно шустро работает. Будет время — расскажем и о нем.