Tuesday, April 28, 2015

Используем Lucene.Net для высокоточного полнотекстового поиска

Сегодня мы рассмотрим такую возможность, как реализацию высокоскоростного полнотекстового поиска в C#. Недавно у меня на работе появилась интересная задача: провести исследования возможностей реализации быстрого поиска по нашей довольно большой базе. Основная проблема заключалась в том, что стандартный поиск у нас работал достаточно медленно, если нужно было искать данные в середине слов. В SQL уже реализован отдельный компонент Full-Text Search, который позволяет выполнять полнотекстовые запросы к символьным данным из таблиц. Для начала для тех, кто работал с поиском через Like, приведу сразу сравнение с MSDN, чтобы показать, в чем же все-таки разница.
В отличие от полнотекстового поиска, предикат LIKE в Transact-SQL работает только с комбинациями символов. Кроме того, предикат LIKE нельзя использовать в запросах к форматированным двоичным данным. Более того, запрос с предикатом LIKE к большому количеству неструктурированных текстовых данных выполняется гораздо медленнее, чем эквивалентный полнотекстовый запрос к тем же данным. Выполнение запроса LIKE к нескольким миллионам строк текстовых данных может занять несколько минут, в то время как полнотекстовый запрос к тем же данным занимает всего несколько секунд или даже меньше, в зависимости от количества возвращаемых строк.
Ниже показан рисунок принципа работы полнотекстового поиска с использованием компонента Full-Text Search.
Мы рассмотрим одну из библиотек, которую я использовал для реализации полнотекстового поиска в готовом проекте. Эта библиотека называется Lucene.Net. Это портированная с Java opensource библиотека Apache Lucene. Основные плюсы данной библиотеки – это легкое построение индексов для поиска, а также мощный и эффективный алгоритм поиска. Я решил не пытать счастья в маленьких примерах, так как это у меня заработало очень легко. Да и все примеры, которые пишутся на коленке, для тестов, по сути, одинаковые, и их никак не пристроишь в нормальный полноценный проект. Я смог найти только один хороший пример Searching with a Lucene.NET wrapper автора Bart De Meyer. Это действительно один из разработчиков, который очень круто описал использование Lucene.Net для бизнес-приложений. Я взял за основу его наработки. Но переделал их с учетом модульности и тестированости кода (чтобы этот код можно было удобно покрыть юнит-тестами). А в исходном коде автор больше предпочитает использовать наследование. Если у вас нет проблем с английским языком, рекомендую эту статью к прочтению. Когда я изучал движок для полнотекстового поиска Lucene.Net, я наткнулся на статью 'Lucene.NET is UGLY', которая рассматривала Lucene с негативной стороны. Я согласен с автором второй статьи, поскольку Lucene.Net – это портированный движок с Java, то некоторый код выглядит действительно ужасно, в принципе, как и все, что пытаются портировать с одного языка на другой (например, Spring.Net). Но в целом, этот фреймворк нереально крут, что я вам хочу продемонстрировать на практике. (Часть исходных кодов я взял со github LuceneWrapper и подстроил под свои нужды).
Первым делом для вашего проекта нужно установить Lucene.Net, например, через NuGet Package Mananger.
Только посмотрите на количество скачиваний. Их практически 750 тыс. на момент написания статьи. Красноречивый факт о том, что данная библиотека действительно популярная.
Если вы хотите, чтобы ваш поиск в Lucene работал, вам нужно добавить необходимые поля для поиска в Document.
Это основной класс, вокруг которого строится вся логика. Я пошел по тому же пути, что и автор статьи, которую я взял за основу, и создал такой же интерфейс IDocument, от которого создал базовый класс ADocument.
 public interface IDocument
{
    long Id { get; }
    Document Document { get; }
}
Исходный код класса ADocument приведен ниже.
public abstract class ADocument : IDocument
{
    private long _id;

    [SearchField]
    public long Id
    {

        set
        {
            _id = value;

            AddParameterToDocument("Id", _id, Field.Store.YES, Field.Index.NOT_ANALYZED);
        }

        get { return _id; }

    }

    private readonly Document _document;

    public Document Document { get { return _document; } }

    protected ADocument()
    {
        _document = new Document();
    }

    private void AddParameterToDocument(string name, dynamic value, Field.Store store, Field.Index index)
    {
        _document.Add(new Field(name, value.ToString(), store, index));
    }

    protected void AddParameterToDocumentStoreParameter(string name, dynamic value)
    {
        AddParameterToDocument(name, value, Field.Store.YES, Field.Index.ANALYZED);
    }


    protected void AddParameterToDocumentNoStoreParameter(string name, dynamic value)
    {
        AddParameterToDocument(name, value, Field.Store.NO, Field.Index.ANALYZED);
    }

}

