在 Spring Boot 中使用搜尋引擎 Elasticsearch

信碼由韁發表於2021-11-18


Elasticsearch 建立在 Apache Lucene 之上,於 2010 年由 Elasticsearch NV(現為 Elastic)首次釋出。據 Elastic 網站稱,它是一個分散式開源搜尋和分析引擎,適用於所有型別的資料,包括文字、數值 、地理空間、結構化和非結構化。Elasticsearch 操作通過 REST API 實現。主要功能是:

  • 將文件儲存在索引中,
  • 使用強大的查詢搜尋索引以獲取這些文件,以及
  • 對資料執行分析函式。

Spring Data Elasticsearch 提供了一個簡單的介面來在 Elasticsearch 上執行這些操作,作為直接使用 REST API 的替代方法
在這裡,我們將使用 Spring Data Elasticsearch 來演示 Elasticsearch 的索引和搜尋功能,並在最後構建一個簡單的搜尋應用程式,用於在產品庫存中搜尋產品。

程式碼示例

本文附有 GitHub 上的工作程式碼示例。

Elasticsearch 概念

Elasticsearch 概念
瞭解 Elasticsearch 概念的最簡單方法是用資料庫進行類比,如下表所示:

Elasticsearch->資料庫
索引->
文件->
文件->

我們要搜尋或分析的任何資料都作為文件儲存在索引中。在 Spring Data 中,我們以 POJO 的形式表示一個文件,並用註解對其進行修飾以定義到 Elasticsearch 文件的對映。

與資料庫不同,儲存在 Elasticsearch 中的文字首先由各種分析器處理。預設分析器通過常用單詞分隔符(如空格和標點符號)拆分文字,並刪除常用英語單詞。

如果我們儲存文字“The sky is blue”,分析器會將其儲存為包含“術語”“sky”和“blue”的文件。我們將能夠使用“blue sky”、“sky”或“blue”形式的文字搜尋此文件,並將匹配程度作為分數。

除了文字之外,Elasticsearch 還可以儲存其他型別的資料,稱為 Field Type(欄位型別),如文件中 mapping-types (對映型別)部分所述。

啟動 Elasticsearch 例項

在進一步討論之前,讓我們啟動一個 Elasticsearch 例項,我們將使用它來執行我們的示例。有多種執行 Elasticsearch 例項的方法:

  • 使用託管服務
  • 使用來自 AWS 或 Azure 等雲提供商的託管服務
  • 通過在虛擬機器叢集中自己安裝 Elasticsearch
  • 執行 Docker 映象
    我們將使用來自 Dockerhub 的 Docker 映象,這對於我們的演示應用程式來說已經足夠了。讓我們通過執行 Docker run 命令來啟動 Elasticsearch 例項:
docker run -p 9200:9200 \
  -e "discovery.type=single-node" \
  docker.elastic.co/elasticsearch/elasticsearch:7.10.0

執行此命令將啟動一個 Elasticsearch 例項,偵聽埠 9200。我們可以通過點選 URL http://localhost:9200 來驗證例項狀態,並在瀏覽器中檢查結果輸出:

{
  "name" : "8c06d897d156",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "Jkx..VyQ",
  "version" : {
  "number" : "7.10.0",
  ...
  },
  "tagline" : "You Know, for Search"
}

如果我們的 Elasticsearch 例項啟動成功,應該看到上面的輸出。

使用 REST API 進行索引和搜尋

Elasticsearch 操作通過 REST API 訪問。 有兩種方法可以將文件新增到索引中:

  • 一次新增一個文件,或者
  • 批量新增文件。

新增單個文件的 API 接受一個文件作為引數。

對 Elasticsearch 例項的簡單 PUT 請求用於儲存文件如下所示:

PUT /messages/_doc/1
{
  "message": "The Sky is blue today"
}

這會將訊息 - “The Sky is blue today”儲存為“messages”的索引中的文件。

