Let's deal with Vespa. Part 2

Content

This article is a continuation of the series about the Vespa search engine. Last time we looked at how to run a Vespa configuration server using Docker, and also explored the process of creating a schema for data models and the ability to disable their validation.

From this article you will learn:

  • What is Document and Query Processing.

  • How Vespa text is processed. What is tokenization and stemming?

  • Which text processor is better suited for the Russian language.

  • How to perform a text search.

  • How the results are ranked.

Document Processing

Vespa supports two chains of handlers for working with data. The first of them, Document Processing, is responsible for processing entities in the database.

When a request to interact with a document arrives at Vespa using standard HTTP methods get/put/update/remove, Document Processing is activated.

You can add your own additional handler to 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>

Using this handler, we can, for example, determine the type of incoming operation and make our database 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");
    }
}

Or we can change the field values ​​at the time the document is saved:

... 
    @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;
    }
...

As well as any other additional field validation or logging.

Query Processing

Query Processing, in turn, processes all requests from the client, presented in the form of search queries.

We can also add our own query handler, for example to log the number of documents that are returned as a result of the search:

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;
    }
}

When we execute any search request, we will receive our messages in the console:

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

Text analysis

Vespa, like any search query system, is built around some kind of linguistic model for processing text in one or another natural language.

When processing any language, it is important to know that modern language models process texts using two main operations: tokenization and stemming.

Tokenization is essentially just breaking text into small parts (tokens), according to some rules. Tokens can be either individual words in a sentence or individual parts of one word. There are a lot of tokenization algorithms – from simple decompositions of sentences based on punctuation marks, ending with an n-gram tokenizer.

An example of the operation of the simplest tokenizer using a whitespace character based on the famous phrase of Albert Einstein:

Only a fool needs order – genius rules over chaos.

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

Stemming is a process that helps a language model get rid of multiple forms of the same word. In other words, this is the process of reducing a word to its basis, stemme. There are two types of stemming: dictionary and algorithmic.

An example of stemming working with the previous example:

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

Now let's discuss what libraries are used to analyze text in Vespa.

By default, the open library from Apache is used – OpenNLP. It supports tokenization and stemming of text in Russian and about 20 other languages. This library can independently determine the language of the text that is located in a specific field of the data model. True, this does not always work; the shorter the text, the more difficult it is for her to correctly identify the language.

Vespa also supports another library from Apache – Lucene Linguistics, it supports many more languages, it is more accurate, but it cannot detect the language independently.

Russian language. OpenNPL vs Lucene

Let's compare the effectiveness of tokenization and stemming in OpenNLP and Lucene for the Russian language.

First of all, we need to make some changes to the data schema: specify the language and add a summary. This will allow us to display information processed by the library in search results.

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
    }  
}

Then we save the document in Vespa, using the description for Nike sports sneakers as an example:

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
}

Then let's try to get the OpenNLP processed data:

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

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

In the response we will see the description_tokens field with a set of tokens from the sneaker description field.

Now let's switch our configuration to work with lucene-linguistics and repeat the operations:

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>

You can see a big difference in the results we get in the description_tokens field:

Euler diagram comparing OpenNLP and Lucene tokenization

Euler diagram comparing OpenNLP and Lucene tokenization

First, OpenNLP creates tokens for conjunctions and prepositions, while tokens should only be created for words that define the context of a sentence. This is necessary so that when searching for text containing conjunctions and prepositions, there is no match with the document, even if the meaning of the text is different.

Difference between the two models

Lucene

OpenNLP

“design”
“maximum”
“new”
“everyday”
“knight”
“trails”
“crossed”

“one”
“from”
“V”
“And”
“design”
“He”
“How”
“For”
“So”
“so that”
“maximum”
“in”
“newish”
“at”
“on”
“il”
“maximum”
“daily”
“can”
“nait”

“By”
“trailov”
“crossed”
“A”

Secondly, OpenNLP cannot always accurately determine the original form of a word (stem). For example, in the case of the word “maximum,” OpenNLP mistakenly added part of the ending “-y”, resulting in “maximum.”

Text search

In order to perform a text search in Vespa, you need to specify, firstly, a set of fields on which this search will be performed, and secondly, pass a model for determining relevance.

The request itself is very simple; to do this, you need to perform a search with the userInput function, where we pass the text request:

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"
}

By default, Vespa searches only one field – default. In our case, there is no such field, therefore, the search result will be empty.

In order to change search fields, you need to update the data schema:

src/main/application/schemas/product.sd

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

If we add a fieldset and try to make the request again, we will again get nothing as a result. Since we didn't tell Vespa which relevance model to use, it uses the default model with 100% matching. In other words, the result will only be obtained if you pass the tokens to the request: “men’s”, “sneakers”, etc.

You can also change the relevance determination model in the data schema:

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)
        }
    }
}

Now, for each document, a rating based on compliance with the request will be calculated. This example uses the nativeRank ranking function, but bm25 is also available, which performs calculations 3-4 times faster, but with less accuracy.

You can read more about ranking functions here:

Now let's see what the query result will be for our full-text query with the native function:

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

The “relevance” field indicates the accuracy of the match; the larger this value, the better. It is the sum for each of the search fields, in this example we display the accuracy of matches of each field in “matchfeatures”.

Now let's execute the request from the bm25 functions:

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 предлагает широкий выбор моделей обуви для разных видов бега, тренировок и повседневной носки. В ассортименте бренда можно найти кроссовки для бега по асфальту, трейловые кроссовки для бега по пересечённой местности, а также универсальные модели, подходящие для любых условий.",
                    ...
                }
            }
        ]
    }
}

The value of the “nativeRank” function is normalized to range from 0 to 1, while the value of “bm25” is output unnormalized.

Conclusion

In this article, we took a closer look at the internal workings of the Vespa. We learned the differences between Document Processing and Query Processing, the OpenNLP language model from Lucene, and the nativeRank ranking functions from bm25. We also discussed how text analysis works and how text search is performed in Vespa.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *