[
'bold_single' => 50,
'bold_multi' => 10,
'title.text.pali' => 3,
'title.text.zh' => 3,
'summary.text' => 2,
'content.text.pali' => 1,
'content.text.zh' => 1,
],
'hybrid' => [
'fuzzy_ratio' => 0.7,
'semantic_ratio' => 0.3,
'bold_single' => 50,
'bold_multi' => 10,
'title.text.pali' => 3,
'title.text.zh' => 3,
'summary.text' => 2,
'content.text.pali' => 1,
'content.text.zh' => 1,
],
];
/**
* OpenSearch 索引定义(settings + mappings)
*
* 字段结构说明:
*
* title
* ├── text
* │ ├── pali (text) 模糊查询 + exact subfield 精确查询
* │ └── zh (text) 中文分词查询
* ├── vector (knn_vector, dim=1536)
* └── suggest
* ├── pali (completion)
* └── zh (completion)
*
* content(结构与 title 一致,额外包含 tokens nested 字段)
* ├── text
* │ ├── pali (text)
* │ └── zh (text)
* ├── tokens (nested)
* ├── vector (knn_vector, dim=1536)
* └── suggest
* ├── pali (completion)
* └── zh (completion)
*
* summary(中文摘要,结构保持不变)
* ├── text (text)
* └── vector (knn_vector, dim=1536)
*
* @var array
*/
private $indexDefinition = [
'settings' => [
'index' => [
'knn' => true,
],
'analysis' => [
'analyzer' => [
'pali_query_analyzer' => [
'tokenizer' => 'standard',
'filter' => ['lowercase', 'pali_synonyms'],
],
'pali_index_analyzer' => [
'type' => 'custom',
'tokenizer' => 'standard',
'char_filter' => ['markdown_strip'],
'filter' => ['lowercase'],
],
'markdown_clean' => [
'type' => 'custom',
'tokenizer' => 'standard',
'char_filter' => ['markdown_strip'],
'filter' => ['lowercase'],
],
// Suggest 专用(忽略大小写 + 变音)
'pali_suggest_analyzer' => [
'tokenizer' => 'standard',
'filter' => ['lowercase', 'asciifolding'],
],
'zh_suggest_analyzer' => [
'tokenizer' => 'ik_max_word',
'char_filter' => ['tsconvert'],
],
// 中文简繁统一 (繁 -> 简)
'zh_index_analyzer' => [
'tokenizer' => 'ik_max_word',
'char_filter' => ['tsconvert'],
],
'zh_query_analyzer' => [
'tokenizer' => 'ik_smart',
'char_filter' => ['tsconvert'],
],
],
'filter' => [
'pali_synonyms' => [
'type' => 'synonym_graph',
'synonyms_path' => 'analysis/pali_synonyms.txt',
'updateable' => true,
],
],
'char_filter' => [
'markdown_strip' => [
'type' => 'pattern_replace',
'pattern' => '\\*\\*|\\*|_|`|~',
'replacement' => '',
],
'tsconvert' => [
'type' => 'stconvert',
'convert_type' => 't2s',
],
],
],
],
'mappings' => [
'properties' => [
'id' => ['type' => 'keyword'],
'resource_id' => ['type' => 'keyword'],
'resource_type' => ['type' => 'keyword'],
// ----------------------------------------------------------------
// title
// text.pali → 模糊查询(+ exact subfield 精确查询)
// text.zh → 中文查询
// vector → 语义向量
// suggest.pali / suggest.zh → 自动建议
// ----------------------------------------------------------------
'title' => [
'properties' => [
'text' => [
'properties' => [
'pali' => [
'type' => 'text',
'analyzer' => 'pali_index_analyzer',
'search_analyzer' => 'pali_query_analyzer',
'fields' => [
'exact' => [
'type' => 'text',
'analyzer' => 'markdown_clean',
],
],
],
'zh' => [
'type' => 'text',
'analyzer' => 'zh_index_analyzer',
'search_analyzer' => 'zh_query_analyzer',
],
],
],
'vector' => [
'type' => 'knn_vector',
'dimension' => 1536,
'method' => [
'name' => 'hnsw',
'space_type' => 'cosinesimil',
'engine' => 'nmslib',
],
],
'suggest' => [
'properties' => [
'pali' => [
'type' => 'completion',
'analyzer' => 'pali_suggest_analyzer',
],
'zh' => [
'type' => 'completion',
'analyzer' => 'zh_suggest_analyzer',
],
],
],
],
],
// ----------------------------------------------------------------
// summary(LLM 生成的简体中文摘要,结构保持不变)
// text → 中文查询
// vector → 语义向量
// ----------------------------------------------------------------
'summary' => [
'properties' => [
'text' => [
'type' => 'text',
'analyzer' => 'zh_index_analyzer',
'search_analyzer' => 'zh_query_analyzer',
],
'vector' => [
'type' => 'knn_vector',
'dimension' => 1536,
'method' => [
'name' => 'hnsw',
'space_type' => 'cosinesimil',
'engine' => 'nmslib',
],
],
],
],
// ----------------------------------------------------------------
// content(结构与 title 对称,额外包含 tokens nested 字段)
// text.pali → 模糊查询(+ exact subfield 精确查询)
// text.zh → 中文查询
// tokens → 词法分析结果(nested)
// vector → 语义向量
// suggest.pali / suggest.zh → 自动建议
// ----------------------------------------------------------------
'content' => [
'properties' => [
'text' => [
'properties' => [
'pali' => [
'type' => 'text',
'analyzer' => 'pali_index_analyzer',
'search_analyzer' => 'pali_query_analyzer',
'fields' => [
'exact' => [
'type' => 'text',
'analyzer' => 'markdown_clean',
],
],
],
'zh' => [
'type' => 'text',
'analyzer' => 'zh_index_analyzer',
'search_analyzer' => 'zh_query_analyzer',
],
],
],
'tokens' => [
'type' => 'nested',
'properties' => [
'surface' => ['type' => 'keyword'],
'lemma' => ['type' => 'keyword'],
'compound_parts' => ['type' => 'keyword'],
'case' => ['type' => 'keyword'],
],
],
'vector' => [
'type' => 'knn_vector',
'dimension' => 1536,
'method' => [
'name' => 'hnsw',
'space_type' => 'cosinesimil',
'engine' => 'nmslib',
],
],
'suggest' => [
'properties' => [
'pali' => [
'type' => 'completion',
'analyzer' => 'pali_suggest_analyzer',
],
'zh' => [
'type' => 'completion',
'analyzer' => 'zh_suggest_analyzer',
],
],
],
// 前端展示用,原始 HTML,不参与索引
'display' => [
'type' => 'text',
'index' => false,
],
],
],
'related_id' => ['type' => 'keyword'],
'bold_single' => [
'type' => 'text',
'analyzer' => 'standard',
'search_analyzer' => 'pali_query_analyzer',
],
'bold_multi' => [
'type' => 'text',
'analyzer' => 'standard',
'search_analyzer' => 'pali_query_analyzer',
],
'path' => ['type' => 'text', 'analyzer' => 'standard'],
'page_refs' => ['type' => 'keyword'],
'tags' => ['type' => 'keyword'],
'category' => ['type' => 'keyword'],
'author' => ['type' => 'text'],
'language' => ['type' => 'keyword'],
'updated_at' => ['type' => 'date'],
'granularity' => ['type' => 'keyword'],
'metadata' => [
'properties' => [
'APA' => ['type' => 'text', 'index' => false],
'MLA' => ['type' => 'text', 'index' => false],
'widget' => ['type' => 'text', 'index' => false],
'author' => ['type' => 'text'],
'channel' => ['type' => 'text'],
],
],
],
],
];
/**
* 创建 OpenSearchService 实例
*
* 从 config('mint.opensearch.config') 读取连接配置,
* 同时初始化 OpenAI HTTP 客户端用于 embedding 调用。
*/
public function __construct()
{
$config = config('mint.opensearch.config');
$hostUrl = "{$config['scheme']}://{$config['host']}:{$config['port']}";
$this->client = (new GuzzleClientFactory())->create([
'base_uri' => $hostUrl,
'auth' => [$config['username'], $config['password']],
'verify' => $config['ssl_verification'],
]);
$this->openaiApiKey = env('OPENAI_API_KEY');
$this->http = new Client([
'base_uri' => 'https://api.openai.com/v1/',
'timeout' => 15,
]);
}
/**
* 动态覆盖指定搜索模式的字段权重
*
* @param string $mode 搜索模式,支持 'fuzzy' | 'hybrid'
* @param array $weights 需要覆盖的权重键值对,例如:['title.text.pali' => 5]
* @return void
*/
public function setWeights(string $mode, array $weights): void
{
if (isset($this->weights[$mode])) {
$this->weights[$mode] = array_merge($this->weights[$mode], $weights);
}
}
/**
* 测试与 OpenSearch 集群的连接状态
*
* @return array{0: bool, 1: string} [连接是否成功, 描述信息]
*/
public function testConnection(): array
{
try {
$info = $this->client->info();
$message = 'OpenSearch 连接成功: ' . json_encode($info['version']['number']);
Log::info($message);
return [true, $message];
} catch (\Exception $e) {
$message = 'OpenSearch 连接失败: ' . $e->getMessage();
Log::error($message);
return [false, $message];
}
}
/**
* 检查当前索引是否已存在
*
* @return bool
*/
public function indexExists(): bool
{
$index = config('mint.opensearch.index');
return $this->client->indices()->exists(['index' => $index]);
}
/**
* 创建 OpenSearch 索引
*
* 使用 $indexDefinition 中定义的 settings 和 mappings 创建索引。
* 若索引已存在则抛出异常,避免覆盖生产数据。
*
* @return array OpenSearch 响应
*
* @throws \Exception 索引已存在时抛出
*/
public function createIndex(): array
{
$index = config('mint.opensearch.index');
$exists = $this->client->indices()->exists(['index' => $index]);
if ($exists) {
throw new \Exception("Index [$index] already exists.");
}
return $this->client->indices()->create([
'index' => $index,
'body' => $this->indexDefinition,
]);
}
/**
* 更新已有索引的 settings 和 mappings
*
* 更新 settings 时会临时关闭索引(close → putSettings → open),
* 更新 mappings 支持热更新(新增字段),不可修改已有字段类型。
*
* @return array 包含 'settings' 和/或 'mappings' 的响应数组
*/
public function updateIndex(): array
{
$index = config('mint.opensearch.index');
$settings = $this->indexDefinition['settings'] ?? [];
$mappings = $this->indexDefinition['mappings'] ?? [];
$response = [];
if (!empty($settings)) {
$this->client->indices()->close(['index' => $index]);
$response['settings'] = $this->client->indices()->putSettings([
'index' => $index,
'body' => ['settings' => $settings],
]);
$this->client->indices()->open(['index' => $index]);
}
if (!empty($mappings)) {
$response['mappings'] = $this->client->indices()->putMapping([
'index' => $index,
'body' => $mappings,
]);
}
return $response;
}
/**
* 删除当前索引
*
* @return array OpenSearch 响应
*/
public function deleteIndex(): array
{
$index = config('mint.opensearch.index');
return $this->client->indices()->delete(['index' => $index]);
}
/**
* 统计索引文档数量(支持可选条件过滤)
*
* @param array|null $query OpenSearch DSL query 子句,为 null 时统计全部文档。
* 示例:['term' => ['language' => 'zh']]
* ['exists' => ['field' => 'content.vector']]
* @return int 文档总数
*
* @throws \Exception
*
* @example
* $service->count();
* $service->count(['exists' => ['field' => 'content.vector']]);
*/
public function count(?array $query = null): int
{
$index = config('mint.opensearch.index');
$params = ['index' => $index];
if (!empty($query)) {
$params['body'] = ['query' => $query];
}
$response = $this->client->count($params);
return (int) ($response['count'] ?? 0);
}
/**
* 写入或覆盖单条文档
*
* @param string $id 文档 ID
* @param array $body 文档内容,字段结构须与 mappings 一致
* @return array OpenSearch 响应
*/
public function create(string $id, array $body): array
{
return $this->client->index([
'index' => config('mint.opensearch.index'),
'id' => $id,
'body' => $body,
]);
}
/**
* 删除单条文档
*
* @param string $id 文档 ID
* @return array OpenSearch 响应
*/
public function delete(string $id): array
{
return $this->client->delete([
'index' => config('mint.opensearch.index'),
'id' => $id,
]);
}
/**
* 执行高级搜索
*
* 支持四种搜索模式:
* - fuzzy 多字段模糊查询(默认),基于 BM25
* - exact 精确匹配,使用 markdown_clean analyzer
* - semantic 纯语义向量搜索,需要 OpenAI embedding
* - hybrid fuzzy + semantic 混合,权重由 fuzzy_ratio / semantic_ratio 控制
*
* 支持的过滤参数:
* resourceType, resourceId, granularity, language, category,
* tags, pageRefs, relatedId, author, channel
*
* @param array $params {
* @type string $query 搜索关键词(必填)
* @type string $searchMode 搜索模式,默认 'fuzzy'
* @type int $page 页码,默认 1
* @type int $pageSize 每页条数,默认 20
* @type string $resourceType 按资源类型过滤
* @type string $resourceId 按资源 ID 过滤
* @type string $granularity 按粒度过滤
* @type string $language 按语言过滤
* @type string $category 按分类过滤
* @type array $tags 按标签过滤(terms)
* @type array $pageRefs 按页码引用过滤(terms)
* @type string $relatedId 按关联 ID 过滤
* @type string $author 按作者过滤
* @type string $channel 按频道过滤
* @type array $highlight_pre_tags 高亮前置标签,默认 ['']
* @type array $highlight_post_tags 高亮后置标签,默认 ['']
* }
* @return array OpenSearch 原始响应
*
* @throws \Exception semantic / hybrid 模式下 embedding 调用失败时抛出
*/
public function search(array $params): array
{
$page = $params['page'] ?? 1;
$pageSize = $params['pageSize'] ?? 20;
$from = ($page - 1) * $pageSize;
$mode = $params['searchMode'] ?? 'fuzzy';
// ---------- 过滤条件 ----------
$filters = [];
if (!empty($params['resourceType'])) {
$filters[] = ['term' => ['resource_type' => $params['resourceType']]];
}
if (!empty($params['resourceId'])) {
$filters[] = ['term' => ['resource_id' => $params['resourceId']]];
}
if (!empty($params['granularity'])) {
$filters[] = ['term' => ['granularity' => $params['granularity']]];
}
if (!empty($params['language'])) {
$filters[] = ['term' => ['language' => $params['language']]];
}
if (!empty($params['category'])) {
$filters[] = ['term' => ['category' => $params['category']]];
}
if (!empty($params['tags'])) {
$filters[] = ['terms' => ['tags' => $params['tags']]];
}
if (!empty($params['pageRefs'])) {
$filters[] = ['terms' => ['page_refs' => $params['pageRefs']]];
}
if (!empty($params['relatedId'])) {
$filters[] = ['term' => ['related_id' => $params['relatedId']]];
}
if (!empty($params['author'])) {
$filters[] = ['match' => ['metadata.author' => $params['author']]];
}
if (!empty($params['channel'])) {
$filters[] = ['term' => ['metadata.channel' => $params['channel']]];
}
// ---------- 查询部分 ----------
switch ($mode) {
case 'exact':
$query = $this->buildExactQuery($params['query']);
break;
case 'semantic':
$query = $this->buildSemanticQuery($params['query']);
break;
case 'hybrid':
$query = $this->buildHybridQuery($params['query']);
break;
case 'fuzzy':
default:
$query = $this->buildFuzzyQuery($params['query']);
}
$highlightPreTags = $params['highlight_pre_tags'] ?? [''];
$highlightPostTags = $params['highlight_post_tags'] ?? [''];
// ---------- 最终 DSL ----------
$dsl = [
'from' => $from,
'size' => $pageSize,
'_source' => ['excludes' => $this->sourceExcludes],
'query' => !empty($filters)
? ['bool' => ['must' => [$query], 'filter' => $filters]]
: $query,
'aggs' => [
'resource_type' => ['terms' => ['field' => 'resource_type']],
'language' => ['terms' => ['field' => 'language']],
'category' => ['terms' => ['field' => 'category']],
'granularity' => ['terms' => ['field' => 'granularity']],
],
'highlight' => [
'fields' => [
'title.text.pali' => new \stdClass(),
'title.text.zh' => new \stdClass(),
'summary.text' => new \stdClass(),
'content.text.pali' => new \stdClass(),
'content.text.zh' => new \stdClass(),
],
'fragmenter' => 'sentence',
'fragment_size' => 200,
'number_of_fragments' => 1,
'pre_tags' => $highlightPreTags,
'post_tags' => $highlightPostTags,
],
];
Log::debug('OpenSearchService::search', ['dsl' => json_encode($dsl, JSON_UNESCAPED_UNICODE)]);
return $this->client->search([
'index' => config('mint.opensearch.index'),
'body' => $dsl,
]);
}
/**
* 构建 exact(精确匹配)查询
*
* 使用 markdown_clean analyzer 的 exact subfield 进行匹配,
* 适合巴利文词形精确检索场景。
*
* 查询字段:title.text.pali.exact, content.text.pali.exact, summary.text
*
* @param string $query 搜索关键词
* @return array OpenSearch DSL query 片段
*/
protected function buildExactQuery(string $query): array
{
return [
'multi_match' => [
'query' => $query,
'fields' => [
'title.text.pali.exact',
'content.text.pali.exact',
'summary.text',
],
'type' => 'best_fields',
],
];
}
/**
* 构建 semantic(纯语义向量)查询
*
* 将查询文本通过 OpenAI embedding API 转为向量,
* 同时对 content.vector、summary.vector、title.vector 三个 knn 字段检索,
* 使用 bool should 合并结果。
*
* @param string $query 搜索关键词
* @return array OpenSearch DSL query 片段
*
* @throws \Exception embedding 调用失败时抛出
*/
protected function buildSemanticQuery(string $query): array
{
$vector = $this->embedText($query);
return [
'bool' => [
'should' => [
['knn' => ['content.vector' => ['vector' => $vector, 'k' => 20]]],
['knn' => ['summary.vector' => ['vector' => $vector, 'k' => 10]]],
['knn' => ['title.vector' => ['vector' => $vector, 'k' => 5]]],
],
'minimum_should_match' => 1,
],
];
}
/**
* 构建 fuzzy(多字段模糊)查询
*
* 基于 BM25 的 multi_match best_fields 查询,
* 字段权重取自 $weights['fuzzy']。
*
* @param string $query 搜索关键词
* @return array OpenSearch DSL query 片段
*/
protected function buildFuzzyQuery(string $query): array
{
$fields = [];
foreach ($this->weights['fuzzy'] as $field => $weight) {
$fields[] = $field . '^' . $weight;
}
return [
'multi_match' => [
'query' => $query,
'fields' => $fields,
'type' => 'best_fields',
],
];
}
/**
* 构建 hybrid(模糊 + 语义混合)查询
*
* 使用 bool should 将 fuzzy(constant_score 包裹)与三路 knn 向量查询合并,
* 权重比例由 $weights['hybrid']['fuzzy_ratio'] 和 'semantic_ratio' 控制。
* title.vector 的语义权重略高(×1.2),以提升标题匹配的排名。
*
* @param string $query 搜索关键词
* @return array OpenSearch DSL query 片段
*
* @throws \Exception embedding 调用失败时抛出
*/
protected function buildHybridQuery(string $query): array
{
$fuzzyFields = [];
foreach ($this->weights['hybrid'] as $field => $weight) {
if (in_array($field, ['fuzzy_ratio', 'semantic_ratio'])) {
continue;
}
$fuzzyFields[] = $field . '^' . $weight;
}
$fuzzyPart = [
'multi_match' => [
'query' => $query,
'fields' => $fuzzyFields,
'type' => 'best_fields',
],
];
$vector = $this->embedText($query);
$fuzzyRatio = $this->weights['hybrid']['fuzzy_ratio'];
$semanticRatio = $this->weights['hybrid']['semantic_ratio'];
return [
'bool' => [
'should' => [
[
'constant_score' => [
'filter' => $fuzzyPart,
'boost' => $fuzzyRatio,
],
],
[
'knn' => [
'content.vector' => [
'vector' => $vector,
'k' => 20,
'boost' => $semanticRatio * 1.0,
],
],
],
[
'knn' => [
'summary.vector' => [
'vector' => $vector,
'k' => 10,
'boost' => $semanticRatio * 0.8,
],
],
],
[
'knn' => [
'title.vector' => [
'vector' => $vector,
'k' => 5,
'boost' => $semanticRatio * 1.2, // title 权重略高
],
],
],
],
],
];
}
/**
* 调用 OpenAI Embedding API 将文本转为向量
*
* 使用 Redis 缓存(TTL 7 天),相同文本不会重复请求 API,
* 缓存 key 格式为 "embedding:{md5(text)}"。
*
* @param string $text 输入文本
* @return array 1536 维 float 向量
*
* @throws \Exception 未设置 OPENAI_API_KEY 或 API 返回异常时抛出
*/
protected function embedText(string $text): array
{
if (!$this->openaiApiKey) {
throw new Exception('请在 .env 设置 OPENAI_API_KEY');
}
$cacheKey = 'embedding:' . md5($text);
return Cache::remember($cacheKey, now()->addDays(7), function () use ($text) {
$response = $this->http->post('embeddings', [
'headers' => [
'Authorization' => 'Bearer ' . $this->openaiApiKey,
'Content-Type' => 'application/json',
],
'json' => [
'model' => 'text-embedding-3-small',
'input' => $text,
],
]);
$json = json_decode((string) $response->getBody(), true);
if (empty($json['data'][0]['embedding'])) {
throw new Exception('OpenAI embedding 返回异常: ' . json_encode($json));
}
return $json['data'][0]['embedding'];
});
}
/**
* 清除指定文本的 embedding 缓存
*
* @param string $text 原始文本(与调用 embedText 时一致)
* @return bool 缓存是否成功删除
*
* @example
* $service->clearEmbeddingCache('sabbe dhammā anattā');
*/
public function clearEmbeddingCache(string $text): bool
{
$cacheKey = 'embedding:' . md5($text);
return Cache::forget($cacheKey);
}
/**
* 清除 Redis 中所有 embedding 缓存
*
* 匹配 "embedding:*" 模式的全部键,生产环境请谨慎调用。
*
* @return int 已删除的缓存条数
*
* @example
* $count = $service->clearAllEmbeddingCache();
* echo "已清理缓存 {$count} 条";
*/
public function clearAllEmbeddingCache(): int
{
$redis = Cache::getRedis();
$keys = $redis->keys('embedding:*');
if (!empty($keys)) {
$redis->del($keys);
}
return count($keys);
}
/**
* 自动建议(Completion Suggest)
*
* 基于 completion 字段实现前缀补全,支持同时查询多个语言字段。
* 结果按 _score 降序排序,跨字段去重。
*
* 可用字段标识符($fields 参数):
* - 'title_pali' → title.suggest.pali
* - 'title_zh' → title.suggest.zh
* - 'content_pali' → content.suggest.pali
* - 'content_zh' → content.suggest.zh
*
* @param string $query 查询前缀文本
* @param array|string|null $fields 要查询的字段标识符,null 表示全部字段
* @param string|null $language 可选的语言过滤(term query)
* @param int $limit 每个字段返回的建议数量,默认 10
* @return array 建议结果列表,每项包含:
* text, source(字段标识符), score, doc_id, doc_source
*
* @throws \InvalidArgumentException $fields 中含无效字段标识符时抛出
*
* @example
* // 查询所有字段
* $service->suggest('nibb');
*
* // 只查询巴利文标题建议
* $service->suggest('nibb', 'title_pali');
*
* // 查询多个字段,限制语言
* $service->suggest('涅', ['title_zh', 'content_zh'], 'zh', 5);
*/
public function suggest(
string $query,
$fields = null,
?string $language = null,
int $limit = 10
): array {
// 字段标识符 → OpenSearch completion 字段路径
$fieldMap = [
'title_pali' => 'title.suggest.pali',
'title_zh' => 'title.suggest.zh',
'content_pali' => 'content.suggest.pali',
'content_zh' => 'content.suggest.zh',
];
// 处理字段参数
if ($fields === null) {
$searchFields = array_keys($fieldMap);
} elseif (is_string($fields)) {
$searchFields = [$fields];
} else {
$searchFields = $fields;
}
// 过滤无效字段
$searchFields = array_values(array_filter(
$searchFields,
fn($field) => isset($fieldMap[$field])
));
if (empty($searchFields)) {
throw new \InvalidArgumentException('Invalid fields specified for suggestion');
}
// 构建 suggest DSL
$suggests = [];
foreach ($searchFields as $field) {
$suggests[$field . '_suggest'] = [
'prefix' => $query,
'completion' => [
'field' => $fieldMap[$field],
'size' => $limit,
'skip_duplicates' => true,
],
];
}
$dsl = ['suggest' => $suggests];
if ($language) {
$dsl['query'] = ['term' => ['language' => $language]];
}
$response = $this->client->search([
'index' => config('mint.opensearch.index'),
'body' => $dsl,
]);
// 整理结果,附加来源字段
$results = [];
foreach ($searchFields as $field) {
$options = $response['suggest'][$field . '_suggest'][0]['options'] ?? [];
foreach ($options as $opt) {
$results[] = [
'text' => $opt['text'] ?? '',
'source' => $field,
'score' => $opt['_score'] ?? 0,
'doc_id' => $opt['_id'] ?? null,
'doc_source' => $opt['_source'] ?? null,
];
}
}
// 按分数降序排序
usort($results, fn($a, $b) => $b['score'] <=> $a['score']);
return $results;
}
/**
* 按文档 ID 获取单条完整文档(包含 content.display)
*
* @param string $id 文档 ID,例如 "term_{guid}"
* @return array OpenSearch 原始响应
*/
public function get(string $id): array
{
return $this->client->get([
'index' => config('mint.opensearch.index'),
'id' => $id,
]);
}
}