使用 lucene 的 Hibernate 搜索不能正确索引相似的名称
Hibernate search with lucene does not index similar names correctly
我正在学习 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)
);
}
正如您在测试中看到的那样,我希望通过使用 green
作为搜索条件的查询来获得名称相似的两种烟草:tobaccoGreen
和 tobaccoGreenhouse
。实体如下:
@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()
不是魔法,它不会只匹配您认为应该匹配的任何内容 :) 有一个 specific definition of what it does,这不是您想要的。
要获得您想要的行为,您可以使用它来代替当前的谓词:
.where(f -> f.simpleQueryString()
.fields("barcode", "name", "manufacturer")
.matching("green*")
)
你失去了模糊性,但你获得了执行 prefix queries 的能力,这将给出你想要的结果(green*
将匹配 greenhouse
)。
然而,前缀查询是显式的:用户必须在“green”之后添加*
才能匹配“所有以green开头的词”。
这导致我们...
通过分析器解决这个问题
如果您希望这种“前缀匹配”行为是自动的,而不需要在查询中添加 *
,那么您需要的是一个不同的分析器。
您当前的分析器使用 space 作为分隔符来分解索引文本(或多或少;它有点复杂,但就是这个想法)。但是您显然希望它将“温室”分解为“绿色”和“房屋”;这是包含“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;
}
现在您的查询“green”也将匹配“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);
}
}
如您所见,我对不使用前缀的字段进行了增强。这是这个变体相对于我上面解释的那个的主要优势:实际单词(而不是前缀)的匹配将被认为更重要,产生更好的分数,因此如果您使用 relevance sort(默认排序)。
只处理复合词而不是所有词
我不会在这里详细说明,但是如果你只想处理复合词,还有另一种方法(“greenhouse”=>“green”+“house”,“superman”=>“super”+“人”等)。您可以使用“dictionaryCompoundWord”过滤器。这不太强大,但会在您的索引中产生更少的噪音(更少无意义的标记),因此可能会导致更好的 relevance sorts。
另一个缺点是您需要为过滤器提供一个字典,其中包含所有可能被“复合”的单词。
有关详细信息,请参阅 class org.apache.lucene.analysis.compound.DictionaryCompoundWordTokenFilterFactory
的源代码和 javadoc,或 equivalent filter in Elasticsearch.
的文档
我正在学习 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)
);
}
正如您在测试中看到的那样,我希望通过使用 green
作为搜索条件的查询来获得名称相似的两种烟草:tobaccoGreen
和 tobaccoGreenhouse
。实体如下:
@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()
不是魔法,它不会只匹配您认为应该匹配的任何内容 :) 有一个 specific definition of what it does,这不是您想要的。
要获得您想要的行为,您可以使用它来代替当前的谓词:
.where(f -> f.simpleQueryString()
.fields("barcode", "name", "manufacturer")
.matching("green*")
)
你失去了模糊性,但你获得了执行 prefix queries 的能力,这将给出你想要的结果(green*
将匹配 greenhouse
)。
然而,前缀查询是显式的:用户必须在“green”之后添加*
才能匹配“所有以green开头的词”。
这导致我们...
通过分析器解决这个问题
如果您希望这种“前缀匹配”行为是自动的,而不需要在查询中添加 *
,那么您需要的是一个不同的分析器。
您当前的分析器使用 space 作为分隔符来分解索引文本(或多或少;它有点复杂,但就是这个想法)。但是您显然希望它将“温室”分解为“绿色”和“房屋”;这是包含“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;
}
现在您的查询“green”也将匹配“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);
}
}
如您所见,我对不使用前缀的字段进行了增强。这是这个变体相对于我上面解释的那个的主要优势:实际单词(而不是前缀)的匹配将被认为更重要,产生更好的分数,因此如果您使用 relevance sort(默认排序)。
只处理复合词而不是所有词
我不会在这里详细说明,但是如果你只想处理复合词,还有另一种方法(“greenhouse”=>“green”+“house”,“superman”=>“super”+“人”等)。您可以使用“dictionaryCompoundWord”过滤器。这不太强大,但会在您的索引中产生更少的噪音(更少无意义的标记),因此可能会导致更好的 relevance sorts。
另一个缺点是您需要为过滤器提供一个字典,其中包含所有可能被“复合”的单词。
有关详细信息,请参阅 class org.apache.lucene.analysis.compound.DictionaryCompoundWordTokenFilterFactory
的源代码和 javadoc,或 equivalent filter in Elasticsearch.