Статьи

Поиск на иврите с ElasticSearch

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

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

0. Что такое HebMorph

HebMorph — это проект, который несколько шире, чем просто предоставление поискового плагина на иврите для ElasticSearch, но для целей этого поста давайте рассмотрим его в этом узком аспекте.

HebMorph состоит из 3 основных частей — файлов словарей hspell, пакета hebmorph-core, который является оболочкой для файлов словаря с важными битами, позволяющими находить слова, даже если они не были написаны точно так, как они появляются в словаре, и hebmorph. -lucene пакет, который содержит различные инструменты для обработки потоков текста в токены Lucene — доступные для поиска части.

Чтобы включить поиск на иврите из ElasticSearch, нам нужно использовать анализатор иврита, который предоставляет класс HebMorph для анализа входящих текстов на иврите. Это достигается путем предоставления ElasticSearch пакетов HebMorph и последующего указания использовать анализатор иврита в текстовых полях по мере необходимости.

1. Получить HebMorph и Hspell

На данный момент вам придется самостоятельно компилировать HebMorph из исходных текстов, используя Maven. В будущем мы могли бы загрузить его в централизованное хранилище, но, поскольку мы по-прежнему активно работаем над многими вещами, это еще слишком рано для этого.

Вероятно, самый простой способ получить HebMorph — это сделать git clone из основного репозитория. Хранилище находится по адресу https://github.com/synhershko/HebMorph и содержит последние файлы hspell, уже находящиеся в каталоге / hspell-data-files. Если вы новичок в git, GitHub предлагает отличные учебные пособия для начала работы с ним, а также они позволяют вам загрузить все дерево исходных текстов в виде zip или tarball.

Получив исходники, запустите mvn package или mvn install, чтобы создать 2 jar — hebmorph-core и hebmorph-lucene. Эти 2 пакета необходимы, прежде чем перейти к следующему шагу.

2. Создайте плагин ElasticSearch

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

Плагины ElasticSearch — это скомпилированные пакеты Java, которые вы просто помещаете в папку плагинов вашей установки ElasticSearch, и она автоматически обнаруживается экземпляром ElasticSearch после его инициализации. Если вы новичок в этом, вы можете прочитать об этом в официальной документации ElasticSearch. Вот отличное руководство для начала: http://jfarrell.github.io/

Суть этого в том, чтобы иметь Java-проект с внедренным в качестве ресурса файлом es-plugin.properties и указанием на класс, который сообщает ElasticSearch, какие классы загружать как плагины, и их тип плагина. В следующем разделе мы будем использовать это, чтобы добавить нашу собственную реализацию Analyzer, которая использует возможности HebMorph.

3. Создание анализатора иврита

HebMorph уже поставляется с MorphAnalyzer — реализацией Analyzer, которая заботится о токенизации, лемматизации и тому подобном на иврите. Поскольку он легко конфигурируется, лично я предпочитаю повторно реализовать его в плагине ElasticSearch, чтобы легче было изменять конфигурации в коде. В случае, если вам интересно, я не планирую поддерживать внешние конфигурации для этого, поскольку это слишком тонко, и вы должны действительно знать, что вы там делаете.

Не забудьте добавить зависимости к hebmorph-core и hebmorph-lucene в ваш проект.

Моя общая настройка Analyzer для поиска на иврите выглядит следующим образом:

public abstract class HebrewAnalyzer extends ReusableAnalyzerBase {

    protected enum AnalyzerType {
        INDEXING, QUERY, EXACT
    }

    private static final DictRadix<Integer> prefixesTree = LingInfo.buildPrefixTree(false);
    private static DictRadix<MorphData> dictRadix;
    private final StreamLemmatizer lemmatizer;
    private final LemmaFilterBase lemmaFilter;

    protected final Version matchVersion;
    protected final AnalyzerType analyzerType;
    protected final char originalTermSuffix = '$';

    static {
        try {
            dictRadix = Loader.loadDictionaryFromHSpellData(new File(resourcesPath + "hspell-data-files"), true);
        } catch (IOException e) {
            // TODO log
        }
    }

    protected HebrewAnalyzer(final AnalyzerType analyzerType) throws IOException {
        this.matchVersion = matchVersion;
        this.analyzerType = analyzerType;
        lemmatizer = new StreamLemmatizer(null, dictRadix, prefixesTree, null);
        lemmaFilter = new BasicLemmaFilter();
    }

    @Override
    protected TokenStreamComponents createComponents(final String fieldName, final Reader reader) {
        // on query - if marked as keyword don't keep origin, else only lemmatized (don't suffix)
        // if word termintates with $ will output word$, else will output all lemmas or word$ if OOV
        if (analyzerType == AnalyzerType.QUERY) {
            final StreamLemmasFilter src = new StreamLemmasFilter(reader, lemmatizer, null, lemmaFilter);
            src.setAlwaysSaveMarkedOriginal(true);
            src.setSuffixForExactMatch(originalTermSuffix);

            TokenStream tok = new SuffixKeywordFilter(src, '$');
            return new TokenStreamComponents(src, tok);
        }

        if (analyzerType == AnalyzerType.EXACT) {
            // on exact - we don't care about suffixes at all, we always output original word with suffix only
            final HebrewTokenizer src = new HebrewTokenizer(reader, prefixesTree, null);
            TokenStream tok = new NiqqudFilter(src);
            tok = new LowerCaseFilter(matchVersion, tok);
            tok = new AlwaysAddSuffixFilter(tok, '$', false);
            return new TokenStreamComponents(src, tok);
        }

        // on indexing we should always keep both the stem and marked original word
        // will ignore $ && will always output all lemmas + origin word$
        // basically, if analyzerType == AnalyzerType.INDEXING)
        final StreamLemmasFilter src = new StreamLemmasFilter(reader, lemmatizer, null, lemmaFilter);
        src.setAlwaysSaveMarkedOriginal(true);


        TokenStream tok = new SuffixKeywordFilter(src, '$');
        return new TokenStreamComponents(src, tok);
    }


    public static class HebrewIndexingAnalyzer extends HebrewAnalyzer {
        public HebrewIndexingAnalyzer() throws IOException {
            super(AnalyzerType.INDEXING);
        }
    }

    public static class HebrewQueryAnalyzer extends HebrewAnalyzer {
        public HebrewQueryAnalyzer() throws IOException {
            super(AnalyzerType.QUERY);
        }
    }

    public static class HebrewExactAnalyzer extends HebrewAnalyzer {
        public HebrewExactAnalyzer() throws IOException {
            super(AnalyzerType.EXACT);
        }
    }
}

You may notice how I created 3 separate analyzers — one for indexing, one for querying and the last for exact querying. I’ll be talking more about this in future posts, but the idea is to be able to provide flexibility on querying while still allow for correct indexing.

Configuring the analyzers to be picked up from ElasticSearch is rather easy now. First, you need to wrap each analyzer in a «provider», like so:

public class HebrewQueryAnalyzerProvider extends AbstractIndexAnalyzerProvider<HebrewAnalyzer.HebrewQueryAnalyzer> {
private final HebrewAnalyzer.HebrewQueryAnalyzer hebrewAnalyzer;

@Inject
public HebrewQueryAnalyzerProvider(Index index, @IndexSettings Settings indexSettings, Environment env, @Assisted String name, @Assisted Settings settings) throws IOException {
super(index, indexSettings, name, settings);
hebrewAnalyzer = new HebrewAnalyzer.HebrewQueryAnalyzer();
}

@Override
public HebrewAnalyzer.HebrewQueryAnalyzer get() {
return hebrewAnalyzer;
}
}

After you’ve created such providers for all types of analyzers, create an AnalysisBinderProcessor like this (or update your existing one with definitions for the Hebrew analyzers):

public class MyAnalysisBinderProcessor extends AnalysisModule.AnalysisBinderProcessor {

    private final static HashMap<String, Class<? extends AnalyzerProvider>> languageAnalyzers = new HashMap<>();
    static {
        languageAnalyzers.put("hebrew", HebrewIndexingAnalyzerProvider.class);
        languageAnalyzers.put("hebrew_query", HebrewQueryAnalyzerProvider.class);
        languageAnalyzers.put("hebrew_exact", HebrewExactAnalyzerProvider.class);
    }

    public static boolean analyzerExists(final String analyzerName) {
        return languageAnalyzers.containsKey(analyzerName);
    }

    @Override
    public void processAnalyzers(final AnalyzersBindings analyzersBindings) {
        for (Map.Entry<String, Class<? extends AnalyzerProvider>> entry : languageAnalyzers.entrySet()) {
            analyzersBindings.processAnalyzer(entry.getKey(), entry.getValue());
        }
    }
}

Don’t forget to update your Plugin class to catch the AnalysisBinderProcessor — it should look something like this (plus any other stuff you want to add there):

public class MyPlugin extends AbstractPlugin {
    @Override
    public String name() {
        return "my-plugin";
    }

    @Override
    public String description() {
        return "Implements custom actions required by me";
    }

    @Override
    public void processModule(Module module) {
        if (module instanceof AnalysisModule) {
            ((AnalysisModule)module).addProcessor(new MyAnalysisBinderProcessor());
        }
    }

}

4. Using the Hebrew analyzers

Compile the ElasticSearch plugin and drop it along with its dependencies in a folder under the /plugins folder of ElasticSearch. You now have 3 new types of analyzers at your disposal: «hebrew», «hebrew_query» and «hebrew_exact».

For indexing, you want to use the «hebrew» analyzer. In your mapping, you can define a certain field or an entire set of fields to use that specific analyzer by setting the analyzer for that field. You can also leave the analyzer configuration blank, and specify the analyzer to use for those fields with unspecified analyzer using the _analyzer field in the index request. See more about both here and here.

The «hebrew» analyzer will expand each term to all recognized lemmas; in case the word wasn’t recognized it will try to tolerate spelling errors or missing Yud/Vav — most of the time it will be successful (with some rate of false positives, which the lemma-filters should remove to some degree). Some words will still remain unrecognized and thus will be indexed as-is.

When querying using a QueryString query you can specify what analyzer to use — use the «hebrew_query» or «hebrew_exact» analyzer. The former will perform lemma expansion similar to the indexing analyzer, and the latter will avoid that and allow you to perform exact matches (useful when searching for names or exact phrases).

I pretty much ignored a lot of the complexity involved in fine tuning searches for Hebrew, and many very cool things HebMorph allows you to do with Hebrew search for the sake of focus. I will revisit them in a later blog post.

5. Administration

The hspell dictionary files are looked up by a physical location on disk — you will need to provide a path they are saved at. Since dictionaries update, it is sometimes easier to update them that way in a distributed environment like the one I’m working with. It may be desirable to have them compiled within the same jar file as the code itself — I’ll be happy to accept a pull request to do that.

The code above is working with ElasticSearch 0.90 GA and Lucene 4.2.1. I also had it running on earlier versions of both technologies, but may had to make a few minor changes. I assume the samples would break on future versions and I’ll probably don’t have much time going back and keeping it up to date, but bear in mind most of the time the changes are minor and easy to understand and make by yourself.

Both HebMorph and the hspell dictionary are released under the AGPL3. For any questions on licensing, feel free to contact me.