Сегодня мы поговорим о том, как реализовать быстрый полнотекстовый поиск в
своем приложении, а также замерим производительность 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 элементов.
[TestMethod, TestCategory("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