Tuesday, May 26, 2015

Updating Document Fields in Lucene.NET

Сегодня мы рассмотрим серьезную часть работы с Lucene.Net (высокоскоростной движок поиска по большому объему текстовых данных), которая плохо освещена в интернете и на ознакомление с  которой я потратил достаточно много времени, – это тема с обновлением полей в документе и как обновить индекс. Если вы не знаете, что такое Lucene.Net и как его использовать, для вас более интересной может быть вводная статья:  "Используем Lucene.Net для высокоточного полнотекстового поиска", которая рассказывает о том, как построить полноценную модель поиска для своего приложения. Мы создадим пример, в котором и реализуем наш поиск и обновление полей. Для этого давайте создадим новое консольное приложение, которое назовем "LuceneUpdateSample".
Затем через NuGet давайте поставим себе Lucene.NET.
А теперь приступим к самой реализации. Мы постараемся реализовать пример, который максимально приближен в реальному использованию Lucene.NET в своих приложениях. Существует два способа обновить Document в индексе. Первый работает по принципу удаления документа и вставки нового, второй – по принципу удаления старого поля и добавления нового поля (field). Ниже на рисунке показаны две функции, которые реализуют оба способа.
Ниже два этих варианта и будут рассмотрены. Первый – для добавления новых полей, второй – для их удаления. Как говорят, меньше слов – больше дела. Каждый шаг я буду комментировать. Первым делом давайте представим, что у нас есть класс Order, подобный приведенному ниже.
Подобный класс мы используем для реальных данных с намного большим количеством полей, но для теста я специально их убрал, чтобы оставить самое необходимое.
public class Order
{
    public long Id { getset; }
    public Dictionary<stringstring> CustomFields { getset; } 
}
Затем вынесем базовую логику по созданию документа в базовый класс, который назовем ADocument.
Теперь создадим индекс, в котором будем сохранять информацию о наших документах, связанных с полями класса Order. Назовем этот класс OrderDocument.
public class OrderDocument : ADocument
{
    private Dictionary<stringstring> _customFields;

    public Dictionary<stringstring> CustomFields
    {
        get { return _customFields; }
        set
        {
            _customFields = value;
            foreach (var cf in _customFields)
            {
                var cfvalue = ReferenceEquals(null, cf.Value) ? string.Empty : cf.Value;
                AddParameterToDocumentStoreParameter(cf.Key, cfvalue);
            }
        }
    }

    public static explicit operator OrderDocument(Order order)
    {
        var orderDocument = new OrderDocument
        {
            Id = order.Id,
            CustomFields = order.CustomFields
        };

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

    Document SearchById(long id);
}
Имплементация этого сервиса приведена ниже в классе SearchService.
public class SearchService : ISearchService
{
    private const string LuceneIndexFolder = "LuceneIndex";

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

    public SearchService()
    {
        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 Document SearchById(long id)
    {
        Document resultDoc = null;
        using (var searcher = new IndexSearcher(LuceneDirectory))
        {
            var analyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);

            var parser = new QueryParser(Lucene.Net.Util.Version.LUCENE_30, "Id", analyzer);
            var query = ParseQuery(id.ToString(), parser);
            ScoreDoc[] hits = searcher.Search(query, HitsLimit).ScoreDocs;

            if (hits != null)
            {
                resultDoc = searcher.Doc(hits[0].Doc);
            }

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

        return resultDoc;
    }

    /// <summary>
    /// Parse the given 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 static 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;
    }
}
В данном классе реализован метод SearchById, позволяющий нам искать документы, которые нужно обновить по Id. При инициализации данного сервиса первым делом проверяется, существует ли каталог для хранения индекса; если же его нет, то он будет создан. Это делает функция InitializeLucene(). Теперь настало время реализовать класс, в котором мы реализуем логику добавления новых документов в индекс, а также обновление существующих (также добавлена логика по удалению документов). Назовем этот класс BaseWriter и посмотрим, как он реализован ниже.
public class BaseWriter
{
    private readonly ISearchService _luceneService;

    public BaseWriter(ISearchService luceneService)
    {
        _luceneService = luceneService;
    }

    public ISearchService LuceneServiceItem
    {
        get { return _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();

        }
    }

    protected void UpdateItemsToIndex(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)
            {
                UpdateItemToIndex(doc, writer);
            }

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

        }
    }

    private void UpdateItemToIndex(ADocument updateDoc, IndexWriter writer)
    {
        var docId = updateDoc.Id;

        //retrieve the old document
        Document searchDoc = _luceneService.SearchById(docId);
        var replacementFields = updateDoc.Document.GetFields().ToList();
        foreach (var field in replacementFields)
        {
            var name = field.Name;
            var currentValue = searchDoc.GetValues(name);
            if (currentValue != null)
            {
                //replacement field value

                //remove all occurrences of the old field
                searchDoc.RemoveField(name);

                //insert the replacement
                searchDoc.Add(field);
            }
            else
            {
                //new field
                searchDoc.Add(field);
            }
        }

        var query = new TermQuery(new Term("Id", updateDoc.Id.ToString()));

        writer.UpdateDocument(query.Term, searchDoc);
    }

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

    }

}
Для обновления документов у нас используются, по сути, два метода.
  1. AddItemToIndex для удаления старых документов и добавления новых;
  2. UpdateItemToIndex для обновления полей в текущем документе.
