Laravel 13 提供了强大的全文搜索支持,本文介绍如何构建高效的全文搜索系统。

全文搜索概述

搜索架构设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php

namespace App\Services\Search;

interface SearchEngineInterface
{
public function index(string $index, array $documents): bool;
public function search(string $index, string $query, array $options = []): array;
public function delete(string $index, string $id): bool;
public function clear(string $index): bool;
}

class SearchConfig
{
public static function getIndices(): array
{
return [
'products' => [
'model' => \App\Models\Product::class,
'fields' => ['name', 'description', 'sku', 'category_name'],
'searchable' => ['name^3', 'description', 'sku^2'],
'filters' => ['category_id', 'brand_id', 'price', 'status'],
'sorts' => ['created_at', 'price', 'popularity'],
],
'articles' => [
'model' => \App\Models\Article::class,
'fields' => ['title', 'content', 'author_name', 'tags'],
'searchable' => ['title^2', 'content'],
'filters' => ['category_id', 'author_id', 'status'],
'sorts' => ['published_at', 'view_count'],
],
];
}
}

搜索模型

可搜索模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php

namespace App\Models;

use App\Services\Search\Searchable;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
use Searchable;

protected $fillable = [
'name',
'description',
'sku',
'price',
'category_id',
'brand_id',
'status',
];

public function toSearchArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'sku' => $this->sku,
'price' => $this->price,
'category_id' => $this->category_id,
'category_name' => $this->category->name,
'brand_id' => $this->brand_id,
'brand_name' => $this->brand->name,
'status' => $this->status,
'created_at' => $this->created_at->timestamp,
];
}

public function getSearchIndex(): string
{
return 'products';
}
}

可搜索 Trait

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<?php

namespace App\Services\Search;

trait Searchable
{
protected static function bootSearchable(): void
{
static::created(function ($model) {
$model->searchIndex();
});

static::updated(function ($model) {
$model->searchIndex();
});

static::deleted(function ($model) {
$model->searchDelete();
});
}

public function searchIndex(): bool
{
return app(SearchEngineInterface::class)->index(
$this->getSearchIndex(),
[$this->toSearchArray()]
);
}

public function searchDelete(): bool
{
return app(SearchEngineInterface::class)->delete(
$this->getSearchIndex(),
(string) $this->id
);
}

public static function search(string $query, array $options = [])
{
$engine = app(SearchEngineInterface::class);

$results = $engine->search(
(new static())->getSearchIndex(),
$query,
$options
);

return static::hydrateSearchResults($results);
}

protected static function hydrateSearchResults(array $results): mixed
{
$ids = collect($results['hits'])->pluck('id')->toArray();

$models = static::whereIn('id', $ids)
->get()
->keyBy('id');

return collect($results['hits'])->map(function ($hit) use ($models) {
$model = $models->get($hit['id']);
if ($model) {
$model->searchScore = $hit['_score'] ?? null;
$model->searchHighlight = $hit['highlight'] ?? null;
}
return $model;
})->filter();
}

abstract public function toSearchArray(): array;
abstract public function getSearchIndex(): string;
}

搜索查询构建器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
<?php

namespace App\Services\Search;

