Monday, June 8, 2015

How search in Lucene.Net works

Здравствуйте, уважаемые читатели. Рад вам сообщить, что сегодня мы будем снова погружаться в мир Lucene.NET. Зацепила меня эта тема, так что постараюсь засесть как можно плотнее с ней, тем более что работы там просто немеряно. И если вас интересует, как реализовать высокоскоростной поиск в Lucene.NET, надеюсь, что эта статья поможет вам понять, как же все-таки работает поиск в Lucene.NET. Так что тема данной статьи как работает поиск в Lucene.Net. Эта тема достаточно мало раскрыта в интернете, поэтому мы с вами будем самостоятельно ее раскрывать. Для начала создадим новый Console Application проект и назовем его “LuceneSearchSample”, как показано на рисунке ниже.
Затем установим пакет Lucene.NET через NuGet Packages Manager.
Начнем, пожалуй, с самого начала, – это написание интерфейса, который будет предоставлять нам нужные функции для поиска по Lucene документам.
public interface IExtendingSearchService
{
    string DataFolder { get; }
    FSDirectory LuceneDirectory { get; }

    List<Document> WildcardSearch(string field, string query);
    List<Document> TermSearch(string field, string query);
    List<Document> PhaseSearch(string field, string query);
    List<Document> PrefixSearch(string field, string query);
    List<Document> FuzzySearch(string field, string query);
    List<Document> BooleanSearch(List<Query> queries);
    List<Document> Search(List<string> fields, string query);
}
А теперь приступим к реализации класса ExtendingSearchService и объясним его функцию.
public class ExtendingSearchService : IExtendingSearchService
{
    private const string LuceneIndexFolder = "LuceneIndex";

    private FSDirectory _luceneDirectory;
    private string _dataFolder;

    public ExtendingSearchService()
    {
        InitializeLucene();
    }

    public string DataFolder
    {
        get { return _dataFolder; }
    }

    public FSDirectory LuceneDirectory
    {
        get { return _luceneDirectory; }
    }

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

        if (!di.Exists)
        {
            di.Create();
        }

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

    public List<Document> WildcardSearch(string field, string query)
    {
        return ExecuteQuery((searcher, analizer) =>
        {
            var wildcardQuery = new WildcardQuery(new Term(field, query));

            return GetDocuments(searcher, wildcardQuery);
        });
    }

    public List<Document> TermSearch(string field, string query)
    {
        return ExecuteQuery((searcher, analizer) =>
        {
            var termQuery = new TermQuery(new Term(field, query));

            return GetDocuments(searcher, termQuery);
        });
    }

    public List<Document> PhaseSearch(string field, string query)
    {
        return ExecuteQuery((searcher, analizer) =>
        {
            var phaseQuery = new PhraseQuery();
            phaseQuery.Add(new Term(field, query));

            return GetDocuments(searcher, phaseQuery);
        });
    }

    public List<Document> PrefixSearch(string field, string query)
    {
        return ExecuteQuery((searcher, analizer) =>
        {
            var prefixQuery = new PrefixQuery(new Term(field, query));

            return GetDocuments(searcher, prefixQuery);
        });
    }

    public List<Document> FuzzySearch(string field, string query)
    {
        return ExecuteQuery((searcher, analizer) =>
        {
            var fuzzyQuery = new FuzzyQuery(new Term(field, query));

            return GetDocuments(searcher, fuzzyQuery);
        });
    }

    public List<Document> BooleanSearch(List<Query> queries)
    {
        return ExecuteQuery((ind, analyzer) =>
        {
            var booleanQuery = new BooleanQuery();
            foreach (var query in queries)
            {
                booleanQuery.Add(query, Occur.MUST);
            }
            return GetDocuments(ind, booleanQuery);
        });
    }

    public List<Document> Search(List<string> fields, string searchQuery)
    {
        return ExecuteQuery((ind, analyzer) =>
        {
            QueryParser parser = fields.Count == 1 ?
                    new QueryParser(Lucene.Net.Util.Version.LUCENE_30, fields.First(), analyzer) :
                    new MultiFieldQueryParser(Lucene.Net.Util.Version.LUCENE_30, fields.ToArray(), analyzer);
            var query = parser.Parse(searchQuery);
            return GetDocuments(ind, query);
        });

    }