我們可以使用傳送到搜尋 REST API 的搜尋查詢來獲取此文件:

GET /messages/search
{
  "query":
  {
  "match": {"message": "blue sky"}
  }
}

這裡我們傳送一個 match 型別的查詢來獲取匹配字串“blue sky”的文件。我們可以通過多種方式指定用於搜尋文件的查詢。Elasticsearch 提供了一個基於 JSON 的 查詢 DSL(Domain Specific Language - 領域特定語言)來定義查詢。

對於批量新增,我們需要提供一個包含類似以下程式碼段的條目的 JSON 文件:

POST /_bulk
{"index":{"_index":"productindex"}}{"_class":"..Product","name":"Corgi Toys .. Car",..."manufacturer":"Hornby"}{"index":{"_index":"productindex"}}{"_class":"..Product","name":"CLASSIC TOY .. BATTERY"...,"manufacturer":"ccf"}

使用 Spring Data 進行 Elasticsearch 操作

我們有兩種使用 Spring Data 訪問 Elasticsearch 的方法,如下所示:

  • Repositories:我們在介面中定義方法,Elasticsearch 查詢是在執行時根據方法名稱生成的。
  • ElasticsearchRestTemplate:我們使用方法鏈和原生查詢建立查詢,以便在相對複雜的場景中更好地控制建立 Elasticsearch 查詢。

我們將在以下各節中更詳細地研究這兩種方式。

建立應用程式並新增依賴項

讓我們首先通過包含 web、thymeleaf 和 lombok 的依賴項,使用 Spring Initializr 建立我們的應用程式。新增 thymeleaf 依賴項以便增加使用者介面。

在 Maven pom.xml 中新增 spring-data-elasticsearch 依賴項:

<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-elasticsearch</artifactId>
</dependency>

連線到 Elasticsearch 例項

Spring Data Elasticsearch 使用 Java High Level REST Client (JHLC)") 連線到 Elasticsearch 伺服器。JHLC 是 Elasticsearch 的預設客戶端。我們將建立一個 Spring Bean 配置來進行設定:

@Configuration
@EnableElasticsearch
Repositories(basePackages
        = "io.pratik.elasticsearch.repositories")@ComponentScan(basePackages = { "io.pratik.elasticsearch" })
public class ElasticsearchClientConfig extends
         AbstractElasticsearchConfiguration {
  @Override
  @Bean
  public RestHighLevelClient elasticsearchClient() {


  final ClientConfiguration clientConfiguration =
    ClientConfiguration
      .builder()
      .connectedTo("localhost:9200")
      .build();


  return RestClients.create(clientConfiguration).rest();
  }
}

在這裡,我們連線到我們之前啟動的 Elasticsearch 例項。我們可以通過新增更多屬性(例如啟用 ssl、設定超時等)來進一步自定義連線。

為了除錯和診斷,我們將在 logback-spring.xml 的日誌配置中開啟傳輸級別的請求/響應日誌:

public class Product {
  @Id
  private String id;
  
  @Field(type = FieldType.Text, name = "name")
  private String name;
  
  @Field(type = FieldType.Double, name = "price")
  private Double price;
  
  @Field(type = FieldType.Integer, name = "quantity")
  private Integer quantity;
  
  @Field(type = FieldType.Keyword, name = "category")
  private String category;
  
  @Field(type = FieldType.Text, name = "desc")
  private String description;
  
  @Field(type = FieldType.Keyword, name = "manufacturer")
  private String manufacturer;


  ...
}

表達文件

在我們的示例中,我們將按名稱、品牌、價格或描述搜尋產品。因此,為了將產品作為文件儲存在 Elasticsearch 中,我們將產品表示為 POJO,並加上 Field 註解以配置 Elasticsearch 的對映,如下所示:

public class Product {
  @Id
  private String id;
  
  @Field(type = FieldType.Text, name = "name")
  private String name;
  
