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++; } } }