Lucene - языковые анализаторы

Как Вам поиск ? Мы вводим в качестве запроса слово сад и получаем 0 (НОЛЬ) результатов, в то время как это же слово в другом падеже - сады - даёт целых 6 (ШЕСТЬ) ! Всё дело в том, что в процессе индексации / поиска использовался стандартный анализатор текста - StandardAnalyzer, который ничего не знает об особенностях того или иного языка. Какие вообще существуют алгоритмы для программного выделения основы слова ? Очень распространёнными, и, что немаловажно в контексте процесса индексации - быстрыми, являются алгоритмы стемминга, среди которых, наиболее популярными и часто используемыми являются Стеммеры Портера - впервые опубликованные ещё в далёком 1980 году. Введите ОДНО слово, выберите язык и нажмите кнопку "Стеммер!":

Хорошая новость - в Lucene уже есть готовые анализаторы для многих распространённых языков, в том числе и для русского, которые можно взять из коробки. Вопрос тут несколько в иной плоскости - как организовать поиск, задействовав одновременно несколько из них - для русскоязычных сайтов было бы неплохо предусмотреть и Английский (EnglishAnalyzer) и Русский (RussianAnalyzer). Можно попробовать автоматически определять язык текста и задействовать соответствующий анализатор, однако у такого решения есть подводный камень. Всё дело в том, что точность определения языка очень сильно зависит от объёма текста - чем он меньше, тем больше вероятность ошибки и это особенно актуально для процесса поиска, когда пользователь вводит всего несколько слов в строку запроса. Очевидно, что алгоритм с использованием автоматического определения языка необходимо дорабатывать.

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

common/src/main/java/common/LuceneBinding.java

package common;

import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.en.EnglishAnalyzer;
import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper;
import org.apache.lucene.analysis.ru.RussianAnalyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;

/* This class is used both by Crawler and SearchServlet */

public final class LuceneBinding {
    public static final Path INDEX_PATH = Paths.get(
        System.getProperty("user.home"), "lucene-tutorial-index");

    public static final String URI_FIELD = "uri";
    public static final String TITLE_FIELD = "title";
    public static final String CONTENT_FIELD = "content";

    /* Russian */

    public static final String RUS_TITLE_FIELD = "rustitle";
    public static final String RUS_CONTENT_FIELD = "ruscontent";

    /* English */

    public static final String ENG_TITLE_FIELD = "engtitle";
    public static final String ENG_CONTENT_FIELD = "engcontent";

    public static Analyzer getAnalyzer() {
        final Map<String, Analyzer> analyzers = new HashMap<String, Analyzer>();
        analyzers.put(LuceneBinding.RUS_TITLE_FIELD, new RussianAnalyzer());
        analyzers.put(LuceneBinding.RUS_CONTENT_FIELD, new RussianAnalyzer());
        analyzers.put(LuceneBinding.ENG_TITLE_FIELD, new EnglishAnalyzer());
        analyzers.put(LuceneBinding.ENG_CONTENT_FIELD, new EnglishAnalyzer());

        return new PerFieldAnalyzerWrapper(new StandardAnalyzer(), analyzers);
    }
}

Данные поля необходимо добавить в индекс - можно с параметром setStored(false), поскольку в нашем случае у документа Document уже имеются соответствующие поля, хранящие оригинальные значения (созданные с параметром setStored(true)) - зачем дублировать информацию и тем самым раздувать размеры индекса.

crawler/src/main/java/crawler/LuceneIndexer.java

package crawler;

import java.io.Closeable;
import java.io.IOException;

import org.apache.log4j.Logger;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.index.IndexOptions;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.IndexWriterConfig.OpenMode;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;

import common.LuceneBinding;

class LuceneIndexer implements Closeable {
    private static final Logger logger = Logger.getLogger(LuceneIndexer.class.getName());

    /* IndexWriter is completely thread safe */

    private IndexWriter indexWriter = null;

    @Override
    public void close() throws IOException {
        if (this.indexWriter != null) {
            LuceneIndexer.logger.info("Closing Index < " + 
                    LuceneBinding.INDEX_PATH + " > NumDocs: " + this.indexWriter.numDocs());
            this.indexWriter.close();
            this.indexWriter = null;
            LuceneIndexer.logger.info("Index Closed OK!");
        } else {
            throw new IOException("Index already closed");
        }
    }

