Tuesday, May 19, 2015

Lucene.NET vs MS SQL Sever FTS vs LIKE

Сегодня мы поговорим о том, как реализовать быстрый полнотекстовый поиск в своем приложении, а также замерим производительность Lucene.Net, Full-Text Search in SQL Server 2008 с обычным оператором Like. Недавно мне на работе досталось достаточно интересная задача: рассмотреть, как можно реализовать “google-like search” для рабочей БД. После долгого анализа и сравнения производительности в качестве основного движка был выбран Lucene.Net. Этот движок является портом с Java полнотекстового поискового движка на язык C#. О том, как использовать данный движок, я написал статью, которая называется "Используем Lucene.Net для высокоточного полнотекстового поиска". Также я рассмотрел возможность использовать в нашем рабочем проекте Full-Text Search в SQL Server 2008 и внес свои небольшие свои заметки в статью "Use Full-Text Search in SQL Server 2008 with EntityFramework". Сегодняшняя статья будет показывать больше характеристики того или иного подхода. Ну что же, давайте приступим к реализации. Первым делом я решил сопоставить работу движка Lucene.Net с обычным sql оператором LIKE. Это немного несравнимые вещи, но я решил дать хотя бы небольшой шанс SQL серверу и проверял его не на холодном старте. Основной задачей была проверка, как отработает Lucene.NET и поиск с помощью Like на большом объеме данных. Для замера производительности я написал простенький тест, который замерял производительность и выводил ее в Output.
private void Get_Search_Score_By_Lucene(string funcName, string text)
{
    var stopWatch = new Stopwatch();
    Debug.WriteLine("Start : {0} at {1}", funcName, DateTime.Now);
    stopWatch.Start();

    var res = _luceneService.Search<OrderDocument>("CustomFields", text);
    Console.WriteLine(res.SearchResultItems);

    stopWatch.Stop();
    TimeSpan ts = stopWatch.Elapsed;
    string elapsedTime = String.Format("{2:000000}ms  ({0:00}.{1:000} sec)", ts.Seconds, ts.Milliseconds, ts.TotalMilliseconds);
    Debug.WriteLine("Finish: {0} at {1} [{2}]", funcName, DateTime.Now, elapsedTime);
}

