Блог

Полезные статьи и новости о жизни WaveAccess

Полнотекстовый поиск с использованием Apache Lucene

Apache Lucene – это библиотека, позволяющая организовать полнотекстовый поиск по множеству документов, то есть поиск с использованием заданных ключевых слов.

Основная реализация данной библиотеки написана на Java. Но в то же время существуют порты этой библиотеки на другие языки и платформы: Lucene.Net, написанная на C#, CLucene (C++) и другие.

Это библиотека с открытым исходным кодом, распространяемым под лицензией Apache License 2.0. Также библиотека имеет достаточно подробную документацию. Всё это позволяет существенно расширять её стандартную функциональность требуемом образом. В данной статье рассмотрена C#-реализация библиотеки.

Построение поискового индекса

Основным источником данных, используемым в Lucene в процессе поиска, является индекс. Индекс представляет собой хранилище, включающее в себя множество документов. Основной составляющей документов являются поля с данными.

В Lucene основным типом данных является String. Существует также поддержка числовых типов и типов Date с различной точностью сохранения, но по умолчанию Lucene предоставляет довольно немного средств для поддержки этих типов данных.

Для построения поискового индекса нужно создать множество документов с соответствующими значениями полей и добавить созданные документы в индекс.

Схематично процесс построения индекса представлен на рис. 1.

Построение Индекса, String, Apache Lucene, библиотека, C#

Рис. 1. Построение индекса

При построении индекса нужно указать используемый анализатор (Analyzer) и директорию (Directory). Остановимся подробнее на этих классах.

Директория отвечает за хранение данных индекса. Формально директория – это абстрактное хранилище, реализующее заданный интерфейс. Интерфейс включает в себя методы для работы с файлами – их создание, изменение и удаление. Каждый файл характеризуется именем и содержимым. Таким образом, логика работы директории напрямую не связана со структурой индекса. Наиболее распространёнными реализациями этого интерфейса являются классы SimpleFSDirectory и RAMDirectory, позволяющие хранить содержимое индекса в файловой системе и в памяти соответственно.

Анализатор отвечает за преобразование значений полей документа в последовательность токенов. Пример такого преобразования показан на рис. 1. Токены – это ключевые слова, по которым ведётся поиск. В индексе сохраняется принадлежность тех или иных токенов к соответствующим полям добавляемых документов.

IndexWriter является связующим звеном между этими классами и выполняет работу по сохранению данных о документах индекса, их полях и токенах с использованием предоставляемого анализатора и директории.

Ниже представлен пример кода, выполняющего построение индекса для тестовых продуктов.

static void BuildIndex() 
{ 
    var sampleProducts = new[] 
    { 
        new Product { Id = 1, Name = "Gibson SG", Brand = "Gibson", Color = "Black" }, 
        new Product { Id = 2, Name = "Ibanez S570", Brand = "Ibanez", Color = "Red" } 
    }; 
    using (var directory = GetDirectory()) 
    using (var analyzer = GetAnalyzer()) 
    using (var writer = new IndexWriter(directory, analyzer, IndexWriter.MaxFieldLength.UNLIMITED)) 
    { 
        writer.DeleteAll(); 
        foreach (var product in sampleProducts) 
        { 
            var document = MapProduct(product); 
            writer.AddDocument(document); 
        } 
    } 
} 

static Document MapProduct(Product product) 
{ 
    var document = new Document(); 
    document.Add(new NumericField("Id", Field.Store.YES, true).SetIntValue(product.Id)); 
    document.Add(new Field("Name", product.Name, Field.Store.YES, Field.Index.ANALYZED)); 
    document.Add(new Field("Color", product.Color, Field.Store.YES, Field.Index.ANALYZED)); 
    document.Add(new Field("Brand", product.Brand, Field.Store.YES, Field.Index.NOT_ANALYZED)); 
    return document; 
} 

static Directory GetDirectory() 
{ 
    return new SimpleFSDirectory(new DirectoryInfo(@"D:\SampleIndex")); 
} 

static Analyzer GetAnalyzer() 
{ 
    return new StandardAnalyzer(Version.LUCENE_30); 
} 

Извлечение данных

После того, как построен индекс, по нему можно вести поиск. Для этого необходимо составить поисковый запрос. Схематично процесс поиска представлен на рис. 2. Объект QueryParser с помощью анализатора преобразует строку запроса в последовательность токенов для поиска. Далее объект IndexSearcher выполняет поиск в директории индекса с использованием составленного запроса и возвращает множество документов в качестве результата.

Поиск По Индексу, Apache Lucene, библиотека, C#

