Введение
Elasticsearch предоставляет мощные возможности полнотекстового поиска, которые значительно превосходят простые запросы базы данных с LIKE. Построенный на Apache Lucene, он предлагает оценку релевантности, нечеткое сопоставление (fuzzy matching), агрегации и поиск почти в реальном времени. В этом руководстве рассматривается практическая реализация Elasticsearch для типовых требований к поиску.
Настройка
Установка через 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:
Проверка установки
curl http://localhost:9200
Управление индексами
Создание индекса с 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"
}
}
}
}'
Типы полей
| Type | Use Case |
|---|
| text | Полнотекстовый поиск (analyzed) |
| keyword | Точное совпадение, сортировка, агрегации |
| integer/long/float | Числа |
| date | Даты |
| boolean | True/false |
| nested | Массивы объектов |
Операции с документами
Индексация документов
# Один документ
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"
}'
# Пакетная индексация
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}
'
Обновление документов
# Частичное обновление
curl -X POST "localhost:9200/products/_update/1" -H 'Content-Type: application/json' -d'
{
"doc": {
"price": 179.99,
"in_stock": false
}
}'
# Обновление с помощью 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 }
}
}'
Поисковые запросы
Базовый поиск
# Запрос match (analyzed)
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"match": {
"name": "wireless headphones"
}
}
}'
# Multi-match (поиск по нескольким полям)
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"
}
}
}'
Boolean-запросы
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" } }
]
}
}
}'
Нечеткий поиск
# Обрабатывает опечатки
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"fuzzy": {
"name": {
"value": "headpohnes",
"fuzziness": "AUTO"
}
}
}
}'
# Match с fuzziness
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"match": {
"name": {
"query": "wireles headpohnes",
"fuzziness": "AUTO"
}
}
}
}'
Автодополнение по префиксу
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"prefix": {
"name.keyword": "Wire"
}
}
}'
# Лучше: используйте completion suggester для автодополнения
Агрегации
Terms-агрегация
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"size": 0,
"aggs": {
"categories": {
"terms": {
"field": "category",
"size": 10
}
}
}
}'
Range-агрегация
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 }
]
}
}
}
}'
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" } }
}
}
}
}'
Интеграция с PHP
composer require elasticsearch/elasticsearch
<?php
use Elasticsearch\ClientBuilder;
$client = ClientBuilder::create()
->setHosts(['localhost:9200'])
->build();
// Индексировать документ
$params = [
'index' => 'products',
'id' => '1',
'body' => [
'name' => 'Wireless Headphones',
'price' => 199.99,
'category' => 'electronics',
],
];
$client->index($params);
// Поиск
$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 = $response['hits']['total']['value'];
Интеграция с Node.js
const { Client } = require('@elastic/elasticsearch');
const client = new Client({ node: 'http://localhost:9200' });
// Индексировать документ
await client.index({
index: 'products',
id: '1',
document: {
name: 'Wireless Headphones',
price: 199.99,
category: 'electronics',
},
});
// Поиск
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);
});
Синхронизация с базой данных
Синхронизация на основе событий
// После вставки/обновления в базе данных
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,
]);
}
}
Команда bulk reindex
// Команда Artisan
class ReindexProducts extends Command
{
protected $signature = 'search:reindex';
public function handle()
{
// Удалить и пересоздать индекс
$this->elasticsearch->indices()->delete(['index' => 'products']);
$this->createIndex();
// Пакетная индексация
$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]);
}
}
}
Советы по производительности
Настройки индекса
{
"settings": {
"index": {
"refresh_interval": "30s",
"number_of_replicas": 1
}
}
}
Bulk-операции
// Используйте bulk API для нескольких документов
$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);
}
Оптимизация запросов
- Используйте filters для точных совпадений (кэшируются)
- Ограничивайте возвращаемые поля с помощью
_source - Используйте пагинацию через
from/size - Избегайте глубокой пагинации — вместо этого используйте
search_after
Лучшие практики
- Тщательно проектируйте mappings — последующие изменения обходятся дорого
- Используйте подходящие analyzers для вашего языка
- Поддерживайте данные в синхронизации с вашей основной базой данных
- Мониторьте состояние кластера и свободное место на диске
- Используйте aliases для reindexing без простоя
- Тестируйте запросы на реалистичных объемах данных
Заключение
Elasticsearch предоставляет мощные возможности поиска, которые улучшают пользовательский опыт. Продуманно проектируйте mappings, используйте подходящие analyzers и поддерживайте данные синхронизированными с вашей основной базой данных. Шаблоны из этого руководства помогут вам реализовать эффективный полнотекстовый поиск в ваших приложениях.