使用 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 作为搜索条件的查询来获得名称相似的两种烟草:tobaccoGreentobaccoGreenhouse。实体如下:

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

现在reindex your data.

现在您的查询“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.

的文档