Перевод статьи «Введение в Lucene»

Lucene logo Перевел статью по индексированию и поиску с использование Lucene API, работающие примеры на Java.

автор Erik Hatcher (01/27/2005) источник
перевод Сергей Лукин

По мере усложнения восприятия мира люди изобретали различные виды классификации, каталоголизации по различным иерархическим схемам (как то по видам и родам животных, по направлениям музыки в каталоге дисков). Развитие интернета и электронных хранилищ позволили сделать огромный шаг в построение сложных каталогов для быстрого поиска информации. Как пример, Yahoo содержит огромный классификатор сайтов интернета. Но поиск по каталогу долог и требует от человека хорошего знания предметной области, поэтому требовался «свободный» поиск, сквозной поиск по всему каталогу, с произвольно составленным запросом. Лучший пример этого демонстрирует Google.

Если же от вашего приложения пользователи требуют такой функциональности, то Lucene это лучший способ сделать это быстро, просто и эффективно.

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

Индексирование

В начале создадим класс Indexer который будем использовать для индексирования всех текстовых файлов в указанной директории. Этот утилитарный класс с единственным внешним (public) методом index(), который принимает два аргумента. Первый indexDir — объект класса File который ссылается на каталог в котором будет создаватся индексная база. Второй аргумент это другой объект класса File который в свою очередь ссылается на каталог который будет индексироваться.

public static void index(File indexDir, File dataDir) throws IOException {

  if (!dataDir.exists() || !dataDir.isDirectory()) {

     throw new IOException(dataDir + " does not exist or is not a directory");

  }

  IndexWriter writer = new IndexWriter(indexDir, new StandardAnalyzer(), true);

  indexDirectory(writer, dataDir);

  writer.close();

}

После проверки что dataDir существует и это каталог, мы инстанцируем объект класса IndexWriter который будет использоваться для создания индекса. Конструктор IndexWriter принимает в качестве первого параметра каталог в котором индексная база будет создана последний аргумент указывает что мы будем пересоздавать индекс, а не дополнять существующий (если таковой есть в каталоге). Средний параметр — это анализатор, который используется для анализа полей. Анализ полей будет описан ниже, но сейчас мы можем быть уверенны что все важные слова в файлах будут проиндексированы благодаря анализатору StandardAnalyzer.

Метод indexDirectory() ходит по дереву каталогов, сканирует его на наличие файлов с расширением .txt. Любой .txt файл будет проиндексирован методом indexFile(), а любой каталог будет обработан методом indexDirectory(), все остальные файлы будут проигнорированы. Ниже приведен код метода indexDirectory

private static void indexDirectory(IndexWriter writer, File dir)  throws IOException

{

      File[] files = dir.listFiles();

      for (int i = 0; i < files.length; i++) {

            File f = files[i];

            if (f.isDirectory()) {

                indexDirectory(writer, f);  // recurse

            } else if (f.getName().endsWith(".txt")) {

                indexFile(writer, f);

            }

      }

}

Метод indexDirectory() живет полностью независимо от Lucene. Этот пример использования Lucene основной — крайне редко при использовании Lucene приходиться много программировать с Lucene API, надо просто использовать готовое. В завершени класса Indexer, мы реализуем самое главное — индексирование отдельного текстового файла:

private static void indexFile(IndexWriter writer, File f) throws IOException {

  System.out.println("Indexing " + f.getName());

  Document doc = new Document();

  doc.add(Field.Text("contents", new FileReader(f)));

  doc.add(Field.Keyword("filename", f.getCanonicalPath()));

  writer.addDocument(doc);

}

