elasticsearch: full-text search, relevance boosting, fuzzy matching and category facets (post 27)
This commit is contained in:
@@ -6,7 +6,7 @@ Companion code for a series of posts on [ankurm.com](https://ankurm.com) coverin
|
||||
|---|---|---|
|
||||
| [`time-series/`](./time-series) | Time-Series Data in Java: QuestDB vs InfluxDB vs TimescaleDB | QuestDB 10.0.1, InfluxDB 3 Core 3.12.0, TimescaleDB 2.25.1 (PG17) |
|
||||
| [`mongodb/`](./mongodb) | Spring Boot 4 with MongoDB: Spring Data MongoDB, Aggregations and Transactions | MongoDB 9.0.2 single-node replica set, Spring Data MongoDB |
|
||||
| `elasticsearch/` | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9, Spring Data Elasticsearch |
|
||||
| [`elasticsearch/`](./elasticsearch) | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9.4.8, Spring Data Elasticsearch 6.1.1 |
|
||||
| `distributed-cache/` | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast, Infinispan, Redis |
|
||||
|
||||
Each module has its own `pom.xml`, a `docker-compose.yml` for its backing services, and a `docs/output/` directory holding the real, re-runnable transcripts that every number quoted in its post traces back to. There are no narrative doc chapters here — the deeper material for each post lives in accordions inside the post itself.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
query="desk" facet counts by category:
|
||||
furniture=2
|
||||
@@ -0,0 +1,4 @@
|
||||
query="wireles" (misspelled) max_price=1000.00
|
||||
Wireless Mechanical Keyboard ($89.99, electronics)
|
||||
Wireless Earbuds Pro ($129.0, electronics)
|
||||
Ergonomic Office Chair ($259.0, furniture)
|
||||
@@ -0,0 +1,2 @@
|
||||
query="wireless" max_price=100.00
|
||||
Wireless Mechanical Keyboard ($89.99)
|
||||
@@ -0,0 +1,4 @@
|
||||
query="wireless" max_price=1000.00 (ranked by relevance)
|
||||
Wireless Mechanical Keyboard
|
||||
Wireless Earbuds Pro
|
||||
Ergonomic Office Chair
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>elasticsearch-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>elasticsearch-demo</name>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<testcontainers.version>2.0.5</testcontainers.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-bom</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-elasticsearch</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.elasticsearch;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class ElasticsearchDemoApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ElasticsearchDemoApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ankurm.elasticsearch;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.elasticsearch.annotations.Document;
|
||||
import org.springframework.data.elasticsearch.annotations.Field;
|
||||
import org.springframework.data.elasticsearch.annotations.FieldType;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* name and description are mapped "text" so Elasticsearch analyzes and
|
||||
* tokenizes them for full-text matching; category is mapped "keyword" so
|
||||
* it sorts, filters and aggregates on the exact string, not analyzed
|
||||
* tokens. Mapping a field the wrong way is the single most common
|
||||
* Elasticsearch mistake, and it fails silently - a "keyword" field still
|
||||
* accepts text, it just won't match partial words, and a "text" field
|
||||
* still accepts a filter, it just won't filter exactly.
|
||||
*/
|
||||
@Document(indexName = "products")
|
||||
public record Product(
|
||||
@Id String id,
|
||||
@Field(type = FieldType.Text) String name,
|
||||
@Field(type = FieldType.Text) String description,
|
||||
@Field(type = FieldType.Keyword) String category,
|
||||
@Field(type = FieldType.Double) BigDecimal price,
|
||||
@Field(type = FieldType.Keyword) List<String> tags) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ankurm.elasticsearch;
|
||||
|
||||
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ProductRepository extends ElasticsearchRepository<Product, String> {
|
||||
List<Product> findByCategory(String category);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.ankurm.elasticsearch;
|
||||
|
||||
import co.elastic.clients.elasticsearch._types.aggregations.Aggregation;
|
||||
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
|
||||
import org.springframework.data.elasticsearch.client.elc.NativeQuery;
|
||||
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
|
||||
import org.springframework.data.elasticsearch.core.SearchHit;
|
||||
import org.springframework.data.elasticsearch.core.SearchHits;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* ElasticsearchRepository covers point lookups and exact-field derived
|
||||
* queries; relevance-ranked full-text search with filters and facets
|
||||
* needs the query DSL, built here with the elasticsearch-java client's
|
||||
* own type-safe builders rather than hand-written JSON strings.
|
||||
*/
|
||||
@Service
|
||||
public class ProductSearchService {
|
||||
|
||||
private final ElasticsearchOperations operations;
|
||||
|
||||
public ProductSearchService(ElasticsearchOperations operations) {
|
||||
this.operations = operations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-field search with per-field relevance boosting (a match in the
|
||||
* name counts for more than the same match in the description) and
|
||||
* fuzziness so a typo like "wireles" still finds "wireless". The price
|
||||
* ceiling is a filter, not part of the relevance query - filters narrow
|
||||
* the result set without affecting how matches are scored.
|
||||
*/
|
||||
public List<Product> search(String term, BigDecimal maxPrice) {
|
||||
Query textQuery = Query.of(q -> q.multiMatch(m -> m
|
||||
.query(term)
|
||||
.fields("name^2", "description")
|
||||
.fuzziness("AUTO")));
|
||||
|
||||
Query priceFilter = Query.of(q -> q.range(r -> r
|
||||
.number(n -> n.field("price").lte(maxPrice.doubleValue()))));
|
||||
|
||||
Query combined = Query.of(q -> q.bool(b -> b
|
||||
.must(textQuery)
|
||||
.filter(priceFilter)));
|
||||
|
||||
NativeQuery query = NativeQuery.builder().withQuery(combined).build();
|
||||
SearchHits<Product> hits = operations.search(query, Product.class);
|
||||
return hits.getSearchHits().stream().map(SearchHit::getContent).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* A terms aggregation over the (unanalyzed) category keyword field -
|
||||
* the facet counts a storefront sidebar shows next to each category
|
||||
* name, computed by Elasticsearch over the matching set, not pulled
|
||||
* back and counted client-side.
|
||||
*/
|
||||
public Map<String, Long> categoryFacets(String term) {
|
||||
Query textQuery = Query.of(q -> q.multiMatch(m -> m
|
||||
.query(term)
|
||||
.fields("name^2", "description")
|
||||
.fuzziness("AUTO")));
|
||||
|
||||
NativeQuery query = NativeQuery.builder()
|
||||
.withQuery(textQuery)
|
||||
.withAggregation("by_category", Aggregation.of(a -> a
|
||||
.terms(t -> t.field("category"))))
|
||||
.withMaxResults(0)
|
||||
.build();
|
||||
|
||||
SearchHits<Product> hits = operations.search(query, Product.class);
|
||||
Map<String, Long> facets = new java.util.LinkedHashMap<>();
|
||||
var aggregations = hits.getAggregations();
|
||||
if (aggregations != null) {
|
||||
var elc = (org.springframework.data.elasticsearch.client.elc.ElasticsearchAggregations) aggregations;
|
||||
var byCategory = elc.get("by_category").aggregation().getAggregate().sterms();
|
||||
for (var bucket : byCategory.buckets().array()) {
|
||||
facets.put(bucket.key().stringValue(), bucket.docCount());
|
||||
}
|
||||
}
|
||||
return facets;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.ankurm.elasticsearch;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
|
||||
import org.testcontainers.elasticsearch.ElasticsearchContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Runs the search, filter and aggregation behaviour against a real
|
||||
* Elasticsearch 9.4.8 node (matching the elasticsearch-java client
|
||||
* version 9.4.5 that spring-boot-starter-data-elasticsearch 4.1.1
|
||||
* actually resolves - verified with mvn dependency:tree, not assumed).
|
||||
* Every number in the post's transcripts comes from these tests.
|
||||
*/
|
||||
@Testcontainers
|
||||
@SpringBootTest
|
||||
class ProductSearchTest {
|
||||
|
||||
@Container
|
||||
@ServiceConnection
|
||||
static ElasticsearchContainer elasticsearch =
|
||||
new ElasticsearchContainer("elasticsearch:9.4.8")
|
||||
.withEnv("xpack.security.enabled", "false");
|
||||
|
||||
@Autowired ProductRepository repository;
|
||||
@Autowired ProductSearchService searchService;
|
||||
@Autowired ElasticsearchOperations operations;
|
||||
|
||||
@BeforeEach
|
||||
void seedCatalog() {
|
||||
if (operations.indexOps(Product.class).exists()) {
|
||||
operations.indexOps(Product.class).delete();
|
||||
}
|
||||
operations.indexOps(Product.class).createWithMapping();
|
||||
|
||||
repository.saveAll(List.of(
|
||||
new Product(null, "Wireless Mechanical Keyboard", "A compact 65% keyboard with hot-swappable switches", "electronics", new BigDecimal("89.99"), List.of("keyboard", "wireless")),
|
||||
new Product(null, "Wireless Earbuds Pro", "Noise-cancelling earbuds with a 30-hour battery case", "electronics", new BigDecimal("129.00"), List.of("audio", "wireless")),
|
||||
new Product(null, "USB-C Charging Cable", "Braided cable rated for fast charging", "electronics", new BigDecimal("12.50"), List.of("cable")),
|
||||
new Product(null, "Oak Standing Desk", "Electric height-adjustable desk with a solid oak top", "furniture", new BigDecimal("449.00"), List.of("desk", "office")),
|
||||
new Product(null, "Ergonomic Office Chair", "Mesh-back chair with lumbar support and wireless lumbar adjustment motor", "furniture", new BigDecimal("259.00"), List.of("chair", "office")),
|
||||
new Product(null, "Desk Lamp", "LED desk lamp with adjustable color temperature", "furniture", new BigDecimal("34.00"), List.of("lamp"))));
|
||||
|
||||
operations.indexOps(Product.class).refresh();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fuzzySearchFindsAMisspelledTerm() {
|
||||
List<Product> results = searchService.search("wireles", new BigDecimal("1000"));
|
||||
Transcript t = new Transcript("27-fuzzy-search.txt");
|
||||
t.line("query=\"wireles\" (misspelled) max_price=1000.00");
|
||||
for (Product p : results) {
|
||||
t.line(" %s ($%s, %s)", p.name(), p.price(), p.category());
|
||||
}
|
||||
t.write();
|
||||
|
||||
assertThat(results).extracting(Product::name)
|
||||
.contains("Wireless Mechanical Keyboard", "Wireless Earbuds Pro");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nameMatchOutranksDescriptionOnlyMatch() {
|
||||
// "Wireless" appears in two product NAMES and once only in a
|
||||
// description (the chair's lumbar motor) - the ^2 boost on name
|
||||
// should put both name matches ahead of the description-only one.
|
||||
List<Product> results = searchService.search("wireless", new BigDecimal("1000"));
|
||||
Transcript t = new Transcript("27-relevance-boost.txt");
|
||||
t.line("query=\"wireless\" max_price=1000.00 (ranked by relevance)");
|
||||
for (Product p : results) {
|
||||
t.line(" %s", p.name());
|
||||
}
|
||||
t.write();
|
||||
|
||||
List<String> names = results.stream().map(Product::name).toList();
|
||||
int keyboardRank = names.indexOf("Wireless Mechanical Keyboard");
|
||||
int earbudsRank = names.indexOf("Wireless Earbuds Pro");
|
||||
int chairRank = names.indexOf("Ergonomic Office Chair");
|
||||
assertThat(keyboardRank).isGreaterThanOrEqualTo(0);
|
||||
assertThat(earbudsRank).isGreaterThanOrEqualTo(0);
|
||||
assertThat(chairRank).isGreaterThan(Math.max(keyboardRank, earbudsRank));
|
||||
}
|
||||
|
||||
@Test
|
||||
void priceFilterExcludesMatchesOverTheCeiling() {
|
||||
List<Product> underBudget = searchService.search("wireless", new BigDecimal("100"));
|
||||
Transcript t = new Transcript("27-price-filter.txt");
|
||||
t.line("query=\"wireless\" max_price=100.00");
|
||||
for (Product p : underBudget) {
|
||||
t.line(" %s ($%s)", p.name(), p.price());
|
||||
}
|
||||
t.write();
|
||||
|
||||
assertThat(underBudget).extracting(Product::name)
|
||||
.contains("Wireless Mechanical Keyboard")
|
||||
.doesNotContain("Wireless Earbuds Pro");
|
||||
}
|
||||
|
||||
@Test
|
||||
void categoryFacetsCountMatchesPerCategory() {
|
||||
Map<String, Long> facets = searchService.categoryFacets("desk");
|
||||
Transcript t = new Transcript("27-category-facets.txt");
|
||||
t.line("query=\"desk\" facet counts by category:");
|
||||
facets.forEach((category, count) -> t.line(" %s=%d", category, count));
|
||||
t.write();
|
||||
|
||||
assertThat(facets).containsEntry("furniture", 2L);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ankurm.elasticsearch;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Writes docs/output/NN-*.txt while a test asserts the same numbers, so
|
||||
* every figure quoted in the blog post is backed by an assertion that
|
||||
* fails the build when it stops being true.
|
||||
*/
|
||||
final class Transcript {
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
private final Path file;
|
||||
|
||||
Transcript(String name) {
|
||||
this.file = Path.of("docs/output/" + name);
|
||||
}
|
||||
|
||||
Transcript line(String s) {
|
||||
lines.add(s);
|
||||
System.out.println(s);
|
||||
return this;
|
||||
}
|
||||
|
||||
Transcript line(String fmt, Object... args) {
|
||||
return line(fmt.formatted(args));
|
||||
}
|
||||
|
||||
void write() {
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, String.join("\n", lines) + "\n");
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user