Wednesday, June 24, 2015

Highlight text using in Lucene.NET

Сегодня мы будем далее углубляться в мир высокоскоростного полнотекстового поиска с помощью движка Lucene.NET. Я размышлял над темами, которые было бы интересно разобрать в Lucene.NET (учитывая их изобилие, а также сложность рассмотрения той или иной части), и решил в первую очередь начать с рассмотрения подсветки в Lucene.NetДля вас также может быть интересно сориентироваться в принципах подсветки текста, который вы ищите, с помощью Lucene.Net. Поскольку нам важно посмотреть на результат, мы создаем Desktop WPF-приложение, в котором рассмотрим, как эта подсветка будет работать. Сначала создаем новый проект WPF Application и назовем его “HightlightSearchInLucene”.
Затем нам нужно поставить сам Lucene.Net через NuGet Package Manager, как показано на рисунке ниже.
К сожалению, подсветка, расширенный поиск и другие возможности не идут в поставке, поэтому нам понадобится установить также Lucene.Net Contrib.
После того как вы все это установите, вы сможете использовать необходимые вам возможности.
Теперь приступим к самой реализации. Для начала добавим новую папку в наш проект, которую назовем “LuceneService”.
Затем приступаем к созданию некой примитивной архитектуры нашего приложения. Создаем интерфейс IHighlightService, который будет нам возвращать найденный текст в отформатированном виде.
public interface IHighlightService
{
    string DataFolder { get; }
    FSDirectory LuceneDirectory { get; }
    string[] Search(string field, string query);
}
Затем рассмотрим, как имплементировать этот интерфейс в класс HighlightService, в котором и будет реализована вся необходимая логика по подсветке.
public class HighlightService : IHighlightService
{
    private const string LuceneIndexFolder = "LuceneIndex";

    private FSDirectory _luceneDirectory;
    private string _dataFolder;

    public HighlightService()
    {
        InitializeLucene();
    }

    public string DataFolder
    {
        get { return _dataFolder; }
    }

    public FSDirectory LuceneDirectory
    {
        get { return _luceneDirectory; }
    }

    public string[] Search(string field, string searchQuery)
    {
        return ExecuteQuery((ind, analyzer) =>
        {
            var parser =
                new QueryParser(Lucene.Net.Util.Version.LUCENE_30, field, analyzer);
            var query = parser.Parse(searchQuery);

            var hits = ind.Search(query, 200);

            // create highlighter
            var formatter = new SimpleHTMLFormatter("<Bold>""</Bold>");
            var fragmenter = new SimpleFragmenter(80);
            var scorer = new QueryScorer(query);
            var highlighter = new Highlighter(formatter, scorer);
            highlighter.TextFragmenter = fragmenter;

            var res = new List<string>();
            for (var i = 0; i < hits.TotalHits; i++)
            {
                // get the document from index
                var doc = ind.Doc(hits.ScoreDocs[i].Doc);

                var content = doc.Get(field);

                var stream = analyzer.TokenStream(""new StringReader(content));
                var sample = highlighter.GetBestFragments(stream, content, 2);

                res.AddRange(sample);
            }

            return res.ToArray();
        });

    }

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

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

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

    private string[] ExecuteQuery(Func<IndexSearcherAnalyzerstring[]> 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;
        }
    }
    #endregion

}
Для своих приложений мне нравится продумывать сразу структуру, чтобы можно было удобно использовать в виде нескольких строк кода. Поэтому приходится писать много кода в классах. Мне очень интересно ваше мнение, какой из подходов лучше: когда пишем сначала всю необходимую логику, а затем ее используем с помощью двух-трех методов, или лучше, когда весь код написан в одну простыню без разбиения на классы, например, в функции Main? Пока я буду использовать такой подход, к которому привык, так как он позволяет строить свои приложения в уме, до того, как занесу их в Visual Studio и скомпилирую.
Так как Lucene.NET работает с документами, предлагаю вам написать базовый класс ADocument, который будет инкапсулировать логику по добавлению документов в Lucene. Например, этот класс может выглядеть так, как показано на рисунке ниже.
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, Field.TermVector vector = Field.TermVector.NO)
    {
        _document.Add(new Field(name, value.ToString(), store, index, vector));
    }

    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);
    }
}
Затем реализуем класс ContentDocument, который наследуем от класса ADocument. Этот класс очень простой и имеет всего одно свойство Content.
public class ContentDocument : ADocument
{
    private string _content;