И… поверите вы или нет, но мы сделали это!
Мы проиндексировали все текстовые файлы в дереве каталогов.
Это действительно так просто. Подведем итог, перечислим шаги которые мы предприняли:

  • Создали IndexWriter.
  • Определили каждый файл который необходимо проиндексировать. (прошлись по дереву каталогов, и выбрали
    все файлы с расширением .txt)
  • Для каждого файла создали объект класса Document с желаемыми полями.
  • Добавили этот документ в инстанс IndexWriter.Теперь соберем все эти методы в класс Indexer и добавим импорт необходимых пакетов.Индексировать файлы можно вызвав метод Indexer.index( indexDir, dataDir). Мы также добавим метод main() класса Indexer, который при запуске из командной строки принимает два аргумента — каталоги для индексной базы и индексируемый каталог.
  • import org.apache.lucene.index.IndexWriter;
    
    import org.apache.lucene.analysis.standard.StandardAnalyzer;
    
    import org.apache.lucene.document.Document;
    
    import org.apache.lucene.document.Field;import java.io.*;
    
    public class Indexer {
    
     public static void index(File indexDir, File dataDir) throws IOException {
    
     	if (!dataDir.exists() || !dataDir.isDirectory()) {
    
     		throw new IOException(dataDir
    
     				+ " does not exist or is not a directory");
    
     	}
    
     	IndexWriter writer = new IndexWriter(indexDir, new StandardAnalyzer(),
    
     			true);
    
     	indexDirectory(writer, dataDir);
    
     	writer.close();
    
     }
    
    private static void indexDirectory(IndexWriter writer, File dir)
    
     throws IOException {
    
     	File[] files = dir.listFiles();
    
     	for (int i = 0; i < files.length; i++) {
    
     		File f = files[i];
    
     		if (f.isDirectory()) {
    
     			indexDirectory(writer, f); // recurse
    
     		} else if (f.getName().endsWith(".txt")) {
    
     			indexFile(writer, f);
    
     		}
    
     	}
    
     }
    
    private static void indexFile(IndexWriter writer, File f)
    
     		throws IOException {
    
     	System.out.println("Indexing " + f.getName());
    
     	Document doc = new Document();
    
     	doc.add(Field.Text("contents", new FileReader(f)));
    
     	doc.add(Field.Keyword("filename", f.getCanonicalPath()));
    
     	writer.addDocument(doc);
    
     }
    
    public static void main(String[] args) throws Exception {
    
     	if (args.length != 2) {
    
     		throw new Exception(
    
     		"Usage: " + Indexer.class.getName() + " < index > < data >");
    
     	}
    
    File indexDir = new File(args[0]);
    
     	File dataDir = new File(args[1]);
    
     	index(indexDir, dataDir);
    
     }
    
    }

    В этом примере в каждый документ входит два поля: contents — содержание текстового файла и его полное имя (с путем) — filename. Поле с содержанием специальным образом обрабатывается с помощью StandardAnalyzer, это будет описано позже. Поле filename индексируется как есть. Статические методы класса Filed: Text и Keywords будут подробно объяснены после краткого взгляда внутрь Lucene.

    Внутри Lucene индекса

    Формат индекса Lucene — это каталог с различными файлами. Вы можете успешно использовать Lucene и без понимания структуры каталога. Можно свободно пропустить этот раздел и рассматривать каталог как черный ящик без заботы о том что внутри. Если же вы готовы рассматривать глубже, то вы обнаружите что файлы созданные в прошлом разделе содержат статистику и другие данные облегчающие быстрый поиск и ранжирование. Индекс содержит последовательность документов. В нашем примере, каждый документ представляет информацию о текстовом файле.

    Документ (Document)

    Документ это основный объект, которым оперирует Lucene. Документы содержат последовательность полей Поля имеют имена («contents» и «filename» в нашем примере).Значения полей это последовательность элементов (terms).

    Элементы (Terms)

    Элементы это мельчайшие части конкретного поля. Поля имеют три атрибута.

  • Stored (Сохранен) — Текст будет доступен в документе полученном в результате поиска.
  • Indexed (Индексированный) — Помещает поля как пригодные для поиска.
  • Tokenized (Размеченный) — Текст добавляется через запущенный анализатор и разделяется на релевантные части (это делается только для индексированных полей).Сохраненные поля удобны для немедленного получения исходного текста сразу после поиска, такие как первичный ключ базы данных или имя файла. Сохраненные поля сильно увеличивают размер индекса, поэтому использовать их нужно мудро.
    Информация размеченных полей хранится очень эффективно, так как одни и те же элементы в тех же полях для множества документов сохраняются только единожды, с указанием на документ что содержит их.Класса Field имеет несколько статических методов для создания полей с различной комбинацией этих атрибутов. Вот они:
  • Field.Keyword — Индексирован и сохранен, но не размеченный. Ключевые поля
    обычно используются для таких данных как имена файлов, номера партий, первичные ключи,
    и другой текст который необходимо оставить незатронутым.
  • Field.Text — Индексирован и размечен. Этот текст также
    сохранен есть он добавлен как класс String, и не будет сохранен если добавлен как класс Reader.
  • Field.UnIndexed — Только сохраняется. По таким поля нельзя искать.
  • Field.UnStored — Индексирован и размечен, но не сохранен. Такие поля идеальны, если вы хотите искать по этому полю, но хранить его отдельно от индексной базы и нет необходимости получать оригинальный текст сразу при выполнении поискового запроса.Глядя на выше сказанное, Lucene кажется относительно простым. Но он просто внешне, внутренняя реализация сложна, и основная сложность заключена в методах анализа текста, и в том как хранить элементы из размеченных полей.
  • Анализ

    Анализ происходит в полях что помечены как размечаемые(tokenized). В нашем примере, мы индексируем поле contents — содержание текстовых файлов. Наша цель сделать все слова в текстовом файле пригодными для поиска,
    но на практике цель немного отличается нам не нужно делать индекс чувствительным к каждому полю. Такие слова как «a», «and» и «the» обычно обдуманно не подходят для поиска и исключением их из индекса можно оптимизировать
    индекс, такие слова называется стоп-слова.

    Какую сущность мы ищем? Что является словом, как отделить одно слово от другого. Акронимы, электронные адреса, интернет ссылки и другие такие конструкции, оставлять незатронутым и делать доступными для поиска? Если
    слово в единственном числе проиндексировано, делать ли доступным для поиска его множественное число?
    Это все интересные и сложные вопросы, ответы на которые позволяют выбрать какой из анализаторов использовать, или создавать свой.

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

    import org.apache.lucene.analysis.Analyzer;
    
    import org.apache.lucene.analysis.WhitespaceAnalyzer;
    
    import org.apache.lucene.analysis.StopAnalyzer;
    
    import org.apache.lucene.analysis.TokenStream;
    
    import org.apache.lucene.analysis.Token;
    
    import org.apache.lucene.analysis.SimpleAnalyzer;
    
    import org.apache.lucene.analysis.snowball.SnowballAnalyzer;
    
    import org.apache.lucene.analysis.standard.StandardAnalyzer;
    
    import java.io.StringReader;
    
    import java.io.IOException;public class AnalysisDemo {
    
        private static final String[] strings = {
    
            "The quick brown fox jumped over the lazy dogs",
    
            "XY&Z Corporation - xyz@example.com"    };
    
    private static final Analyzer[] analyzers = new Analyzer[]{
    
            new WhitespaceAnalyzer(),
    
            new SimpleAnalyzer(),
    
            new StopAnalyzer(),
    
            new StandardAnalyzer(),
    
            new SnowballAnalyzer("English", StopAnalyzer.ENGLISH_STOP_WORDS)
    
        };
    
    public static void main(String[] args) throws IOException {
    
            for (int i = 0; i < strings.length; i++) {
    
                analyze(strings[i]);
    
            }
    
        }
    
    private static void analyze(String text) throws IOException {
    
            System.out.println("Analzying \"" + text + "\"");
    
            for (int i = 0; i < analyzers.length; i++) {
    
                Analyzer analyzer = analyzers[i];
    
                System.out.println("\t" + analyzer.getClass().getName() + ":");
    
                System.out.print("\t\t");
    
                TokenStream stream = analyzer.tokenStream("contents",
    
                      new StringReader(text));
    
                      new StringReader(text));
    
                while (true) {
    
                    Token token = stream.next();
    
                    if (token == null) break;
    
    System.out.print("[" + token.termText() + "] ");
    
                }
    
                System.out.println("\n");
    
            }
    
        }
    
    }

    Метод analyze использует исследовательскую форму Lucene API, для обычного индексирования эти функции не нужны, но удобны для рассмотрения как различные анализаторы размечают текст на элементы (термы). Результат выполнения следующий:

    Analzying "The quick brown fox jumped over the lazy dogs"org.apache.lucene.analysis.WhitespaceAnalyzer:[The] [quick] [brown] [fox] [jumped] [over] [the] [lazy] [dogs]
    
    org.apache.lucene.analysis.SimpleAnalyzer:
    
    [the] [quick] [brown] [fox] [jumped] [over] [the] [lazy] [dogs]
    
    org.apache.lucene.analysis.StopAnalyzer:
    
    [quick] [brown] [fox] [jumped] [over] [lazy] [dogs]
    
    org.apache.lucene.analysis.standard.StandardAnalyzer:
    
    [quick] [brown] [fox] [jumped] [over] [lazy] [dogs]
    
    org.apache.lucene.analysis.snowball.SnowballAnalyzer:
    
    [quick] [brown] [fox] [jump] [over] [lazi] [dog]
    
    Analzying "XY&Z Corporation - xyz@example.com"
    
    org.apache.lucene.analysis.WhitespaceAnalyzer:
    
    [XY&Z] [Corporation] [-] [xyz@example.com]
    
    org.apache.lucene.analysis.SimpleAnalyzer:
    
    [xy] [z] [corporation] [xyz] [example] [com]
    
    org.apache.lucene.analysis.StopAnalyzer:
    
    [xy] [z] [corporation] [xyz] [example] [com]
    
    org.apache.lucene.analysis.standard.StandardAnalyzer:
    
    [xy&z] [corporation] [xyz@example] [com]
    
    org.apache.lucene.analysis.snowball.SnowballAnalyzer:
    
    [xy&z] [corpor] [xyz@exampl] [com]

    Анализатор WhitespaceAnalyzer самый простой, он просто разбивает на элементы основываясь на пробелах.
    Он даже не меняет регистр букв. Поиск регистрочувствительный поэтому обычной практикой приведение к нижнему регистру текста в процессе фазы анализа. Остальные анализаторы приводят к нижнему регистру в процессе анализа. SimpleAnalyzer разбивает текс основываясь на несимвольных разделителях, таких как специальные символы (‘&’, ‘@’, и ‘.’). StopAnalyzer использует фенкциональность SimpleAnalyzer и так же удаляет основные английские стоп-слова.

    Наиболее сложный анализатор встроенный в ядро Lucene это StandardAnalyzer. Под ним скрывается основанный на JavaCC парсер с правилами для электронных адресов, акронимов, веб-адресов, дробных чисел, а так же он приводит к нижнему регистру и удаляет слова входящие в список стоп-слов. Анализатор построен по архитектуре цепочки фильтров, таким образом несколько разноцелевых правил комбинируются.

    Анализатор SnowballAnalyzer демонстрирует не встроенный на данных момент в Lucene функциональность. Эта часть кода доступна в jakarta-lucene-sandbox CVS хранилище. Он показывает наиболее специфичные результаты. Алгоритм его основан на языке текста, и использует стемминг (stemming). Алгоритмы стемминга пытаются привести слово к его основной корневой форме. Это мы видим в примере с «lazy» который был приведен к «lazi». Слово «laziness» так же
    будет приведено к «lazi», тем самым при поиск мы найдем оба документа сразу. Другой интересный пример работы
    SnowballAnalzyer с текстом «corporate corporation corporations corpse», который приведет к следующим результатам

    [corpor] [corpor] [corpor] [corps]

    На этом мы остановим исследование текстовых анализаторов. Так как за этой темой стоит множество диссертаций и патентов, и конечно множество исследований. Теперь, зная как разбивается текст при индексировании, построим класс для поиска.

    Поиск

    В соответствии с нашим примером индексирования, создадим класс поиска Seacher который показывает результаты для того же индекса. Основные части этого класса:

    import org.apache.lucene.document.Document;
    
    import org.apache.lucene.search.IndexSearcher;
    
    import org.apache.lucene.search.Query;
    
    import org.apache.lucene.search.Hits;
    
    import org.apache.lucene.store.FSDirectory;
    
    import org.apache.lucene.store.Directory;
    
    import org.apache.lucene.queryParser.QueryParser;
    
    import org.apache.lucene.analysis.standard.StandardAnalyzer;
    
    import java.io.File;public class Searcher {
    
        public static void main(String[] args) throws Exception {
    
            if (args.length != 2) {
    
                throw new Exception(
    
                "Usage: " + Searcher.class.getName() + " < index > <query>");
    
            }
    
    File indexDir = new File(args[0]);
    
            String q = args[1];
    
    if (!indexDir.exists() || !indexDir.isDirectory()) {
    
                throw new Exception(indexDir + " is does not exist or is not a directory.");
    
            }
    
    search(indexDir, q);
    
        }
    
    public static void search(File indexDir, String q)  throws Exception{
    
            Directory fsDir = FSDirectory.getDirectory(indexDir, false);
    
            IndexSearcher is = new IndexSearcher(fsDir);
    
    Query query = QueryParser.parse(q, "contents", new StandardAnalyzer());
    
            Hits hits = is.search(query);
    
            System.out.println("Found "
    
            hits.length() + " document(s) that matched query '" + q + "':");
    
            for (int i = 0; i < hits.length(); i++) {
    
                Document doc = hits.doc(i);
    
                System.out.println(doc.get("filename"));
    
            }
    
        }
    
    }

    Объект Query из API Lucene создается в IndexSearcher.search методе. Объект Query может быть создан через
    API используя встроенные подклассы Query:

  • TermQuery
  • BooleanQuery
  • PrefixQuery
  • WildcardQuery
  • RangeQuery
  • и несколько других. В нашем случае мы используем метод parse класса QueryParser для разбора введенного пользователем запроса. QueryParser это сложный основанный на JavaCC парсер который разбирает из запроса, похожего на Google запрос, в представление Lucene API Query.
    Синтаксис запросов Lucene документирован на сайте Lucene, выражения могут содержать логические операции, указание на поля в которых искать, группировку, ранжирование запросов и многое другое. Как пример, выражение «+java -microsoft», которое вернет совпадения для документов содержащих слово «java», но не содержащих слово «microsoft.» QueryParser.parse необходимо указать поле по умолчанию для поиска, и в нашем случае мы укажем «contents» поле. Это будет эквивалентно запросу «+contents:java — contents:microsoft», но будет намного удобнее в использовании пользователям.
    Так же разработчик должен указать какой анализатор использовать для разметки запроса. В нашем случае мы используем StandardAnalyzer, который тот же самый что и при индексировании. Обычно один и тот же анализатор используется и для индексирования и для разбора строки запроса. Если мы использовали SnowballAnalyzer то как было показано в примере исследований анализатора, то запрос со словом «laziness» вернет все документы с элементом «lazi».После поиска, возвращается набор ссылок на результаты — Hints Collection.
    Ссылки возвращаются в порядке определяемый «очками» — релевантностью документа. Обсуждение алгоритма начисления очков не входит в рамки этой статьи, но можно быть уверенным что алгоритм по умолчанию подойдет к большинству приложений, и существует возможность настройки его в тех редких случаях когда этого алгоритма недостаточно.
    Набор результатов сам по себе не является набором документов которые были найдены. Так сделано в частности и для повышения эффективности. Но ссылка (hint) представляет простой метод для получения документа. В нашем примере
    мы выводим на экран поле с именем «filename» для каждого документа что были получены в результатах поиска.

    Подводя итоги

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

  • Что является моим «документом»? (строки из базы, параграфы текстового файла)
  • Какие поля и как мне индексировать?
  • Как пользователи будут использовать поиск?Эта статья служит введением в возможности Lucene,
    демонстрируя как наиболее просто использовать Lucene.[от переводчика] В этой статье не рассмотрены такие интересные вопросы как:
  • Индексирование русскоязычных документов.
  • «Подсветка» найденного слова в результатах поиска.
  • Оптимизация индексирования, сведение двух индексов в один.
  • Источники

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

    Erik Hatcher соавтор книги об Ant’е,
    «Java Development with Ant» (опубликовано издательством Manning),
    и так же соавтор книги «Lucene in Action».