Теперь давайте реализуем простой враппер для нашего класса OrderDocument, который запишет все необходимые поля в индекс. Назовем этот класс OrderWriter и наследуем его от класса BaseWriter. Этот класс очень простой и, по сути, является всего лишь врапером для добавления документов в индекс.
public class OrderWriter : BaseWriter
{
    public OrderWriter(ISearchService luceneService) : base(luceneService)
    {
    }

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

    public void UpdateCustomFieldsToIndex(long id, Dictionary<stringstring> customFields)
    {
        UpdateItemsToIndex(new List<OrderDocument> { new OrderDocument
        {
            Id = id,
            CustomFields = customFields
        } });
    }


    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(long id)
    {
        DeleteItemsFromIndex(new List<OrderDocument> { new OrderDocument { Id = id } });
    }
}
Теперь реализуем тестовый пример, в котором это все проверим. Для того чтобы посмотреть, как построился ваш индекс, вам понадобится тулза под названием Luke. Она позволяет просматривать индексы Lucene. Написана она на Java и предназначена для чтения индекса Lucene, но также успешно работает с Lucene.NET. Рекомендую вам ее загрузить, если вы хотите посмотреть, как будет построен ваш индекс. В конце статьи мы посмотрим с помощью Luke на то, что у нас получилось.
Для начала реализуем функцию, которая для нас сгенерирует 1000 ордеров, которые мы запишем в наш индекс в Lucene.NET.
public static List<Order> GenerateOrders()
{
    var customFields = new Dictionary<stringstring>();
    for (int i = 0; i < 20; i++)
    {
        var key = "Field" + i;
        var value = BigTestData();
        customFields.Add(key, value);
    }

    var orders = new List<Order>();
    for (int i = 1; i <= 1000; i++)
    {
        orders.Add(new Order
        {
            Id = i,
            CustomFields = customFields
        });
    }

    return orders;
}

public static 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?";
}
Сам тест рассмотрен ниже в функции TestLuceneUpdateFields().
private static void TestLuceneUpdateFields()
{
    ISearchService luceneService = new SearchService();
    var writer = new OrderWriter(luceneService);

    var orders = GenerateOrders();
    writer.AddUpdateOrderToIndex(orders);

    var random = new Random();
    var position = random.Next(orders.Count);
    var order = orders[position];

    Console.WriteLine("Order Id = {0}", order.Id);

    var customFields = new Dictionary<stringstring>
    {
        {"Field1""My test changes"},
        {"Field2""This is test data"}
    };

    writer.UpdateCustomFieldsToIndex(order.Id, customFields);

    var document = luceneService.SearchById(order.Id);
    var field1 = document.Get("Field1");
    var field2 = document.Get("Field2");

    Debug.Assert(field1 == "My test changes");
    Debug.Assert(field2 == "This is test data");
}
Использование этой функции приведено в методе Main.
static void Main(string[] args)
{
    TestLuceneUpdateFields();

    Console.Read();
}
Теперь запустим наш пример и посмотрим на результат.
Затем запускаем нашу тулзу Luke и указываем ей нашу директорию.
Кстати, ваша директория будет иметь подобный вид, потому что в таком виде хранятся индексы в Lucene.NET.
После того как вы нажмете OK в редакторе Luke вы увидите результат, как показано ниже.
Как видите, по каждому нашему полю есть нужная информация. Теперь нам нужно найти наш документ с индексом 688. Для этого мы переходим на вкладку Documents в Luke и переходим на последний документ (в нашем примере это 1000), поскольку Lucene сначала удаляет старый документ, затем создает новый.
Как видите, Id у него 688. Именно тот, который мы искали. В целом, как видите, работа с Lucene особой сложности не представляет.

Итоги
В этой статье мы рассмотрели, как работает процесс обновления документов в Lucene.NET на практике. Надеюсь, если у вас возникали с этим определенные сложности, то они останутся далеко позади. Lucene.NET предоставляет нам огромное количество возможностей, которые, к сожалению, не очень хорошо документированы, а если говорить начистоту, то они документированы очень плохо.  Но это же дает возможность где развиваться и где можно оттачивать свое мастерство и умения. Обещаю, что в следующих статьях мы с вами разберем еще что-либо интересное с мира Lucene.Net. Тем более, задач, которые можно решать с Lucene.NET, – просто огромное количество.