    public string Content
    {
        get { return _content; }
        set
        {
            _content = value;
            AddParameterToDocument("Content", _content, Field.Store.YES, Field.Index.ANALYZED);
        }
    }
}
Следующим этапом нам необходимо реализовать класс, который будет добавлять необходимые документы в наш индекс. Назовем этот класс BaseWriter.
public class BaseWriter
{
    private readonly IHighlightService _luceneService;

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

    }

}
Затем осталось написать класс для сохранения документов с класса ContentDocument. Назовем этот класс просто ContentWriter и посмотрим, как он реализован.
public class ContentWriter : BaseWriter
{
    public ContentWriter(IHighlightService luceneService)
        : base(luceneService)
    {
    }

    public void AddUpdateCityToIndex(List<ContentDocument> documents)
    {
        AddUpdateItemsToIndex(documents);
    }
}
Теперь осталось всё склеить и посмотреть, что из этого получилось. Для этого начнем с обновления UI, чтобы можно было увидеть результат поиска. Для начала нужно перейти в App.xaml и удалить строку StartupUri. Затем перейдем в наше окно MainWindow.xaml и отредактируем его следующим образом:
<Window x:Class="HightlightSearchInLucene.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <TextBlock x:Name="SearchDocument" Margin="10" TextWrapping="Wrap" />
    </Grid>
</Window>
После этого откроем файл App.xaml.cs и переопределим метод OnStartup, как приведено в примере ниже, для того чтобы загружать наше окно при старте приложения.
public partial class App : Application
{
    protected override void OnStartup(StartupEventArgs e)
    {
        IHighlightService hs = new HighlightService();
        var writer = new ContentWriter(hs);

        var documents = GenerateDocuments();
        writer.AddUpdateCityToIndex(documents);

        var res = hs.Search("Content""page");
        var view = new MainWindow();

        foreach (var formattedString in res)
        {
            var resString = string.Format("{0} {1} {2}",
                "<Span xmlns=\"http://schemas.microsoft.com/winfx/2006/xaml/presentation\">", formattedString,
                "</Span>");
            SetTextBlockXAMLContent(view.SearchDocument, resString);
        }
     
        view.Show();
    }

    private void SetTextBlockXAMLContent(TextBlock textBlock, string xaml)
    {
        if (xaml == null)
            return;

        // Add xaml content
        textBlock.Inlines.Add(XamlReader.Parse(xaml) as Inline);
    }

    private static List<ContentDocument> GenerateDocuments()
    {
        var documents = new List<ContentDocument>();

        documents.Add(new ContentDocument
        {
            Id = 1,
            Content = @"I created custom styles by just overriding jQuery classes in inline style. So on top of the page, you have the jQuery CSS linked and right after that override the classes you need to modify"
        });

        documents.Add(new ContentDocument
        {
            Id = 2,
            Content = @"right, but .ui-dialog-titlebar doesn't have the class .dialog1. .dialog1 {display:none;} will hide the entire contents of the dialog box, which I don't want"
        });

        documents.Add(new ContentDocument
        {
            Id = 3,
            Content = @"The best recommendation I can give for you is to load the page in Firefox, open the dialog and inspect it with Firebug, then try different selectors in the console, and see what works. You may need to use some of the other descendant selectors"
        });

        documents.Add(new ContentDocument
        {
            Id = 4,
            Content = @"I'm sure i'm missing something obvious, but the other examples write to the input it is linked too, but there seems to be no obvious way the data is output from the inline version."
        });

        documents.Add(new ContentDocument
        {
            Id = 5,
            Content = "Nevermind, the answer was given via a Google Group."
        });
        documents.Add(new ContentDocument
        {
            Id = 6,
            Content = "For the Highlighter, TermVectors need to be available and you have a choice of either computing and storing them with the index at index time or computing them as you need them when the search is performed. Above, Field.TermVector.WITH_POSITIONS_OFFSETS indicates were are computing and storing them in the index at index time"
        });
        return documents;
    }
}
Код данного класса очень примитивный. 
Например, функция GenerateDocuments генерирует список документов с текстами, взятыми со stackoverflow с какого-то топика. Функция SetTextBlockXAMLContent позволяет с нашей текстовой информации, создать элементы отображения для TextBlock с подсветкой синтаксиса и т.д. После проделанной работы мы можем запустить наш пример и увидеть результат.
Так как мы уже все написали и запустили, самое время детально рассмотреть, как же происходит сама подсветка синтаксиса. Для этого более детально разберем функцию Search[] с класса HighlightService. Приведу ниже повторно ее описание.
public string[] Search(string field, string searchQuery)
{
    return ExecuteQuery((ind, analyzer) =>
    {
        var parser =
            new QueryParser(Lucene.Net.Util.Version.LUCENE_30, field, analyzer);
        var query = parser.Parse(searchQuery);

        var hits = ind.Search(query, 200);

        // create highlighter
        var formatter = new SimpleHTMLFormatter("<Bold>""</Bold>");
        var fragmenter = new SimpleFragmenter(80);
        var scorer = new QueryScorer(query);
        var highlighter = new Highlighter(formatter, scorer);
        highlighter.TextFragmenter = fragmenter;

        var res = new List<string>();
        for (var i = 0; i < hits.TotalHits; i++)
        {
            // get the document from index
            var doc = ind.Doc(hits.ScoreDocs[i].Doc);

            var content = doc.Get(field);

            var stream = analyzer.TokenStream(""new StringReader(content));
            var sample = highlighter.GetBestFragments(stream, content, 2);

            res.AddRange(sample);
        }

        return res.ToArray();
    });

}
По переменную hits у нас происходит обычный классический поиск с помощью Lucene.NET с ограничением выводимых результатов количеством 200 документов.
var parser =
    new QueryParser(Lucene.Net.Util.Version.LUCENE_30, field, analyzer);