    public void new_index() throws IOException {
        final Directory directory = FSDirectory.open(LuceneBinding.INDEX_PATH);
        final IndexWriterConfig iwConfig = new IndexWriterConfig(LuceneBinding.getAnalyzer());
        iwConfig.setOpenMode(OpenMode.CREATE);
        this.indexWriter = new IndexWriter(directory, iwConfig);
    }

    public void add(final String url, final String html) {

        final String title = HtmlHelper.extractTitle(html);
        final String content = HtmlHelper.extractContent(html);

        LuceneIndexer.logger.info("***** " + url + " *****");
        if (title != null) {
            LuceneIndexer.logger.info(title);
        }
        LuceneIndexer.logger.info(content);

        final Document doc = new Document();

        final FieldType urlType = new FieldType();
        urlType.setIndexOptions(IndexOptions.DOCS);
        urlType.setStored(true);
        urlType.setTokenized(false);
        urlType.setStoreTermVectorOffsets(false);
        urlType.setStoreTermVectorPayloads(false);
        urlType.setStoreTermVectorPositions(false);
        urlType.setStoreTermVectors(false);
        doc.add(new Field(LuceneBinding.URI_FIELD, url, urlType));

        final FieldType tokType = new FieldType();
        tokType.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
        tokType.setStored(true);
        tokType.setTokenized(true);
        tokType.setStoreTermVectorOffsets(true);
        tokType.setStoreTermVectorPayloads(true);
        tokType.setStoreTermVectorPositions(true);
        tokType.setStoreTermVectors(true);
        if (title != null) {
            doc.add(new Field(LuceneBinding.TITLE_FIELD, title, tokType));
        }
        doc.add(new Field(LuceneBinding.CONTENT_FIELD, content, tokType));

        // Language setStored(false) - we already have original value

        final FieldType lngType = new FieldType();
        lngType.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
        lngType.setStored(false);
        lngType.setTokenized(true);
        lngType.setStoreTermVectorOffsets(true);
        lngType.setStoreTermVectorPayloads(true);
        lngType.setStoreTermVectorPositions(true);
        lngType.setStoreTermVectors(true);

        /* Russian */

        if (title != null) {
            doc.add(new Field(LuceneBinding.RUS_TITLE_FIELD, title, lngType));
        }
        doc.add(new Field(LuceneBinding.RUS_CONTENT_FIELD, content, lngType));

        /* English */

        if (title != null) {
            doc.add(new Field(LuceneBinding.ENG_TITLE_FIELD, title, lngType));
        }
        doc.add(new Field(LuceneBinding.ENG_CONTENT_FIELD, content, lngType));

        try {
            if (this.indexWriter != null) {
                this.indexWriter.addDocument(doc);
            }
        } catch (final IOException ex) {
            LuceneIndexer.logger.error(ex);
        }
    }
}

Для поиска по нескольким полям одновременно используем уже известный MultiFieldQueryParser, просто расширив перечень интересующих нас полей:

server/src/main/java/server/QueryHelper.java

package server;

import org.apache.lucene.queryparser.classic.MultiFieldQueryParser;
import org.apache.lucene.queryparser.classic.ParseException;
import org.apache.lucene.queryparser.classic.QueryParser;
import org.apache.lucene.queryparser.classic.QueryParserBase;
import org.apache.lucene.search.Query;

import common.LuceneBinding;

final class QueryHelper {
    static Query generate(final String story) throws ParseException {
        final QueryParser parser = new MultiFieldQueryParser(
                new String[] { 
                        LuceneBinding.TITLE_FIELD, 
                        LuceneBinding.CONTENT_FIELD,
                        /* Russian */
                        LuceneBinding.RUS_TITLE_FIELD, 
                        LuceneBinding.RUS_CONTENT_FIELD,
                        /* English */
                        LuceneBinding.ENG_TITLE_FIELD, 
                        LuceneBinding.ENG_CONTENT_FIELD },
                LuceneBinding.getAnalyzer());

        /* Operator OR is used by default */

        parser.setDefaultOperator(QueryParser.Operator.AND);

        return parser.parse(QueryParserBase.escape(story));
    }
}

После переидексации сайта результаты поисковой выдачи выглядят уже намного лучше:

~$ mvn clean install && bash -c 'mvn -pl server/ jetty:run & sleep 10 && \
    mvn -pl crawler/ exec:java -Dexec.mainClass="crawler.App" & \
    trap "kill -TERM -$$" SIGINT ; wait'

screenshot

screenshot

screenshot

screenshot

Далее реализуем подсветку найденных вхождений.

Исходники

links

social