Desafios da Busca Textual em Bancos Relacionais
Quando sistemas precisam lidar com funcionalidades de pesquisa — seja em catálogos de e-commerce, plataformas de recrutamento ou diretórios corporativos — os bancos de dados relacionais tradicionais (como MySQL ou PostgreSQL) rapidamente se tornam um gargalo. Consultas utilizando o operador LIKE '%termo%' em tabelas com milhões de registros forçam o banco a realizar full table scans, resultando em tempos de resposta inaceitáveis.
Além da performance, há uma limitação semântica severa: bancos SQL não compreendem a linguagem humana. Se um usuário pesquisar por "João Silva", o banco não saberá como lidar com variações como "Silva, João" ou "Joao", a menos que haja uma lógica complexa e custosa de manipulação de strings no lado da aplicação.
O Motor de Busca: Lucene e o Índice Invertido
Para resolver essas ineficiências, motores de busca utilizam uma estrutura de dados chamada índice invertido. Imagine buscar por um artigo sobre "erros de compilação Java". O sistema não varre todos os artigos caracter por caracter. Em vez disso, o texto é previamente fragmentado em palavras-chave (tokens) durante a indexação. O índice invertido mapeia cada token para os IDs dos documentos onde ele aparece.
O Apache Lucene é a biblioteca Java de código aberto que fornece toda a fundação matemática e algorítmica para criar esses índices invertidos, aplicar análise de texto (stemming, stop-words) e calcular a relevância dos resultados. No entanto, o Lucene opera apenas em nível de biblioteca (um arquivo JAR), exigindo que os desenvolvedores construam sua própria lógica para distribuição, tolerância a falhas e sincronização de dados.
A Arquitetura do Elasticsearch
O Elasticsearch surge como uma solução distribuída construída sobre o Lucene. Ele abstrai a complexidade de gerenciar clusters de servidores, fornecendo uma API RESTful robusta. Suas principais vantagens incluem:
- Escalabilidade Horizontal: Distribuição automática de shards (fragmentos de dados) e réplicas através de múltiplos nós.
- Alta Disponibilidade: Tolerância a falhas nativa; se um nó cair, as réplicas assumem o tráfego sem perda de dados.
- Análise em Tempo Quase Real: Capacidade de indexar e tornar documentos pesquisáveis em frações de segundo, ideal para monitoramento de logs e dashboards de BI.
Mapeamento de Conceitos: RDBMS vs Elasticsearch
Para desenvolvedores acostumados com SQL, a transição exige uma adaptação de vocabulário:
| Elasticsearch | Banco Relacional (MySQL) |
|---|---|
| Index | Database |
| Type (Depreciado nas versões recentes) | Table |
| Document | Row |
| Field | Column |
Configuração do Ambiente Spring Boot
A integração com o ecossistema Spring é facilitada pelo starter oficial. Adicione a seguinte dependência ao seu gerenciador de pacotes:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
No arquivo de propriedades (application.yml), defina os parâmetros de conexão:
spring:
elasticsearch:
uris: http://localhost:9200
connection-timeout: 5s
socket-timeout: 10s
Embora o Spring Boot ofereça autoconfiguração, em cenários onde é necessário manipular o cliente de baixo nível diretamente, podemos expor um bean customizado do RestHighLevelClient:
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
@Configuration
public class ElasticsearchConfig {
@Bean
public RestHighLevelClient elasticsearchRestClient() {
return new RestHighLevelClient(
RestClient.builder(new HttpHost("127.0.0.1", 9200, "http"))
.setRequestConfigCallback(requestConfigBuilder ->
requestConfigBuilder.setConnectTimeout(5000).setSocketTimeout(10000))
);
}
}
Modelagem do Domínio
Os documentos no Elasticsearch são representados por classes Java anotadas. Utilizaremos um modelo de Article para um portal de notícias tecnológicas, aplicando analisadores de texto específicos.
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.*;
import java.util.Date;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Document(indexName = "tech_articles")
public class Article {
@Id
private String articleId;
@Field(type = FieldType.Text, analyzer = "standard")
private String title;
@Field(type = FieldType.Keyword)
private String category;
@Field(type = FieldType.Text)
private String summary;
@Field(type = FieldType.Integer)
private Integer viewCount;
@Field(type = FieldType.Date, format = DateFormat.date_time)
private Date publishedAt;
@Field(type = FieldType.Geo_Point)
private GeoCoordinates authorLocation;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GeoCoordinates {
private double lat;
private double lon;
}
Operações de Manipulação de Dados (CRUD)
As operações fundamentais de indexação, leitura e exclusão são executadas através da API do cliente REST. Abaixo, testes de integração demonstram o ciclo de vida de um documento.
import com.fasterxml.jackson.databind.ObjectMapper;
import org.elasticsearch.action.bulk.BulkRequest;
import org.elasticsearch.action.bulk.BulkResponse;
import org.elasticsearch.action.delete.DeleteRequest;
import org.elasticsearch.action.get.GetRequest;
import org.elasticsearch.action.get.GetResponse;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.action.index.IndexResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.client.indices.DeleteIndexRequest;
import org.elasticsearch.common.xcontent.XContentType;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.IOException;
import java.util.List;
@SpringBootTest
public class ArticleCrudOperationsTest {
private static final String TARGET_INDEX = "tech_articles";
@Autowired
private RestHighLevelClient esClient;
private final ObjectMapper mapper = new ObjectMapper();
@Test
public void indexNewArticle() throws IOException {
Article newPost = Article.builder()
.articleId("ART-100")
.title("Otimização de Queries no PostgreSQL")
.category("Database")
.viewCount(150)
.build();
IndexRequest request = new IndexRequest(TARGET_INDEX)
.id(newPost.getArticleId())
.source(mapper.writeValueAsString(newPost), XContentType.JSON);
IndexResponse response = esClient.index(request, RequestOptions.DEFAULT);
System.out.println("Status da Indexação: " + response.status());
}
@Test
public void fetchArticleById() throws IOException {
GetRequest request = new GetRequest(TARGET_INDEX, "ART-100");
GetResponse document = esClient.get(request, RequestOptions.DEFAULT);
if (document.isExists()) {
System.out.println("Conteúdo: " + document.getSourceAsString());
}
}
@Test
public void removeArticle() throws IOException {
DeleteRequest request = new DeleteRequest(TARGET_INDEX, "ART-100");
esClient.delete(request, RequestOptions.DEFAULT);
}
@Test
public void bulkIndexArticles() throws IOException {
List<Article> mockData = List.of(
Article.builder().articleId("ART-201").title("Introdução ao Docker").viewCount(500).build(),
Article.builder().articleId("ART-202").title("Kubernetes para Iniciantes").viewCount(820).build()
);
BulkRequest bulkRequest = new BulkRequest();
for (Article article : mockData) {
bulkRequest.add(new IndexRequest(TARGET_INDEX)
.id(article.getArticleId())
.source(mapper.writeValueAsString(article), XContentType.JSON));
}
BulkResponse bulkResponse = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
System.out.println("Erros na operação em lote: " + bulkResponse.hasFailures());
}
}
Construção de Consultas Complexas
O verdadeiro poder do Elasticsearch reside em sua DSL (Domain Specific Language) para buscas. Através do SearchSourceBuilder, é possível compor filtros booleanos, buscas difusas (fuzzy) e projeções de campos específicos.
import com.fasterxml.jackson.databind.ObjectMapper;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.unit.Fuzziness;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.sort.SortOrder;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.IOException;
@SpringBootTest
public class AdvancedSearchTests {
@Autowired
private RestHighLevelClient esClient;
private final ObjectMapper mapper = new ObjectMapper();
private final String INDEX = "tech_articles";
@Test
public void matchAllQueryWithSorting() throws IOException {
SearchRequest searchRequest = new SearchRequest(INDEX);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchAllQuery());
sourceBuilder.sort("viewCount", SortOrder.DESC);
searchRequest.source(sourceBuilder);
executeAndPrint(searchRequest);
}
@Test
public void booleanCombinationQuery() throws IOException {
SearchRequest searchRequest = new SearchRequest(INDEX);
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery()
.should(QueryBuilders.matchQuery("category", "DevOps"))
.filter(QueryBuilders.rangeQuery("viewCount").gte(1000));
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder().query(boolQuery);
searchRequest.source(sourceBuilder);
executeAndPrint(searchRequest);
}
@Test
public void fieldProjectionQuery() throws IOException {
SearchRequest searchRequest = new SearchRequest(INDEX);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.query(QueryBuilders.matchAllQuery())
.fetchSource(new String[]{"title", "category"}, new String[]{"summary"});
searchRequest.source(sourceBuilder);
executeAndPrint(searchRequest);
}
@Test
public void fuzzyTextSearch() throws IOException {
// Busca tolerante a erros de digitação, ex: "Dockr" retornará "Docker"
SearchRequest searchRequest = new SearchRequest(INDEX);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.query(QueryBuilders.fuzzyQuery("title", "Dockr").fuzziness(Fuzziness.AUTO));
searchRequest.source(sourceBuilder);
executeAndPrint(searchRequest);
}
private void executeAndPrint(SearchRequest request) throws IOException {
SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);
for (SearchHit hit : response.getHits().getHits()) {
Article parsedArticle = mapper.readValue(hit.getSourceAsString(), Article.class);
System.out.printf("ID: %s | Título: %s\n", parsedArticle.getArticleId(), parsedArticle.getTitle());
}
}
}