Рис. 2. Поиск по индексу

Кроме составления запроса, при поиске можно также выполнять сортировку и фильтрацию. Далее эти возможности рассмотрены более подробно.

Составление запросов

Библиотека Lucene предоставляет достаточно широкий набор классов для составления поисковых запросов. Ниже представлены некоторые из них:

  • TermQuery – поиск по ключевым словам
  • BooleanQuery – объединение нескольких запросов
  • FuzzyQuery – поиск с неточным соответствием
  • PhraseQuery – поиск по последовательности ключевых слов
  • RegexQuery – поиск по регулярному выражению

Рассмотрим пример составления поискового запроса.

static Query GetQuery(string keywords) 
{ 
    using (var analyzer = GetAnalyzer()) 
    { 
        var parser = new QueryParser(Version.LUCENE_30, "Name", analyzer); 
                 
        var query = new BooleanQuery(); 
        var keywordsQuery = parser.Parse(keywords); 
        var termQuery = new TermQuery(new Term("Brand", "Fender")); 
                 
        var phraseQuery = new PhraseQuery(); 
        phraseQuery.Add(new Term("Name", "electric")); 
        phraseQuery.Add(new Term("Name", "guitar")); 
        query.Add(keywordsQuery, Occur.MUST); 
        query.Add(termQuery, Occur.MUST_NOT); 
        query.Add(phraseQuery, Occur.SHOULD); 
        return query; // +Name:ibanez -Brand:Fender Name:"electric guitar" 
    } 
} 

В данном примере сначала создаётся парсер запросов для поля Name. Анализатор используется тот же, что и при создании индекса. С помощью этого парсера создаётся запрос для поиска передаваемых ключевых слов в поле Name. Затем создаётся запрос для поиска слова "Fender" в поле Brand. Далее создаётся объект PhraseQuery для поиска последовательного вхождения заданных слов в поле Name. Созданны запросы объединяются с помощью BooleanQuery: первый запрос должен выполняться, второй запрос должен не выполняться. Выполнение третьего запроса является необязательным, но влияет на релевантность документа в результирующей выдаче.

В результате мы получаем один составной запрос, который будет использоваться при поиске.

Сортировка

При выполнении поиска можно задать параметры сортировки. Документы можно сортировать по релевантности (по умолчанию), по номеру документа в индексе или по одному из полей. Также можно выполнять сортировку по нескольким полям, указывая их порядок.

Ниже представлен пример формирования объекта сортировки, задающего сортировку по двум полям: по полю Brand, а затем по релевантности.

static Sort GetSort() 
{ 
    var fields = new[] 
    { 
        new SortField("Brand", SortField.STRING), 
        SortField.FIELD_SCORE 
    }; 
    return new Sort(fields); // sort by brand, then by score 
}

Указание сортировки не является обязательным.

Фильтрация

Также при выполнении поиска можно применять механизм фильтрации. В целом он похож на механизм составления запросов. Зачастую классы для создания фильтров имеют аналогичные классы для составления запросов. Основным отличием фильтрации от запросов является то, что она не влияет на результирующую релевантность документов: она только ограничивает выборку.

Ниже представлен пример создания фильтра по полю Id: в результат будут включены только документы с полем Id в диапазоне [2; 5).

static Filter GetFilter() 
{ 
    return NumericRangeFilter.NewIntRange("Id", 2, 5, true, false); // [2; 5) range 
}

Применение фильтрации также является необязательным.

Выполнение поиска

После того как задан запрос, параметры сортировки, а также фильтрации, можно выполнять сам поиск. Ниже приведён пример кода, выполняющего поиск заданных ключевых слов по ранее созданному индексу, а также выводящего результаты на консоль.

static void Main() 
{ 
    BuildIndex(); 
    int count; 
    var products = Search("Ibanez", 10, out count); 

    foreach (var product in products) 
        Console.WriteLine("ID={0}; Name={1}; Brand={2}; Color={3}", product.Id, product.Name, product.Brand, product.Color); 
    Console.WriteLine("Total: {0}", count); 

    Console.ReadKey(); 
}

static List Search(string keywords, int limit, out int count) 
{ 
    using (var directory = GetDirectory()) 
    using (var searcher = new IndexSearcher(directory)) 
    { 
        var query = GetQuery(keywords); 
        var sort = GetSort(); 
        var filter = GetFilter(); 

        var docs = searcher.Search(query, filter, limit, sort); 
        count = docs.TotalHits; 

        var products = new List(); 
        foreach (var scoreDoc in docs.ScoreDocs) 
        { 
            var doc = searcher.Doc(scoreDoc.Doc); 
            var product = new Product 
            { 
                Id = int.Parse(doc.Get("Id")), 
                Name = doc.Get("Name"), 
                Brand = doc.Get("Brand"), 
                Color = doc.Get("Color") 
            }; 
            products.Add(product); 
        } 

        return products; 
    } 
} 

