Files
YwxAppThink/ywxapp/library/Search/EsSearch.php
T

227 lines
8.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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++;
}
}
}