  @Field(type = FieldType.Double, name = "price")
  private Double price;
  
  @Field(type = FieldType.Integer, name = "quantity")
  private Integer quantity;
  
  @Field(type = FieldType.Keyword, name = "category")
  private String category;
  
  @Field(type = FieldType.Text, name = "desc")
  private String description;
  
  @Field(type = FieldType.Keyword, name = "manufacturer")
  private String manufacturer;


  ...
}

@Document 註解指定索引名稱。

@Id 註解使註解欄位成為文件的 _id,作為此索引中的唯一識別符號。id 欄位有 512 個字元的限制。

@Field 註解配置欄位的型別。我們還可以將名稱設定為不同的欄位名稱。

在 Elasticsearch 中基於這些註解建立了名為 productindex 的索引。

使用 Spring Data Repository 進行索引和搜尋

儲存庫提供了使用 finder 方法訪問 Spring Data 中資料的最方便的方法。Elasticsearch 查詢是根據方法名稱建立的。但是,我們必須小心避免產生低效的查詢並給叢集帶來高負載。

讓我們通過擴充套件 ElasticsearchRepository 介面來建立一個 Spring Data 儲存庫介面:

public interface ProductRepository
    extends ElasticsearchRepository<Product, String> {

}

此處 ProductRepository 類繼承了 ElasticsearchRepository 介面中包含的 save()saveAll()find()findAll() 等方法。

索引

我們現在將通過呼叫 save() 方法儲存一個產品,呼叫 saveAll() 方法來批量索引,從而在索引中儲存一些產品。在此之前,我們將儲存庫介面放在一個服務類中:

@Service
public class ProductSearchServiceWithRepo {


  private ProductRepository productRepository;


  public void createProductIndexBulk(final List<Product> products) {
    productRepository.saveAll(products);
  }


  public void createProductIndex(final Product product) {
    productRepository.save(product);
  }
}

當我們從 JUnit 呼叫這些方法時,我們可以在跟蹤日誌中看到 REST API 呼叫索引和批量索引。

搜尋

為了滿足我們的搜尋要求,我們將向儲存庫介面新增 finder 方法:

public interface ProductRepository
    extends ElasticsearchRepository<Product, String> {
  List<Product> findByName(String name);
  
  List<Product> findByNameContaining(String name);
  List<Product> findByManufacturerAndCategory
       (String manufacturer, String category);
}

在使用 JUnit 執行 findByName() 方法時,我們可以看到在傳送到伺服器之前在跟蹤日誌中生成的 Elasticsearch 查詢:

TRACE Sending request POST /productindex/_search? ..:
Request body: {.."query":{"bool":{"must":[{"query_string":{"query":"apple","fields":["name^1.0"],..}

類似地,通過執行
findByManufacturerAndCategory() 方法,我們可以看到使用兩個 query_string 引數對應兩個欄位——“manufacturer”和“category”生成的查詢:

TRACE .. Sending request POST /productindex/_search..:
Request body: {.."query":{"bool":{"must":[{"query_string":{"query":"samsung","fields":["manufacturer^1.0"],..}},{"query_string":{"query":"laptop","fields":["category^1.0"],..}}],..}},"version":true}

有多種方法命名模式可以生成各種 Elasticsearch 查詢。

使用 ElasticsearchRestTemplate進行索引和搜尋

當我們需要更多地控制我們設計查詢的方式,或者團隊已經掌握了 Elasticsearch 語法時,Spring Data 儲存庫可能就不再適合。

在這種情況下,我們使用 ElasticsearchRestTemplate。它是 Elasticsearch 基於 HTTP 的新客戶端,取代以前使用節點到節點二進位制協議的 TransportClient。

ElasticsearchRestTemplate 實現了介面 ElasticsearchOperations,該介面負責底層搜尋和叢集操的繁雜工作。

索引

該介面具有用於新增單個文件的方法 index() 和用於向索引新增多個文件的 bulkIndex() 方法。此處的程式碼片段顯示瞭如何使用 bulkIndex() 將多個產品新增到索引“productindex”:

@Service
@Slf4j
public class ProductSearchService {


  private static final String PRODUCT_INDEX = "productindex";
  private ElasticsearchOperations elasticsearchOperations;


  public List<String> createProductIndexBulk
            (final List<Product> products) {


      List<IndexQuery> queries = products.stream()
      .map(product->
        new IndexQueryBuilder()
        .withId(product.getId().toString())
        .withObject(product).build())
      .collect(Collectors.toList());;
    
      return elasticsearchOperations
      .bulkIndex(queries,IndexCoordinates.of(PRODUCT_INDEX));
  }
  ...
}

要儲存的文件包含在 IndexQuery 物件中。bulkIndex() 方法將 IndexQuery 物件列表和包含在 IndexCoordinates 中的 Index 名稱作為輸入。當我們執行此方法時,我們會獲得批量請求的 REST API 跟蹤:

Sending request POST /_bulk?timeout=1m with parameters:
Request body: {"index":{"_index":"productindex","_id":"383..35"}}{"_class":"..Product","id":"383..35","name":"New Apple..phone",..manufacturer":"apple"}
..
{"_class":"..Product","id":"d7a..34",.."manufacturer":"samsung"}

接下來,我們使用 index() 方法新增單個文件:

@Service
@Slf4j
public class ProductSearchService {


  private static final String PRODUCT_INDEX = "productindex";
   
  private ElasticsearchOperations elasticsearchOperations;


  public String createProductIndex(Product product) {


    IndexQuery indexQuery = new IndexQueryBuilder()
         .withId(product.getId().toString())
         .withObject(product).build();


    String documentId = elasticsearchOperations
     .index(indexQuery, IndexCoordinates.of(PRODUCT_INDEX));


    return documentId;
  }
}

跟蹤相應地顯示了用於新增單個文件的 REST API PUT 請求。

Sending request PUT /productindex/_doc/59d..987..:
Request body: {"_class":"..Product","id":"59d..87",..,"manufacturer":"dell"}

搜尋

ElasticsearchRestTemplate 還具有 search() 方法,用於在索引中搜尋文件。此搜尋操作類似於 Elasticsearch 查詢,是通過構造 Query 物件並將其傳遞給搜尋方法來構建的。

Query 物件具有三種變體 - NativeQueryyStringQueryCriteriaQuery,具體取決於我們如何構造查詢。讓我們構建一些用於搜尋產品的查詢。

NativeQuery

NativeQuery 為使用表示 Elasticsearch 構造(如聚合、過濾和排序)的物件構建查詢提供了最大的靈活性。這是用於搜尋與特定製造商匹配的產品的 NativeQuery

@Service
@Slf4j
public class ProductSearchService {


  private static final String PRODUCT_INDEX = "productindex";
  private ElasticsearchOperations elasticsearchOperations;


  public void findProductsByBrand(final String brandName) {


    QueryBuilder queryBuilder =
      QueryBuilders
      .matchQuery("manufacturer", brandName);


    Query searchQuery = new NativeSearchQueryBuilder()
      .withQuery(queryBuilder)
      .build();


    SearchHits<Product> productHits =
      elasticsearchOperations
      .search(searchQuery,
          Product.class,
          IndexCoordinates.of(PRODUCT_INDEX));
  }
}

在這裡,我們使用 NativeSearchQueryBuilder 構建查詢,該查詢使用 MatchQueryBuilder 指定包含欄位“製造商”的匹配查詢。

StringQuery

StringQuery 通過允許將原生 Elasticsearch 查詢用作 JSON 字串來提供完全控制,如下所示:

@Service
@Slf4j
public class ProductSearchService {


  private static final String PRODUCT_INDEX = "productindex";
  private ElasticsearchOperations elasticsearchOperations;


  public void findByProductName(final String productName) {
    Query searchQuery = new StringQuery(
      "{\"match\":{\"name\":{\"query\":\""+ productName + "\"}}}\"");
    
    SearchHits<Product> products = elasticsearchOperations.search(
      searchQuery,
      Product.class,
      IndexCoordinates.of(PRODUCT_INDEX_NAME));
  ...     
   }
}

在此程式碼片段中,我們指定了一個簡單的 match 查詢,用於獲取具有作為方法引數傳送的特定名稱的產品。

CriteriaQuery

使用 CriteriaQuery,我們可以在不瞭解 Elasticsearch 任何術語的情況下構建查詢。查詢是使用帶有 Criteria 物件的方法鏈構建的。每個物件指定一些用於搜尋文件的標準:

@Service
@Slf4j
public class ProductSearchService {


  private static final String PRODUCT_INDEX = "productindex";
   
  private ElasticsearchOperations elasticsearchOperations;


  public void findByProductPrice(final String productPrice) {
    Criteria criteria = new Criteria("price")
                  .greaterThan(10.0)
                  .lessThan(100.0);


    Query searchQuery = new CriteriaQuery(criteria);


    SearchHits<Product> products = elasticsearchOperations
       .search(searchQuery,
           Product.class,
           IndexCoordinates.of(PRODUCT_INDEX_NAME));
  }
}

在此程式碼片段中,我們使用 CriteriaQuery 形成查詢以獲取價格大於 10.0 且小於 100.0 的產品。

構建搜尋應用程式

我們現在將向我們的應用程式新增一個使用者介面,以檢視產品搜尋的實際效果。使用者介面將有一個搜尋輸入框,用於按名稱或描述搜尋產品。輸入框將具有自動完成功能,以顯示基於可用產品的建議列表,如下所示:

我們將為使用者的搜尋輸入建立自動完成建議。然後根據與使用者輸入的搜尋文字密切匹配的名稱或描述搜尋產品。我們將構建兩個搜尋服務來實現這個用例:

  • 獲取自動完成功能的搜尋建議
  • 根據使用者的搜尋查詢處理搜尋產品的搜尋
    服務類 ProductSearchService 將包含搜尋和獲取建議的方法。

GitHub 儲存庫中提供了帶有使用者介面的成熟應用程式。

建立產品搜尋索引

productindex 與我們之前用於執行 JUnit 測試的索引相同。我們將首先使用 Elasticsearch REST API 刪除 productindex,以便在應用程式啟動期間使用從我們的 50 個時尚系列產品的示例資料集中載入的產品建立新的 productindex

curl -X DELETE http://localhost:9200/productindex

如果刪除操作成功,我們將收到訊息 {"acknowledged": true}

現在,讓我們為庫存中的產品建立一個索引。我們將使用包含 50 種產品的示例資料集來構建我們的索引。這些產品在 CSV 檔案中被排列為單獨的行。

每行都有三個屬性 - id、name 和 description。我們希望在應用程式啟動期間建立索引。請注意,在實際生產環境中,索引建立應該是一個單獨的過程。我們將讀取 CSV 的每一行並將其新增到產品索引中:

@SpringBootApplication
@Slf4j
public class ProductsearchappApplication {
  ...
  @PostConstruct
  public void buildIndex() {
    esOps.indexOps(Product.class).refresh();
    productRepo.saveAll(prepareDataset());
  }


  private Collection<Product> prepareDataset() {
    Resource resource = new ClassPathResource("fashion-products.csv");
    ...
    return productList;
  }
}

在這個片段中,我們通過從資料集中讀取行並將這些行傳遞給儲存庫的 saveAll() 方法以將產品新增到索引中來進行一些預處理。在執行應用程式時,我們可以在應用程式啟動中看到以下跟蹤日誌。

...Sending request POST /_bulk?timeout=1m with parameters:
Request body: {"index":{"_index":"productindex"}}{"_class":"io.pratik.elasticsearch.productsearchapp.Product","name":"Hornby 2014 Catalogue","description":"Product Desc..talogue","manufacturer":"Hornby"}{"index":{"_index":"productindex"}}{"_class":"io.pratik.elasticsearch.productsearchapp.Product","name":"FunkyBuys..","description":"Size Name:Lar..& Smoke","manufacturer":"FunkyBuys"}{"index":{"_index":"productindex"}}.
...

使用多欄位和模糊搜尋搜尋產品

下面是我們在方法 processSearch() 中提交搜尋請求時如何處理搜尋請求:

@Service
@Slf4j
public class ProductSearchService {


  private static final String PRODUCT_INDEX = "productindex";


  private ElasticsearchOperations elasticsearchOperations;


  public List<Product> processSearch(final String query) {
  log.info("Search with query {}", query);
  
  // 1. Create query on multiple fields enabling fuzzy search
  QueryBuilder queryBuilder =
    QueryBuilders
    .multiMatchQuery(query, "name", "description")
    .fuzziness(Fuzziness.AUTO);


  Query searchQuery = new NativeSearchQueryBuilder()
            .withFilter(queryBuilder)
            .build();


  // 2. Execute search
  SearchHits<Product> productHits =
    elasticsearchOperations
    .search(searchQuery, Product.class,
    IndexCoordinates.of(PRODUCT_INDEX));


  // 3. Map searchHits to product list
  List<Product> productMatches = new ArrayList<Product>();
  productHits.forEach(searchHit->{
    productMatches.add(searchHit.getContent());
  });
  return productMatches;
  }...
}

在這裡,我們對多個欄位執行搜尋 - 名稱和描述。 我們還附加了 fuzziness() 來搜尋緊密匹配的文字以解釋拼寫錯誤。

使用萬用字元搜尋獲取建議

接下來,我們為搜尋文字框構建自動完成功能。 當我們在搜尋文字欄位中輸入內容時,我們將通過使用搜尋框中輸入的字元執行萬用字元搜尋來獲取建議。

我們在 fetchSuggestions() 方法中構建此函式,如下所示:

@Service
@Slf4j
public class ProductSearchService {


  private static final String PRODUCT_INDEX = "productindex";


  public List<String> fetchSuggestions(String query) {
    QueryBuilder queryBuilder = QueryBuilders
      .wildcardQuery("name", query+"*");


    Query searchQuery = new NativeSearchQueryBuilder()
      .withFilter(queryBuilder)
      .withPageable(PageRequest.of(0, 5))
      .build();


    SearchHits<Product> searchSuggestions =
      elasticsearchOperations.search(searchQuery,
        Product.class,
      IndexCoordinates.of(PRODUCT_INDEX));
    
    List<String> suggestions = new ArrayList<String>();
    
    searchSuggestions.getSearchHits().forEach(searchHit->{
      suggestions.add(searchHit.getContent().getName());
    });
    return suggestions;
  }
}

我們以搜尋輸入文字的形式使用萬用字元查詢,並附加 * 以便如果我們輸入“red”,我們將獲得以“red”開頭的建議。我們使用 withPageable() 方法將建議的數量限制為 5。可以在此處看到正在執行的應用程式的搜尋結果的一些螢幕截圖:

結論

在本文中,我們介紹了 Elasticsearch 的主要操作——索引文件、批量索引和搜尋——它們以 REST API 的形式提供。Query DSL 與不同分析器的結合使搜尋變得非常強大。

Spring Data Elasticsearch 通過使用 Spring Data Repositories 或 ElasticsearchRestTemplate 提供了方便的介面來訪問應用程式中的這些操作。

我們最終構建了一個應用程式,在其中我們看到了如何在接近現實生活的應用程式中使用 Elasticsearch 的批量索引和搜尋功能。


相關文章