В данном примере выполняется поиск по ключевому слову "Ibanez" с ограничением в 10 документов. Поисковый запрос, фильтр и сортировка задаются с помощью описанных ранее функций. Далее из индекса извлекаются поля найденных документов и возвращаются в качестве результата. Результат выполнения программы представлен ниже.

ID=2; Name=Ibanez S570; Brand=Ibanez; Color=Red

Total: 1

Stemming

Одним из полезных средств, используемых в механизмах полнотекстового поиска, является stemming. Этот механизм позволяет приводить разные формы одного слова к общей форме. Например, слова "guitar" и "guitars" – разные формы одного и того же слова. Без применения данного механизма они будут сохраняться в поисковом индексе в качестве разных токенов, а запросы по этом словам будут возвращать разные результаты. Если же привести их к одной форме, то проблема решается, и запросы будут возвращать один результат.

Использование Stemming Анализатора, Apache Lucene, библиотека, C#

Рис. 3. Использование stemming-анализатора

Для поддержки данной возможности в Lucene используется специальный анализатор. Остановимся подробней на том, как работает любой анализатор в Lucene.

Входными данными для анализатора является поток символов, представленный объектом Reader. Этот поток с помощью объекта Tokenizer разбивается на последовательность токенов. Далее эта последовательность проходит через группу фильтров. Количество фильтров может быть произвольным, в том числе нулевым. Фильтры могут добавлять новые токены, модифицировать, а также удалять существующие. Примером такого фильтра может быть StopFilter, который позволяет исключить из индексации лишние слова, например, союзы, предлоги, артикли.

Работа Анализатора, Apache Lucene, библиотека, C#

Рис. 4. Работа анализатора

Другим примером фильтра являются Stemming-фильтры. Данный фильтр преобразует каждый токен к общей форме, и в результате Stemming-анализатор возвращает последовательность токенов, преобразованных к общей форме.

В Lucene stemming реализован в виде класса SnowballAnalyzer в сборке Lucene.Net.Contrib.Snowball. При создании анализатора необходимо задать язык, для которого будет использоваться stemming. Поддерживается довольно много языков. Но возможности использовать многоязычный stemming стандартная реализация не предоставляет.

Spellchecker

Другим полезным средством в полнотекстовом поиске является Spellchecker. Во-первых, он позволяет находить опечатки в запросах. Во-вторых, он позволяет сформировать список вариантов исправления запроса, близких по написанию к исходному.

В Lucene эта функциональность реализована в виде сборки Lucene.Net.Contrib.SpellChecker. Сначала необходимо построить индекс, который будет своего рода словарём. Используя этот словарь, мы можем проверить ключевые слова запроса: если они отсутствуют в словаре, то с большой вероятностью в этом слове есть опечатка. Далее этот индекс можно использовать для поиска слов, наиболее близких по написанию к исходному.

Рассмотрим подробней механизм поиска похожих по написанию слов.

Сначала выполняется приблизительный поиск с использованием n-грам. Это подход, при котором слова при индексации разбиваются на группы по 2 буквы, 3 буквы и так до n. Эти группы и есть n-грамы. Пример 3-грам и 4-грам для слова lettuce приведён на рис. 5.

Разбиение Слов На N Граммы

Рис. 5. Разбиение слов на n-граммы

Можно увидеть, что для слова с опечаткой список полученных n-грам достаточно близок с списку n-грам исходного слова. Стоит отметить, что использование такого критерия является неточным и используется только для ограничения выборки для более точного сравнения слов. Далее в ограниченной выборке полученные слова сравниваются с исходным путём вычисления расстояния Левенштейна – величины, соответствующей числу перестановок и замен, требуемых для получения результирующего слова из исходного. Таким образом, в ограниченной с помощью n-грам выборке можно получить список слов с максимальным значением этой величины по отношению к исходному слову. Это и будут слова, наиболее близкие по написанию.

Заключение

В статье приведён краткий обзор основных возможностей Lucene. Стоит отметить, что Lucene предоставляет не только широкий набор встроенных средств для организации полнотекстового поиска, но и достаточно много путей для кастомизации поиска.

Заказать звонок

Удобное время:

Отменить

Пишите!

Присоединить
Файл не больше 30 Мб.
Отменить