Поиск в спящем режиме с lucene неправильно индексирует похожие имена

Я изучаю Hibernate Search 6.1.3.Final с Lucene 8.11.1 в качестве бэкэнда и Spring Boot 2.6.6. Я пытаюсь создать поиск по названиям продуктов, штрих-кодам и производителям. В настоящее время я делаю интеграционный тест, чтобы посмотреть, что произойдет, если несколько продуктов имеют одинаковое имя:

    @Test
    void shouldFindSimilarTobaccosByQuery() {
        var tobaccoGreen = TobaccoBuilder.builder()
            .name("TobaCcO GreEN")
            .build();
        var tobaccoRed = TobaccoBuilder.builder()
            .name("TobaCcO ReD")
            .build();
        var tobaccoGreenhouse = TobaccoBuilder.builder()
            .name("TobaCcO GreENhouse")
            .build();
        tobaccoRepository.saveAll(List.of(tobaccoGreen, tobaccoRed, tobaccoGreenhouse));

        webTestClient
            .get().uri("/tobaccos?query=green")
            .exchange()
            .expectStatus().isOk()
            .expectBodyList(Tobacco.class)
            .value(tobaccos -> assertThat(tobaccos)
                .hasSize(2)
                .contains(tobaccoGreen, tobaccoGreenhouse)
            );
    }

Как вы можете видеть в тесте, я ожидаю получить два табака с похожими названиями: tobaccoGreen и tobaccoGreenhouse, используя green как запрос для критериев поиска. Сущность следующая:

@Data
@Entity
@Indexed
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
@EqualsAndHashCode(of = "id")
@EntityListeners(AuditingEntityListener.class)
public class Tobacco {
    @Id
    @GeneratedValue
    private UUID id;
    @NotBlank
    @KeywordField
    private String barcode;
    @NotBlank
    @FullTextField(analyzer = "name")
    private String name;
    @NotBlank
    @FullTextField(analyzer = "name")
    private String manufacturer;
    @CreatedDate
    private Instant createdAt;
    @LastModifiedDate
    private Instant updatedAt;
}

Я следил за документами и настроил анализатор имен:

@Component("luceneTobaccoAnalysisConfigurer")
public class LuceneTobaccoAnalysisConfigurer implements LuceneAnalysisConfigurer {
    @Override
    public void configure(LuceneAnalysisConfigurationContext context) {
        context.analyzer("name").custom()
            .tokenizer("standard")
            .tokenFilter("lowercase")
            .tokenFilter("asciiFolding");
    }
}

И используя простой запрос с нечеткой опцией:

@Component
@AllArgsConstructor
public class IndexSearchTobaccoRepository {

    private final EntityManager entityManager;

    public List<Tobacco> find(String query) {
        return Search.session(entityManager)
            .search(Tobacco.class)
            .where(f -> f.match()
                .fields("barcode", "name", "manufacturer")
                .matching(query)
                .fuzzy()
            )
            .fetch(10)
            .hits();
    }
}

Тест показывает, что может найти только tobaccoGreen, но не tobaccoGreenhouse, и я не понимаю, почему, как я могу искать похожие названия продуктов (или штрих-коды, производителя)?

Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
В компьютерном программировании биты играют важнейшую роль в представлении и манипулировании данными на двоичном уровне. Побитовые операции...
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Приходилось ли вам сталкиваться с требованиями, в которых вас могли попросить поднять тревогу или выдать ошибку, когда метод Java занимает больше...
Полный курс Java для разработчиков веб-сайтов и приложений
Полный курс Java для разработчиков веб-сайтов и приложений
Получите сертификат Java Web и Application Developer, используя наш курс.
0
0
28
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Прежде чем я отвечу на ваш вопрос, я хотел бы указать, что вызов .fetch(10).hits() неоптимален, особенно при использовании сортировки по умолчанию (как вы):

        return Search.session(entityManager)
            .search(Tobacco.class)
            .where(f -> f.match()
                .fields("barcode", "name", "manufacturer")
                .matching(query)
                .fuzzy()
            )
            .fetch(10)
            .hits();

