Introdução
O Elasticsearch disponibiliza capacidades poderosas de pesquisa de texto integral que vão muito além de simples consultas LIKE em bases de dados. Construído sobre o Apache Lucene, oferece pontuação de relevância, correspondência difusa (fuzzy matching), agregações e pesquisa quase em tempo real. Este guia aborda uma implementação prática de Elasticsearch para requisitos comuns de pesquisa.
Configuração
Instalação com Docker
# docker-compose.yml
version: '3.8'
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ports:
- "9200:9200"
volumes:
- esdata:/usr/share/elasticsearch/data
kibana:
image: docker.elastic.co/kibana/kibana:8.11.0
ports:
- "5601:5601"
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
volumes:
esdata:
Verificar a Instalação
curl http://localhost:9200
Gestão de Índices
Criar Índice com Mapping
curl -X PUT "localhost:9200/products" -H 'Content-Type: application/json' -d'
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"analysis": {
"analyzer": {
"custom_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "snowball"]
}
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "custom_analyzer",
"fields": {
"keyword": { "type": "keyword" }
}
},
"description": {
"type": "text",
"analyzer": "custom_analyzer"
},
"category": {
"type": "keyword"
},
"price": {
"type": "float"
},
"in_stock": {
"type": "boolean"
},
"created_at": {
"type": "date"
},
"tags": {
"type": "keyword"
}
}
}
}'
Tipos de Campos
| Tipo | Caso de Uso |
|---|
| text | Pesquisa de texto integral (analisado) |
| keyword | Correspondência exata, ordenação, agregações |
| integer/long/float | Números |
| date | Datas |
| boolean | Verdadeiro/falso |
| nested | Arrays de objetos |
Operações sobre Documentos
Indexar Documentos
# Documento único
curl -X POST "localhost:9200/products/_doc/1" -H 'Content-Type: application/json' -d'
{
"name": "Wireless Bluetooth Headphones",
"description": "Premium noise-canceling headphones with 30-hour battery",
"category": "electronics",
"price": 199.99,
"in_stock": true,
"tags": ["audio", "wireless", "premium"],
"created_at": "2024-01-15"
}'
# Indexação em massa
curl -X POST "localhost:9200/_bulk" -H 'Content-Type: application/json' -d'
{"index": {"_index": "products", "_id": "2"}}
{"name": "USB-C Charging Cable", "category": "accessories", "price": 19.99}
{"index": {"_index": "products", "_id": "3"}}
{"name": "Laptop Stand", "category": "accessories", "price": 49.99}
'
Atualizar Documentos
# Atualização parcial
curl -X POST "localhost:9200/products/_update/1" -H 'Content-Type: application/json' -d'
{
"doc": {
"price": 179.99,
"in_stock": false
}
}'
# Atualização com script
curl -X POST "localhost:9200/products/_update/1" -H 'Content-Type: application/json' -d'
{
"script": {
"source": "ctx._source.price -= params.discount",
"params": { "discount": 20 }
}
}'
Consultas de Pesquisa
Pesquisa Básica
# Consulta match (analisada)
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"match": {
"name": "wireless headphones"
}
}
}'
# Multi-match (pesquisar vários campos)
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"multi_match": {
"query": "wireless audio",
"fields": ["name^2", "description", "tags"],
"type": "best_fields"
}
}
}'
Consultas Booleanas
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"bool": {
"must": [
{ "match": { "name": "headphones" } }
],
"filter": [
{ "term": { "category": "electronics" } },
{ "range": { "price": { "lte": 300 } } },
{ "term": { "in_stock": true } }
],
"should": [
{ "term": { "tags": "premium" } }
],
"must_not": [
{ "term": { "tags": "refurbished" } }
]
}
}
}'
Pesquisa Difusa
# Lida com gralhas
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"fuzzy": {
"name": {
"value": "headpohnes",
"fuzziness": "AUTO"
}
}
}
}'
# Match com fuzziness
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"match": {
"name": {
"query": "wireles headpohnes",
"fuzziness": "AUTO"
}
}
}
}'
Autocomplete com Prefixo
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"prefix": {
"name.keyword": "Wire"
}
}
}'
# Melhor: utilizar o completion suggester para autocomplete
Agregações
Agregação de Termos
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"size": 0,
"aggs": {
"categories": {
"terms": {
"field": "category",
"size": 10
}
}
}
}'
Agregação por Intervalos
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"size": 0,
"aggs": {
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "to": 50 },
{ "from": 50, "to": 100 },
{ "from": 100, "to": 200 },
{ "from": 200 }
]
}
}
}
}'
Agregações Nested
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"size": 0,
"aggs": {
"categories": {
"terms": { "field": "category" },
"aggs": {
"avg_price": { "avg": { "field": "price" } },
"price_stats": { "stats": { "field": "price" } }
}
}
}
}'
Integração com PHP
composer require elasticsearch/elasticsearch
<?php
use Elasticsearch\ClientBuilder;
$client = ClientBuilder::create()
->setHosts(['localhost:9200'])
->build();
// Indexar documento
$params = [
'index' => 'products',
'id' => '1',
'body' => [
'name' => 'Wireless Headphones',
'price' => 199.99,
'category' => 'electronics',
],
];
$client->index($params);
// Pesquisar
$params = [
'index' => 'products',
'body' => [
'query' => [
'bool' => [
'must' => [
['match' => ['name' => $searchQuery]],
],
'filter' => [
['term' => ['category' => $category]],
['range' => ['price' => ['lte' => $maxPrice]]],
],
],
],
'from' => $offset,
'size' => $limit,
'sort' => [
['_score' => 'desc'],
['created_at' => 'desc'],
],
],
];
$response = $client->search($params);
foreach ($response['hits']['hits'] as $hit) {
$product = $hit['_source'];
$score = $hit['_score'];
echo "{$product['name']} (score: {$score})\n";
}
// Total de resultados
$total = $response['hits']['total']['value'];
Integração com Node.js
const { Client } = require('@elastic/elasticsearch');
const client = new Client({ node: 'http://localhost:9200' });
// Indexar documento
await client.index({
index: 'products',
id: '1',
document: {
name: 'Wireless Headphones',
price: 199.99,
category: 'electronics',
},
});
// Pesquisar
const result = await client.search({
index: 'products',
query: {
bool: {
must: [
{ match: { name: searchQuery } },
],
filter: [
{ term: { category: 'electronics' } },
{ range: { price: { lte: 300 } } },
],
},
},
from: 0,
size: 10,
sort: [
{ _score: 'desc' },
{ created_at: 'desc' },
],
});
result.hits.hits.forEach((hit) => {
console.log(hit._source.name, hit._score);
});
Sincronização com a Base de Dados
Sincronização Orientada a Eventos
// Após inserção/atualização na base de dados
class ProductObserver
{
private $elasticsearch;
public function saved(Product $product)
{
$this->elasticsearch->index([
'index' => 'products',
'id' => $product->id,
'body' => [
'name' => $product->name,
'description' => $product->description,
'price' => $product->price,
'category' => $product->category->name,
'updated_at' => $product->updated_at->toIso8601String(),
],
]);
}
public function deleted(Product $product)
{
$this->elasticsearch->delete([
'index' => 'products',
'id' => $product->id,
]);
}
}
Comando de Reindexação em Massa
// Comando Artisan
class ReindexProducts extends Command
{
protected $signature = 'search:reindex';
public function handle()
{
// Eliminar e recriar o índice
$this->elasticsearch->indices()->delete(['index' => 'products']);
$this->createIndex();
// Indexação em massa
$products = Product::with('category')->cursor();
$batch = [];
foreach ($products as $product) {
$batch[] = ['index' => ['_index' => 'products', '_id' => $product->id]];
$batch[] = $this->formatProduct($product);
if (count($batch) >= 1000) {
$this->elasticsearch->bulk(['body' => $batch]);
$batch = [];
}
}
if (!empty($batch)) {
$this->elasticsearch->bulk(['body' => $batch]);
}
}
}
Dicas de Desempenho
Definições do Índice
{
"settings": {
"index": {
"refresh_interval": "30s",
"number_of_replicas": 1
}
}
}
Operações em Massa
// Utilizar a Bulk API para vários documentos
$params = ['body' => []];
foreach ($products as $product) {
$params['body'][] = ['index' => ['_index' => 'products', '_id' => $product->id]];
$params['body'][] = $product->toSearchArray();
if (count($params['body']) >= 2000) {
$client->bulk($params);
$params['body'] = [];
}
}
if (!empty($params['body'])) {
$client->bulk($params);
}
Otimização de Consultas
- Utilize filtros para correspondências exatas (com cache)
- Limite os campos devolvidos com
_source - Utilize paginação com
from/size - Evite paginação profunda — utilize
search_after em alternativa
Boas Práticas
- Desenhe os mappings cuidadosamente — alterar mais tarde é dispendioso
- Utilize analyzers apropriados para o seu idioma
- Mantenha os dados sincronizados com a sua base de dados principal
- Monitorize a saúde do cluster e o espaço em disco
- Utilize aliases para reindexação sem downtime
- Teste as consultas com volumes de dados realistas
Conclusão
O Elasticsearch disponibiliza capacidades de pesquisa poderosas que melhoram a experiência do utilizador. Conceba os seus mappings de forma ponderada, utilize analyzers apropriados e mantenha os dados sincronizados com a sua base de dados principal. Os padrões deste guia ajudam-no a implementar uma pesquisa de texto integral eficaz nas suas aplicações.