在 Elasticsearch 中執行通配符搜尋
1. 概述
Elasticsearch 是一款功能強大且應用廣泛的搜尋引擎,具備強大的全文搜尋能力。在建立需要搜尋大型文件集的應用程式時,通配符匹配(例如,以…開頭或包含)是一項常見的需求。
在本教程中,我們將探討使用 Java 在 Elasticsearch 中執行通配符搜尋的實用方法。
2. 理解通配符搜尋
首先,讓我們回顧一下實作通配符式搜尋的主要策略以及每種方法的注意事項。
2.1. 通配符查詢-靈活的模式(* 和 ?)
通配符查詢是進行簡單模式比對最容易上手的技術。它針對的是精確的詞組字串,因此,如果我們以關鍵字欄位為目標,例如name.keyword ,就能獲得最可預測的結果。
wildcard()查詢的語法結構如下:
-
*可以匹配零個或多個字元。例如,“john*”→ 匹配“john”、“johnson”、“johnstone” -
?匹配一個字元。例如,“jo?n”→ 匹配“john”和“joan”
Elasticsearch 用戶端提供了一種執行通配符搜尋的方法:
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.wildcard(w -> w.field(fieldName)
.value(lowercaseSearchTerm)
.caseInsensitive(true)))
.size(maxResults), ObjectNode.class);
在這種情況下,我們需要指定要匹配的搜尋詞。
2.2. 前綴查詢 — 最佳化的“以…開頭”
` prefix() ` 查詢符合以給定前綴開頭的詞條。例如,前綴“pre”將符合以下任何一項: “prefix” 、 “premium”或“preset” 。此方法著重於左錨匹配和自動完成,同時排除子字串或後綴匹配。
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.prefix(p -> p.field(fieldName)
.value(prefix)))
.size(maxResults), ObjectNode.class);
2.3. 正規表示式查詢-複雜模式
接下來,` **regexp()查詢使用 Lucene 風格的正規表示式來支援豐富的模式**。例如,正規表示式“jo(hn|n?y).*”將符合下列項目: “john” 、 “jony”和“jonny bravo” 。在這種情況下,我們需要指定一個精確的表達式來執行搜尋:
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.regexp(r -> r.field(fieldName)
.value(pattern)))
.size(maxResults), ObjectNode.class);
2.4. 模糊查詢-容錯(非通配符)
與通配符不同, fuzzy()查詢並不尋找模式。相反,它通過編輯距離來查找相似的詞語,因此像“jon”這樣的查詢仍然可以匹配“john” 。模糊查詢利用了萊文斯坦距離,這是一種透過計算將一個字串轉換為另一個字串所需的最少單字元編輯次數來衡量兩個字串差異程度的方法:
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.fuzzy(f -> f.field(fieldName)
.value(searchTerm)
.fuzziness("AUTO")))
.size(maxResults), ObjectNode.class);
我們使用模糊性參數的AUTO值,以啟用基於萊文斯坦編輯距離的近似字串匹配。
3. 實施
現在,我們將看到我們討論過的每種策略的具體實現。對於wildcard()查詢,我們將利用 Elasticsearch 映射中定義的.keyword子欄位來執行精確比對查詢:
public List<Map<String, Object>> wildcardSearchOnKeyword(String indexName, String fieldName,
String searchTerm) throws IOException {
logger.info("Performing wildcard search on keyword field - index: {}, field: {}, term: {}",
indexName, fieldName, searchTerm);
// Use the .keyword subfield for exact matching
String keywordField = fieldName + ".keyword";
// Convert to lowercase for case-insensitive matching
String lowercaseSearchTerm = searchTerm.toLowerCase();
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.wildcard(w -> w.field(keywordField)
.value(lowercaseSearchTerm)
.caseInsensitive(true)))
.size(maxResults), ObjectNode.class);
return extractSearchResults(response);
}
請注意,我們在執行搜尋之前,首先將關鍵字轉換為小寫。
現在,讓我們看看如何執行prefix()搜尋:
public List<Map<String, Object>> prefixSearch(String indexName, String fieldName,
String prefix) throws IOException {
logger.info("Performing prefix search on index: {}, field: {}, prefix: {}",
indexName, fieldName, prefix);
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.prefix(p -> p.field(fieldName)
.value(prefix)))
.size(maxResults), ObjectNode.class);
return extractSearchResults(response);
}
對於regexp()查詢,我們需要指定要在搜尋中符合的正規表示式:
public List<Map<String, Object>> regexpSearch(String indexName, String fieldName,
String pattern) throws IOException {
logger.info("Performing regexp search on index: {}, field: {}, pattern: {}",
indexName, fieldName, pattern);
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.regexp(r -> r.field(fieldName)
.value(pattern)))
.size(maxResults), ObjectNode.class);
return extractSearchResults(response);
}
最後, fuzzy()查詢將使用萊文斯坦編輯距離模式執行搜尋:
public List<Map<String, Object>> fuzzySearch(String indexName, String fieldName,
String searchTerm) throws IOException {
logger.info("Performing fuzzy search on index: {}, field: {}, term: {}",
indexName, fieldName, searchTerm);
SearchResponse<ObjectNode> response = elasticsearchClient.search(s -> s.index(indexName)
.query(q -> q.fuzzy(f -> f.field(fieldName)
.value(searchTerm)
.fuzziness("AUTO")))
.size(maxResults), ObjectNode.class);
return extractSearchResults(response);
}
然後,我們可以根據從 Elasticsearch 用戶端收到的回應來解析和提取搜尋結果:
private List<Map<String, Object>> extractSearchResults(SearchResponse<ObjectNode> response) {
List<Map<String, Object>> results = new ArrayList<>();
logger.info("Search completed. Total hits: {}", response.hits()
.total()
.value());
for (Hit<ObjectNode> hit : response.hits()
.hits()) {
Map<String, Object> sourceMap = new HashMap<>();
if (hit.source() != null) {
hit.source()
.fields()
.forEachRemaining(entry -> {
// Extract the actual value from JsonNode
Object value = extractJsonNodeValue(entry.getValue());
sourceMap.put(entry.getKey(), value);
});
}
results.add(sourceMap);
}
return results;
}
現在我們已經實作了所有核心通配符搜尋功能,讓我們開始為主要用例編寫一些測試吧。
4. 測試實現情況
現在我們的實作已經準備就緒,我們可以為每種搜尋策略定義單元測試和整合測試。
4.1. 通配符搜尋的單元測試
我們可以透過模擬搜尋結果並添加驗證結果所需的存根,為搜尋方法建立單元測試。在下一個測試中,我們執行並檢查通配符搜尋結果:
@Test
@DisplayName("Return matching documents when performing wildcard search")
void whenWildcardSearch_thenReturnMatchingDocuments() throws IOException {
// Given
SearchResponse<ObjectNode> mockResponse = createMockResponse(
createHit("1", "John Doe", "[email protected]"),
createHit("2", "Johnny Cash", "[email protected]"));
when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))).thenReturn(mockResponse);
// When
List<Map<String, Object>> results = wildcardService.wildcardSearch("users", "name", "john*");
// Then
assertThat(results).hasSize(2)
.extracting(result -> result.get("name"))
.containsExactly("John Doe", "Johnny Cash");
verify(elasticsearchClient).search(any(Function.class), eq(ObjectNode.class));
}
此外,我們可以在執行通配符搜尋時檢查特定情況,例如,通配符搜尋應該不區分大小寫:
@Test
@DisplayName("Perform case-insensitive wildcard search")
void whenWildcardSearch_thenBeCaseInsensitive() throws IOException {
// Given
SearchResponse<ObjectNode> mockResponse =
createMockResponse(createHit("1", "John Doe", "[email protected]"));
when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))).thenReturn(mockResponse);
// When
List<Map<String, Object>> results = wildcardService.wildcardSearch("users", "name", "JOHN*");
// Then
assertThat(results)
.hasSize(1)
.extracting(result -> result.get("name"))
.contains("John Doe");
}
4.2 整合測試
為了針對 Elasticsearch 實例測試通配符搜尋服務並執行整合測試,我們可以使用 Docker 容器來執行這些測試,從而確保在不同的系統中實現更一致、隔離和可複現的測試環境。
首先,我們需要定義一個元件,該元件使用ElasticsearchContainer類別初始化 Elasticsearch 實例:
@Container
static ElasticsearchContainer elasticsearchContainer = new ElasticsearchContainer(
"docker.elastic.co/elasticsearch/elasticsearch:8.11.1")
.withExposedPorts(9200)
.withEnv("discovery.type", "single-node")
.withEnv("xpack.security.enabled", "false")
.withEnv("xpack.security.http.ssl.enabled", "false");
現在我們的容器已經準備就緒,我們的通配符搜尋服務可以連接到此實例並執行搜尋:
@Test
void whenWildcardSearchOnKeyword_thenReturnMatchingDocuments() throws IOException {
// When
List<Map<String, Object>> results = wildcardService.wildcardSearchOnKeyword(TEST_INDEX, "name", "john*");
// Then
assertThat(results)
.isNotEmpty()
.hasSize(2)
.extracting(result -> result.get("name"))
.doesNotContainNull()
.extracting(Object::toString)
.allSatisfy(name -> assertThat(name.toLowerCase()).startsWith("john"));
logger.info("Found {} results for 'john*'", results.size());
}
讓我們來看另一個有趣的整合測試案例,這次的用例是跨多個字段執行搜尋:
@Test
void whenMultiFieldWildcardSearch_thenReturnDocumentsMatchingAnyField() throws IOException {
// When
List<Map> results = wildcardService.multiFieldWildcardSearch(TEST_INDEX, "john", "name", "email");
// Then
assertThat(results).isNotEmpty()
.allSatisfy(result -> {
String name = result.get("name") != null ? result.get("name")
.toString()
.toLowerCase() : "";
String email = result.get("email") != null ? result.get("email")
.toString()
.toLowerCase() : "";
assertThat(name.contains("john") || email.contains("john")).as("Expected 'john' in name or email")
.isTrue();
});
}
整合測試可能需要更長時間才能運行,因為它們需要啟動一個基於 Docker 的 Elasticsearch 執行個體並執行完整的流程。然而,這種方法的優點在於,它允許我們針對特定的 Elasticsearch 引擎版本進行測試。
5. 結論
在本文中,我們探討了在 Elasticsearch 實例中執行通配符搜尋的主要策略,以及如何在不同的場景中測試每種方法。
與往常一樣,本教程的完整程式碼可在 GitHub 上找到。