Содержание

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

Из этой статьи вы узнаете:

  • Что такое Document и Query Processing.

  • Как обрабатывается текст Vespa. Что такое токенизация и стемминг.

  • Какой из обработчиков текста лучше подходит для русского языка.

  • Как выполнить текстовый поиск.

  • Как происходит ранжирование результата.

Document Processing

Vespa поддерживает две цепочки обработчиков для работы с данными. Первый из них, Document Processing, отвечает за обработку сущностей в БД.

Когда на Vespa приходит запрос взаимодействия с документом при помощи стандартных методов HTTP get/put/update/remove, в работу включается Document Processing.

В Document Processing можно добавить свой дополнительный обработчик:

src/main/application/services.xml

<services version="1.0">

    <container version="1.0" id="default">
        ...
        <!-- Добавление собственных обработчиков в document-processing -->
        <document-processing>
            <!-- inherits - наследование другой цепочки вызовов -->
            <chain id="custom-processing" inherits="indexing">
                <!-- bundle — это artifactId из pom.xml -->
                <documentprocessor id="ru.sportmaster.processing.document.CustomProcessing" bundle="vespa-config"/>
            </chain>
        </document-processing>
    </container>

    <content id="product" version="1.0">
        <documents>
            ...
            <!-- chain — это идентификатор созданной цепочки, наследуемой от indexing -->
            <document-processing chain="custom-processing"/>
        </documents>
        ...
    </content>
</services>

С помощью этого обработчика мы, например, можем определять тип входящей операции и сделать нашу базу данных read-only:

src/main/java/ru/sportmaster/processing/document/CustomProcessing.java

public class CustomProcessing extends DocumentProcessor {

    @Override
    public Progress process(Processing processing) {
        val operations = processing.getDocumentOperations();
        for (val operation : operations) {
            if (operation instanceof DocumentGet) {
                return Progress.DONE;
            }
        }
        return Progress.FAILED.withReason("This operation is not supported");
    }
}

Или мы можем поменять значения поля в момент сохранения документа:

... 
    @Override
    public Progress process(Processing processing) {
        val operations = processing.getDocumentOperations();
        for (val operation : operations) {
            if (operation instanceof DocumentPut doc) {
                val document = doc.getDocument();
                val isSneakers = document.getDataType().isA(SNEAKERS);
                if (isSneakers) {
                    // Тут мы меняем значения поля sport на FOOTBALL
                    document.setFieldValue("sport", SPORT_FOOTBALL);
                }
            }
        }
        return Progress.DONE;
    }
...

А также любые другие дополнительные валидации полей или их логирование.

Query Processing

Query Processing, в свою очередь, обрабатывает все запросы от клиента, представленных в виде запросов поиска.

Мы также можем добавить собственный обработчик запроса, например, чтобы логировать количество документов, которые вернулись в результате поиска:

src/main/application/services.xml

<?xml version="1.0" encoding="utf-8" ?>
<services version="1.0">

    <container version="1.0" id="default">
        ...
        <search>
            <chain id="default" inherits="vespa">
                <searcher id="ru.sportmaster.processing.search.CustomSearcher" bundle="vespa-config" />
            </chain>
        </search>
        ...
    </container>
    ...
</services>

src/main/java/ru/sportmaster/processing/search/CustomSearcher.java

public class CustomSearcher extends Searcher {

    @Override
    public Result search(Query query, Execution execution) {
        System.out.println("------------- Start Customer Searcher -------------");
        val result = execution.search(query);
        val totalHitCount = result.getTotalHitCount();
        System.out.println("Total Hit Count: " + totalHitCount);
        System.out.println("------------- End Customer Searcher -------------");
        return result;
    }
}

При выполнении любого поискового запроса мы получим в консоль наши сообщения:

select * from sneakers where true
2024-09-05 16:54:49 [2024-09-05 13:54:49.750] INFO    container        stdout   Start Customer Searcher
2024-09-05 16:54:49 [2024-09-05 13:54:49.784] INFO    container        stdout   Total Hit Count: 1

Текстовый анализ

Vespa, как и любая система для поисковых запросов, построена вокруг некоторой лингвистической модели для обработки текста на том или ином естественном языке.

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

