elasticsearch: full-text search, relevance boosting, fuzzy matching and category facets (post 27)

This commit is contained in:
2026-10-15 02:09:34 +00:00
parent 23f4b1bad9
commit 565ca61814
12 changed files with 378 additions and 1 deletions
+1 -1
View File
@@ -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
+72
View File
@@ -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>
@@ -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);
}
}
}