Я изучаю 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
, и я не понимаю, почему, как я могу искать похожие названия продуктов (или штрих-коды, производителя)?
Прежде чем я отвечу на ваш вопрос, я хотел бы указать, что вызов .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.
Нп. На данный момент книги по Hibernate Search 6 нет. Я подумал, что предоставление исчерпывающей документации будет лучшим использованием времени команды, и это не платный доступ: hibernate.org/поиск/документация Кроме того, я стараюсь ссылаться на интересные статьи/доклады здесь: hibernate.org/search/статьи Что касается Lucene... Я не знаю ни одного исчерпывающая и актуальная документация. Лично я обычно копаюсь в исходном коде. Здесь есть несколько вводных ссылок: lucene.apache.org/core/8_11_0/index.html
Прежде всего, я хотел бы поблагодарить вас за ваш действительно хорошо объясненный ответ и его уровень детализации. Я прочитаю его и постараюсь извлечь из него как можно больше информации. Я также хотел воспользоваться возможностью, чтобы спросить вас о некоторых обновленных ресурсах о lucene и поиске в спящем режиме (книги, блоги и т. д.).