private void Get_Search_Score_By_Like_in_Database(string funcName, string text)
{
    var stopWatch = new Stopwatch();
    Debug.WriteLine("Start : {0} at {1}", funcName, DateTime.Now);
    stopWatch.Start();

    _service.SearchOrderInDatabaseByLike(text);

    stopWatch.Stop();
    TimeSpan ts = stopWatch.Elapsed;
    string elapsedTime = String.Format("{2:000000}ms  ({0:00}.{1:000} sec)", ts.Seconds, ts.Milliseconds, ts.TotalMilliseconds);
    Debug.WriteLine("Finish: {0} at {1} [{2}]", funcName, DateTime.Now, elapsedTime);
}
Давайте немного о том, что же мы все-таки проверяем. Я создал на SQL сервере таблицу на 20 полей, именуя их в формате [Feild0…Field19]. Затем создал индекс в Lucene.Net и заполнил такую же структуру. Выглядит это для Lucene.NET как-то так:
В индекс мы добавляем кучу документов, и каждый из них представляет собой поле в формате "ключ-значение".
Затем я написал тест, который динамически создает сначала 10 элементов, затем 1000 и, наконец, 10000 тысяч. Больше попросту не было смысла создавать, так как на таком количестве уже видно было разницу в поиске. Да и другая причина, по которой я это не сделал, – это ограничение со стороны реализации на количество элементов, которые можно создать.
Начнем с реализации теста на 10 элементов.
[TestMethodTestCategory("Integration")]
public void Search_contains_text_for_10_items()
{
    CreateDafaultData(10);

    //Search full equals
    Get_Search_Score_By_Like_in_Database("Get_Search_Score_By_Like_in_Database with 10 items (text: simple)""simple");

    Get_Search_Score_By_Lucene("Get_Search_Score_By_Lucene with 10 items (text: simple)""simple");

    //search part of text
    Get_Search_Score_By_Like_in_Database("Get_Search_Score_By_Like_in_Database with 10 items for part text (text: %simple%)""%simple%");

    Get_Search_Score_By_Lucene("Get_Search_Score_By_Lucene with 10 items for part text (text: %simple%)""%simple%");

    //search for part of word
    Get_Search_Score_By_Like_in_Database("Get_Search_Score_By_Like_in_Database with 10 items for part of word (text: %imple%)""%imple%");

    Get_Search_Score_By_Lucene("Get_Search_Score_By_Lucene with 10 items for part of word (text: %imple%)""%imple%");

    //search if data not found
    var search = "%" + Guid.NewGuid() + "%";
    Get_Search_Score_By_Like_in_Database(string.Format("Get_Search_Score_By_Like_in_Database with 10 items for part of word (text: {0})", search)
        , search);

    Get_Search_Score_By_Lucene(string.Format("Get_Search_Score_By_Lucene with 10 items for part of word (text: {0})", search), search);
}
Его цель заключалась в поиске целого слова, слова в середине, слово в начале текста и самый сложный вариант – когда мы не можем найти искомую информацию. Для того чтобы тестировать поиск по большим тестовым данным, я взял какой-то топик со stackoverflow.
public string BigTestData()
{
    return @"Hi I'm creating a simple library system for charity. Now I have a form that allows the users to search for books. To do this work I get a text write in a imput text and use inside a book => book.Name.Contains(text) expression (LINQ+EF).

        But I want to go a little further. I'm thinking in mix it up a little big and use the same text to find book title, subtitle, author, publishing house, and serie.

        Now I have 3 problems:

        Search all columns
        Disregarding the order of the input text words
        Sort the results for the book that most fit into the input text.
        I know these topics are a little vague. But I don't know how or where to started. Someone can get me some help?";
}
Результат выполнения теста можно увидеть в виде таблицы ниже.
Не удивляйтесь тем результатам, в которых Like вернул 0.004 сек. Чем больше данных, тем медленнее работает Like. Особенно плохо, когда он ничего не может найти и сканирует всю таблицу, как в последнем варианте с поиском GUID-а.
Давайте сразу сравним на 1000 записей и покажем все в такой же таблице.
Тест идентичный, за исключением создания вместо 10 элементов – одной тысячи.
public void Search_contains_text_for_1000_items()
{
    CreateDafaultData(1000);

    //Search full equals
    Get_Search_Score_By_Like_in_Database("Get_Search_Score_By_Like_in_Database with 1000 items (text: simple)""simple");

    Get_Search_Score_By_Lucene("Get_Search_Score_By_Lucene with 1000 items (text: simple)""simple");

    //search part of text
    Get_Search_Score_By_Like_in_Database("Get_Search_Score_By_Like_in_Database with 1000 items for part text (text: %simple%)""%simple%");

    Get_Search_Score_By_Lucene("Get_Search_Score_By_Lucene with 1000 items for part text (text: %simple%)""%simple%");

    //search for part of word
    Get_Search_Score_By_Like_in_Database("Get_Search_Score_By_Like_in_Database with 1000 items for part of word (text: %imple%)""%imple%");

    Get_Search_Score_By_Lucene("Get_Search_Score_By_Lucene with 1000 items for part of word (text: %imple%)""%imple%");

    //search if data not found
    var search = "%" + Guid.NewGuid() + "%";
    Get_Search_Score_By_Like_in_Database(string.Format("Get_Search_Score_By_Like_in_Database with 1000 items for part of word (text: {0})", search)
        , search);

    Get_Search_Score_By_Lucene(string.Format("Get_Search_Score_By_Lucene with 1000 items for part of word (text: {0})", search), search);
}
Таблица результатов приведена ниже.
Здесь результаты уже получше. Как видите, чем сложнее выборка по тексту, тем лучше себя показывает Lucene.NET, что видно по последнем тесте.
И последний запуск на 10000 тысяч элементов показан ниже.
Заметьте, чем больше данных, тем больше просядет поиск по Like. На ста тысячах записей он достигал у меня в районе 15-20 секунд. На миллионе, думаю, время пойдет измеряться минутами, что точно не соответствует “google-like” поиску. Если посмотреть на Lucene.Net, то у него стабильные результаты. А подумайте, что будет с Like, если вам нужно будет сделать поиск текста по разным таблицам, которые перед поиском нужно скомбинировать. Результат стает еще более печальным.
Сама по себе идея сравнивать полнотекстовый движок с оператором Like была неразумной, потому что и так понятно, что полнотекстовый поиск здесь выигрывает. Но как быть, если посмотреть со стороны, какой же все-таки движок лучше использовать: Lucene.NET или MS FTS in SQL Server? Как минимум, у них разное поведение. Один предоставляет API для работы (Lucene), второй – чистый SQL. Второй хранит индексы на диске и позволяет управлять ими с помощь таких утилит, как  Luke и копирование индекса, это простая операция copy-paste. В MS FTS управление происходит через SQL код, и этим всем можно управлять через SQL Server Management Studio. Ниже на рисунке показано сравнение MS SQL Full-Text Search и Lucene движка. Результаты сравнения взяты с данного сайта. Я не проделывал, к сожалению, столько работы, чтобы сравнить эти два движка, из-за нехватки времени.
Как вы понимаете, основные характеристики данных движков зависят от того, сколько места занимает индекс от оригинальных данных, какую скорость чтения данных обеспечивает тот или иной движок, и насколько быстро происходит поиск. Ниже показана таблица этих характеристик в виде рисунка.
Ну и напоследок – измерение производительности поиска с использованием данных движков на 10 потоках.

Итоги
Выбор способа решения задачи с поиском большого объема текстовых данных зависит только от вас. Главное – не использовать при этом SQL оператор Like. В каждом из рассмотренных примеров есть свои плюсы и минусы. Но если рассматривать Lucene.Net или MS SQL FTS, то я бы, наверное, выбрал то, с чем удобнее работать. Так как я предпочитаю работать с базой через ORM, то писать чистый row sql код не люблю. А так как MS SQL Full-Text Search плохо интегрируется с Entity Framework, который очень часто использую, вопрос отпал практически сам собой. Lucene.NET просто внедрить в ваше приложение, но во всем есть свои недостатки. Например, по Lucene.Net не так много документации (не путать с документацией к Lucene движку, написанному на Java, так как там кучи классов нет для C#), а большинство примеров очень примитивные и покрывают очень малую область применения Lucene. Поэтому вам здесь предстоит немало работы.  А для себя я открыл новую, еще не проторенную досконально область, которая мне интересна и в которой можно проявить свои знания и умения. 

No comments:

Post a Comment