Thursday, July 2, 2015

Using text analyzers in Lucene.NET

Здравствуйте, уважаемые читатели. Сегодня мы снова рассмотрим высокоскоростной полнотекстовый поисковый движок Lucene.NET, а также выясним, как работают анализаторы текста в Lucene.NET.  Если у вас неплохие отношения с английским языком, то рекомендую прочитать статью 'Lucene.Net - Text Analysis'. Только стоит учитывать, что статья написана в 2010 году. Особых изменений в анализаторах не было, разве только они немного расширились, что вы можете увидеть на рисунке ниже.
Для того чтобы понять, как работают анализаторы, этого достаточно, но так как реализация с тех пор полностью изменилась, рекомендую посмотреть ту статью и эту, чтобы сложить целостную картину. В данной статье мы будем больше выполнять практические задания, чтобы понять, как все работает и как все устроено. Теории будет по минимуму, потому что я считаю, что лучше всего воспринимать принцип работы того или иного функционала, так сказать, на ощупь. Для начала создадим Console Application приложение и назовем его 'LuceneAnalyzerSample'.
Затем следующим этапом нам нужно добавить к нашему приложению Lucene.Net ,что можно сделать через NuGet Package Manager, как показано на рисунке ниже.
А теперь самое время приступить к реализации. Для начала определим понятие анализатора и его цель в Lucene.NET. Так вот, основной задачей анализатора является разбиение текста на более мелкие единицы, которые в Lucene называются токенами (Token).
Пример такого разбиения показан на рисунке ниже.
Например, на такие части, как показано ниже, разбивается текст благодаря StandartAnalyzer из Lucene.NET. В нашем примере мы рассмотрим следующие типы анализаторов, которые идут с установкой только Lucene.NET.
public static Analyzer[] Analyzers =
{
    new WhitespaceAnalyzer(),
    new StandardAnalyzer(Version.LUCENE_30),
    new StopAnalyzer(Version.LUCENE_30),
    new SimpleAnalyzer(),
    new PerFieldAnalyzerWrapper(new WhitespaceAnalyzer()),
    new KeywordAnalyzer()
};
Если нам нужен какой-то особенный анализатор, например, анализатор русского языка, то нам необходимо поставить с помощью NuGet Package Manager пакет Lucene.Net Contrib.
Тогда вам будет поставлено еще пачка самых разнообразных анализаторов, что продемонстрировано на рисунке ниже.
Так что все зависит от задачи, которую вы пытаетесь решить с помощью Lucene.NET.
А пока мы пройдемся по списку анализаторов, для которых и набросаем наш пример. Начнем со списка терминов, чтобы вы ориентировались с тем, что и как работает.
TokenStream – перечисляет последовательность лексем.
Tokenizer – то же самое, по сути, что и TokenStream, так как этот класс наследуется от него и добавляет алгоритмы для разбиения текста на лексемы.
Filter – позволяет отфильтровывать с исходной последовательности лексем те, которые не соответствуют данному критерию, либо просто устанавливают для входной последовательности некий критерий.
Анализаторы
WhitespaceAnalyzer использует класс WhitespaceTokenizer, задачей которого является разбиение текста по пробелам.
StandartAnalyzer использует разбиение на слова в нижнем регистре (LowerCaseFilter), игнорирует английский список стоп-слов (StopFilter) и использует также StandartFilter для определения апострофа и т.д. Все это реализовано в классе StandartTokenizer.
StopAnalyzer использует в качестве разделителя список стоп-слов (StopFilter), а также вывод последовательности лексем в нижнем регистре (LowerCaseFilter).
Список стоп-слов приведен на рисунке ниже.
SimpleAnalyzer использует для вывода последовательности элементов нижний регистр (LowerCaseFilter) и простое разбиение на слова, используя стандартные разделители LetterTokenizer.
PerFieldAnalyzedWrapper используется в том случае, когда нам нужны разные типы анализаторов для каждого поля в Lucene документе. Добавлять анализатор для конкретного можно через метод AddAnalyzer, в котором первым параметром идет имя поля, а вторым –анализатор, который будет использоваться для этой цели. Либо через конструктор данного класса, первым параметром которого идет указание стандартного анализатора для полей, а вторым – коллекция ключей, в которой в качестве ключа выступает имя поля, а в качестве значения – анализатор для этого поля.
KeywordAnalyzer определяет всю последовательность как единый токен.
А теперь приступим к самой реализации. Для начала в наш класс Program добавим список наших анализаторов, которые мы будем использовать для анализа текста.
public static Analyzer[] Analyzers =
{
    new WhitespaceAnalyzer(),
    new StandardAnalyzer(Version.LUCENE_30),
    new StopAnalyzer(Version.LUCENE_30),
    new SimpleAnalyzer(),
    new PerFieldAnalyzerWrapper(new WhitespaceAnalyzer()),
    new KeywordAnalyzer()
};
Затем напишем функцию, которая на вход будет принимать анализатор и строку для анализа, а наружу будет отдавать список лексем данной последовательности.
public static IEnumerable<string> GetTokenFromAnalyzers(Analyzer analyzer, string text)
{
    TokenStream stream = analyzer.TokenStream("Context", new StringReader(text));
    var termAttr = stream.GetAttribute<ITermAttribute>();

    while (stream.IncrementToken())
    {
        yield return termAttr.Term;
    }
}
И наконец напишем функцию, которая будет выводить наш список лексем на экран.
public static void DisplayTokens(Analyzer analyzer, string text)
{
    Console.WriteLine(analyzer + ":");
    foreach (var token in GetTokenFromAnalyzers(analyzer, text))
    {
        Console.Write("[" + token + "]  ");
    }
    Console.Write(Environment.NewLine);
}
Теперь перейдем в нашу функцию Main и изменим ее следующим образом:
static void Main(string[] args)
{
    foreach (var analyzer in Analyzers)
    {
        DisplayTokens(analyzer, "The quick red fox jumped over the lazy brown dogs.");
    }
    Console.ReadLine();
}
Приведем ниже полный исходный код.
class Program
{
    static void Main(string[] args)
    {
        foreach (var analyzer in Analyzers)
        {
            DisplayTokens(analyzer, "The quick red fox jumped over the lazy brown dogs.");
        }
        Console.ReadLine();
    }