    private static List<Document> GetDocuments(IndexSearcher ind, Query query)
    {
        var hits = ind.Search(query, 1000).ScoreDocs;

        var documents = new List<Document>();
        if (hits != null)
        {
            documents.AddRange(hits.Select(hit => ind.Doc(hit.Doc)));
        }

        return documents;
    }

    private List<Document> ExecuteQuery(Func<IndexSearcherAnalyzerList<Document>> action)
    {
        using (var searcher = new IndexSearcher(LuceneDirectory))
        {
            var analyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);

            var res = action(searcher, analyzer);

            analyzer.Close();
            searcher.Dispose();

            return res;
        }
    }
}
Здесь много методов, но по сути, в них ничего страшного нет. Ниже представлена таблица, в которой расписаны разные варианты поиска.
Ниже приведена таблица всех рассмотренных вариантов, которые мы рассмотрим далее в статье. У нас написан также универсальный метод Search, который позволяет с помощью класса QueryParser получить нужный тип запроса для поиска. Первым делом напишем базовый абстрактный класс ADocument, в котором реализуем логику по добавлению нового документа в Lucene.NET. Ниже приведена логика этого класса.
public abstract class ADocument
{
    private long _id;

    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();
    }

    protected 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);
    }
}
Теперь давайте реализуем тестовый класс CityDocument, который будет хранить всю нашу необходимую структуру.
public class CityDocument : ADocument
{
    private string _location;

    public string Location
    {
        get { return _location; }
        set
        {
            _location = value;
            AddParameterToDocument("Location", _location, Field.Store.YES, Field.Index.NOT_ANALYZED);
        }
    }
}
Теперь нам нужно добавить класс, который будет записывать наши филды в конкретный документ в индексе. Назовем этот класс BaseWriter, в котором реализуем эту логику. У этого класса наружу смотрят только три метода: AddUpdateItemsToIndex для добавления новых значений в индекс, метод DeleteItemsFromIndex для удаления старых значений, ну и метод Optimize для того чтобы запустить оптимизацию индекса. Ниже приведена реализация всего этого.
public class BaseWriter
{
    private readonly IExtendingSearchService _luceneService;

    public BaseWriter(IExtendingSearchService 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();
        }

    }

}
Следующим делом мы сделаем враппер для нашего класса CityDocument, который будет записывать список документов в наш индекс. Назовем этот класс CityWriter.
public class CityWriter : BaseWriter
{
    public CityWriter(IExtendingSearchService luceneService) : base(luceneService)
    {
    }

    public void AddUpdateCityToIndex(List<CityDocument> cities)
    {
        AddUpdateItemsToIndex(cities);
    }
}
В этом классе только один метод, в котором мы добавляем список наших документов в список.
Теперь перейдем в класс Program, который у нас был создан с самого начала, так как у нас консольное приложение, и реализуем следующую логику.
class Program
{
    static void Main(string[] args)
    {
        TestSearchingInLucene();

        Console.ReadLine();
    }

