Tuesday, September 29, 2015

Multiple results and determining which field matched in Lucene.Net

Сегодня мы с вами будем снова говорить о Lucene.Net, точнее о том, как найти поле, в котором содержится искомый нам результат, если мы выполняем поиск сразу по нескольким полям. В предыдущей статье уже рассматривались способы поиска позиции текста. Поэтому немного усложним нашу задачу, добавив в запрос несколько полей одновременно.
Для начала нужно создать консольное приложение, которое я назвал “FindTextPositionInLucene”, как показано на рисунке ниже.
Затем через NuGet Package Manager устанавливаем себе Lucene.Net и Lucene.Net Contrib.
Теперь можно приступить к самой реализации. Начнем в противоположном направлении: с самого конца к началу. Так, пожалуй, будет понятнее. Для начала, чтобы добавление новых полей в документ Lucene было не так напряжно, добавим базовый класс ADocument, который будет скрывать эту логику. Ниже приведена реализация этого класса.
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 и который будет реализовать всю необходимую логику.
public class ContentDocument : ADocument
{
    private string _content1;

    public string Content1
    {
        get { return _content1; }
        set
        {
            _content1 = value;
            AddParameterToDocument("Content1", _content1, Field.Store.YES, Field.Index.ANALYZED, Field.TermVector.WITH_POSITIONS_OFFSETS);
        }
    }

    private string _content2;
    public string Content2
    {
        get { return _content2; }
        set
        {
            _content2 = value;
            AddParameterToDocument("Content2", _content2, Field.Store.YES, Field.Index.ANALYZED, Field.TermVector.WITH_POSITIONS_OFFSETS);
        }
    }

