В прошлый раз, когда я принимался за прикручивание поиска в форум, я буквально во втором абзаце "похоронил" штуку под названием Sphinx из-за того, что он, как мне показалось, работает только с MySQL. Андрей Аксенов, автор Сфинкса, в комментариях это заблуждение опроверг, и я оставил у себя пометку в памяти, что надо будет посмотреть на него.
Окончательно же мое желание сформировалось, когда на Highload 2007 мне удалось послушать доклад о Сфинксе и пообщаться с Андреем, который и разрешил все мои оставшиеся вопросы. Помнится, "купила" меня в итоге фраза о том, что он дает большую релевантность в поиске точному совпадению введенной фразы, а не просто близко стоящим словам (или что-то в таком роде :-) ).
В итоге, Cicero теперь имеет поиск, использующий Sphinx. Дальше — традиционый рассказ о реализации.
Bzr-репозиторий | http://softwaremaniacs.org/code/cicero/ |
---|---|
Работающий форум | http://softwaremaniacs.org/forum/ |
Индексация
Sphinx умеет читать данные из MySQL и PostgreSQL, но также и из пользовательской программы, которая выдает на stdout некий псевдо-XML. Я воспользовался именно этим способом, потому что 1) мне, как я уже упоминал, не хочется привязываться к конкретной СУБД и 2) потому что я хочу индексировать статьи, а искать топики. Поясню второй пункт.
Когда Sphinx индексирует документы в БД, он просит указать в конфигурации SQL-запрос, который будет выдавать строки, которые такими документами и считаются. В них должен быть ID и текст. Все бы хорошо, но у меня в форуме единица информации — целиком топик, а не отдельная статья. Получается, мне нужен запрос, который бы выдавал ID топика и весь его текст, то есть текст всех его статей. Я слишком плохо знаю SQL, чтобы составить такой запрос, поэтому я и написал для этого несложный код, который выдает топики с текстом всех их статей, соединеных вместе:
# Составляет текст топика из текста его статей
def text(topic):
return ' '.join([a.text for a in topic.article_set.all()])
# Превращает dict с данными топика в sphinx'овый псевдо-XML
def format_document(document):
return '\n'.join(
['<document>'] +
['<%s>%s</%s>' % (k, document[k], k) for k in ['id', 'group', 'timestamp', 'title', 'body']] +
['</document>', '']
)
# Выбирает и форматирует данные топика в dict
def topic_dict(topic):
return {
'id': topic.id,
'group': topic.forum_id,
'timestamp': int(mktime(topic.created.timetuple())),
'title': escape(topic.subject.encode('utf-8')),
'body': escape(text(topic).encode('utf-8')),
}
for topic in Topic.objects.all():
print format_document(topic_dict(topic))
API
Из Джанго к серверу Sphinx'а можно обращаться двумя способами: через django-sphinx или через включенный в поставку самого Sphinx'а общий питоновский API. Я попробовал оба, причем переключался с одного на другой и обратно раза два. Остановился на сфинксовом API. Почему не подошел django-sphinx определенно сказать не берусь :-). Видимо, на мой вкус он оказался недостаточно вылизанным. По идее он старается быть похожим на джанговский queryset, но в паре мест некрасиво лажается:
- цепочные вызовы не генерят новые instance'ы поискового запроса, а модифицируют изначальный, что странно: уж либо функциональная парадигма, либо объектная
- слайсинг (указание [offset:limit]) возвращает уже таки результат, а не модифицированный запрос, что заставляет делать его обязательно последним
В итоге остановился на sphinxapi.py из поставки. Код вьюхи, которая его использует, очень прямолинейный и скучный, но чтобы не отходить от традиций приведу его здесь:
def search(request, slug):
forum = get_object_or_404(Forum, slug=slug)
try:
from sphinxapi import SphinxClient, SPH_MATCH_EXTENDED, SPH_SORT_RELEVANCE
except ImportError:
return render_to_response(request, 'cicero/search_unavailable.html', {})
try:
page = int(request.GET.get('page', '1'))
if page < 1:
raise Http404
except ValueError:
raise Http404
term = request.GET.get('term', '').encode('utf-8')
if term:
sphinx = SphinxClient()
sphinx.SetServer(settings.SPHINX_SERVER, settings.SPHINX_PORT)
sphinx.SetMatchMode(SPH_MATCH_EXTENDED)
sphinx.SetSortMode(SPH_SORT_RELEVANCE)
sphinx.SetFilter('gid', [forum.id])
sphinx.SetLimits((page - 1) * settings.PAGINATE_BY, settings.PAGINATE_BY)
results = sphinx.Query(term)
pages = _page_count(results['total_found'])
if pages > 0 and page > pages:
raise Http404
ids = [m['id'] for m in results['matches']]
topics = ids and Topic.objects.filter(id__in=ids)
else:
topics, pages = None, None
return render_to_response(request, 'cicero/search.html', {
'page_id': 'search',
'forum': forum,
'topics': topics,
'term': term,
'has_next': page < pages,
'has_previous': page > 1,
'page': page,
'pages': pages,
'query_dict': request.GET,
})
Впрочем с sphinxapi.py тоже не все было гладко. Я нашел там баг. Выражался он в том, что после поиска по некому слову сначала в одном форуме, потом в другом, а потом опять в первом, поиск начинал возвращать нулевые результаты. Причем после перезагрузки моего приложения баг пропадал, а затем возвращался снова. Я предположил, что где-то там сохраняется состояние предыдущих выборок, которое не должно сохраняться, и так оно, судя по всему, и есть. Вот начало кода класса SphinxClient:
class SphinxClient:
_host = 'localhost'
_port = 3312
_offset = 0
_limit = 20
_mode = SPH_MATCH_ALL
# и еще 12 параметров
def __init__ (self):
pass
То есть все то, что должно быть, кажется, полями экземпляров объектов, лежит не в экземплярах, а в классе и, следовательно, является общим для всех. Видимо, туда куда-то и попадают результаты предыдущих поисков. Решилась проблема использованием метода __init__
по назначению: я переписал назначение всех параметров туда:
class SphinxClient:
def __init__ (self):
self._host = 'localhost'
self._port = 3312
self._offset = 0
self._limit = 20
self._mode = SPH_MATCH_ALL
# и еще 12 параметров
Андрею Аксенову. Я извиняюсь, что не оформил это патчем: очень лень было в багтракере регистрироваться :-). Может, поправите при случае?
Живой индекс
Sphinx'овый индекс — монолитный, в него нельзя добавить новый документ, а надо перестраивать с самого начала (что ни в коем случае не плохо, потому что из-за этого он очень быстрый, насколько я понимаю). Если индекс пересчитывать редко, то полезность поиска уменьшается, а пересчитывать часто — дорого. Для решения этой проблемы в документации рекомендуется завести два индекса: основной, который пресчитывается редко, и маленький дополнительный ("дельта"), в который попадают только новые статьи. Sphinx умеет прозрачно искать сразу по двум индексам.
Я так и сделал, причем для отделения новых топиков от старых получилось элегантно использовать уже существующую систему определения прочитанности. То есть я просто завел одного системного пользователя "cicero_search", который "прочитывает" все статьи при полной переиндексации, а его непрочитанные топики попадают в дельта-индекс. В коде это выглядит так:
cicero_search = Profile.objects.get(user__username='cicero_search')
if sys.argv[1] == 'unread':
for topic in cicero_search.unread_topics():
print format_document(topic_dict(topic))
elif sys.argv[1] == 'all':
for topic in Topic.objects.all():
print format_document(topic_dict(topic))
cicero_search.add_read_articles(Article.objects.all())
cicero_search.save()
Сейчас дельта-индекс пересчитывается раз в 10 минут, основной — раз в сутки.
Интерфейс
В большинстве форумов, которые я видел, интерфейс поиска упрятан за какие-нибудь ссылки-кнопки, причем упрятан хорошо! Наверное для форумов, где в десятке топиков идет вечный чат, это не самая нужная фича, но у меня — одна из самых главных. Поэтому строка поиска размещается на странице форума прямо в самом верху.
Надо будет еще ссылочку в сайдбары второстепенных страниц распихать, пока я это просто позабыл.
Впечатления и пожелания
В целом, все хорошо! Работает, ищет, все очень быстро (хотя при нынешних объемах это не показательно), русская морфология есть, релевантность вроде тоже вменяемая получается.
Но если помечтать, то у меня есть несколько пожеланий, в духе "каким я вижу идеальный поисковый сервер".
Было бы страшно удобно, если бы у сервера был не свой собственный (бинарный!) протокол обмена данными, а чтобы для этого использовался бы HTTP с XML'ом или JSON'ом в качестве формата выдачи. Тогда необходимости в языковых библиотеках просто бы не возникло.
XML-интерфейс индексатора все таки должен быть полнофункциональным (сейчас в Sphinx он может не все, что можно сделать SQL'ом) и, на самом деле, основным. Просто потому что этот подход гибче, да и свет клином не сошелся на двух СУБД. Собственно и доступ в СУБД можно реализовать над XML-интерфейсом, написав готовые индексаторы, которые будут делать запросы и выдавать XML. (Да, и это все таки должен быть XML, потому что на дворе 2007-й год, и смысла делать псевдо-XML со своим парсингом я, честно, не вижу :-) ).
Как типичному представителю "девелоперов с Убунтой из коробки", мне очень хочется, чтобы можно было скачать готовый deb-пакет, а не собирать сервер из исходников.
Комментарии: 44
Я тоже уже наверное месяц назад прикрутил к своему блогу поиск на Sphynx, но в "продакшн" пока так и не выложил, и всё по той же самой банальной причине — никак не хватает времени собрать нормальный дебиановский пакет, а компилять это дело на хостинге мне не позволяет чувство прекрасного :)
Да, Сфинкс — штука очень приятная и перспективная. Но у меня два вопроса возникли по этому постингу: 1) неужели в Цицеро уже так много материалов, что потребовался дельта-индекс? и 2) какая скорость индексирования (время, байт/сек, док/сек) получается в Вашем случае? Интересно, насколько велик оверхед по сравнению с SQL-доступом.
Нет, конечно. Но лучше уж написать этот код сразу, чем через пару лет, когда он срочно понадобится, впопыхах вспоминать, как же это делается.
Вот полная индексация (всего 182 документа, оказывается):
Самый большой оверхед, я подозреваю, не в том, что это в XML конвертируется, а в том, что на каждый топик делается отдельный подзапрос его статей. Но пока меня все устраивает.
Мне кажется, тут имеет место антипаттерн «преждевременная оптимизация». Через пару лет или шах помрёт или… в общем, на действительно больших проектах не факт, что дельта — оптимальный алгоритм (от характера проекта зависит). А на небольших и средних скорость полной переиндексации сфинкса более чем достаточна. Он действительно крайне быстр. Должен быть…
Но вот Ваши цифры мне как-то совсем не понравилсь. Может быть, конечно, влияет малое число документов, но вообще-то это песец. Вот так у меня индексируется реальный форум (20K небольших постингов):
А вот реальный СМИ-сайт (10К полноценных статей — видно, что и тут можно спокойно делать полный реиндекс раз в 10—20 минут без всякой дельты):
Мне кажетя, если и оптимизировать, то где-то здесь, потому что разница в скорости слишком велика.
Я не вижу здесь преждевременной оптимизации, если честно. Из двух решений я выбрал то, которое в принципе масштабируется, и много времени это не отняло.
А про скорость в документах в секунду — это скорее всего как раз та разница, которая возникает из-за того, что я делаю не один запрос, который выдает все документы, а по запросу на каждый. Очень похоже по порядку цифр, по крайней мере. Но еще раз повторюсь, меня это устраивает :-).
Мне это трудновато понять. Зачем в одном месте предусматривать масштабируемость, если система гарантированно заткнётся гораздо раньше и совершенно в другом месте?
А я не понимаю, почему эти вопросы вообще связаны :-). Скорость индексации как таковую можно будет еще пооптимизировать, буде это необходимо. Разделение же индекса на дельту и основу — это просто дешевый способ добиться того, чтобы переиндексацию можно было запускать чаще. А если полный индекс будет пересчитываться даже час-другой, его можно будет просто запускать реже, раз в неделю например.
Давид, я наверное догадываюсь, что Вы считаете двойной индекс преждевременной оптимизацией, потому что оно кажется сложным эзотерическим решением. Но по мне оно — очень простое и очевидное, сделанное за 10 минут. А вот пытаться придумать какой-нибудь мудреный алгоритм, для поднятия скорости индексации — это как раз затраты времени на ту самую преждевременную оптимизацию.
Я для себя посмотрел и решил что лучше использовать Solr(http://lucene.apache.org/solr/) или что-то подобное.
Подкупает открытый протокол на базе HTTP, то что поиск основан на Lucene (слышал о ней только хорошие отзывы), ну и к тому-же там прям из коробки есть возможность делать фасетную классификацию результатов.
Не первый раз замечаю, как ты делаешь импорты внутри функций. Я стараюсь избегать таких конструкций. Дело, конечно, не в скорости. Просто когда импорты собраны в одном месте, сразу видны внешние зависимости модуля.
Да нет... Когда все импорты собраны в начале модуля, они создают зависимости. Например во view'хе, которую я тут в статье привел, очень явно видно, зачем импорт sphinxapi делается внутри: если Сфинкс не установлен, форумом все равно можно пользоваться, а при поиске юзеру напишется сообщение о том, что поиск недоступен.
Я не считаю, что надо догматично держаться одной или другой стратегии. И у той, и у другой есть свои применения. В частности, джанговский views — это такой "мегамодуль", в котором собран целый уровень очень разной функциональности. Поэтому мне кажется неправильным выносить наверх локальные зависимости, нужные только одной довольно независимой части этой функциональности.
В качестве мнемонического правила в таких мегамодулях я обычно придерживаюсь того, что выношу импорт наверх либо когда он встретился в двух локальных местах, либо когда это стандартный рантайм, который всегда есть (типа datetime или os).
как вариант - можно было довольно просто прикрутить lucene/solr-сервер. как минимум, там индекс гибкий. а связать с db можно и вручную
Пользуйте ленивый импорт.
Тут есть интересное размышление на тему lazy import.
Хм... А зачем? Если импорт из локальной функции и проблему решает, и более явно передает намерения.
Я, честно говоря, вообще не вижу смысла во что бы то ни стало выносить импорты наверх файла, нарушая инкапсуляцию — связь импорта с кодом, для которого он нужен.
Травмы после Pascal'я и C наверно :D
Иван, у меня такой вопрос. В чём преимущество прикручивания таких движков по сравнению с Google API или Yandex XML (кроме скорости индексации или она тут критична)? Просто и качество морфологии, например, у них выше и работ по приделыванию на час-два где-то.
Сторонние сервисы такого рода, мягко говоря, сильно ограничены по функциональности. Например у Яндекс.XML есть лимит в 1000 запросов в день, а Google, если я не ошибаюсь, то ли вообще закрыл свой SOAP-интерфейс к поиску, то ли сделал его платным. Опять-таки, ни тот, ни другой сервис не будут "ложиться костьми" для того, чтобы проиндексировать именно мой форум именно так, как мне надо. Другими словами — это очень половинчатое и ненадежное решение.
А насчет двух часов... Я не могу сказать, что прикручивал Сфинкс очень долго. Не два часа, конечно, а два вечера, но поскольку для меня программирование — удовольствие, я не могу сказать, что как-то этим недоволен :-)
Иван, спасибо!
Да, у меня пока не набирается 1000 поисков в день, а SOAP-аккаунт я успел отхватить в своё время :)
Про точность индексации согласен, мне пришлось посидеть над сочинением поискового запроса, выбирающего из проиндексированных страниц только те, которые нужны пользователю.
Так что когда выйду за пределы допустимого в Гугле, то буду знать, что ставить на замену.
Сказывается специфика web-программирования. Умолкаю.
Хотел бы я знать, что это значит, в смысле "большая релеватность точному совпадению введенной фразы" :)
Скорость мерять на данных, целиком влезающих в память - ну это как-то не очень правильно, хороший форум это все-таки несколько сотен тысяч ниток, или даже несколько пар миллионов, если форум популярен. Тестировать стоит начинать на объемах, превышающих объем ОЗУ.
Скажите, Иван, а вы пробовали использовать Xapian? В предыдущей статье про поиск вы грозились это сделать. Лично я попробовал его именно после вашей статьи, и нашёл весьма удобным инструментом!
Расскажите, чем он пришёлся не по вкусу? Сам я его применяю уже несколько месяцев, без проблем. Но вдруг какие-то грабли всё же есть... Cам я, поначалу, тоже хотел использовать Sphinx, и пока безмерно рад, что этого не сделал.
Это значит, что если я введу слова "язык запросов", то документы, где эти слова написаны точно так и в том же порядке, будут гарантировано встречаться выше, чем те, где написано, например, "запросы на языке" или "запрос языка".
Для Xapian, если я правильно понял, нет уже написанного сервера, а мне писать было лениво :-). А без отдельного сервера, как я понял после прошлых изысканий, поиск все таки не делается. Впрочем, все не высечено в камне, и возможно я и Xapian когда-нибудь попробую. Чисто по отзывам, что Sphinx, что Xapian, что Lucene — примерно одинаковы по качеству. Я решил таки уже выбрать что-то одно и попробовать руками.
Бинарный будет завсегда.
Но понятно что можно прикрутить XML обертку в том числе внешнюю.
Это скоро будет.
Оно далеко не хардкодом там ровно 2 СУБД поддерживает ;)
Те. драйвер для очередного спец-источника данных в общем-то не шибкая
проблема дописать.. даже и самостоятельно.
Можно, но далеко не вегда не нужно.
Потому что лишние ненужные оверхеды на создание и разбор XML.
http://sphinxsearch.com/doc.html#weighting
Причем просьба понимать правильно.
Ранжирование на основе LCS далеко НЕ панацея во-1х, слишком упрощенный подход во-2х,
не сделана куча фокусов для дальнейшего улучшения ранжирования в-3х - все это известно.
Но оно с ходу заметно лучше чем обычный BM25.
И будет, таки я надеюсь, со временем улучшаться!
:)
Кроме отсутствия инкрементальных обновлений интересно есть какие-то showstoppers?
В перечисленном списке их нет - все кроме обновлений Sphinx в общем-то тоже умеет.
Понимаю, что не захардкожено :-). Мой поинт в том, что сейчас для того, чтобы написать нативный драйвер, нужно написать кусок C++ кода и положить его в Sphinx и пересобрать его. Именно поэтому нынешний XML Pipe лучше: его может написать пользователь, а не ждать, пока разработчик сервера поиска поддержит разлюбимую СУБД пользователя или отревьюит патч пользователя.
Переделанный (с поддержкой всех новых фич) будет лучше для среднего пользователя, спору нет.
Про showstoppers Xapian:
Честно говоря, лениво сравнивать Sphinx и Nginx по фичам :) Я ведь никого особо не агитирую :) Тем более, Sphinx не применял, и сравнивать права не имею. Но инкрементный индекс, на мой взгляд, уже достаточно шикарная фича, чтобы только ради неё сделать выбор в пользу поддерживающего его поисковика. Вы можете содержать один, два, десять дельта-индексов, но рано или поздно придётся делать полную переиндексацию. На мой взгляд, это совершенно неприемлемо. Иван выбрал Sphinx за наличие сервера. Пусть так. А если сервер используется для индексации данных из нескольких источников? Гонять периодически ВСЕ ИХ ДОКУМЕНТЫ по сети в виде XML'а?! Как-то, гхм,... некрасиво.
Следующее преимущество инкрементного индекса - простота поддержки. Никаких записей в cron, информацию можно добавлять и обновлять в тот же момент, как она поступила. И она сразу доступна ищущим.
К слову, я не использовал Xapian напрямую, а сам написал сервер минут за 20-30, используя в качестве протокола обмена JSON. Вышло не так уж и сложно.
Возможно, Sphinx ищет немного лучше. И в каких-то проектах это настолько заметно и критично, что является решающим фактором. Но поиск вообще вещь довольно приблизительная, и оценивать его качество, как вы понимаете, очень нелегко. Мне результаты, выдаваемые Xapian на русскоязычных документах, показались отличными (частенько приходится пользоваться).
Просьба в предыдущем комментарии читать Nginx как Xapian :)
А с любой инкрементальной структурой рано или поздно придется делать дефрагментацию - чтобы поиск не так жутко тормозил ;)
Но в общем мысль ясна, спасибо - в вашем случае именно преимущества онлайн апдейтов заслонили все остальное.
Что-то это объяснение совсем все запутало... Вы можете привести пример поисковой машины, где это не так ?
Я думал будет речь идти о "язык запросов" и "язык логических запросов".
А пострегсовский tsearch2 не был выбран как вариант по той причине, что это бы привязывало поиск по форуму к определенному движку? Или есть и другие весомые причины?
Андрей, а Вы бы сами что про Xapian (по сравнению со Сфинксом) сказали? Вы же наверняка его смотрели тоже.
Я смотрел очень по диагонали, причем высматривал недостатки ;) С ходу высмотрел следующее:
SQL индексатор на основе Perl как-то немного подозрителен (см. оверхеды). Скорость не сравнивал. В общем и целом, yet another BM25 library, так что ни малейшего желания срочно закрывать Sphinx не возникло.
Я вот, глядя на твой цицеро, думаю, может сделать похожую штуку для рельс?
У нас проблема такая: вставить в софтину на рельсах своё приложение оооочень сложно. Практически невозможно, потому что ребятам из 37signals это не нужно. Однако, всё равно, попытаться стоит. Меня в создании форума останавливает только одно: я сам рисовать не умею, а нужен нормальный дизайн.
Дизайн можно у комьюнити попросить. Когда форум будет работать. Зачем оно (комьюинити) иначе еще нужно, как не делиться друг с другом умениями :-)
Ну может и так.
У меня тоже такая проблема возникла.
Задача: Есть 2 таблички Threads(темы) и Topics(посты)
Нас интересует табличка Topics:
topic_id|thread_id |message |...
1 |1 |тра та та |
2 |1 |мы везём с собой кота|
Логично искать не в каждом посте и во всей теме, т.е. во всех постах одной темы. Задача решилась подстановкой в конфиг sphinx такой вот выборки:
т.е. в рез-те
|1 |tratata мы везём с собой кота|
Спасибо за решение.
Хотя, оно специфичное для MySQL, опять-таки...
Интересно, а если к Сфинксу прикрутить небольшую программу паука, которая будет обходить заданные сайты, парсить оттуда текст, формировать ID на основе URL и какой-то системной информации и скармливать это сфинксу, вполне может получиться классический поисковичек. В кач-ве дополнительных полей имя сайта (для группировки). Со ссылочным ранжированием проблемы, паука надо научить считать ссылки (он их всё равно должен уметь парсить) и отдавать их Сфинксу, а тот в выдаче отдавать страницы не только релевантные, но и на которые больше ссылок.
Глядишь через пару лет увидим полноценный Web-поисковик
Коллеги, а вам не приходилось при работе со сфинксом реализовывать контекстный поиск? Например чтобы для поста в форуме или блоге отображались сходные посты?