var query = parser.Parse(searchQuery);

var hits = ind.Search(query, 200);

Следующим шагом мы создаем экземпляр класса SimpleHTMLFormatter, для того чтобы форматировать найденный текст выбранными html тегами. Мы решили немного схитрить и указали форматированные как Bold, которое также приемлемо для форматирования текста в WPF.
var formatter = new SimpleHTMLFormatter("<Bold>""</Bold>");
Экземпляр класса SimpleFragmenter позволяет нам разбивать текста для поиска на фрагменты конкретного размера.
var fragmenter = new SimpleFragmenter(80);
Класс QueryScorer, думаю, будет для вас очень интересен, особенно если вы планируете работать с поиском, когда для вас приоритетной является позиция искомого текста в запросе. Этот класс позволяет нам строить так SpanQueries, в которых содержится начало и конец искомой строки.
var scorer = new QueryScorer(query);
Класс Highlighter реализует саму логику по добавлении подсветки в искомый текст. Достать этот подсвеченный текст можно с помощью функции GetBestFragments.
var highlighter = new Highlighter(formatter, scorer);
highlighter.TextFragmenter = fragmenter;
Последним параметром в функцию GetBestFragments передается разделитель для наших фрагментов. Можно разделитель не указывать (разделитель идет четвертым параметром), но обычно разделитель можно ставить что-то типа “…”. Третьим параметром идет максимальное количество фрагментов, которые могут быть выделены. Мы для примера указали количество 2, так как их у нас больше не встречается.
var stream = analyzer.TokenStream(""new StringReader(content));
var sample = highlighter.GetBestFragments(stream, content, 2);
Как работает TokenStream и как происходит разбитие на токены, мы рассмотрим в следующей статье, которую я посвятил работе с анализаторами в Lucene. Сейчас же для вас важно знать, что TokenStream позволяет разбить ваш текст на отдельные токены (это своего рода лексическая единица нашего текста).
В Lucene.NET существует также еще один тип форматтера, который называется GradientFormatter и позволяет сделать цветную подсветку текста. Например, одной из реализаций данного класса является класс SpanGradientFormatter форматирования текста в виде <span> атрибутов с определенным цветом background и color.
На этом завершаю рассмотрение подсветки в Lucene.NET. В ближайшее время планирую разобрать, как работают анализаторы в Lucene.NET а также как использовать более сложный поиск. Если у вас остались какие-то вопросы, которые не были затронуты в статье, и вы хотите, чтобы я их освятил, пишите об этом в комментариях к статье. Постараюсь их по возможности рассмотреть в следующих статьях либо ответить в комментариях.