На prom.uaесть сервис, предлагающий подсказки по мере ввода текста в строку поиска:
Несколько фактов о нем:
- полнотекстовый поиск подсказок осуществляется среди миллиона фраз;
- индекс из миллиона фраз занимает менее 100Мб оперативной памяти;
- все запросы к сервису, идущие с prom.ua, обслуживаются по очереди одним процессом в одном потоке;
- сервис написан на Python.
История развития сервиса
Сначала подсказки хранились в таблице БД (MongoDB), проиндексированной по фразам, приведенным к каноническому виду. Под каноническим видом понимается фраза в нижнем регистре с удаленными знаками пунктуации и без лишних пробелов. Чтобы показать подсказки к введенному пользователем запросу, этот запрос приводился к каноническому виду, а затем выполнялся запрос к MongoDB, который можно представить в виде SQL:
SELECT suggestion FROM search_suggestions WHERE text_canonical LIKE ‘search_query_canonical%’ ORDER BY weight DESC LIMIT N
Таким образом, выборка делалась только по началу поисковой фразы и выводились первые N подсказок c максимальным весом. У этой реализации было две проблемы:
- Запрос к БД мог тормозить, если под фильтр
text_canonical LIKE ‘search_query_canonical%’
попадало слишком много фраз, которые нужно было отсортировать по весу и только затем выбрать первые N из них. Т.к. эта же MongoDB использовалась другими сервисами, то у этих сервисов могли начаться проблемы, если кто-то решал заDoSить сервис поисковых подсказок. - Поиск подсказок производился строго по полному совпадению с началом поискового запроса. Например, по запросу «
платье
» выводились подсказки «Платье женское
», «платье летнее
», но не выводились подсказки «женское платье
», «чем красное платье лучше белого?
». По запросу «s4 gala
» выводились подсказки «s4 Galaxy
», «S4 GALAXY круче iphone5
», но не выводились подсказки «samsung galaxy s4200
», «s4 Samsung galaxy - копия s3?
».
Однажды было решено переписать сервис поисковых подсказок, чтобы он удовлетворял следующим условиям:
- Должен быть полностью автономным. Т.е. не должен зависеть от внешних сервисов типа БД, а также не должен влиять на работу других сервисов.
- Должен искать по совпадению отдельных слов поисковой фразы в любом месте подсказки, чтобы по запросу «
бетон аренда
» находились не только «бетон аренда дешево
», но и «аренда бетономешалок
», «бетононасос аренда
», «аренда миксера с бетононасосом в Одессе
». В дальнейшем будем называть это полнотекстовым поиском по префиксам. - По возможности должен возвращать найденные подсказки с максимальным весом. Т.е. если по запросу «
перфоратор
» найдено 100500 подсказок, то нужно вернуть только N из них с максимальным весом. - Должен работать быстрее старого сервиса.
- Должен поддерживать поиск среди нескольких миллионов подсказок.
- Должен поддерживать периодическое обновление базы поисковых подсказок.
Удивительно, но было решено писать этот сервис на Python. Чтобы был супер-скоростным :) На самом деле, чтобы все наши программисты, которые знают python, могли быстро разобраться в коде этого сервиса. К тому же, разработка с нуля аналогичного сервиса на каком-нибудь C заняла бы в несколько раз больше времени.
Т.к. к сервису идут AJAX-запросы, он должен поддерживать http и делать это с минимальными накладными расходами. Поэтому было решено использовать проверенный временемgevent, который уже успешно использовался в других сервисах prom.ua.
Технические детали реализации
Как сделать полнотекстовый поиск по префиксам? Правильно — с помощью слегка модифицированного инвертированного индекса. Вот классический алгоритм. Каждая фраза, среди которых осуществляется поиск, разбивается на отдельные слова (токены). Для каждого токена создается список фраз, содержащих этот токен. Теперь, чтобы найти фразы, содержащие заданный набор слов, достаточно найти пересечение списков для этих слов. Вот псевдокод на Python:
def get_suggestions(search_query): return frozenset.intersection(*( get_suggestions_for_token(token) for token in token_split(search_query) ))
Это алгоритм полнотекстового поиска по точному совпадению слов. Т.е. по запросу «шило
» будут найдены все фразы с этим словом, но не будет найдены фразы, содержащие префиксы этого слова — «прибор Шилова
» или «шиловидный флокс
». Для того, чтобы можно было находить фразы по префиксам, нужно для каждого слова из поискового запроса найти все токены, начинающиеся с этого слова, после чего объединить связанные с этими токенами списки фраз. Затем нужно найти пересечение получившихся списков по каждому слову из поисковой фразы. Псевдокод на Python:
def get_suggestions_for_prefix(prefix): return frozenset.union(*( get_suggestions_for_token(token) for token in get_tokens_with_prefix(prefix) )) def get_suggestions(search_query): return frozenset.intersection(*( get_suggestions_for_prefix(prefix) for prefix in token_split(search_query) ))
Первый рабочий прототип класса, реализующего полнотекстовый поиск по префиксам, был написан за полдня и занимал около 100 строчек кода на Python. Затем началась работа по оптимизации потребления памяти и скорости работы, по защите от DoS-атак и улучшению качества выдачи.
Первое, что нужно было исправлять — повышенное потребление памяти. Для построения индекса по миллиону фраз требовалось 3Гб памяти, а нужно было уложиться хотя бы в 500Мб. Вначале список фраз для каждого токена был заменен списком индексов этих фраз в глобальном списке фраз под названием keywords:
# если раньше get_suggestions_for_token(“foobar”) # возвращал frozenset((“foobar”, “bar fooBar”)), # то теперь возвращает frozenset((9000,100500)), # где 9000 и 100500 - индексы фраз “foobar” и “bar booBar” # в глобальном списке фраз keywords.
Затем keywordsбыл преобразован в один большой bytearray, где фразы отделялись друг от друга с помощью специального символа-разделителя. Псевдокод формирования keywords:
def get_keywords_bytearray(keywords_list): keywords = bytearray() for keyword in keywords_list: keywords.extend(keyword) keywords.extend(KEYWORDS_DELIMITER) return keywordsЗатем списки индексов фраз были заменены списками смещений в keywords, закодировнных в бинарную строку с помощью struct.pack(). Теперь каждое смещение занимало ровно 4 байта. Затем списки смещений для всех токенов были объединены в один большой bytearray под названием offsets. Затем отсортированный по алфавиту список пар (токен, смещение в offsets)был заменен на все тот же bytearray под названием tokens. В итоге мы получили три больших bytearray’я:
- keywords — список фраз, по которым производится полнотекстовый поиск.
- offsets — список, содержащий список смещений в keywordsдля каждого токена.
- tokens — отсортированный по алфавиту список пар (токен, смещение в offsets).
Теперь индекс для миллиона поисковых фраз стал занимать меньше 100Мб. Но для перестроении индекса все еще требовалось 1Гб памяти — столько занимал список пар (токен, смещение в keywords)для миллиона фраз, из которого затем строился сжатый индекс. Была предпринята попытка положить этот список в bytearray, сохраняя смещение каждой пары в array(‘L’). Но это не дало существенного выигрыша в потреблении памяти. Поэтому было решено разбить список фраз на подсписки меньшей длины — шарды — и строить индекс по каждому шарду. Это немного усложнило индексацию и алгоритм поиска, но позволило снизить максимальное потребление памяти при построении индекса. Например, на шардах размером 500К фраз потребление памяти не выходило за пределы 400Мб при построении индекса для миллиона фраз. На этом этапе оптимизация по потреблению памяти была окончена и началась оптимизация по скорости.
Сперва разобрались с тормозами при поиске часто встречающихся префиксов (например «про
» или «купить
»). Такому запросу удовлетворяет примерно 100500 фраз, из которых нужно выбрать Nс наибольшим весом. Очевидно, что каждый раз выбирать и сортировать все эти фразы по весу было бы очень накладно. Поэтому список фраз для каждого токена заранее сортировался по весу во время построения индекса, а при поиске выбирались первые Nфраз из этого списка. Встал вопрос — как выбирать фразы с наибольшим весом, если несколько токенов начинается с заданного префикса (например, токены «проект
» и «продать
» для префикса «про
»)? Попробовали использовать алгоритм объединения сортированных списков на основе heapq.merge(), чтобы выбрать Nфраз с наибольшим весом. Но это оказалось слишком медленным при большом количестве токенов с заданным префиксом, поэтому было решено «схалтурить» и выдавать подряд первые встретившиеся Nфраз, содержащих заданный префикс.
Далее нужно было решить вопрос со скоростью поиска по поисковым запросам, содержащим несколько слов. Нужно было находить пересечение списков фраз, содержащих каждое слово. Как уже известно, такие списки могли оказаться слишком большими. Поэтому в целях оптимизации было решено снова «схалтурить» и выбирать не более заданного количества фраз (Nmax) для каждого префикса перед тем, как находить их пересечение. Это ухудшило качество подсказок для поисковых запросов, состоящих из нескольких слов. Поэтому, чтобы немного сгладить вину, в алгоритм поиска были внесены изменения — если хотя бы один из списков содержит меньше, чем Nmaxфраз, то вместо того, чтобы находить пересечение списков, сканировались все фразы из списка, содержащего минимальное количество фраз, на предмет содержания всех слов из поискового запроса.
Производительность и потребление памяти
Вот лог тестирования скорости поиска среди миллиона фраз в интерактивном режиме с помощью ipythonна моем ноуте:
$ ipython -i suggester.py In [1]: s = Suggester() In [2]: s.update_keywords( ...: ('keyword %d' % i, 'payload %d' % i) ...: for i in range(1000000) ...: ) In [3]: def bench_suggester(suggester, query, n): ...: import time ...: start_time = time.time() ...: for i in range(n): ...: suggester.suggest_keywords(query) ...: print '%.0f ops/s' % ( ...: n / (time.time() - start_time) ...: ) ...: In [4]: bench_suggester(s, u'foobar', 10000) 10009 ops/s In [5]: bench_suggester(s, u'1234', 10000) 4654 ops/s In [6]: bench_suggester(s, u'key', 10000) 4702 ops/s In [7]: bench_suggester(s, u'key 1234', 10000) 1077 ops/s In [8]: bench_suggester(s, u'zkey 1234', 10000) 1226 ops/s In [9]: bench_suggester(s, u'zkey z1234', 10000) 6416 ops/s
Нагрузочное тестирование показало, что один сервис поисковых подсказок, работающий в одном потоке на одном процессоре, справляется с нагрузкой в 2000 типичных запросов в секунду. На данный момент один сервис поисковых подсказок спокойно справляется с трафиком prom.ua, потребляя при этом не более 200 Мб памяти (для сервиса, содержащего около миллиона подсказок, хватило бы и 100Мб — остальные 100Мб нужны для подсказок, используемых в других частях сайта). На остальных наших сайтах — tiu.ru, deal.by, satu.kzи prom.md — установлены отдельные копии сервиса поисковых подсказок. Это сделано не из-за того, что один сервис не справился бы с нагрузкой со всех сайтов, а из-за того, что в разных странах нужно показывать разные поисковые подсказки.
Заключение
Новый сервис поисковых подсказок достиг всех поставленных целей:
- является полностью автономным — если он выходит из строя либо его начинают DoS’ить, то остальные части сайта продолжают работать как ни в чем не бывало;
- быстро осуществляет полнотекстовый поиск по префиксам среди миллиона подсказок, учитывая по возможности их вес;
- поддерживает периодическое обновление базы подсказок.
Конечно, он не является идеальным. Существует море улучшений, которые можно было бы реализовать в этом сервисе. Надеюсь, ваши комментарии к этой статье помогут улучшить качество выводимых подсказок.
В данной статье описаны лишь некоторые детали разработки нашего сервиса поисковых подсказок. Эта статья является примером того, что на Python можно и нужно разрабатывать высокопроизводительные сервисы, оптимизированные по потреблению памяти. Gevent, struct, bytearray, array, heapq — лучшие друзья при разработке таких сервисов.
P.S. (небольшой холиварчик)
У Python’а есть преимущество перед другими популярными языками программирования типа Java и C# — обычно код на Python пишется быстрее и получается понятнее и короче. Например, на данный момент весь код, ответственный за индексацию и поиск подсказок, занимает 268 строк, включая комментарии, пустые строчки и дополнительные возможности, не описанные в этой статье — привязка произвольных данных к каждой фразе в индексе, запись и чтение индекса в/из файла, возможность поиска по не полному совпадению фраз, возможность задания различных методов разбиения фраз на токены, разбиение списка фраз на шарды с последующим объединением результатов поиска по каждому шарду.
Желающие могут ознакомиться с исходниками Suggester’а на github — главной составляющей сервиса поисковых подсказок на prom.ua. Если кому-нибудь интересны технические детали, упущенные в этой статье, задавайте вопросы в комментариях — постараюсь ответить.