Мне очень нравится этот подход с выделением базового класса тем, что это чем-то напоминает использование паттерна Repository, в котором мы строим логику вокруг базовой Entity. Здесь же в роли Entity выступает документ. Это больше похоже на подход, в котором вы создаете объекты DTO (data transfer object), которые нужны вам для пересылки данных. Допустим, у нас есть такая структура данных:
 public abstract class Entity
{
    public int Id { getset; }

    public override string ToString()
    {
        return string.Format("{0} - {1}", GetType().Name, Id);
    }
}

public class Order : Entity
{
    private DateTime _dueDate;
    public DateTime DueDate
    {
        get { return _dueDate; }
        set { _dueDate = value; }
    }

    public bool IsPriceManual { getset; }

    public string Url { getset; }
        
    public bool HasOrderedParts { getset; }

    private DateTime _buildDueDate;

    public DateTime BuildDueDate
    {
        get { return _buildDueDate; }
        set { _buildDueDate = value; }
    }

    public bool HasAttachments { getset; }

    public List<Comment> Comments { getset; }
}

public class Comment : Entity
{
    string Name { getset; } 

}
Данные приведены в упрощенном виде. Как вы понимаете, для поиска реальных данных нам наверняка не понадобится искать по всяким булевым флагам в классе Order, датам и т.д. А может понадобиться найти, например, информацию о заказе по комментариям в заказе (свойство Comments). Поэтому наша основная задача состоит в построении правильного документа и, соответственно, индекса поиска по данному документу. Сразу сделаю небольшое отступление по атрибуту SearchField, который используется в классе ADocument. 
public class SearchField : System.Attribute
{
    public string[] CombinedSearchFields;

    public SearchField(params string[] values)
    {
        CombinedSearchFields = values;
    }
}
Этот атрибут я взял без изменения с github-а автора, так как он вытаскивает данные через рефлексию. Мне не очень импонирует синтаксис, аналогичный Fluent APi for SphinxConnector.Net, но так как этот компонент платный, то использую бесплатный аналог. Я переписал исходное решение, которое взял с исходников автора, так как мне не нравится такое явное использование рефлексии. Так как это решение займет много времени, покажу первый вариант имплементации поиска.
А вот и сам документ, построенный для поиска по заказам.
public class OrderDocument : ADocument
{
    private IEnumerable<string> _customFields;

    [SearchField]
    public IEnumerable<string> CustomFields
    {
        get { return _customFields; }
        set
        {
            _customFields = value;
            foreach (var cf in _customFields)
            {
                AddParameterToDocumentNoStoreParameter("CustomFields", cf);
            }
        }
    }

    public static explicit operator OrderDocument(Order order)
    {
        var orderDocument =  new OrderDocument
        {
            Id = order.Id,
            CustomFields = order.CustomFields.Values.Select(x => x == null ? string.Empty : x.ToString()).ToList()
        };

        return orderDocument;
    }
}
Документ работает больше с реальной базой, поэтому я оставил в нем только самые необходимые поля для поиск. Дальше я решил основную реализацию по поиску вынести в отдельный интерфейс и назвать его ILuceneService.
public interface ILuceneService
{
    string DataFolder { get; }
    FSDirectory LuceneDirectory { get; }

    SearchResult Search<T>(string field, string searchQuery) where T : ADocument;
}
Поиск я взял готовый, но приведу реализацию через интерфейс ILuceneService. Для начала нужны вспомогательные классы, в которых можно хранить результаты поиска.
public class SearchResult
{
    public string SearchTerm { getset; }
    public List<SearchResultItem> SearchResultItems { getset; }
    public int Hits { getset; }
}

public class SearchResultItem
{
    public int Id { getset; }
    public float Score { getset; }
}
А теперь реализация самого поиска. Больше всего интерфейс нам представляет функция Search, которая и реализует поиск по индексу. 
 public class LuceneService : ILuceneService
{
    private const string LuceneIndexFolder = "LuceneIndex";

    private FSDirectory _luceneDirectory;
    private string _dataFolder;
    private const int HitsLimit = 1000;

    public LuceneService()
    {
        InitialiseLucene();
    }

    public string DataFolder
    {
        get { return _dataFolder; }
    }

    public FSDirectory LuceneDirectory
    {
        get { return _luceneDirectory; }
    }

    private void InitialiseLucene()
    {
        _dataFolder = Path.Combine(Environment.CurrentDirectory, LuceneIndexFolder);
        var di = new DirectoryInfo(_dataFolder);

        if (!di.Exists)
        {

            di.Create();

        }

        _luceneDirectory = FSDirectory.Open(di.FullName);
    }