Если вы вызовете .fetchHits(10) напрямую, Lucene сможет пропустить часть поиска (часть, где подсчитывается общее количество обращений), и в больших индексах это может привести к значительному приросту производительности. Итак, сделайте это вместо этого:

        return Search.session(entityManager)
            .search(Tobacco.class)
            .where(f -> f.match()
                .fields("barcode", "name", "manufacturer")
                .matching(query)
                .fuzzy()
            )
            .fetchHits(10);

Теперь собственно ответ:


Подход к этому через поисковый запрос

.fuzzy() не волшебство, оно не будет просто соответствовать всему, что, по вашему мнению, должно совпадать :) Здесь есть конкретное определение того, что он делает, и это не то, что вам нужно.

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

            .where(f -> f.simpleQueryString()
                .fields("barcode", "name", "manufacturer")
                .matching("green*")
            )

Вы теряете нечеткость, но получаете возможность выполнять префиксные запросы, что даст желаемые результаты (green* будет соответствовать greenhouse).

Однако префиксные запросы являются явными: пользователь должен добавить * после «зеленого», чтобы соответствовать «всем словам, начинающимся с зеленого».

Что приводит нас к...

Подход к этому через анализаторы

Если вы хотите, чтобы это поведение «сопоставления префиксов» было автоматическим, без необходимости добавлять * в запрос, вам нужен другой анализатор.

Ваш текущий анализатор разбивает проиндексированный текст, используя пробел в качестве разделителя (более или менее; это немного сложнее, но идея в этом). Но вы, видимо, хотите, чтобы "теплица" разбивалась на "зелень" и "дом"; только так запрос со словом «зеленый» будет соответствовать слову «теплица».

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

Добавьте еще один анализатор в ваш конфигуратор:

@Component("luceneTobaccoAnalysisConfigurer")
public class LuceneTobaccoAnalysisConfigurer implements LuceneAnalysisConfigurer {
    @Override
    public void configure(LuceneAnalysisConfigurationContext context) {
        context.analyzer("name").custom()
            .tokenizer("standard")
            .tokenFilter("lowercase")
            .tokenFilter("asciiFolding");

        // THIS PART IS NEW
        context.analyzer("name_prefix").custom()
            .tokenizer("standard")
            .tokenFilter("lowercase")
            .tokenFilter("asciiFolding")
            .tokenFilter("edgeNGram")
                    // Handling prefixes from 2 to 7 characters.
                    // Prefixes of 1 character or more than 7 will
                    // not be matched.
                    // You can extend the range, but this will take more
                    // space in the index for little gain.
                    .param( "minGramSize", "2" )
                    .param( "maxGramSize", "7" );
    }
}

И измените свое сопоставление, чтобы использовать анализатор name при запросе, но анализатор name_prefix при индексировании:

@Data
@Entity
@Indexed
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
@EqualsAndHashCode(of = "id")
@EntityListeners(AuditingEntityListener.class)
public class Tobacco {
    @Id
    @GeneratedValue
    private UUID id;
    @NotBlank
    @KeywordField
    private String barcode;
    @NotBlank
    // CHANGE THIS
    @FullTextField(analyzer = "name_prefix", searchAnalyzer = "name")
    private String name;
    @NotBlank
    // CHANGE THIS
    @FullTextField(analyzer = "name_prefix", searchAnalyzer = "name")
    private String manufacturer;
    @CreatedDate
    private Instant createdAt;
    @LastModifiedDate
    private Instant updatedAt;
}

Теперь переиндексировать ваши данные.

Теперь ваш запрос «зеленый» также будет соответствовать «TobaCcO GreENhouse», потому что «GreENhouse» был проиндексирован как ["greenhouse", "gr", "gre", "gree", "green", "greenh", "greenho"].