Токенизация — по сути говоря, это просто разбиение текста на небольшие части (токены), по некоторым правилам. Токены могут быть как отдельные слова в предложении, так и отдельные части одного слова. Алгоритмов токенизации бывает очень много — от простых декомпозиций предложения по знакам пунктуации, заканчивая n-gram токенизатором.

Пример работы самого простого токенизатора по пробельному символу на знаменитой фразе Альберта Эйнштейна:

Только дурак нуждается в порядке — гений господствует над хаосом.

["Только","дурак","нуждается","в","порядке","—","гений","господствует","над","хаосом","."]

Стемминг — это процесс, который помогает языковой модели избавиться от разнообразия форм одного и того же слова. Другими словами, это процесс приведения слова к его основе, стемме. Стемминг бывает двух видов, словарный и алгоритмический.

Пример работы стемминга с предыдущим примером:

["тольк","дурак","нужда","в","порядк","—","ген","господств","над","хаос"]

Теперь обсудим, какие же библиотеки используются для анализа текста в Vespa.

По умолчанию используется открытая библиотека от Apache — OpenNLP. Она поддерживает токенизацию и стемминг текста на русском языке и еще порядка 20 языках. Эта библиотека умеет самостоятельно определять язык текста, который находится в конкретном поле модели данных. Правда, работает это не всегда, чем короче текст, тем труднее ей правильно определить язык.

Также Vespa поддерживает еще одну библиотеку от Apache — Lucene Linguistics, в ней поддерживается куда больше языков, она точнее, но не умеет определять язык самостоятельно.

Русский язык. OpenNPL vs Lucene

Сравним эффективность токенизации и стемминга в OpenNLP и Lucene для русского языка.

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

src/main/application/schemas/product.sd

# Базовая схема продукта
schema product {
    ...
    # Устанавливаем язык для всех полей данной схемы
    field language type string {
        indexing: "ru" | set_language
    }
    ...
    # Сводка, которая выводит только поле с описанием и его токены.
    document-summary get-tokens {
        summary description {}
        summary description_tokens {
            source: description
            tokens
        }
        from-disk
    }  
}

Затем сохраним документ в Vespa, в качестве примера возьмем описание для спортивных кроссовок Nike:

POST /document/v1/shop/sneakers/docid/1 HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 1885

{
    "fields": {
        "id": 26261760299,
        "name": "Кроссовки мужские Nike Revolution 6",
        "description": "Спортивная обувь Nike заслуженно считается одной из самых популярных в мире благодаря своему безупречному качеству, комфорту и стильному дизайну. Она идеально подходит как для профессиональных спортсменов, так и для любителей активного образа жизни. Nike постоянно совершенствует свои технологии и материалы, чтобы обеспечить максимальный комфорт и поддержку во время тренировок и соревнований. Обувь этого бренда использует новейшие разработки в области амортизации, поддержки стопы, вентиляции и других аспектов, важных для спортсменов. Одной из ключевых технологий, применяемых в обуви Nike, является система амортизации, которая обеспечивает мягкую и плавную посадку при каждом шаге. Это позволяет снизить нагрузку на суставы и позвоночник, предотвращая травмы и усталость. Ещё одна важная особенность — поддержка стопы, которая помогает сохранить правильное положение ноги во время бега или тренировок. Это особенно важно для спортсменов, которые хотят достичь максимальной эффективности и результативности. Nike предлагает широкий выбор моделей обуви для разных видов бега, тренировок и повседневной носки. В ассортименте бренда можно найти кроссовки для бега по асфальту, трейловые кроссовки для бега по пересечённой местности, а также универсальные модели, подходящие для любых условий.",
        "price": 9239,
        "availabilities": [
            {
                "id": 1,
                "name": "ТЦ Щелковский",
                "count": 3
            }
        ],
        "season": "SUMMER",
        "material": {
            "Текстиль": 90,
            "Термополиуретан": 10
        },
        "gender": "MALE",
        "sport": "RUNNING",
        "pavement": "ASPHALT",
        "insole": false
    },
    "root": null
}

Затем попробуем получить обработанные OpenNLP данные:

POST /search/ HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 81

{
    "yql": "select * from product where true",
    "summary": "get-tokens"
}

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

Теперь переключим нашу конфигурацию на работу с lucene-linguistics и повторим операции:

src/main/application/services.xml