    public SearchResult Search<T>(string field, string searchQuery) where T : ADocument
    {
        //Fetch the possible fields to search on
        PropertyInfo[] properties = typeof(T).GetProperties();
        var fields = new List<string>();

        var fieldsToSearchOn = new List<string>();

        foreach (PropertyInfo property in properties)
        {
            var attributes = property.GetCustomAttributes(true);

            foreach (var o in attributes)
            {
                var attr = o as SearchField;
                if (attr != null)
                {
                    fields.Add(property.Name);
                    if (attr.CombinedSearchFields.Any() && field == property.Name)
                    {
                        fieldsToSearchOn.Add(property.Name);
                        for (int i = 0; i < attr.CombinedSearchFields.Count(); i++)
                        {
                            fieldsToSearchOn.Add(attr.CombinedSearchFields[i]);
                        }
                    }
                    else if (field == property.Name)
                    {
                        fieldsToSearchOn.Add(property.Name);
                    }
                }
            }
        }

        using (var searcher = new IndexSearcher(LuceneDirectory))
        {
            var analyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);
            var searchResults = new SearchResult
            {
                SearchTerm = searchQuery,
                SearchResultItems = new List<SearchResultItem>()
            };

            ScoreDoc[] hits;
            if (!string.IsNullOrEmpty(field))
            {
                if (!fields.Contains(field))
                {
                    throw new Exception(string.Format("Field {0} is not a search field for type {1}", field, typeof(T)));
                }
                QueryParser parser = fieldsToSearchOn.Count == 1 ?
                    new QueryParser(Lucene.Net.Util.Version.LUCENE_30, fieldsToSearchOn.First(), analyzer) :
                    new MultiFieldQueryParser(Lucene.Net.Util.Version.LUCENE_30, fieldsToSearchOn.ToArray(), analyzer);
                var query = ParseQuery(searchQuery, parser);
                hits = searcher.Search(query, HitsLimit).ScoreDocs;
            }
            else
            {
                var parser = new MultiFieldQueryParser(Lucene.Net.Util.Version.LUCENE_30, fields.ToArray(), analyzer);
                var query = ParseQuery(searchQuery, parser);
                hits = searcher.Search(query, null, HitsLimit, Sort.RELEVANCE).ScoreDocs;
            }
            if (hits != null)
            {
                searchResults.Hits = hits.Count();
                foreach (var hit in hits)
                {
                    var doc = searcher.Doc(hit.Doc);
                    searchResults.SearchResultItems.Add(new SearchResultItem
                    {
                        Id = Convert.ToInt32(doc.Get("Id")),
                        Score = hit.Score,
                    });
                }
            }

            analyzer.Close();
            searcher.Dispose();
            return searchResults;
        }
    }

    /// <summary>
    /// Parse the givven query string to a Lucene Query object
    /// </summary>
    /// <param name="searchQuery">The query string</param>
    /// <param name="parser">The Lucense QueryParser</param>
    /// <returns>A Lucene Query object</returns>
    private Query ParseQuery(string searchQuery, QueryParser parser)
    {
        parser.AllowLeadingWildcard = true;
        Query q;
        try
        {
            q = parser.Parse(searchQuery);
        }

        catch (ParseException e)
        {
            q = null;
        }

        if (q == null || string.IsNullOrEmpty(q.ToString()))
        {
            string cooked = Regex.Replace(searchQuery, @"[^\w\.@-]"" ");
            q = parser.Parse(cooked);
        }

        return q;
    }
}
Функция Search состоит из двух частей: взятие свойств через рефлексию и непосредственно поиск с помощью таких классов как IndexSearcherStandartAnalyzer и других.
Чтобы поиск работал, необходимо добавить наш искомый объект в индекс поиска. Для этого автор статьи создал класс BaseWriter, а я этот класс адаптировал с использованием DependencyInjection, чтобы уменьшить связность компонентов. 
public class BaseWriter
{
    private readonly ILuceneService _luceneService;
        
    public BaseWriter(ILuceneService luceneService)
    {
        _luceneService = luceneService;
    }

    protected void AddUpdateItemsToIndex(IEnumerable<ADocument> docs)
    {
        var standardAnalyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);