Вариации

edgeNGram фильтровать по отдельным полям

Вместо того, чтобы менять анализатор ваших текущих полей, вы можете добавлять новых полей для тех же свойств Java, но используя новый анализатор с фильтром edgeNGram:

@Data
@Entity
@Indexed
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
@EqualsAndHashCode(of = "id")
@EntityListeners(AuditingEntityListener.class)
public class Tobacco {
    @Id
    @GeneratedValue
    private UUID id;
    @NotBlank
    @KeywordField
    private String barcode;
    @NotBlank
    @FullTextField(analyzer = "name")
    // ADD THIS
    @FullTextField(name = "name_prefix", analyzer = "name_prefix", searchAnalyzer = "name")
    private String name;
    @NotBlank
    @FullTextField(analyzer = "name")
    // ADD THIS
    @FullTextField(name = "manufacturer_prefix", analyzer = "name_prefix", searchAnalyzer = "name")
    private String manufacturer;
    @CreatedDate
    private Instant createdAt;
    @LastModifiedDate
    private Instant updatedAt;
}

Затем вы можете настроить таргетинг на эти поля, а также на обычные поля в вашем запросе:

@Component
@AllArgsConstructor
public class IndexSearchTobaccoRepository {

    private final EntityManager entityManager;

    public List<Tobacco> find(String query) {
        return Search.session(entityManager)
            .search(Tobacco.class)
            .where(f -> f.match()
                .fields("barcode", "name", "manufacturer").boost(2.0f)
                .fields("name_prefix", "manufacturer_prefix")
                .matching(query)
                .fuzzy()
            )
            .fetchHits(10);
    }
}

Как видите, я добавил усиление к полям, которые не используют префикс. Это главное преимущество этого варианта по сравнению с тем, который я объяснил выше: совпадения фактических слов (а не префиксов) будут считаться более важными, что даст лучший результат и, таким образом, вытянет документы в начало списка результатов, если вы используете сортировка релевантности. (что является сортировкой по умолчанию).

Обработка только составных слов вместо всех слов

Я не буду подробно описывать это здесь, но есть другой подход, если все, что вам нужно, это обрабатывать составные слова («теплица» => «зеленый» + «дом», «супермен» => «супер» + «мужчина» и т. д. ). Вы можете использовать фильтр DictionaryCompoundWord. Это менее мощно, но создаст меньше шума в вашем индексе (меньше бессмысленных токенов) и, следовательно, может привести к лучшему релевантность сортирует. Другим недостатком является то, что вам нужно предоставить фильтру словарь, который содержит все слова, которые могут быть «составлены». Для получения дополнительной информации см. исходный код и javadoc класса org.apache.lucene.analysis.compound.DictionaryCompoundWordTokenFilterFactory или документацию эквивалентный фильтр в Elasticsearch.

Прежде всего, я хотел бы поблагодарить вас за ваш действительно хорошо объясненный ответ и его уровень детализации. Я прочитаю его и постараюсь извлечь из него как можно больше информации. Я также хотел воспользоваться возможностью, чтобы спросить вас о некоторых обновленных ресурсах о lucene и поиске в спящем режиме (книги, блоги и т. д.).

Nico 06.04.2022 10:07

Нп. На данный момент книги по Hibernate Search 6 нет. Я подумал, что предоставление исчерпывающей документации будет лучшим использованием времени команды, и это не платный доступ: hibernate.org/поиск/документация Кроме того, я стараюсь ссылаться на интересные статьи/доклады здесь: hibernate.org/search/статьи Что касается Lucene... Я не знаю ни одного исчерпывающая и актуальная документация. Лично я обычно копаюсь в исходном коде. Здесь есть несколько вводных ссылок: lucene.apache.org/core/8_11_0/index.html

yrodiere 06.04.2022 13:17

Другие вопросы по теме