    private static void TestSearchingInLucene()
    {
        IExtendingSearchService luceneService = new ExtendingSearchService();
        var writer = new CityWriter(luceneService);

        var cities = GenerateCityDocuments();
        writer.AddUpdateCityToIndex(cities);

        IExtendingSearchService extendingSearchService = new ExtendingSearchService();

        #region WildcardQuery Search

        Console.WriteLine("WildcardQuery Search");
        var docs = extendingSearchService.WildcardSearch("Location""chicago he*");

        foreach (var doc in docs)
        {
            Console.WriteLine("Id = {0}, Location = {1}", doc.GetField("Id"), doc.GetField("Location"));
        }
        #endregion

        #region FuzzyQuery Search
        Console.WriteLine("FuzzyQuery Search");
        docs = extendingSearchService.FuzzySearch("Location""montre~");

        foreach (var doc in docs)
        {
            Console.WriteLine("Id = {0}, Location = {1}", doc.GetField("Id"), doc.GetField("Location"));
        }
        #endregion

        #region PhaseQuery Search
        Console.WriteLine("PhaseQuery Search");
        docs = extendingSearchService.PhaseSearch("Location""new-york");

        foreach (var doc in docs)
        {
            Console.WriteLine("Id = {0}, Location = {1}", doc.GetField("Id"), doc.GetField("Location"));
        }
        #endregion

        #region PrefixQuery Search
        Console.WriteLine("PrefixQuery Search");
        docs = extendingSearchService.PrefixSearch("Location""chica");

        foreach (var doc in docs)
        {
            Console.WriteLine("Id = {0}, Location = {1}", doc.GetField("Id"), doc.GetField("Location"));
        }
        #endregion

        #region TermQuery Search
        Console.WriteLine("TermQuery Search");
        docs = extendingSearchService.TermSearch("Id""5");

        foreach (var doc in docs)
        {
            Console.WriteLine("Id = {0}, Location = {1}", doc.GetField("Id"), doc.GetField("Location"));
        }
        #endregion

        #region BooleanQuery Search
        Console.WriteLine("BooleanQuery Search");
        var prefixQuery = new PrefixQuery(new Term("Location""chica"));
        var wildcardQuery = new TermQuery(new Term("Id""3"));
        docs = extendingSearchService.BooleanSearch(new List<Query>(new Query[] { prefixQuery, wildcardQuery }));

        foreach (var doc in docs)
        {
            Console.WriteLine("Id = {0}, Location = {1}", doc.GetField("Id"), doc.GetField("Location"));
        }
        #endregion

        #region MultiFieldQuery Search
        Console.WriteLine("MultiFieldQuery Search");
        docs = extendingSearchService.Search(new List<string>(new[] { "Location""Id" }), "6");

        foreach (var doc in docs)
        {
            Console.WriteLine("Id = {0}, Location = {1}", doc.GetField("Id"), doc.GetField("Location"));
        }
        #endregion

        Console.ReadLine();
    }

    private static List<CityDocument> GenerateCityDocuments()
    {
        var cities = new List<CityDocument>();

        cities.Add(new CityDocument
        {
            Id = 1,
            Location = "chicago heights"
        });

        cities.Add(new CityDocument
        {
            Id = 2,
            Location = "new-york"
        });

        cities.Add(new CityDocument
        {
            Id = 3,
            Location = "chicago low"
        });

        cities.Add(new CityDocument
        {
            Id = 4,
            Location = "montreal"
        });

        cities.Add(new CityDocument
        {
            Id = 5,
            Location = "paris"
        });
        cities.Add(new CityDocument
        {
            Id = 6,
            Location = "this is my test"
        });
        return cities;
    }
}
Для того чтобы посмотреть, как был построен наш индекс и как работает наш поиск, нам понадобится тулза Luke, которая позволяет просматривать индексы Lucene. Дальше запустим наш проект и посмотрим, что же у нас получилось.
Далее откроем нашу тулзу Luke и посмотрим, как у нас построен индекс. (P.S. Ваша папка LuceneIndex, в которой физически хранится индекс Lucene, лежит в папке Debug.)
После того как мы укажем путь к директории, где у нас хранится физически файлы, которые строят Lucene индекс, нужно нажать кнопку OK и посмотреть на структуру, которая у нас вышла.
Справа видно заполнение наших филдов, слева – структуру самого индекса. Кстати, в самом Luke вы можете открыть вкладку Search и даже писать самим простые запросы для поиска по документам.
Единственное, что, наверное, будет непривычно для тех, кто работает с Lucene.Net, – это то, что аналайзеры для Lucene.NET немного отличаются. Но если вы посмотрите на пример выше, то увидите, что запросы в Luke писать несложно.

Итоги
Сегодня мы рассмотрели, как работает поиск в Lucene.NET с разными типами запросов, а также способы проверки поиска с помощью утилиты Luke. Надеюсь, что статья получилась не очень скучной, и вы сможете использовать полученные знания с этой статьи на реальных примерах. Надеюсь, что мы с вами еще не раз вернемся к Lucene и рассмотрим еще что-то интересное, что может нам пригодится для реализации высокоскоростного поиска в своем проекте.
Примеры кода к статье: Search in Lucene.NET

No comments:

Post a Comment