<?xml version="1.0" encoding="utf-8" ?>
<services version="1.0">

    <container version="1.0" id="default">
        <!-- Включает lucene linguistics вместо OpenNLP -->
        <components>
            <component id="linguistics" bundle="vespa-config" class="com.yahoo.language.lucene.LuceneLinguistics">
                <config name="com.yahoo.language.lucene.lucene-analysis"/>
            </component>
        </components>
        ...
    </container>
    ...
</services>

vespa-config/pom.xml

    <dependencies>
        ...
        <dependency>
            <groupId>com.yahoo.vespa</groupId>
            <artifactId>lucene-linguistics</artifactId>
            <version>${lucene-linguistics.version}</version>
            <scope>compile</scope>
        </dependency>
        <dependency>
            <groupId>com.yahoo.vespa</groupId>
            <artifactId>linguistics</artifactId>
            <version>${lucene-linguistics.version}</version>
            <scope>provided</scope>
        </dependency>
        ...
    </dependencies>

В результатах, которые мы получаем в поле description_tokens, можно заметить большую разницу:

Диаграмма Эйлера для сравнения токенизации OpenNLP и Lucene
Диаграмма Эйлера для сравнения токенизации OpenNLP и Lucene

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

Разница между двумя моделями

Lucene

OpenNLP

"дизайн"
"максимальн"
"нов"
"повседневн"
"найт"
"трейлов"
"пересечен"

"одно"
"из"
"в"
"и"
"дизаин"
"он"
"как"
"для"
"так"
"чтоб"
"максимальны"
"во"
"новеиш"
"при"
"на"
"ил"
"максимально"
"повседневно"
"можн"
"наит"

"по"
"треилов"
"пересеченно"
"а"

Во-вторых, OpenNLP не всегда может точно определить исходную форму слова (стемму). Например, в случае со словом «максимальный» OpenNLP ошибочно добавил часть окончания «-ый», в результате получилось «максимальны».

Текстовый поиск

Для того чтобы выполнить текстовый поиск в Vespa, требуется указать, во-первых, набор полей, по которым этот поиск будет выполняться, а во-вторых, передать модель определения релевантности.

Сам запрос выполняется очень просто, для этого нужно выполнить поиск с функцией userInput, куда мы передаём текстовый запрос:

POST /search/ HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 138

{
    "yql": "select * from sneakers where userInput(@user-query)",
    "user-query": "мужские кроссовки nike",
    "language": "ru",
    "ranking": "native"
}

По умолчанию в Vespa выполняется поиск только по одному полю — default. В нашем случае такого поля нет, следовательно, результат поиска будет пустым.

Для того чтобы изменить поля для поиска, нужно обновить схему данных:

src/main/application/schemas/product.sd

# Базовая схема продукта
schema product {
    ...
    # Набор полей, по которым будет выполняться текстовый поиск
    fieldset default {
        fields: name, description
    }
}

Если мы добавим fieldset и снова попробуем сделать запрос, то мы опять ничего не получим в результате. Так как мы не указали Vespa, какую модель релевантности использовать, она использует default модель со 100% совпадением. Другими словами, результат будет, только если передать в запрос токены: «мужск», «кроссовк» и т. д.

Изменить модель определения релевантности можно также в схеме данных:

src/main/application/schemas/product.sd

# Базовая схема продукта
schema product {
    ...
    # Базовый документ продукта
    document product {
        ...
        # Название товара
        field name type string {
            ...
            index: enable-bm25
        }
        # Описание товара
        field description type string {
            ...
            index: enable-bm25
        }
        ...
    }
    ...
    # Ранжирование результата при помощи nativeRank
    rank-profile native inherits default {
        first-phase {
            expression: nativeRank(name, description)
        }
        # Выводит показатель ранжирования по каждому полю
        match-features {
            nativeRank(name)
            nativeRank(description)
        }
    }
    # Ранжирование результата с помощью bm25
    rank-profile bm25 inherits default {
        first-phase {
            expression: bm25(name) + bm25(description)
        }
        # Выводит показатель ранжирования по каждому полю
        match-features {
            bm25(name)
            bm25(description)
        }
    }
}

Теперь для каждого документа будет вычисляться рейтинг по соответствию к запросу. В данном примере используется функция ранжирования nativeRank, Но также доступна bm25, которая выполняет вычисления в 3–4 раза быстрее, но с меньшей точностью.

Более подробно про функции ранжирования можно прочитать здесь:

Теперь посмотрим, какой результат запроса будет на наш полнотекстовый запрос с функцией native:

{
    "root": {
        ...
        "children": [
            {
                ...
                "relevance": 0.21185066194677957,
                ...
                "fields": {
                    "matchfeatures": {
                        "nativeRank(description)": 0.13889476905667314,
                        "nativeRank(name)": 0.28480655483688605
                    },
                    ...
                    "name": "Кроссовки мужские Nike Revolution 6",
                    "description": "Спортивная обувь Nike заслуженно считается одной из самых популярных в мире благодаря своему безупречному качеству, комфорту и стильному дизайну. Она идеально подходит как для профессиональных спортсменов, так и для любителей активного образа жизни. Nike постоянно совершенствует свои технологии и материалы, чтобы обеспечить максимальный комфорт и поддержку во время тренировок и соревнований. Обувь этого бренда использует новейшие разработки в области амортизации, поддержки стопы, вентиляции и других аспектов, важных для спортсменов. Одной из ключевых технологий, применяемых в обуви Nike, является система амортизации, которая обеспечивает мягкую и плавную посадку при каждом шаге. Это позволяет снизить нагрузку на суставы и позвоночник, предотвращая травмы и усталость. Ещё одна важная особенность — поддержка стопы, которая помогает сохранить правильное положение ноги во время бега или тренировок. Это особенно важно для спортсменов, которые хотят достичь максимальной эффективности и результативности. Nike предлагает широкий выбор моделей обуви для разных видов бега, тренировок и повседневной носки. В ассортименте бренда можно найти кроссовки для бега по асфальту, трейловые кроссовки для бега по пересечённой местности, а также универсальные модели, подходящие для любых условий.",
                    ...
                }
            }
        ]
    }
}

Поле «relevance» указывает на точность совпадения, чем больше это значение, тем лучше. Оно является суммой по каждому из поисковых полей, в данном примере мы выводим точность совпадений каждого поля в «matchfeatures».

Теперь выполним запрос с функций bm25:

POST /search/ HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 161

{
    "yql": "select * from product where userInput(@user-query)",
    "user-query": "мужские кроссовки nike",
    "language": "ru",
    "ranking": "bm25"
}
{
    "root": {
        ...
        "children": [
            {
                ...
                "relevance": 1.7454556511257089,
                ...
                "fields": {
                    "matchfeatures": {
                        "bm25(description)": 0.8824094337703663,
                        "bm25(name)": 0.8630462173553426
                    },
                    ...
                    "name": "Кроссовки мужские Nike Revolution 6",
                    "description": "Спортивная обувь Nike заслуженно считается одной из самых популярных в мире благодаря своему безупречному качеству, комфорту и стильному дизайну. Она идеально подходит как для профессиональных спортсменов, так и для любителей активного образа жизни. Nike постоянно совершенствует свои технологии и материалы, чтобы обеспечить максимальный комфорт и поддержку во время тренировок и соревнований. Обувь этого бренда использует новейшие разработки в области амортизации, поддержки стопы, вентиляции и других аспектов, важных для спортсменов. Одной из ключевых технологий, применяемых в обуви Nike, является система амортизации, которая обеспечивает мягкую и плавную посадку при каждом шаге. Это позволяет снизить нагрузку на суставы и позвоночник, предотвращая травмы и усталость. Ещё одна важная особенность — поддержка стопы, которая помогает сохранить правильное положение ноги во время бега или тренировок. Это особенно важно для спортсменов, которые хотят достичь максимальной эффективности и результативности. Nike предлагает широкий выбор моделей обуви для разных видов бега, тренировок и повседневной носки. В ассортименте бренда можно найти кроссовки для бега по асфальту, трейловые кроссовки для бега по пересечённой местности, а также универсальные модели, подходящие для любых условий.",
                    ...
                }
            }
        ]
    }
}

Значение функции «nativeRank» нормализуется в диапазоне от 0 до 1, тогда как значение «bm25» выводится в ненормализованном виде.

Заключение

В этой статье мы подробно рассмотрели внутреннее устройство Vespa. Мы узнали, чем отличаются Document Processing и Query Processing, языковая модель OpenNLP от Lucene и функции ранжирования nativeRank от bm25. Также мы обсудили, как работает текстовый анализ и как осуществляется текстовый поиск в Vespa.

Комментарии (0)