class SearchQueryBuilder
{
protected string $index;
protected ?string $query = null;
protected array $filters = [];
protected array $sorts = [];
protected int $page = 1;
protected int $perPage = 15;
protected array $aggregations = [];
protected array $highlights = [];

public function __construct(string $index)
{
$this->index = $index;
}

public function query(string $query): self
{
$this->query = $query;
return $this;
}

public function where(string $field, $value): self
{
$this->filters[] = [
'type' => 'term',
'field' => $field,
'value' => $value,
];
return $this;
}

public function whereIn(string $field, array $values): self
{
$this->filters[] = [
'type' => 'terms',
'field' => $field,
'values' => $values,
];
return $this;
}

public function whereBetween(string $field, $min, $max): self
{
$this->filters[] = [
'type' => 'range',
'field' => $field,
'min' => $min,
'max' => $max,
];
return $this;
}

public function whereGreaterThan(string $field, $value): self
{
$this->filters[] = [
'type' => 'range',
'field' => $field,
'min' => $value,
];
return $this;
}

public function whereLessThan(string $field, $value): self
{
$this->filters[] = [
'type' => 'range',
'field' => $field,
'max' => $value,
];
return $this;
}

public function orderBy(string $field, string $direction = 'asc'): self
{
$this->sorts[] = [
'field' => $field,
'direction' => $direction,
];
return $this;
}

public function paginate(int $page, int $perPage = 15): self
{
$this->page = $page;
$this->perPage = $perPage;
return $this;
}

public function limit(int $limit): self
{
$this->perPage = $limit;
return $this;
}

public function aggregate(string $name, string $type, string $field): self
{
$this->aggregations[$name] = [
'type' => $type,
'field' => $field,
];
return $this;
}

public function highlight(array $fields): self
{
$this->highlights = $fields;
return $this;
}

public function get(): SearchResult
{
$engine = app(SearchEngineInterface::class);

$results = $engine->search($this->index, $this->query, $this->toArray());

return new SearchResult(
hits: $results['hits'],
total: $results['total'],
page: $this->page,
perPage: $this->perPage,
aggregations: $results['aggregations'] ?? [],
);
}

public function toArray(): array
{
return [
'query' => $this->query,
'filters' => $this->filters,
'sorts' => $this->sorts,
'page' => $this->page,
'per_page' => $this->perPage,
'aggregations' => $this->aggregations,
'highlights' => $this->highlights,
];
}
}

class SearchResult
{
public function __construct(
public array $hits,
public int $total,
public int $page,
public int $perPage,
public array $aggregations = []
) {}

public function hasMore(): bool
{
return ($this->page * $this->perPage) < $this->total;
}

public function totalPages(): int
{
return (int) ceil($this->total / $this->perPage);
}

public function from(): int
{
return ($this->page - 1) * $this->perPage + 1;
}

public function to(): int
{
return min($this->page * $this->perPage, $this->total);
}
}

搜索服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<?php

namespace App\Services\Search;

class SearchService
{
protected SearchEngineInterface $engine;

public function __construct(SearchEngineInterface $engine)
{
$this->engine = $engine;
}

public function for(string $index): SearchQueryBuilder
{
return new SearchQueryBuilder($index);
}

public function index(string $index, array $documents): bool
{
return $this->engine->index($index, $documents);
}

public function indexModel($model): bool
{
if (!method_exists($model, 'toSearchArray')) {
throw new \InvalidArgumentException('Model must implement toSearchArray method');
}

return $this->engine->index(
$model->getSearchIndex(),
[$model->toSearchArray()]
);
}

public function indexCollection(string $index, iterable $models): bool
{
$documents = [];

foreach ($models as $model) {
if (method_exists($model, 'toSearchArray')) {
$documents[] = $model->toSearchArray();
}
}

return $this->engine->index($index, $documents);
}

public function delete(string $index, string $id): bool
{
return $this->engine->delete($index, $id);
}

public function clear(string $index): bool
{
return $this->engine->clear($index);
}

public function reindex(string $index): int
{
$config = SearchConfig::getIndices()[$index] ?? null;

if (!$config) {
throw new \InvalidArgumentException("Unknown index: {$index}");
}

$model = $config['model'];
$count = 0;

$model::chunk(500, function ($models) use ($index, &$count) {
$documents = $models->map(fn($m) => $m->toSearchArray())->toArray();
$this->engine->index($index, $documents);
$count += count($documents);
});

return $count;
}
}

搜索分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<?php

namespace App\Services\Search;

use Illuminate\Support\Facades\Cache;