    public static Analyzer[] Analyzers =
    {
        new WhitespaceAnalyzer(),
        new StandardAnalyzer(Version.LUCENE_30),
        new StopAnalyzer(Version.LUCENE_30),
        new SimpleAnalyzer(),
        new PerFieldAnalyzerWrapper(new WhitespaceAnalyzer()),
        new KeywordAnalyzer()
    };
    public static IEnumerable<string> GetTokenFromAnalyzers(Analyzer analyzer, string text)
    {
        TokenStream stream = analyzer.TokenStream("Context", new StringReader(text));
        var termAttr = stream.GetAttribute<ITermAttribute>();

        while (stream.IncrementToken())
        {
            yield return termAttr.Term;
        }
    }

    public static void DisplayTokens(Analyzer analyzer, string text)
    {
        Console.WriteLine(analyzer + ":");
        foreach (var token in GetTokenFromAnalyzers(analyzer, text))
        {
            Console.Write("[" + token + "]  ");
        }
        Console.Write(Environment.NewLine);
   
}
После этого запустим наш пример и посмотрим на результат.
Раньше существовал способ, который позволял пробежаться по последовательности лексем и вывести детальную информацию о них в виде позиции текста, названия лексемы и т.д. Выглядело когда-то это дело вот так:
public virtual string GetView(TokenStream tokenStream, out int numberOfTokens)
{
    var sb = new StringBuilder();

    var token = tokenStream.Next();

    numberOfTokens = 0;

    while (token != null)
    {
        numberOfTokens++;
        var tokenInfo = token.TermText() + "   Start: " + token.StartOffset().ToString().PadLeft(5) +
            "  End: " + token.EndOffset().ToString().PadLeft(5) + "\r\n";
        sb.Append(tokenInfo);
        token = tokenStream.Next();
    }

    return sb.ToString();
}
Но поскольку Lucene.NET очень сильно изменился, то такие понятия, как StartOffset, TermText, Type и другие решили вынести в атрибуты.
И для того чтобы подход, который описан выше, заработал, нужно код переписать следующим образом:
public static void DisplayDetailsTokens()
{
    const string test = "This is a test. How about that?! Huh?";
    var analyzer = new StandardAnalyzer(Version.LUCENE_30);
    TokenStream stream = analyzer.TokenStream("Context", new StringReader(test));

    var termAttr = stream.GetAttribute<ITermAttribute>();
    var offsetAttr = stream.GetAttribute<OffsetAttribute>();
    var typeAttr = stream.GetAttribute<TypeAttribute>();

    while (stream.IncrementToken())
    {
        var startOffset = offsetAttr.StartOffset;
        var endOffset = offsetAttr.EndOffset;
        var token= termAttr.Term + ":" + startOffset + "->" + endOffset + ":" + typeAttr.Type;
        Console.WriteLine("[" + token + "]  ");
    }
}
Казалось, что может быть плохого в этом подходе, тем более что такой же подход демонстрируют на stackoverflow ('How to get a Token from a Lucene TokenStream?'). Правда, код со stackoverflow написан на Java, но адаптировать его под .NET не составляет особого труда. Но не тут-то было. Оказывается, что такой подход не работает в Lucene.NET версии 3.0.3.0.
Оказывается, что с таких анализаторов, как WhitespaceAnalyzer, просто убрали атрибуты OffsetAttribute. Мало того, что это убрали, так для Lucene.NET нет нормальной документации, и можно целый день угробить, чтобы понять, почему оно не работает. Я долго думал, что, наверное, где-то ошибся и все должно работать, и даже прочел книгу 'Lucene in Action' на Java, чтобы понять, что и где я делаю не так (для Lucene.NET вообще нет нормальной документации, поэтому будьте готовы использовать то, что есть, то есть, статьи и книги в основном на Java). Но оказалось, что OffsetAttribute просто не используется для всего списка анализаторов, которые мы сегодня рассмотрели. Для того чтобы получить более детальную информацию по каждой лексеме, нужно использовать другой подход, например, с использованием классов TermPositionVector, TermVectorOffsetInfo и др., которые мы рассмотрим в следующей статье.
На этом закончу обзор по анализаторам и  принципам их работы. Также, надеюсь, я вас заинтриговал тем, как получить более детальную информацию по каждой лексеме, и которую мы разберем в ближайшее время. Надеюсь, что статья будет вам полезна, и вы не будете натыкаться на те же грабли, что и я.
Исходники к статье: Analyzers in Lucene.Net.