Skip to content

索引与查询进阶

本节深入讲解 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_stringLucene 语法查询高级用户自定义查询
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

最佳实践

  1. Mapping 预定义:使用 dynamic: strict 避免脏数据写入
  2. 索引模板:用模板管理索引配置,减少手动配置错误
  3. 别名切换:使用索引别名实现零停机索引重建
  4. 聚合 + 搜索:搜索时附带聚合结果,一次请求同时获取过滤选项
  5. 评分调优:使用 function_score 结合业务指标(销量、评分、时间)优化排序
  6. 深度分页:使用 search_after 实现无限滚动翻页

下一节

继续学习:最佳实践

参考链接