227 lines
8.2 KiB
PHP
227 lines
8.2 KiB
PHP
<?php
|
||
/**
|
||
* EsSearch —— 轻量 ElasticSearch REST 封装(零新增依赖,复用项目已引入的 GuzzleHttp)
|
||
*
|
||
* 设计原则(对应路线 C3「ES 全文检索替代 LIKE」):
|
||
* - 薄封装:ES 仅负责「按关键词返回匹配的 id 列表」,命中后由调用方 `whereIn('id', $ids)` 回表取完整行 + 关联,
|
||
* 最大限度复用现有视图与模型关联,降低改造面。
|
||
* - 可降级:本类方法在 ES 不可用时抛出异常,由上层 SearchService 捕获并回退到原生 LIKE。
|
||
* - 中文分词:依赖 ES 服务端 IK 分词插件(index 创建时指定 analyzer);若未装,ES 默认 standard 仍可按词/字匹配。
|
||
*
|
||
* 依赖:项目根 composer.json 已间接引入 guzzlehttp/guzzle(经 yansongda/pay)。
|
||
*/
|
||
declare(strict_types=1);
|
||
|
||
namespace ywxapp\library\Search;
|
||
|
||
use GuzzleHttp\Client;
|
||
use GuzzleHttp\Exception\TransferException;
|
||
|
||
class EsSearch
|
||
{
|
||
/** @var Client|null 懒加载的 HTTP 客户端 */
|
||
protected static ?Client $client = null;
|
||
|
||
/**
|
||
* 获取 HTTP 客户端
|
||
*/
|
||
protected static function client(): Client
|
||
{
|
||
if (self::$client === null) {
|
||
self::$client = new Client(['timeout' => 3.0, 'connect_timeout' => 2.0]);
|
||
}
|
||
return self::$client;
|
||
}
|
||
|
||
/**
|
||
* 拼接 ES 端点 URL
|
||
*/
|
||
protected static function url(string $host, string $path): string
|
||
{
|
||
return rtrim($host, '/') . $path;
|
||
}
|
||
|
||
/**
|
||
* 确保索引存在(带中文 IK 分词器映射,缺失则创建)
|
||
* @throws \RuntimeException
|
||
*/
|
||
public static function ensureIndex(string $host, string $index, array $fields): void
|
||
{
|
||
$url = self::url($host, '/' . $index);
|
||
try {
|
||
$resp = self::client()->request('HEAD', $url);
|
||
if ($resp->getStatusCode() === 200) {
|
||
return; // 已存在
|
||
}
|
||
} catch (TransferException $e) {
|
||
throw new \RuntimeException('ES 连接失败:' . $e->getMessage());
|
||
}
|
||
|
||
// 创建索引,默认用 ik_max_word(若有 IK 插件),否则 standard
|
||
$properties = [];
|
||
foreach ($fields as $f) {
|
||
$properties[$f] = ['type' => 'text', 'analyzer' => 'ik_max_word'];
|
||
}
|
||
$body = json_encode([
|
||
'settings' => ['number_of_shards' => 1, 'number_of_replicas' => 0],
|
||
'mappings' => ['properties' => $properties],
|
||
], JSON_UNESCAPED_UNICODE);
|
||
try {
|
||
self::client()->request('PUT', $url, ['body' => $body, 'headers' => ['Content-Type' => 'application/json']]);
|
||
} catch (TransferException $e) {
|
||
// IK 分词器不存在时降级为 standard
|
||
$properties = [];
|
||
foreach ($fields as $f) {
|
||
$properties[$f] = ['type' => 'text'];
|
||
}
|
||
$body = json_encode([
|
||
'settings' => ['number_of_shards' => 1, 'number_of_replicas' => 0],
|
||
'mappings' => ['properties' => $properties],
|
||
], JSON_UNESCAPED_UNICODE);
|
||
self::client()->request('PUT', $url, ['body' => $body, 'headers' => ['Content-Type' => 'application/json']]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 索引单条文档
|
||
* @throws \RuntimeException
|
||
*/
|
||
public static function indexDoc(string $host, string $index, int $id, array $body): void
|
||
{
|
||
try {
|
||
self::client()->request(
|
||
'PUT',
|
||
self::url($host, '/' . $index . '/_doc/' . $id),
|
||
['body' => json_encode($body, JSON_UNESCAPED_UNICODE), 'headers' => ['Content-Type' => 'application/json']]
|
||
);
|
||
} catch (TransferException $e) {
|
||
throw new \RuntimeException('ES 写入失败:' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除文档
|
||
*/
|
||
public static function deleteDoc(string $host, string $index, int $id): void
|
||
{
|
||
try {
|
||
self::client()->request('DELETE', self::url($host, '/' . $index . '/_doc/' . $id));
|
||
} catch (TransferException $e) {
|
||
// 索引不存在或文档已删,忽略
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 全文检索,返回匹配的 id 列表(按相关度排序)
|
||
* @param array $fields 检索字段,如 ['title','content']
|
||
* @return int[] 命中文档 id(相关度降序)
|
||
* @throws \RuntimeException
|
||
*/
|
||
public static function search(string $host, string $index, string $keyword, array $fields, int $limit = 50, int $from = 0): array
|
||
{
|
||
if (trim($keyword) === '') {
|
||
return [];
|
||
}
|
||
$should = [];
|
||
foreach ($fields as $f) {
|
||
$should[] = ['match' => [$f => ['query' => $keyword, 'boost' => 1.0]]];
|
||
}
|
||
$body = json_encode([
|
||
'from' => $from,
|
||
'size' => $limit,
|
||
'query' => ['bool' => ['should' => $should, 'minimum_should_match' => 1]],
|
||
'_source' => false,
|
||
], JSON_UNESCAPED_UNICODE);
|
||
try {
|
||
$resp = self::client()->request(
|
||
'POST',
|
||
self::url($host, '/' . $index . '/_search'),
|
||
['body' => $body, 'headers' => ['Content-Type' => 'application/json']]
|
||
);
|
||
} catch (TransferException $e) {
|
||
throw new \RuntimeException('ES 检索失败:' . $e->getMessage());
|
||
}
|
||
$data = json_decode((string) $resp->getBody(), true);
|
||
$ids = [];
|
||
foreach (($data['hits']['hits'] ?? []) as $hit) {
|
||
$ids[] = (int) ($hit['_id'] ?? 0);
|
||
}
|
||
return $ids;
|
||
}
|
||
|
||
/**
|
||
* 相关推荐(基于 ES more_like_this,按内容相似度返回相似文档 id)
|
||
* @param array $fields 参与相似的字段,如 ['title','content']
|
||
* @param string $likeText 源文档文本(title + content 拼接)
|
||
* @return int[] 相似文档 id(相关度降序,不含自身)
|
||
* @throws \RuntimeException
|
||
*/
|
||
public static function moreLikeThis(string $host, string $index, string $likeText, array $fields, int $excludeId, int $limit = 6): array
|
||
{
|
||
$likeText = trim($likeText);
|
||
if ($likeText === '') {
|
||
return [];
|
||
}
|
||
$body = json_encode([
|
||
'size' => $limit + 1,
|
||
'query' => [
|
||
'more_like_this' => [
|
||
'fields' => $fields,
|
||
'like' => $likeText,
|
||
'min_term_freq' => 1,
|
||
'min_doc_freq' => 1,
|
||
'minimum_should_match' => '20%',
|
||
],
|
||
],
|
||
'_source' => false,
|
||
], JSON_UNESCAPED_UNICODE);
|
||
try {
|
||
$resp = self::client()->request(
|
||
'POST',
|
||
self::url($host, '/' . $index . '/_search'),
|
||
['body' => $body, 'headers' => ['Content-Type' => 'application/json']]
|
||
);
|
||
} catch (TransferException $e) {
|
||
throw new \RuntimeException('ES 相关推荐失败:' . $e->getMessage());
|
||
}
|
||
$data = json_decode((string) $resp->getBody(), true);
|
||
$ids = [];
|
||
foreach (($data['hits']['hits'] ?? []) as $hit) {
|
||
$id = (int) ($hit['_id'] ?? 0);
|
||
if ($id !== $excludeId) {
|
||
$ids[] = $id;
|
||
}
|
||
}
|
||
return array_slice($ids, 0, $limit);
|
||
}
|
||
|
||
/**
|
||
* 全量重建索引(清空后批量写入)
|
||
* @param callable $each 迭代器回调:function(int $page, int $size): array 返回 [id=>body] 映射
|
||
*/
|
||
public static function rebuild(string $host, string $index, array $fields, callable $each, int $size = 200): void
|
||
{
|
||
// 删除旧索引重建
|
||
try {
|
||
self::client()->request('DELETE', self::url($host, '/' . $index));
|
||
} catch (TransferException $e) {
|
||
}
|
||
self::ensureIndex($host, $index, $fields);
|
||
|
||
$page = 1;
|
||
while (true) {
|
||
$batch = $each($page, $size);
|
||
if (empty($batch)) {
|
||
break;
|
||
}
|
||
foreach ($batch as $id => $doc) {
|
||
self::indexDoc($host, $index, (int) $id, $doc);
|
||
}
|
||
if (count($batch) < $size) {
|
||
break;
|
||
}
|
||
$page++;
|
||
}
|
||
}
|
||
}
|