    private string _content3;
    public string Content3
    {
        get { return _content3; }
        set
        {
            _content3 = value;
            AddParameterToDocument("Content3", _content3, Field.Store.YES, Field.Index.ANALYZED, Field.TermVector.WITH_POSITIONS_OFFSETS);
        }
    }
}
Если посмотреть на код выше, то очень хотелось бы использовать возможности C# 6 с оператором nameofчтобы жестко не хардкодить имена Content1, Content2 и т.д.
Следующим делом мы реализуем функцию, которая сгенерирует список наших документов для Lucene.
private static IEnumerable<ContentDocument> GenerateDocuments()
{
    var documents = new List<ContentDocument>();

    documents.Add(new ContentDocument
    {
        Id = 1,
        Content1 = @"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",
        Content2 = "hello world",
        Content3 = "hello kitty"
    });

    documents.Add(new ContentDocument
    {
        Id = 2,
        Content1 = @"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",
        Content2 = "one style",
        Content3 = "Could anyone tell me"
    });

    documents.Add(new ContentDocument
    {
        Id = 3,
        Content1 = @"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",
        Content2 = "newer version of Lucene for Java",
        Content3 = "to be able to access the text "
    });

    documents.Add(new ContentDocument
    {
        Id = 4,
        Content1 = @"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.",
        Content2 = "hi world",
        Content3 = "hi, guys"
    });

    documents.Add(new ContentDocument
    {
        Id = 5,
        Content1 = "Nevermind, the answer was given via a Google Group.",
        Content2 = "First of all, I discovered that Lucene is still faster than SQL query",
        Content3 = "Lucene.Net solves most of those problems for you"
    });
    documents.Add(new ContentDocument
    {
        Id = 6,
        Content1 = "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",
        Content2 = "and this article is not only about Lucene",
        Content3 = "search querries long and complicated"
    });
    return documents;
}
Затем напишем вспомогательный класс SearchResultHelper, который будет хранить в себе информацию об Id документа, поле, в котором найден исходный текст, сам исходный текст и класс FieldTermStack, который хранит саму информацию о позиции этого текста.
public class SearchResultHelper
{
    public string Id { getset; }
    public string FieldName { getset; }
    public string OriginalText { getset; }
    public FieldTermStack FieldTerm { getset; }
}
Теперь приступим с реализации самой функции поиска. Она также очень простая и не отличается ничем особенным.
public static List<SearchResultHelper> Search(Lucene.Net.Store.Directory directory, List<string> fields, string searchQuery)
{
    var termList = new List<SearchResultHelper>();
    using (var searcher = new IndexSearcher(directory))
    {
        var analyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);
        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 indexReader = IndexReader.Open(directory, true);
        var query = parser.Parse(searchQuery);
        var fieldQuery = new FieldQuery(query, truefalse);

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

        for (var i = 0; i < hits.TotalHits; i++)
        {
            var docId = hits.ScoreDocs[i].Doc;
            var doc = searcher.Doc(docId);
            var id = doc.Get("Id");
            foreach (var field in fields)
            {
                var ftl = new FieldTermStack(indexReader, docId, field, fieldQuery);
                        
                if (!ftl.IsEmpty())
                {
                    var findData = doc.Get(field);
                            
                    termList.Add(new SearchResultHelper
                    {
                        Id = id,
                        FieldName = field,
                        OriginalText = findData,
                        FieldTerm = ftl
                    });
                }
            }
                    
        }

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

        return termList;
    };
}
На вход мы подаем директорию, в которой нужно искать необходимый текст, список полей, по которым мы будем проводить поиск, и сам запрос, который содержит то, что мы хотим найти.
Затем все по старинке: создаем QueryParse объект, вызываем метод Parse, пробуем найти по тому query, что получился, нужные данные; если что-то нашлось, то пробегаем по тем полям, которые мы передавали, и пытаемся с документа найти совпадения с текстом с помощью класса FieldTermStack. Если он не пустой, значит, там есть текст, который мы ищем. Создаем вспомогательный класс, записываем в него нужную информацию и сохраняем в список. Если посмотреть на код, то это легко осмыслить. Осталось только написать функцию, которая это все в красивом виде выведет на экран.
static void Main(string[] args)
{
    const string luceneIndexFolder = "LuceneIndex";
    var dataFolder = Path.Combine(Environment.CurrentDirectory, luceneIndexFolder);
    var di = new DirectoryInfo(dataFolder);

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

    var luceneDirectory = FSDirectory.Open(di.FullName);
    var docs = GenerateDocuments();
    using (var writer = new IndexWriter(luceneDirectory, new StandardAnalyzer(Version.LUCENE_30), IndexWriter.MaxFieldLength.UNLIMITED))
    {
        foreach (var doc in docs)
        {
            writer.AddDocument(doc.Document);
        }

        writer.Dispose();
    }

    var fields = new List<string>()
    {
        "Content1",
        "Content2",
        "Content3"
    };
    var res = Search(luceneDirectory, fields, "Lucene");
    foreach (var token in res)
    {
        var originalIformation = "Id: " + token.Id.PadLeft(5) +
            "  FieldName: " + token.FieldName.PadLeft(5) +
            "  Original Text: " + token.OriginalText.PadLeft(5) + Environment.NewLine;

        Console.WriteLine(originalIformation);
        foreach (var term in token.FieldTerm.termList)
        {
            var tokenInfo = term.Text + "   Start: " + term.StartOffset.ToString().PadLeft(5) +
                "  End: " + term.EndOffset.ToString().PadLeft(5) + "   Position :" 
                + term.Position.ToString().PadLeft(5) + Environment.NewLine ;

            Console.WriteLine(tokenInfo);
        }
               
    }

    Console.ReadLine();
}
После того, как запустим наш проект, результат сможем увидеть на экране.
Исходные коды к статье вы можете получить по ссылке: Search Text Position in Lucene.NET.

Итоги
Я решил написать данную статью, поскольку одной из первых задач по Lucene.Net было определить, как найти поле, в котором найден нужный текст, если поиск происходил по нескольким полям сразу. Я много времени потратил на эту задачу, так как пытался заимплементировать логику с первой книги Lucene in Action, и у меня ничего не работало. Спустя некоторое время я понял причину и написал рабочий вариант, которым делюсь с вами. Надеюсь, что это вам когда-либо пригодится, так как информации об этом в интернете немного, и может быть, это сэкономит вам некоторое время.