索引与查询进阶
本节深入讲解 Elasticsearch 的索引管理、Mapping 设计、全文搜索的高级用法、聚合分析以及搜索建议等功能。掌握这些进阶内容,能够帮助你在 PHP 项目中构建高性能、功能丰富的搜索引擎应用。
前置知识
阅读本节前,建议先了解:Elasticsearch 基础 和 PHP 客户端操作
索引管理
索引模板(Index Template)
索引模板允许在创建新索引时自动应用预定义的 settings 和 mappings,非常适合按时间分片的索引(如日志索引)。
php
<?php
declare(strict_types=1);
namespace App\Service;
use Elasticsearch\Client;
class IndexTemplateService
{
public function __construct(
private readonly Client $esClient
) {}
/**
* 创建索引模板
*/
public function createTemplate(string $templateName, array $template): array
{
$params = [
'name' => $templateName,
'body' => $template,
];
return $this->esClient->indices()->putTemplate($params);
}
/**
* 创建日志索引模板(按日期自动创建)
*/
public function createLogTemplate(): array
{
return $this->createTemplate('log-template', [
'index_patterns' => ['logs-*'],
'template' => [
'settings' => [
'number_of_shards' => 3,
'number_of_replicas' => 1,
'refresh_interval' => '5s',
'analysis' => [
'analyzer' => [
'log_analyzer' => [
'type' => 'custom',
'tokenizer' => 'ik_max_word',
'filter' => ['lowercase'],
],
],
],
],
'mappings' => [
'properties' => [
'timestamp' => ['type' => 'date', 'format' => 'yyyy-MM-dd HH:mm:ss||epoch_millis'],
'level' => ['type' => 'keyword'],
'message' => ['type' => 'text', 'analyzer' => 'log_analyzer'],
'service' => ['type' => 'keyword'],
'trace_id' => ['type' => 'keyword'],
'host' => ['type' => 'keyword'],
'ip' => ['type' => 'ip'],
'metadata' => ['type' => 'object', 'dynamic' => true],
],
],
],
'priority' => 100,
'composed_of' => [],
]);
}
/**
* 创建商品索引模板(按月份分片)
*/
public function createProductTemplate(): array
{
return $this->createTemplate('product-template', [
'index_patterns' => ['products-*'],
'template' => [
'settings' => [
'number_of_shards' => 5,
'number_of_replicas' => 1,
'max_result_window' => 500000,
'analysis' => [
'analyzer' => [
'product_search_analyzer' => [
'type' => 'custom',
'tokenizer' => 'ik_smart',
'filter' => ['lowercase', 'synonym_filter'],
],
'product_index_analyzer' => [
'type' => 'custom',
'tokenizer' => 'ik_max_word',
'filter' => ['lowercase'],
],
],
'filter' => [
'synonym_filter' => [
'type' => 'synonym',
'synonyms' => [
'智能手机,手机,移动电话',
'笔记本,笔记本电脑,便携电脑',
],
],
],
],
],
'mappings' => [
'dynamic' => 'strict',
'date_detection' => true,
'numeric_detection' => true,
'properties' => [
'product_id' => ['type' => 'keyword'],
'title' => [
'type' => 'text',
'analyzer' => 'product_index_analyzer',
'search_analyzer' => 'product_search_analyzer',
'fields' => [
'keyword' => ['type' => 'keyword', 'ignore_above' => 128],
'completion' => ['type' => 'completion', 'analyzer' => 'ik_max_word'],
],
],
'description' => ['type' => 'text', 'analyzer' => 'ik_max_word', 'search_analyzer' => 'ik_smart'],
'price' => ['type' => 'scaled_float', 'scaling_factor' => 100],
'category' => ['type' => 'keyword'],
'brand' => ['type' => 'keyword'],
'tags' => ['type' => 'keyword'],
'sales_count' => ['type' => 'integer'],
'rating' => ['type' => 'float'],
'location' => ['type' => 'geo_point'],
'created_at' => ['type' => 'date'],
'updated_at' => ['type' => 'date'],
],
],
'aliases' => [
'products-latest' => [],
],
],
'priority' => 200,
]);
}
/**
* 查看索引模板
*/
public function getTemplate(string $templateName): array
{
return $this->esClient->indices()->getTemplate(['name' => $templateName]);
}
/**
* 删除索引模板
*/
public function deleteTemplate(string $templateName): array
{
return $this->esClient->indices()->deleteTemplate(['name' => $templateName]);
}
}别名管理
索引别名(Alias)是索引的虚拟映射,常用于零停机切换索引(如重建索引时)。
php
<?php
declare(strict_types=1);
namespace App\Service;
use Elasticsearch\Client;
class AliasService
{
public function __construct(
private readonly Client $esClient
) {}
/**
* 创建别名
*/
public function addAlias(string $index, string $alias): array
{
return $this->esClient->indices()->putAlias([
'index' => $index,
'name' => $alias,
]);
}
/**
* 原子切换索引别名(零停机重建索引)
* 将别名从旧索引切换到新索引
*/
public function switchAlias(string $alias, string $oldIndex, string $newIndex): array
{
return $this->esClient->indices()->updateAliases([
'body' => [
'actions' => [
// 先移除旧索引的别名
['remove' => ['index' => $oldIndex, 'alias' => $alias]],
// 再添加新索引的别名
['add' => ['index' => $newIndex, 'alias' => $alias]],
],
],
]);
}
/**
* 获取别名信息
*/
public function getAlias(string $alias): array
{
return $this->esClient->indices()->getAlias(['name' => $alias]);
}
/**
* 删除别名
*/
public function removeAlias(string $index, string $alias): array
{
return $this->esClient->indices()->deleteAlias([
'index' => $index,
'name' => $alias,
]);
}
}索引生命周期管理(ILM)
索引生命周期管理(Index Lifecycle Management)用于自动管理索引的创建、滚动、删除等。
php
<?php
declare(strict_types=1);
namespace App\Service;
use Elasticsearch\Client;
class ILMService
{
public function __construct(
private readonly Client $esClient
) {}
/**
* 创建 ILM 策略(日志索引场景)
*/
public function createLogPolicy(): array
{
return $this->esClient->ilm()->putLifecycle([
'name' => 'log-policy',
'body' => [
'policy' => [
'phases' => [
'hot' => [
'min_age' => '0ms',
'actions' => [
'rollover' => [
'max_size' => '50gb',
'max_docs' => 10000000,
'max_age' => '7d',
],
'set_priority' => [
'priority' => 100,
],
],
],
'warm' => [
'min_age' => '30d',
'actions' => [
'shrink' => [
'number_of_shards' => 1,
],
'forcemerge' => [
'max_num_segments' => 1,
],
'set_priority' => [
'priority' => 50,
],
],
],
'delete' => [
'min_age' => '90d',
'actions' => [
'delete' => [],
],
],
],
],
],
]);
}
/**
* 将 ILM 策略绑定到索引模板
*/
public function attachPolicyToTemplate(string $templateName, string $policyName): void
{
// 在索引模板的 settings 中设置
$this->esClient->indices()->putTemplate([
'name' => $templateName,
'body' => [
'index_patterns' => ['logs-*'],
'settings' => [
'index.lifecycle.name' => $policyName,
'index.lifecycle.rollover_alias' => 'logs-write',
],
],
]);
}
}映射(Mapping)设计
Dynamic 映射策略
php
<?php
declare(strict_types=1);
namespace App\Service;
use Elasticsearch\Client;
class MappingService
{
public function __construct(
private readonly Client $esClient
) {}
/**
* 严格的 Mapping 定义(推荐生产环境使用)
*/
public function createStrictMapping(): array
{
return $this->esClient->indices()->create([
'index' => 'articles',
'body' => [
'mappings' => [
'dynamic' => 'strict', // 未知字段直接报错
'dynamic_date_formats' => [
'yyyy-MM-dd',
'yyyy-MM-dd HH:mm:ss',
'epoch_millis',
],
'properties' => [
'id' => [
'type' => 'keyword',
],
'title' => [
'type' => 'text',
'analyzer' => 'ik_max_word',
'search_analyzer' => 'ik_smart',
'fields' => [
'keyword' => ['type' => 'keyword', 'ignore_above' => 256],
'completion' => ['type' => 'completion'],
],
],
'content' => [
'type' => 'text',
'analyzer' => 'ik_max_word',
'search_analyzer' => 'ik_smart',
],
'author' => [
'properties' => [
'name' => ['type' => 'keyword'],
'email' => ['type' => 'keyword'],
'avatar'=> ['type' => 'keyword', 'index' => false], // 只存储不索引
],
],
'category' => ['type' => 'keyword'],
'tags' => ['type' => 'keyword'],
'view_count' => ['type' => 'integer'],
'like_count' => ['type' => 'integer'],
'comment_count' => ['type' => 'integer'],
'status' => ['type' => 'keyword'], // draft/published/archived
'published_at' => ['type' => 'date'],
'created_at' => ['type' => 'date'],
'updated_at' => ['type' => 'date'],
'extra' => [
'dynamic' => true, // 额外字段自动映射(宽松策略)
'properties' => [],
],
],
],
],
]);
}
/**
* 更新 Mapping(只能添加新字段)
*/
public function addNewField(string $index, string $fieldName, array $fieldMapping): array
{
return $this->esClient->indices()->putMapping([
'index' => $index,
'body' => [
'properties' => [
$fieldName => $fieldMapping,
],
],
]);
}
}Mapping 不可变原则
- Elasticsearch 的 Mapping 字段一旦创建,不可修改已有字段的类型
- 如需修改字段类型,必须创建新索引、使用 Reindex 迁移数据、然后通过别名切换
- 只能向已有 Mapping 中添加新字段
全文搜索进阶
多种查询类型对比
| 查询类型 | 说明 | 适用场景 |
|---|---|---|
match | 基础全文搜索 | 单字段模糊搜索 |
multi_match | 多字段搜索 | 跨多个字段搜索 |
match_phrase | 短语匹配 | 搜索固定词组 |
match_phrase_prefix | 短语前缀匹配 | 搜索建议 |
query_string | Lucene 语法查询 | 高级用户自定义查询 |
simple_query_string | 简化语法查询 | 安全的用户输入查询 |
fuzzy | 模糊查询 | 容错搜索(拼写纠错) |
wildcard | 通配符查询 | 模式匹配 |
regexp | 正则查询 | 复杂模式匹配 |
php
<?php
declare(strict_types=1);
namespace App\Repository;
use Elasticsearch\Client;
class AdvancedSearchRepository
{
public function __construct(
private readonly Client $esClient
) {}
/**
* multi_match 搜索类型详解
*
* best_fields:取最匹配字段评分(默认)
* most_fields:跨字段评分叠加
* cross_fields:将字段视为一个大的字段处理
* phrase:短语匹配
* phrase_prefix:短语前缀匹配
* bool:使用 bool 查询组合
*/
public function multiMatchSearch(string $keyword): array
{
// best_fields:取最佳匹配字段的评分
$bestFields = [
'multi_match' => [
'query' => $keyword,
'fields' => ['title^3', 'description^1'],
'type' => 'best_fields',
'tie_breaker' => 0.3, // 当多个字段都匹配时,次优字段的权重系数
],
];
// cross_fields:适合搜索词分散在不同字段
$crossFields = [
'multi_match' => [
'query' => $keyword,
'fields' => ['title', 'author.name'],
'type' => 'cross_fields',
'operator' => 'and',
],
];
return $this->esClient->search([
'index' => 'articles',
'body' => [
'query' => $bestFields,
],
]);
}
/**
* Query String 搜索(支持 Lucene 语法)
*/
public function queryStringSearch(string $queryString): array
{
return $this->esClient->search([
'index' => 'articles',
'body' => [
'query' => [
'query_string' => [
'query' => $queryString,
'default_field' => 'title',
'default_operator' => 'AND',
'fields' => ['title^3', 'description^1', 'content'],
'allow_leading_wildcard' => false, // 禁止前缀通配符(性能优化)
'analyze_wildcard' => true,
],
],
],
]);
}
/**
* Simple Query String(安全版本,不会因语法错误报错)
*/
public function simpleQueryStringSearch(string $query): array
{
return $this->esClient->search([
'index' => 'articles',
'body' => [
'query' => [
'simple_query_string' => [
'query' => $query,
'fields' => ['title', 'description'],
'flags' => 'OR|AND|NOT|PHRASE|PRECEDENCE', // 允许的操作符
],
],
],
]);
}
/**
* 模糊搜索(容错)
*/
public function fuzzySearch(string $keyword): array
{
return $this->esClient->search([
'index' => 'products',
'body' => [
'query' => [
'match' => [
'title' => [
'query' => $keyword,
'fuzziness' => 'AUTO', // 自动根据词长决定编辑距离
'prefix_length' => 2, // 前 2 个字符不模糊
'max_expansions' => 50, // 最大扩展词数
],
],
],
],
]);
}
/**
* Nested 查询(嵌套对象查询)
*/
public function nestedSearch(string $keyword, float $minRating): array
{
return $this->esClient->search([
'index' => 'products',
'body' => [
'query' => [
'bool' => [
'must' => [
['match' => ['title' => $keyword]],
[
'nested' => [
'path' => 'comments',
'query' => [
'bool' => [
'must' => [
['range' => ['comments.rating' => ['gte' => $minRating]]],
],
],
],
],
],
],
],
],
],
]);
}
}相关性评分调优
php
<?php
declare(strict_types=1);
namespace App\Repository;
use Elasticsearch\Client;
class ScoringRepository
{
public function __construct(
private readonly Client $esClient
) {}
/**
* Function Score Query(自定义评分函数)
*/
public function functionScoreSearch(string $keyword): array
{
return $this->esClient->search([
'index' => 'products',
'body' => [
'query' => [
'function_score' => [
'query' => [
'match' => ['title' => $keyword],
],
'functions' => [
// 根据销量加权
[
'field_value_factor' => [
'field' => 'sales_count',
'factor' => 0.001,
'modifier' => 'log1p', // log(1 + x),避免大值主导
'missing' => 0,
],
'weight' => 2,
],
// 根据评分加权
[
'field_value_factor' => [
'field' => 'rating',
'factor' => 1.5,
'modifier' => 'sqrt',
'missing' => 3.0,
],
'weight' => 3,
],
// 根据时间衰减(越新越好)
[
'gauss' => [
'created_at' => [
'origin' => 'now',
'scale' => '30d', // 30 天衰减到一半
'decay' => 0.5,
'offset' => '7d', // 7 天内不衰减
],
],
'weight' => 1.5,
],
],
'score_mode' => 'sum', // multiply/sum/avg/first/max/min
'boost_mode' => 'multiply', // multiply/replace/sum/avg/max/min
],
],
'size' => 20,
],
]);
}
/**
* Boost 控制(字段权重)
*/
public function boostSearch(string $keyword, string $category): array
{
return $this->esClient->search([
'index' => 'products',
'body' => [
'query' => [
'bool' => [
'should' => [
[
'match' => [
'title' => [
'query' => $keyword,
'boost' => 3,
],
],
],
[
'match' => [
'description' => [
'query' => $keyword,
'boost' => 1,
],
],
],
[
'match' => [
'tags' => [
'query' => $keyword,
'boost' => 2,
],
],
],
],
'filter' => [
['term' => ['category' => $category]],
],
],
],
'size' => 20,
],
]);
}
}聚合分析
基础聚合
php
<?php
declare(strict_types=1);
namespace App\Repository;
use Elasticsearch\Client;
class AggregationRepository
{
public function __construct(
private readonly Client $esClient
) {}
/**
* Terms 聚合(类似 SQL GROUP BY)
* 统计各分类下的商品数量
*/
public function categoryAggregation(): array
{
$response = $this->esClient->search([
'index' => 'products',
'body' => [
'size' => 0, // 不需要搜索结果,只看聚合
'aggs' => [
'category_counts' => [
'terms' => [
'field' => 'category',
'size' => 100, // 返回的桶数量
'order' => ['_count' => 'desc'],
'min_doc_count' => 1, // 最少出现次数
],
],
],
],
]);
return $this->extractAggregation($response, 'category_counts');
}
/**
* Range 聚合(价格区间统计)
*/
public function priceRangeAggregation(): array
{
$response = $this->esClient->search([
'index' => 'products',
'body' => [
'size' => 0,
'aggs' => [
'price_ranges' => [
'range' => [
'field' => 'price',
'ranges' => [
['key' => '0-50', 'from' => 0, 'to' => 50],
['key' => '50-100', 'from' => 50, 'to' => 100],
['key' => '100-200', 'from' => 100, 'to' => 200],
['key' => '200-500', 'from' => 200, 'to' => 500],
['key' => '500+', 'from' => 500],
],
],
'aggs' => [
'avg_rating' => [
'avg' => ['field' => 'rating'],
],
],
],
],
],
]);
return $response['aggregations']['price_ranges']['buckets'] ?? [];
}
/**
* Stats / Extended Stats 聚合(统计指标)
*/
public function priceStats(): array
{
$response = $this->esClient->search([
'index' => 'products',
'body' => [
'size' => 0,
'aggs' => [
'price_stats' => [
'extended_stats' => [
'field' => 'price',
],
],
],
],
]);
return $response['aggregations']['price_stats'] ?? [];
// 返回: count, min, max, avg, sum, sum_of_squares, variance, std_deviation
}
/**
* Date Histogram 聚合(时间序列)
*/
public function dailySalesAggregation(string $startDate, string $endDate): array
{
$response = $this->esClient->search([
'index' => 'orders',
'body' => [
'size' => 0,
'query' => [
'range' => [
'created_at' => [
'gte' => $startDate,
'lte' => $endDate,
],
],
],
'aggs' => [
'daily_sales' => [
'date_histogram' => [
'field' => 'created_at',
'calendar_interval' => 'day', // day/week/month/year
'format' => 'yyyy-MM-dd',
'min_doc_count' => 0, // 无数据的日期也显示
],
'aggs' => [
'total_amount' => [
'sum' => ['field' => 'amount'],
],
'order_count' => [
'value_count' => ['field' => 'amount'],
],
],
],
],
],
]);
return $response['aggregations']['daily_sales']['buckets'] ?? [];
}
/**
* 提取聚合结果
*/
private function extractAggregation(array $response, string $aggName): array
{
return $response['aggregations'][$aggName]['buckets'] ?? [];
}
}复合聚合
php
<?php
declare(strict_types=1);
namespace App\Repository;
use Elasticsearch\Client;
class CompositeAggregationRepository
{
public function __construct(
private readonly Client $esClient
) {}
/**
* 多级嵌套聚合(分类 -> 品牌 -> 价格统计)
*/
public function multiLevelAggregation(): array
{
$response = $this->esClient->search([
'index' => 'products',
'body' => [
'size' => 0,
'aggs' => [
'by_category' => [
'terms' => ['field' => 'category', 'size' => 20],
'aggs' => [
'by_brand' => [
'terms' => ['field' => 'brand', 'size' => 10],
'aggs' => [
'price_stats' => [
'stats' => ['field' => 'price'],
],
'top_products' => [
'top_hits' => [
'size' => 3,
'sort' => [['sales_count' => ['order' => 'desc']]],
'_source' => ['product_id', 'title', 'price'],
],
],
],
],
],
],
],
],
]);
return $response['aggregations']['by_category']['buckets'] ?? [];
}
/**
* 搜索结果聚合(先搜索再聚合)
*/
public function searchWithAggregation(string $keyword): array
{
$response = $this->esClient->search([
'index' => 'products',
'body' => [
'query' => [
'match' => ['title' => $keyword],
],
'size' => 20,
'aggs' => [
'category_filter' => [
'terms' => ['field' => 'category', 'size' => 50],
],
'price_ranges' => [
'range' => [
'field' => 'price',
'ranges' => [
['to' => 50], ['from' => 50, 'to' => 200],
['from' => 200, 'to' => 500], ['from' => 500],
],
],
],
'brand_filter' => [
'terms' => ['field' => 'brand', 'size' => 30],
],
'rating_avg' => [
'avg' => ['field' => 'rating'],
],
],
],
]);
$hits = $response['hits']['hits'] ?? [];
$aggs = $response['aggregations'] ?? [];
return [
'items' => array_map(fn($h) => $h['_source'], $hits),
'total' => $response['hits']['total']['value'] ?? 0,
'aggregations' => [
'categories' => $aggs['category_filter']['buckets'] ?? [],
'price_ranges' => $aggs['price_ranges']['buckets'] ?? [],
'brands' => $aggs['brand_filter']['buckets'] ?? [],
'avg_rating' => $aggs['rating_avg']['value'] ?? 0,
],
];
}
/**
* Cardinality 聚合(去重计数)
*/
public function uniqueBrandCount(): float
{
$response = $this->esClient->search([
'index' => 'products',
'body' => [
'size' => 0,
'aggs' => [
'unique_brands' => [
'cardinality' => [
'field' => 'brand',
'precision_threshold' => 3000,
],
],
],
],
]);
return $response['aggregations']['unique_brands']['value'] ?? 0;
}
}高级搜索功能
搜索建议(Suggest)
php
<?php
declare(strict_types=1);
namespace App\Repository;
use Elasticsearch\Client;
class SuggestRepository
{
public function __construct(
private readonly Client $esClient
) {}
/**
* Term Suggester(拼写纠错建议)
*/
public function termSuggest(string $input): array
{
$response = $this->esClient->search([
'index' => 'products',
'body' => [
'suggest' => [
'title_suggest' => [
'text' => $input,
'term' => [
'field' => 'title',
'suggest_mode' => 'missing', // missing/popular/always
'min_doc_freq' => 1,
'prefix_length' => 1,
'max_edits' => 2,
'max_term_freq' => 32768,
'max_inspections' => 3,
'min_word_length' => 4,
],
],
],
'size' => 0,
],
]);
$suggestions = [];
foreach ($response['suggest']['title_suggest'] ?? [] as $suggestion) {
$suggestions[] = array_column(
$suggestion['options'] ?? [],
'text'
);
}
return $suggestions;
}
/**
* Completion Suggester(自动补全/前缀搜索)
*/
public function completionSuggest(string $prefix): array
{
$response = $this->esClient->search([
'index' => 'products',
'body' => [
'suggest' => [
'product_suggest' => [
'prefix' => $prefix,
'completion' => [
'field' => 'title.completion',
'size' => 10,
'skip_duplicates' => true,
'fuzzy' => [
'fuzziness' => 'AUTO',
],
],
],
],
'size' => 0,
],
]);
$options = $response['suggest']['product_suggest'][0]['options'] ?? [];
return array_map(fn($opt) => [
'text' => $opt['_source']['title'] ?? '',
'id' => $opt['_id'] ?? '',
], $options);
}
/**
* Phrase Suggester(短语建议)
*/
public function phraseSuggest(string $input): array
{
$response = $this->esClient->search([
'index' => 'products',
'body' => [
'suggest' => [
'phrase_suggest' => [
'text' => $input,
'phrase' => [
'field' => 'title',
'max_terms' => 5,
'confident' => true,
'collate' => [
'query' => [
'match' => ['title' => '{{suggestion}}'],
],
'params' => ['suggestion' => '{{suggestion}}'],
'prune' => true,
],
],
],
],
'size' => 0,
],
]);
return $response['suggest']['phrase_suggest'][0]['options'] ?? [];
}
}地理位置搜索
php
<?php
declare(strict_types=1);
namespace App\Repository;
use Elasticsearch\Client;
class GeoSearchRepository
{
public function __construct(
private readonly Client $esClient
) {}
/**
* 按距离搜索附近商品
*/
public function searchNearby(
float $lat,
float $lon,
string $keyword = '',
float $distance = '10km'
): array {
$query = [];
if (!empty($keyword)) {
$query['bool']['must'][] = ['match' => ['title' => $keyword]];
}
$query['bool']['filter'][] = [
'geo_distance' => [
'distance' => $distance,
'location' => ['lat' => $lat, 'lon' => $lon],
],
];
$response = $this->esClient->search([
'index' => 'products',
'body' => [
'query' => $query,
'sort' => [
'_geo_distance' => [
'location' => ['lat' => $lat, 'lon' => $lon],
'order' => 'asc',
'unit' => 'km',
],
],
'size' => 20,
],
]);
return array_map(function (array $hit) {
$hit['_source']['distance'] = $hit['sort'][0] ?? 0;
return $hit['_source'];
}, $response['hits']['hits']);
}
/**
* 按矩形区域搜索
*/
public function searchInBoundingBox(
float $topLeftLat,
float $topLeftLon,
float $bottomRightLat,
float $bottomRightLon
): array {
return $this->esClient->search([
'index' => 'products',
'body' => [
'query' => [
'geo_bounding_box' => [
'location' => [
'top_left' => ['lat' => $topLeftLat, 'lon' => $topLeftLon],
'bottom_right' => ['lat' => $bottomRightLat, 'lon' => $bottomRightLon],
],
],
],
'size' => 100,
],
]);
}
}实战示例:电商搜索服务
php
<?php
declare(strict_types=1);
namespace App\Service;
use Elasticsearch\Client;
class EcommerceSearchService
{
public function __construct(
private readonly Client $esClient,
private readonly string $index = 'products'
) {}
/**
* 综合商品搜索(搜索 + 过滤 + 排序 + 分页 + 聚合 + 高亮)
*/
public function searchProducts(array $params): array
{
// 构建查询
$query = $this->buildQuery($params);
// 构建排序
$sort = $this->buildSort($params);
// 构建聚合
$aggs = $this->buildAggregations($params);
// 构建高亮
$highlight = $this->buildHighlight();
$page = (int) ($params['page'] ?? 1);
$size = (int) ($params['size'] ?? 20);
$body = [
'query' => $query,
'sort' => $sort,
'aggs' => $aggs,
'highlight' => $highlight,
'from' => ($page - 1) * $size,
'size' => $size,
'_source' => [
'product_id', 'title', 'description', 'price',
'original_price', 'brand', 'category', 'tags',
'sales_count', 'rating', 'is_on_sale', 'main_image',
],
'track_total_hits' => true,
];
$response = $this->esClient->search([
'index' => $this->index,
'body' => $body,
]);
return $this->formatResponse($response);
}
private function buildQuery(array $params): array
{
$bool = [];
// 全文搜索
if (!empty($params['keyword'])) {
$bool['must'][] = [
'function_score' => [
'query' => [
'bool' => [
'should' => [
['match' => ['title' => ['query' => $params['keyword'], 'boost' => 3]]],
['match' => ['description' => ['query' => $params['keyword'], 'boost' => 1]]],
['match' => ['brand' => ['query' => $params['keyword'], 'boost' => 2]]],
],
],
],
'functions' => [
[
'field_value_factor' => [
'field' => 'sales_count',
'factor' => 0.0001,
'modifier' => 'log1p',
],
],
[
'field_value_factor' => [
'field' => 'rating',
'factor' => 1.2,
'modifier' => 'sqrt',
],
],
],
'score_mode' => 'sum',
'boost_mode' => 'multiply',
],
];
} else {
$bool['must'][] = ['match_all' => (object)[]];
}
// 过滤条件
$filters = [];
if (!empty($params['category'])) {
$filters[] = ['term' => ['category' => $params['category']]];
}
if (!empty($params['brand'])) {
$filters[] = ['term' => ['brand' => $params['brand']]];
}
if (!empty($params['tags'])) {
$filters[] = ['terms' => ['tags' => (array) $params['tags']]];
}
if (isset($params['min_price'])) {
$filters[] = ['range' => ['price' => ['gte' => (float) $params['min_price']]]];
}
if (isset($params['max_price'])) {
$filters[] = ['range' => ['price' => ['lte' => (float) $params['max_price']]]];
}
if (isset($params['is_on_sale'])) {
$filters[] = ['term' => ['is_on_sale' => (bool) $params['is_on_sale']]];
}
if (!empty($filters)) {
$bool['filter'] = $filters;
}
return ['bool' => $bool];
}
private function buildSort(array $params): array
{
$sortField = $params['sort'] ?? '_score';
$sortOrder = $params['order'] ?? 'desc';
return match ($sortField) {
'price' => [['price' => ['order' => $sortOrder]]],
'sales' => [['sales_count' => ['order' => $sortOrder]]],
'rating' => [['rating' => ['order' => $sortOrder]]],
'newest' => [['created_at' => ['order' => $sortOrder]]],
default => [['_score' => ['order' => 'desc']]],
};
}
private function buildAggregations(array $params): array
{
return [
'categories' => [
'terms' => ['field' => 'category', 'size' => 50],
],
'brands' => [
'terms' => ['field' => 'brand', 'size' => 30],
],
'price_ranges' => [
'range' => [
'field' => 'price',
'ranges' => [
['to' => 50], ['from' => 50, 'to' => 200],
['from' => 200, 'to' => 500], ['from' => 500],
],
],
],
];
}
private function buildHighlight(): array
{
return [
'pre_tags' => ['<em class="hl">'],
'post_tags' => ['</em>'],
'fields' => [
'title' => [
'fragment_size' => 100,
'number_of_fragments' => 1,
],
'description' => [
'fragment_size' => 200,
'number_of_fragments' => 2,
],
],
];
}
private function formatResponse(array $response): array
{
$hits = $response['hits'] ?? [];
$aggs = $response['aggregations'] ?? [];
return [
'total' => $hits['total']['value'] ?? 0,
'page' => 1,
'size' => count($hits['hits'] ?? []),
'items' => array_map(function (array $hit): array {
return array_merge(
$hit['_source'],
[
'_id' => $hit['_id'],
'_score' => $hit['_score'] ?? 0,
'highlights' => $hit['highlight'] ?? [],
]
);
}, $hits['hits'] ?? []),
'facets' => [
'categories' => $aggs['categories']['buckets'] ?? [],
'brands' => $aggs['brands']['buckets'] ?? [],
'price_ranges' => $aggs['price_ranges']['buckets'] ?? [],
],
];
}
}注意事项
深度分页问题
Elasticsearch 的 from + size 分页在深度分页时性能急剧下降:
php
<?php
// 当 from + size > 10000 时会报错
// 解决方案 1:使用 scroll API(适合数据导出)
// 解决方案 2:使用 search_after(适合实时翻页)
// 解决方案 3:增大 max_result_window(不推荐,增加内存压力)
// search_after 使用示例(需要唯一排序字段)
public function searchAfter(array $params, ?array $searchAfter = null): array
{
$body = [
'query' => ['match_all' => (object)[]],
'size' => 20,
'sort' => [
'created_at' => 'desc',
'_id' => 'asc',
],
];
if ($searchAfter !== null) {
$body['search_after'] = $searchAfter;
}
$response = $this->esClient->search(['index' => 'products', 'body' => $body]);
$hits = $response['hits']['hits'];
return [
'items' => array_column($hits, '_source'),
'next_after' => !empty($hits) ? end($hits)['sort'] : null,
'has_more' => count($hits) >= 20,
];
}查询缓存优化
- filter 上下文的查询会被缓存,尽量使用 filter 而非 must 做精确过滤
- 避免在
range查询中使用时间now,因为每秒变化会导致缓存失效 - 对于固定时间段范围查询,使用绝对时间值代替
now
最佳实践
- Mapping 预定义:使用
dynamic: strict避免脏数据写入 - 索引模板:用模板管理索引配置,减少手动配置错误
- 别名切换:使用索引别名实现零停机索引重建
- 聚合 + 搜索:搜索时附带聚合结果,一次请求同时获取过滤选项
- 评分调优:使用
function_score结合业务指标(销量、评分、时间)优化排序 - 深度分页:使用
search_after实现无限滚动翻页
下一节
继续学习:最佳实践