        using (var writer = new IndexWriter(_luceneService.LuceneDirectory, standardAnalyzer, IndexWriter.MaxFieldLength.UNLIMITED))
        {

            foreach (var doc in docs)
            {
                AddItemToIndex(doc, writer);
            }

            standardAnalyzer.Close();
            writer.Dispose();

        }
    }

    private void AddItemToIndex(ADocument doc, IndexWriter writer)
    {
        var query = new TermQuery(new Term("Id", doc.Id.ToString()));

        writer.DeleteDocuments(query);

        writer.AddDocument(doc.Document);
    }

    private void DeleteItemFromIndex(ADocument doc, IndexWriter writer)
    {
        var query = new TermQuery(new Term("Id", doc.Id.ToString()));

        writer.DeleteDocuments(query);
    }

    protected void DeleteItemsFromIndex(IEnumerable<ADocument> docs)
    {
        var standardAnalyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);

        using (var writer = new IndexWriter(_luceneService.LuceneDirectory, standardAnalyzer, IndexWriter.MaxFieldLength.UNLIMITED))
        {

            foreach (var doc in docs)
            {
                DeleteItemFromIndex(doc, writer);
            }

            standardAnalyzer.Close();
            writer.Dispose();
        }

    }

    protected void Optimize()
    {
        var standardAnalyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);

        using (var writer = new IndexWriter(_luceneService.LuceneDirectory, standardAnalyzer, IndexWriter.MaxFieldLength.UNLIMITED))
        {
            standardAnalyzer.Close();
            writer.Optimize();
            writer.Dispose();
        }

    }

}
Я предпочитаю SOLID подход, в котором инициалы I (Interface segregation principle) и D (dependency inversion principle) говорят нам о том, что лучше много специализированных интерфейсов, чем один большой, а также что абстракции не должны зависеть от деталей реализации. Единственное, что хотелось бы добавить, – так это то, что внимательный читатель заметит, что я сам нарушил принцип SRP (Single Responsibility Principle). Класс LuceneService, который инициализирует Lucene.Net, а также реализовывает функцию поиска, лучше вынести в два отдельных интерфейса, как это сделал автор исходной статьи. У меня эта реализация осталась по той причине, что я хотел сделать поиск немного круче, реализовать построение индекса в базовом классе, а также поиск для каждой сущности, для которой мы выполняем индексацию отдельно. Что-то вроде фабричного метода. Я не успел это все заимплементировать, но постараюсь, если все-таки подход с Lucene решать внедрять в моем проекте, сделать это более элегантно.
Теперь осталось добавить враппер для нашего класса Order, который реализует всю необходимую логику по созданию индекса. Ниже приведена реализация этого класса.
public class OrderWriter : BaseWriter
{
    public OrderWriter(ILuceneService luceneService) : base(luceneService)
    {
    }

    public void AddUpdateOrderToIndex(Order order)
    {
        AddUpdateItemsToIndex(new List<OrderDocument> { (OrderDocument)order });
    }

    public void AddUpdateOrderToIndex(List<Order> orders)
    {
        AddUpdateItemsToIndex(orders.Select(p => (OrderDocument)p).ToList());
    }

    public void DeleteOrderFromIndex(Order order)
    {
        DeleteItemsFromIndex(new List<OrderDocument> { (OrderDocument)order });
    }

    public void DeleteOrderFromIndex(int id)
    {
        DeleteItemsFromIndex(new List<OrderDocument> { new OrderDocument { Id = id } });
    }
}
А теперь давайте посмотрим, как это работает на практике.
ILuceneService luceneService = new LuceneService();
var writer = new OrderWriter(luceneService);
writer.AddUpdateOrderToIndex(orders);

var res = luceneService.Search<OrderDocument>("CustomFields", search);

foreach (var item in res.SearchResultItems)
{
    Console.WriteLine("Result with ID: {0}", item.Id);

    Console.WriteLine(orders.First(o => o.Id == item.Id));
}
Этот пример синтаксический, поскольку индекс строится сразу перед поиском. В реальном проекте построение индекса начинается асинхронно для всех сущностей. А уже поиск работает по построенным индексам. Я сейчас рассматриваю этот подход, чтобы как можно удобнее построить механизм обновления индексов.
Вот как работает сам поиск:
Огромный плюс Lucene.Net заключается в том, что мы можем задать порядок поиска по полям. 
var res = luceneService.Search<OrderDocument>("CustomFields", search);

var searchForIds = luceneService.Search<OrderDocument>("Id", search);
Удобно, что можно создавать индекс, который ищет по нескольким полям одновременно. Это замечательный открытый движок, который вы можете с удовольствием использовать для своих проектов.

Итоги
Как вы видите, Lucene.Net – достаточно удобный движок для поиска в БД. В данной статье я не ставил себе за цель замерить производительность данного подхода, но сравнивал, как говорят, “на глаз” прошлый поиск с тем, который работает в Lucene, работая с кучей join-ов и через like. Это несравнимые показатели, я бы сказал. Если не верите, создайте простую таблицу, например, с полем Comment, в котором будут комментарии с большим объемом текста, и попробуйте выполнить поиск, используя like, когда слово, которое вы хотите найти, находится в середине строки. Вы сразу заметите, насколько медленно работает в данном случае Like. А вот Lucene.Net очень хорошо создает индекс для таких полей и ищет объект за считаные доли секунды, в зависимости от размера БД, конечно же. В Lucene.Net есть еще очень и очень много возможностей, которые не вошли в данную статью по той причине, что не было времени все попробовать, но в целом, надеюсь, данная статья может стать для вас отправной точкой в изучении скоростного поиска с помощью Lucene