class SearchAnalytics
{
protected string $prefix = 'search:analytics:';

public function recordQuery(string $index, string $query, int $resultsCount): void
{
$date = today()->format('Y-m-d');
$key = $this->prefix . "queries:{$index}:{$date}";

Cache::increment($key . ':total');

$queryKey = md5($query);
Cache::increment($key . ':query:' . $queryKey);

if ($resultsCount === 0) {
Cache::increment($key . ':zero_results');
}
}

public function recordClick(string $index, string $query, string $documentId, int $position): void
{
$date = today()->format('Y-m-d');
$key = $this->prefix . "clicks:{$index}:{$date}";

Cache::increment($key . ':total');
Cache::increment($key . ':position:' . $position);
}

public function getTopQueries(string $index, int $days = 7, int $limit = 10): array
{
$queries = [];

for ($i = 0; $i < $days; $i++) {
$date = today()->subDays($i)->format('Y-m-d');
$pattern = $this->prefix . "queries:{$index}:{$date}:query:*";

foreach (Cache::get($pattern, []) as $key => $count) {
$queryHash = str_replace($pattern, '', $key);
$queries[$queryHash] = ($queries[$queryHash] ?? 0) + $count;
}
}

arsort($queries);

return array_slice($queries, 0, $limit, true);
}

public function getZeroResultQueries(string $index, int $days = 7): array
{
$queries = [];

for ($i = 0; $i < $days; $i++) {
$date = today()->subDays($i)->format('Y-m-d');
$key = $this->prefix . "queries:{$index}:{$date}:zero_results";

if ($count = Cache::get($key)) {
$queries[$date] = $count;
}
}

return $queries;
}

public function getClickThroughRate(string $index, int $days = 7): float
{
$totalQueries = 0;
$totalClicks = 0;

for ($i = 0; $i < $days; $i++) {
$date = today()->subDays($i)->format('Y-m-d');

$totalQueries += Cache::get($this->prefix . "queries:{$index}:{$date}:total", 0);
$totalClicks += Cache::get($this->prefix . "clicks:{$index}:{$date}:total", 0);
}

return $totalQueries > 0 ? $totalClicks / $totalQueries : 0.0;
}
}

搜索建议

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?php

namespace App\Services\Search;

class SearchSuggester
{
protected SearchEngineInterface $engine;
protected array $cache = [];

public function __construct(SearchEngineInterface $engine)
{
$this->engine = $engine;
}

public function suggest(string $index, string $prefix, int $limit = 10): array
{
$cacheKey = "{$index}:{$prefix}";

if (isset($this->cache[$cacheKey])) {
return $this->cache[$cacheKey];
}

$results = $this->engine->search($index, $prefix, [
'type' => 'suggest',
'limit' => $limit,
]);

$this->cache[$cacheKey] = $results['suggestions'] ?? [];

return $this->cache[$cacheKey];
}

public function suggestWithHighlight(string $index, string $prefix, int $limit = 10): array
{
$suggestions = $this->suggest($index, $prefix, $limit);

return array_map(function ($suggestion) use ($prefix) {
$highlighted = preg_replace(
'/(' . preg_quote($prefix, '/') . ')/i',
'<strong>$1</strong>',
$suggestion
);

return [
'text' => $suggestion,
'highlighted' => $highlighted,
];
}, $suggestions);
}
}

搜索命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php

namespace App\Console\Commands;

use App\Services\Search\SearchService;
use Illuminate\Console\Command;

class SearchIndexCommand extends Command
{
protected $signature = 'search:index {index : The index to reindex} {--clear : Clear index before reindexing}';
protected $description = 'Reindex search data';

public function handle(SearchService $search): int
{
$index = $this->argument('index');

if ($this->option('clear')) {
$this->info("Clearing index: {$index}");
$search->clear($index);
}

$this->info("Reindexing: {$index}");

$count = $search->reindex($index);

$this->info("Indexed {$count} documents");

return self::SUCCESS;
}
}

总结

Laravel 13 的全文搜索系统需要结合搜索引擎、查询构建器、搜索分析和建议功能来构建。通过合理的架构设计和优化,可以实现高效、精准的搜索体验。