OpenSearchService.php 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213
  1. <?php
  2. // api-v8/app/Services/OpenSearchService.php
  3. namespace App\Services;
  4. use Exception;
  5. use GuzzleHttp\Client;
  6. use Illuminate\Support\Facades\Cache;
  7. use Illuminate\Support\Facades\Log;
  8. use Illuminate\Support\Str;
  9. use OpenSearch\GuzzleClientFactory;
  10. class OpenSearchService
  11. {
  12. protected $client;
  13. protected $http;
  14. protected $openaiApiKey;
  15. /**
  16. * 默认查询排除字段
  17. *
  18. * @var array
  19. */
  20. private $sourceExcludes = [
  21. 'title.suggest.pali',
  22. 'title.suggest.zh',
  23. 'content.suggest.pali',
  24. 'content.suggest.zh',
  25. 'content.display', // 新增,列表页不返回 HTML
  26. ];
  27. /**
  28. * 默认权重配置
  29. *
  30. * fuzzy / hybrid 两种模式各自的字段权重。
  31. * hybrid 额外包含 fuzzy_ratio / semantic_ratio 用于控制两路得分的混合比例。
  32. *
  33. * 字段名已按新映射结构调整:
  34. * title.text.pali → 原 title.pali.text
  35. * title.text.zh → 原 title.zh
  36. * content.text.pali → 原 content.pali.text
  37. * content.text.zh → 原 content.zh
  38. *
  39. * @var array
  40. */
  41. private $weights = [
  42. 'fuzzy' => [
  43. 'bold_single' => 50,
  44. 'bold_multi' => 10,
  45. 'title.text.pali' => 3,
  46. 'title.text.zh' => 3,
  47. 'summary.text' => 2,
  48. 'content.text.pali' => 1,
  49. 'content.text.zh' => 1,
  50. ],
  51. 'hybrid' => [
  52. 'fuzzy_ratio' => 0.7,
  53. 'semantic_ratio' => 0.3,
  54. 'bold_single' => 50,
  55. 'bold_multi' => 10,
  56. 'title.text.pali' => 3,
  57. 'title.text.zh' => 3,
  58. 'summary.text' => 2,
  59. 'content.text.pali' => 1,
  60. 'content.text.zh' => 1,
  61. ],
  62. ];
  63. /**
  64. * OpenSearch 索引定义(settings + mappings)
  65. *
  66. * 字段结构说明:
  67. *
  68. * title
  69. * ├── text
  70. * │ ├── pali (text) 模糊查询 + exact subfield 精确查询
  71. * │ └── zh (text) 中文分词查询
  72. * ├── vector (knn_vector, dim=1536)
  73. * └── suggest
  74. * ├── pali (completion)
  75. * └── zh (completion)
  76. *
  77. * content(结构与 title 一致,额外包含 tokens nested 字段)
  78. * ├── text
  79. * │ ├── pali (text)
  80. * │ └── zh (text)
  81. * ├── tokens (nested)
  82. * ├── vector (knn_vector, dim=1536)
  83. * └── suggest
  84. * ├── pali (completion)
  85. * └── zh (completion)
  86. *
  87. * summary(中文摘要,结构保持不变)
  88. * ├── text (text)
  89. * └── vector (knn_vector, dim=1536)
  90. *
  91. * @var array
  92. */
  93. private $indexDefinition = [
  94. 'settings' => [
  95. 'index' => [
  96. 'knn' => true,
  97. ],
  98. 'analysis' => [
  99. 'analyzer' => [
  100. 'pali_query_analyzer' => [
  101. 'tokenizer' => 'standard',
  102. 'filter' => ['lowercase', 'pali_synonyms'],
  103. ],
  104. 'pali_index_analyzer' => [
  105. 'type' => 'custom',
  106. 'tokenizer' => 'standard',
  107. 'char_filter' => ['markdown_strip'],
  108. 'filter' => ['lowercase'],
  109. ],
  110. 'markdown_clean' => [
  111. 'type' => 'custom',
  112. 'tokenizer' => 'standard',
  113. 'char_filter' => ['markdown_strip'],
  114. 'filter' => ['lowercase'],
  115. ],
  116. // Suggest 专用(忽略大小写 + 变音)
  117. 'pali_suggest_analyzer' => [
  118. 'tokenizer' => 'standard',
  119. 'filter' => ['lowercase', 'asciifolding'],
  120. ],
  121. 'zh_suggest_analyzer' => [
  122. 'tokenizer' => 'ik_max_word',
  123. 'char_filter' => ['tsconvert'],
  124. ],
  125. // 中文简繁统一 (繁 -> 简)
  126. 'zh_index_analyzer' => [
  127. 'tokenizer' => 'ik_max_word',
  128. 'char_filter' => ['tsconvert'],
  129. ],
  130. 'zh_query_analyzer' => [
  131. 'tokenizer' => 'ik_smart',
  132. 'char_filter' => ['tsconvert'],
  133. ],
  134. ],
  135. 'filter' => [
  136. 'pali_synonyms' => [
  137. 'type' => 'synonym_graph',
  138. 'synonyms_path' => 'analysis/pali_synonyms.txt',
  139. 'updateable' => true,
  140. ],
  141. ],
  142. 'char_filter' => [
  143. 'markdown_strip' => [
  144. 'type' => 'pattern_replace',
  145. 'pattern' => '\\*\\*|\\*|_|`|~',
  146. 'replacement' => '',
  147. ],
  148. 'tsconvert' => [
  149. 'type' => 'stconvert',
  150. 'convert_type' => 't2s',
  151. ],
  152. ],
  153. ],
  154. ],
  155. 'mappings' => [
  156. 'properties' => [
  157. 'id' => ['type' => 'keyword'],
  158. // 分类维度一律 keyword:它们是枚举值,不该被分词。
  159. // 若声明成 text,'zh-Hans' 会被切成 zh + hans,查 language=zh 就会把
  160. // zh-Hans 的文档一并捞进来(实测 401 vs 410),而且不报错。
  161. 'resource_id' => ['type' => 'keyword'],
  162. 'resource_type' => ['type' => 'keyword'],
  163. // ----------------------------------------------------------------
  164. // title
  165. // text.pali → 模糊查询(+ exact subfield 精确查询)
  166. // text.zh → 中文查询
  167. // vector → 语义向量
  168. // suggest.pali / suggest.zh → 自动建议
  169. // ----------------------------------------------------------------
  170. 'title' => [
  171. 'properties' => [
  172. 'text' => [
  173. 'properties' => [
  174. 'pali' => [
  175. 'type' => 'text',
  176. 'analyzer' => 'pali_index_analyzer',
  177. 'search_analyzer' => 'pali_query_analyzer',
  178. 'fields' => [
  179. 'exact' => [
  180. 'type' => 'text',
  181. 'analyzer' => 'markdown_clean',
  182. ],
  183. ],
  184. ],
  185. 'zh' => [
  186. 'type' => 'text',
  187. 'analyzer' => 'zh_index_analyzer',
  188. 'search_analyzer' => 'zh_query_analyzer',
  189. ],
  190. ],
  191. ],
  192. 'vector' => [
  193. 'type' => 'knn_vector',
  194. 'dimension' => 1536,
  195. 'method' => [
  196. 'name' => 'hnsw',
  197. 'space_type' => 'innerproduct',
  198. 'engine' => 'faiss',
  199. ],
  200. ],
  201. 'suggest' => [
  202. 'properties' => [
  203. 'pali' => [
  204. 'type' => 'completion',
  205. 'analyzer' => 'pali_suggest_analyzer',
  206. ],
  207. 'zh' => [
  208. 'type' => 'completion',
  209. 'analyzer' => 'zh_suggest_analyzer',
  210. ],
  211. ],
  212. ],
  213. ],
  214. ],
  215. // ----------------------------------------------------------------
  216. // summary(LLM 生成的简体中文摘要,结构保持不变)
  217. // text → 中文查询
  218. // vector → 语义向量
  219. // ----------------------------------------------------------------
  220. 'summary' => [
  221. 'properties' => [
  222. 'text' => [
  223. 'type' => 'text',
  224. 'analyzer' => 'zh_index_analyzer',
  225. 'search_analyzer' => 'zh_query_analyzer',
  226. ],
  227. 'vector' => [
  228. 'type' => 'knn_vector',
  229. 'dimension' => 1536,
  230. 'method' => [
  231. 'name' => 'hnsw',
  232. 'space_type' => 'innerproduct',
  233. 'engine' => 'faiss',
  234. ],
  235. ],
  236. ],
  237. ],
  238. // ----------------------------------------------------------------
  239. // content(结构与 title 对称,额外包含 tokens nested 字段)
  240. // text.pali → 模糊查询(+ exact subfield 精确查询)
  241. // text.zh → 中文查询
  242. // tokens → 词法分析结果(nested)
  243. // vector → 语义向量
  244. // suggest.pali / suggest.zh → 自动建议
  245. // ----------------------------------------------------------------
  246. 'content' => [
  247. 'properties' => [
  248. 'text' => [
  249. 'properties' => [
  250. 'pali' => [
  251. 'type' => 'text',
  252. 'analyzer' => 'pali_index_analyzer',
  253. 'search_analyzer' => 'pali_query_analyzer',
  254. 'fields' => [
  255. 'exact' => [
  256. 'type' => 'text',
  257. 'analyzer' => 'markdown_clean',
  258. ],
  259. ],
  260. ],
  261. 'zh' => [
  262. 'type' => 'text',
  263. 'analyzer' => 'zh_index_analyzer',
  264. 'search_analyzer' => 'zh_query_analyzer',
  265. ],
  266. ],
  267. ],
  268. 'tokens' => [
  269. 'type' => 'nested',
  270. 'properties' => [
  271. 'surface' => ['type' => 'keyword'],
  272. 'lemma' => ['type' => 'keyword'],
  273. 'compound_parts' => ['type' => 'keyword'],
  274. 'case' => ['type' => 'keyword'],
  275. ],
  276. ],
  277. 'vector' => [
  278. 'type' => 'knn_vector',
  279. 'dimension' => 1536,
  280. 'method' => [
  281. 'name' => 'hnsw',
  282. 'space_type' => 'innerproduct',
  283. 'engine' => 'faiss',
  284. ],
  285. ],
  286. 'suggest' => [
  287. 'properties' => [
  288. 'pali' => [
  289. 'type' => 'completion',
  290. 'analyzer' => 'pali_suggest_analyzer',
  291. ],
  292. 'zh' => [
  293. 'type' => 'completion',
  294. 'analyzer' => 'zh_suggest_analyzer',
  295. ],
  296. ],
  297. ],
  298. // 前端展示用,原始 HTML,不参与索引
  299. 'display' => [
  300. 'type' => 'text',
  301. 'index' => false,
  302. ],
  303. ],
  304. ],
  305. 'related_id' => ['type' => 'keyword'],
  306. 'bold_single' => [
  307. 'type' => 'text',
  308. 'analyzer' => 'standard',
  309. 'search_analyzer' => 'pali_query_analyzer',
  310. ],
  311. 'bold_multi' => [
  312. 'type' => 'text',
  313. 'analyzer' => 'standard',
  314. 'search_analyzer' => 'pali_query_analyzer',
  315. ],
  316. 'path' => ['type' => 'text', 'analyzer' => 'standard'],
  317. 'page_refs' => ['type' => 'keyword'],
  318. 'tags' => ['type' => 'keyword'],
  319. 'category' => ['type' => 'keyword'],
  320. 'author' => ['type' => 'text'],
  321. 'language' => ['type' => 'keyword'],
  322. 'updated_at' => ['type' => 'date'],
  323. 'granularity' => ['type' => 'keyword'],
  324. 'metadata' => [
  325. 'properties' => [
  326. 'APA' => ['type' => 'text', 'index' => false],
  327. 'MLA' => ['type' => 'text', 'index' => false],
  328. 'widget' => ['type' => 'text', 'index' => false],
  329. 'author' => ['type' => 'text'],
  330. 'channel' => ['type' => 'text'],
  331. ],
  332. ],
  333. ],
  334. ],
  335. ];
  336. /**
  337. * 创建 OpenSearchService 实例
  338. *
  339. * 从 config('mint.opensearch.config') 读取连接配置,
  340. * 同时初始化 OpenAI HTTP 客户端用于 embedding 调用。
  341. */
  342. public function __construct()
  343. {
  344. $config = config('mint.opensearch.config');
  345. $hostUrl = "{$config['scheme']}://{$config['host']}:{$config['port']}";
  346. $this->client = (new GuzzleClientFactory)->create([
  347. 'base_uri' => $hostUrl,
  348. 'auth' => [$config['username'], $config['password']],
  349. 'verify' => $config['ssl_verification'],
  350. ]);
  351. $this->openaiApiKey = env('OPENAI_API_KEY');
  352. $this->http = new Client([
  353. 'base_uri' => 'https://api.openai.com/v1/',
  354. 'timeout' => 15,
  355. ]);
  356. }
  357. /**
  358. * 动态覆盖指定搜索模式的字段权重
  359. *
  360. * @param string $mode 搜索模式,支持 'fuzzy' | 'hybrid'
  361. * @param array $weights 需要覆盖的权重键值对,例如:['title.text.pali' => 5]
  362. */
  363. public function setWeights(string $mode, array $weights): void
  364. {
  365. if (isset($this->weights[$mode])) {
  366. $this->weights[$mode] = array_merge($this->weights[$mode], $weights);
  367. }
  368. }
  369. /**
  370. * 测试与 OpenSearch 集群的连接状态
  371. *
  372. * @return array{0: bool, 1: string} [连接是否成功, 描述信息]
  373. */
  374. public function testConnection(): array
  375. {
  376. try {
  377. $info = $this->client->info();
  378. $message = 'OpenSearch 连接成功: ' . json_encode($info['version']['number']);
  379. Log::info($message);
  380. return [true, $message];
  381. } catch (Exception $e) {
  382. $message = 'OpenSearch 连接失败: ' . $e->getMessage();
  383. Log::error($message);
  384. return [false, $message];
  385. }
  386. }
  387. /**
  388. * 检查当前索引是否已存在
  389. */
  390. public function indexExists(): bool
  391. {
  392. $index = config('mint.opensearch.index');
  393. return $this->client->indices()->exists(['index' => $index]);
  394. }
  395. /**
  396. * 创建 OpenSearch 索引
  397. *
  398. * 使用 $indexDefinition 中定义的 settings 和 mappings 创建索引。
  399. * 若索引已存在则抛出异常,避免覆盖生产数据。
  400. *
  401. * @return array OpenSearch 响应
  402. *
  403. * @throws Exception 索引已存在时抛出
  404. */
  405. public function createIndex(): array
  406. {
  407. $index = config('mint.opensearch.index');
  408. $exists = $this->client->indices()->exists(['index' => $index]);
  409. if ($exists) {
  410. throw new Exception("Index [$index] already exists.");
  411. }
  412. return $this->client->indices()->create([
  413. 'index' => $index,
  414. 'body' => $this->indexDefinition,
  415. ]);
  416. }
  417. /**
  418. * 更新已有索引的 settings 和 mappings
  419. *
  420. * 更新 settings 时会临时关闭索引(close → putSettings → open),
  421. * 更新 mappings 支持热更新(新增字段),不可修改已有字段类型。
  422. *
  423. * @return array 包含 'settings' 和/或 'mappings' 的响应数组
  424. */
  425. public function updateIndex(): array
  426. {
  427. $index = config('mint.opensearch.index');
  428. $settings = $this->indexDefinition['settings'] ?? [];
  429. $mappings = $this->indexDefinition['mappings'] ?? [];
  430. $response = [];
  431. if (! empty($settings)) {
  432. $this->client->indices()->close(['index' => $index]);
  433. $response['settings'] = $this->client->indices()->putSettings([
  434. 'index' => $index,
  435. 'body' => ['settings' => $settings],
  436. ]);
  437. $this->client->indices()->open(['index' => $index]);
  438. }
  439. if (! empty($mappings)) {
  440. $response['mappings'] = $this->client->indices()->putMapping([
  441. 'index' => $index,
  442. 'body' => $mappings,
  443. ]);
  444. }
  445. return $response;
  446. }
  447. /**
  448. * 切换 pali 同义词文件版本
  449. *
  450. * 把 pali_synonyms filter 的 synonyms_path 指向
  451. * analysis/pali-synonyms-{$version}.txt(文件须已存在于 OpenSearch
  452. * 各节点的 config 目录中,否则索引无法重新打开)。
  453. *
  454. * synonyms_path 属于静态 settings,必须 close → putSettings → open。
  455. * 无论 putSettings 成功与否都会尝试重新打开索引,避免索引停留在 close 状态。
  456. *
  457. * @param string $version 版本号,仅允许 [A-Za-z0-9._-]
  458. * @return array{path: string, settings: array} 新路径与 OpenSearch 响应
  459. *
  460. * @throws Exception 版本号非法、索引不存在或 OpenSearch 拒绝时抛出
  461. *
  462. * @example
  463. * $service->updatePaliSynonymsPath('20260731');
  464. */
  465. public function updatePaliSynonymsPath(string $filename): array
  466. {
  467. $index = config('mint.opensearch.index');
  468. if (! $this->client->indices()->exists(['index' => $index])) {
  469. throw new Exception("Index [$index] does not exist.");
  470. }
  471. $path = Str::finish(config('mint.opensearch.config.synonyms_path'), '/') . $filename;
  472. Log::debug('OpenSearchService::updatePaliSynonymsPath', [
  473. 'index' => $index,
  474. 'synonyms_path' => $path,
  475. ]);
  476. // 以代码中的 analysis 定义为准,只替换同义词文件路径
  477. $analysis = $this->indexDefinition['settings']['analysis'];
  478. $analysis['filter']['pali_synonyms']['synonyms_path'] = $path;
  479. $this->client->indices()->close(['index' => $index]);
  480. try {
  481. $response = $this->client->indices()->putSettings([
  482. 'index' => $index,
  483. 'body' => ['settings' => ['analysis' => $analysis]],
  484. ]);
  485. } finally {
  486. $this->client->indices()->open(['index' => $index]);
  487. }
  488. return ['path' => $path, 'settings' => $response];
  489. }
  490. /**
  491. * 读取当前索引使用的 pali 同义词文件路径
  492. *
  493. * @return string|null 形如 "analysis/pali-synonyms-20260731.txt",未设置时返回 null
  494. */
  495. public function getPaliSynonymsPath(): ?string
  496. {
  497. return $this->getPaliSynonymsSetting()['synonyms_path'] ?? null;
  498. }
  499. /**
  500. * 读取当前索引 pali_synonyms filter 的完整设置
  501. *
  502. * @return array{type?: string, synonyms_path?: string, updateable?: string}|null 未设置时返回 null
  503. */
  504. public function getPaliSynonymsSetting(): ?array
  505. {
  506. $index = config('mint.opensearch.index');
  507. $settings = $this->client->indices()->getSettings(['index' => $index]);
  508. return $settings[$index]['settings']['index']['analysis']['filter']['pali_synonyms'] ?? null;
  509. }
  510. /**
  511. * 删除当前索引
  512. *
  513. * @return array OpenSearch 响应
  514. */
  515. public function deleteIndex(): array
  516. {
  517. $index = config('mint.opensearch.index');
  518. return $this->client->indices()->delete(['index' => $index]);
  519. }
  520. /**
  521. * 统计索引文档数量(支持可选条件过滤)
  522. *
  523. * @param array|null $query OpenSearch DSL query 子句,为 null 时统计全部文档。
  524. * 示例:['term' => ['language' => 'zh']]
  525. * ['exists' => ['field' => 'content.vector']]
  526. * @return int 文档总数
  527. *
  528. * @throws Exception
  529. *
  530. * @example
  531. * $service->count();
  532. * $service->count(['exists' => ['field' => 'content.vector']]);
  533. */
  534. public function count(?array $query = null): int
  535. {
  536. $index = config('mint.opensearch.index');
  537. $params = ['index' => $index];
  538. if (! empty($query)) {
  539. $params['body'] = ['query' => $query];
  540. }
  541. $response = $this->client->count($params);
  542. return (int) ($response['count'] ?? 0);
  543. }
  544. /**
  545. * 写入或覆盖单条文档
  546. *
  547. * @param string $id 文档 ID
  548. * @param array $body 文档内容,字段结构须与 mappings 一致
  549. * @return array OpenSearch 响应
  550. */
  551. public function create(string $id, array $body): array
  552. {
  553. return $this->client->index([
  554. 'index' => config('mint.opensearch.index'),
  555. 'id' => $id,
  556. 'body' => $body,
  557. ]);
  558. }
  559. /**
  560. * 删除单条文档
  561. *
  562. * @param string $id 文档 ID
  563. * @return array OpenSearch 响应
  564. */
  565. public function delete(string $id): array
  566. {
  567. return $this->client->delete([
  568. 'index' => config('mint.opensearch.index'),
  569. 'id' => $id,
  570. ]);
  571. }
  572. /**
  573. * 执行高级搜索
  574. *
  575. * 支持四种搜索模式:
  576. * - fuzzy 多字段模糊查询(默认),基于 BM25
  577. * - exact 精确匹配,使用 markdown_clean analyzer
  578. * - semantic 纯语义向量搜索,需要 OpenAI embedding
  579. * - hybrid fuzzy + semantic 混合,权重由 fuzzy_ratio / semantic_ratio 控制
  580. *
  581. * 支持的过滤参数:
  582. * resourceType, resourceId, granularity, language, category,
  583. * tags, pageRefs, relatedId, author, channel
  584. *
  585. * @param array $params {
  586. *
  587. * @type string $query 搜索关键词(必填)
  588. * @type string $searchMode 搜索模式,默认 'fuzzy'
  589. * @type int $page 页码,默认 1
  590. * @type int $pageSize 每页条数,默认 20
  591. * @type string $resourceType 按资源类型过滤
  592. * @type string $resourceId 按资源 ID 过滤
  593. * @type string $granularity 按粒度过滤
  594. * @type string $language 按语言过滤
  595. * @type string $category 按分类过滤
  596. * @type array $tags 按标签过滤(terms)
  597. * @type array $pageRefs 按页码引用过滤(terms)
  598. * @type string $relatedId 按关联 ID 过滤
  599. * @type string $author 按作者过滤
  600. * @type string $channel 按频道过滤
  601. * @type array $highlight_pre_tags 高亮前置标签,默认 ['<mark>']
  602. * @type array $highlight_post_tags 高亮后置标签,默认 ['</mark>']
  603. * }
  604. *
  605. * @return array OpenSearch 原始响应
  606. *
  607. * @throws Exception semantic / hybrid 模式下 embedding 调用失败时抛出
  608. */
  609. public function search(array $params): array
  610. {
  611. $page = $params['page'] ?? 1;
  612. $pageSize = $params['pageSize'] ?? 20;
  613. $from = ($page - 1) * $pageSize;
  614. $mode = $params['searchMode'] ?? 'fuzzy';
  615. // 排除字段
  616. if (! empty($params['excludes']) && is_array($params['excludes'])) {
  617. $excludes = array_merge($this->sourceExcludes, $params['excludes']);
  618. } else {
  619. $excludes = $this->sourceExcludes;
  620. }
  621. // ---------- 过滤条件 ----------
  622. $filters = [];
  623. if (! empty($params['resourceType'])) {
  624. $filters[] = ['term' => ['resource_type' => $params['resourceType']]];
  625. }
  626. if (! empty($params['resourceId'])) {
  627. $filters[] = ['term' => ['resource_id' => $params['resourceId']]];
  628. }
  629. if (! empty($params['granularity'])) {
  630. $filters[] = ['term' => ['granularity' => $params['granularity']]];
  631. }
  632. if (! empty($params['language'])) {
  633. $filters[] = ['term' => ['language' => $params['language']]];
  634. }
  635. if (! empty($params['category'])) {
  636. if (is_array($params['category'])) {
  637. $categories = $params['category'];
  638. } else {
  639. $categories = [$params['category']];
  640. }
  641. // 必须匹配全部:为每个 category 创建一个 term 条件
  642. foreach ($categories as $category) {
  643. $filters[] = ['term' => ['category' => $category]];
  644. }
  645. }
  646. if (! empty($params['tags'])) {
  647. $filters[] = ['terms' => ['tags' => $params['tags']]];
  648. }
  649. if (! empty($params['pageRefs'])) {
  650. $filters[] = ['terms' => ['page_refs' => $params['pageRefs']]];
  651. }
  652. if (! empty($params['relatedId'])) {
  653. $filters[] = ['term' => ['related_id' => $params['relatedId']]];
  654. }
  655. if (! empty($params['author'])) {
  656. $filters[] = ['match' => ['metadata.author' => $params['author']]];
  657. }
  658. if (! empty($params['channel'])) {
  659. $filters[] = ['term' => ['metadata.channel' => $params['channel']]];
  660. }
  661. // ---------- 查询部分 ----------
  662. $queryText = trim($params['query'] ?? '');
  663. if ($queryText === '') {
  664. $query = ['match_all' => new \stdClass];
  665. } else {
  666. switch ($mode) {
  667. case 'exact':
  668. $query = $this->buildExactQuery($queryText);
  669. break;
  670. case 'semantic':
  671. $query = $this->buildSemanticQuery($queryText);
  672. break;
  673. case 'hybrid':
  674. $query = $this->buildHybridQuery($queryText);
  675. break;
  676. case 'fuzzy':
  677. default:
  678. $query = $this->buildFuzzyQuery($queryText);
  679. break;
  680. }
  681. }
  682. $highlightPreTags = $params['highlight_pre_tags'] ?? ['<mark>'];
  683. $highlightPostTags = $params['highlight_post_tags'] ?? ['</mark>'];
  684. // ---------- 最终 DSL ----------
  685. $dsl = [
  686. 'from' => $from,
  687. 'size' => $pageSize,
  688. '_source' => ['excludes' => $excludes],
  689. 'query' => ! empty($filters)
  690. ? [
  691. 'bool' => [
  692. 'must' => [$query],
  693. 'filter' => $filters,
  694. ],
  695. ]
  696. : $query,
  697. 'aggs' => [
  698. 'resource_type' => [
  699. 'terms' => ['field' => 'resource_type'],
  700. ],
  701. 'language' => [
  702. 'terms' => ['field' => 'language'],
  703. ],
  704. 'category' => [
  705. 'terms' => ['field' => 'category'],
  706. ],
  707. 'granularity' => [
  708. 'terms' => ['field' => 'granularity'],
  709. ],
  710. ],
  711. ];
  712. // 只有有搜索词时才开启高亮
  713. if ($queryText !== '') {
  714. $dsl['highlight'] = [
  715. 'fields' => [
  716. 'title.text.pali' => new \stdClass,
  717. 'title.text.zh' => new \stdClass,
  718. 'summary.text' => new \stdClass,
  719. 'content.text.pali' => new \stdClass,
  720. 'content.text.zh' => new \stdClass,
  721. ],
  722. 'fragmenter' => 'sentence',
  723. 'fragment_size' => 200,
  724. 'number_of_fragments' => 1,
  725. 'pre_tags' => $highlightPreTags,
  726. 'post_tags' => $highlightPostTags,
  727. ];
  728. }
  729. Log::debug(
  730. 'OpenSearchService::search',
  731. ['dsl' => json_encode($dsl, JSON_UNESCAPED_UNICODE)]
  732. );
  733. return $this->client->search([
  734. 'index' => config('mint.opensearch.index'),
  735. 'body' => $dsl,
  736. ]);
  737. }
  738. /**
  739. * 构建 exact(精确匹配)查询
  740. *
  741. * 使用 markdown_clean analyzer 的 exact subfield 进行匹配,
  742. * 适合巴利文词形精确检索场景。
  743. *
  744. * 查询字段:title.text.pali.exact, content.text.pali.exact, summary.text
  745. *
  746. * @param string $query 搜索关键词
  747. * @return array OpenSearch DSL query 片段
  748. */
  749. protected function buildExactQuery(string $query): array
  750. {
  751. return [
  752. 'multi_match' => [
  753. 'query' => $query,
  754. 'fields' => [
  755. 'title.text.pali.exact',
  756. 'content.text.pali.exact',
  757. 'summary.text',
  758. ],
  759. 'type' => 'best_fields',
  760. ],
  761. ];
  762. }
  763. /**
  764. * 构建 semantic(纯语义向量)查询
  765. *
  766. * 将查询文本通过 OpenAI embedding API 转为向量,
  767. * 同时对 content.vector、summary.vector、title.vector 三个 knn 字段检索,
  768. * 使用 bool should 合并结果。
  769. *
  770. * @param string $query 搜索关键词
  771. * @return array OpenSearch DSL query 片段
  772. *
  773. * @throws Exception embedding 调用失败时抛出
  774. */
  775. protected function buildSemanticQuery(string $query): array
  776. {
  777. $vector = $this->embedText($query);
  778. return [
  779. 'bool' => [
  780. 'should' => [
  781. ['knn' => ['content.vector' => ['vector' => $vector, 'k' => 20]]],
  782. ['knn' => ['summary.vector' => ['vector' => $vector, 'k' => 10]]],
  783. ['knn' => ['title.vector' => ['vector' => $vector, 'k' => 5]]],
  784. ],
  785. 'minimum_should_match' => 1,
  786. ],
  787. ];
  788. }
  789. /**
  790. * 构建 fuzzy(多字段模糊)查询
  791. *
  792. * 基于 BM25 的 multi_match best_fields 查询,
  793. * 字段权重取自 $weights['fuzzy']。
  794. *
  795. * @param string $query 搜索关键词
  796. * @return array OpenSearch DSL query 片段
  797. */
  798. protected function buildFuzzyQuery(string $query): array
  799. {
  800. $fields = [];
  801. foreach ($this->weights['fuzzy'] as $field => $weight) {
  802. $fields[] = $field . '^' . $weight;
  803. }
  804. return [
  805. 'multi_match' => [
  806. 'query' => $query,
  807. 'fields' => $fields,
  808. 'type' => 'best_fields',
  809. ],
  810. ];
  811. }
  812. /**
  813. * 构建 hybrid(模糊 + 语义混合)查询
  814. *
  815. * 使用 bool should 将 fuzzy(constant_score 包裹)与三路 knn 向量查询合并,
  816. * 权重比例由 $weights['hybrid']['fuzzy_ratio'] 和 'semantic_ratio' 控制。
  817. * title.vector 的语义权重略高(×1.2),以提升标题匹配的排名。
  818. *
  819. * @param string $query 搜索关键词
  820. * @return array OpenSearch DSL query 片段
  821. *
  822. * @throws Exception embedding 调用失败时抛出
  823. */
  824. protected function buildHybridQuery(string $query): array
  825. {
  826. $fuzzyFields = [];
  827. foreach ($this->weights['hybrid'] as $field => $weight) {
  828. if (in_array($field, ['fuzzy_ratio', 'semantic_ratio'])) {
  829. continue;
  830. }
  831. $fuzzyFields[] = $field . '^' . $weight;
  832. }
  833. $fuzzyPart = [
  834. 'multi_match' => [
  835. 'query' => $query,
  836. 'fields' => $fuzzyFields,
  837. 'type' => 'best_fields',
  838. ],
  839. ];
  840. $vector = $this->embedText($query);
  841. $fuzzyRatio = $this->weights['hybrid']['fuzzy_ratio'];
  842. $semanticRatio = $this->weights['hybrid']['semantic_ratio'];
  843. return [
  844. 'bool' => [
  845. 'should' => [
  846. [
  847. 'constant_score' => [
  848. 'filter' => $fuzzyPart,
  849. 'boost' => $fuzzyRatio,
  850. ],
  851. ],
  852. [
  853. 'knn' => [
  854. 'content.vector' => [
  855. 'vector' => $vector,
  856. 'k' => 20,
  857. 'boost' => $semanticRatio * 1.0,
  858. ],
  859. ],
  860. ],
  861. [
  862. 'knn' => [
  863. 'summary.vector' => [
  864. 'vector' => $vector,
  865. 'k' => 10,
  866. 'boost' => $semanticRatio * 0.8,
  867. ],
  868. ],
  869. ],
  870. [
  871. 'knn' => [
  872. 'title.vector' => [
  873. 'vector' => $vector,
  874. 'k' => 5,
  875. 'boost' => $semanticRatio * 1.2, // title 权重略高
  876. ],
  877. ],
  878. ],
  879. ],
  880. ],
  881. ];
  882. }
  883. /**
  884. * 调用 OpenAI Embedding API 将文本转为向量
  885. *
  886. * 使用 Redis 缓存(TTL 7 天),相同文本不会重复请求 API,
  887. * 缓存 key 格式为 "embedding:{md5(text)}"。
  888. *
  889. * @param string $text 输入文本
  890. * @return array 1536 维 float 向量
  891. *
  892. * @throws Exception 未设置 OPENAI_API_KEY 或 API 返回异常时抛出
  893. */
  894. protected function embedText(string $text): array
  895. {
  896. if (! $this->openaiApiKey) {
  897. throw new Exception('请在 .env 设置 OPENAI_API_KEY');
  898. }
  899. $cacheKey = 'embedding:' . md5($text);
  900. return Cache::remember($cacheKey, now()->addDays(7), function () use ($text) {
  901. $response = $this->http->post('embeddings', [
  902. 'headers' => [
  903. 'Authorization' => 'Bearer ' . $this->openaiApiKey,
  904. 'Content-Type' => 'application/json',
  905. ],
  906. 'json' => [
  907. 'model' => 'text-embedding-3-small',
  908. 'input' => $text,
  909. ],
  910. ]);
  911. $json = json_decode((string) $response->getBody(), true);
  912. if (empty($json['data'][0]['embedding'])) {
  913. throw new Exception('OpenAI embedding 返回异常: ' . json_encode($json));
  914. }
  915. return $json['data'][0]['embedding'];
  916. });
  917. }
  918. /**
  919. * 清除指定文本的 embedding 缓存
  920. *
  921. * @param string $text 原始文本(与调用 embedText 时一致)
  922. * @return bool 缓存是否成功删除
  923. *
  924. * @example
  925. * $service->clearEmbeddingCache('sabbe dhammā anattā');
  926. */
  927. public function clearEmbeddingCache(string $text): bool
  928. {
  929. $cacheKey = 'embedding:' . md5($text);
  930. return Cache::forget($cacheKey);
  931. }
  932. /**
  933. * 清除 Redis 中所有 embedding 缓存
  934. *
  935. * 匹配 "embedding:*" 模式的全部键,生产环境请谨慎调用。
  936. *
  937. * @return int 已删除的缓存条数
  938. *
  939. * @example
  940. * $count = $service->clearAllEmbeddingCache();
  941. * echo "已清理缓存 {$count} 条";
  942. */
  943. public function clearAllEmbeddingCache(): int
  944. {
  945. $redis = Cache::getRedis();
  946. $keys = $redis->keys('embedding:*');
  947. if (! empty($keys)) {
  948. $redis->del($keys);
  949. }
  950. return count($keys);
  951. }
  952. /**
  953. * 自动建议(Completion Suggest)
  954. *
  955. * 基于 completion 字段实现前缀补全,支持同时查询多个语言字段。
  956. * 结果按 _score 降序排序,跨字段去重。
  957. *
  958. * 可用字段标识符($fields 参数):
  959. * - 'title_pali' → title.suggest.pali
  960. * - 'title_zh' → title.suggest.zh
  961. * - 'content_pali' → content.suggest.pali
  962. * - 'content_zh' → content.suggest.zh
  963. *
  964. * @param string $query 查询前缀文本
  965. * @param array|string|null $fields 要查询的字段标识符,null 表示全部字段
  966. * @param string|null $language 可选的语言过滤(term query)
  967. * @param int $limit 每个字段返回的建议数量,默认 10
  968. * @return array 建议结果列表,每项包含:
  969. * text, source(字段标识符), score, doc_id, doc_source
  970. *
  971. * @throws \InvalidArgumentException $fields 中含无效字段标识符时抛出
  972. *
  973. * @example
  974. * // 查询所有字段
  975. * $service->suggest('nibb');
  976. *
  977. * // 只查询巴利文标题建议
  978. * $service->suggest('nibb', 'title_pali');
  979. *
  980. * // 查询多个字段,限制语言
  981. * $service->suggest('涅', ['title_zh', 'content_zh'], 'zh', 5);
  982. */
  983. public function suggest(
  984. string $query,
  985. $fields = null,
  986. ?string $language = null,
  987. int $limit = 10
  988. ): array {
  989. // 字段标识符 → OpenSearch completion 字段路径
  990. $fieldMap = [
  991. 'title_pali' => 'title.suggest.pali',
  992. 'title_zh' => 'title.suggest.zh',
  993. 'content_pali' => 'content.suggest.pali',
  994. 'content_zh' => 'content.suggest.zh',
  995. ];
  996. // 处理字段参数
  997. if ($fields === null) {
  998. $searchFields = array_keys($fieldMap);
  999. } elseif (is_string($fields)) {
  1000. $searchFields = [$fields];
  1001. } else {
  1002. $searchFields = $fields;
  1003. }
  1004. // 过滤无效字段
  1005. $searchFields = array_values(array_filter(
  1006. $searchFields,
  1007. fn($field) => isset($fieldMap[$field])
  1008. ));
  1009. if (empty($searchFields)) {
  1010. throw new \InvalidArgumentException('Invalid fields specified for suggestion');
  1011. }
  1012. // 构建 suggest DSL
  1013. $suggests = [];
  1014. foreach ($searchFields as $field) {
  1015. $suggests[$field . '_suggest'] = [
  1016. 'prefix' => $query,
  1017. 'completion' => [
  1018. 'field' => $fieldMap[$field],
  1019. 'size' => $limit,
  1020. 'skip_duplicates' => true,
  1021. ],
  1022. ];
  1023. }
  1024. $dsl = ['suggest' => $suggests];
  1025. if ($language) {
  1026. $dsl['query'] = ['term' => ['language' => $language]];
  1027. }
  1028. $response = $this->client->search([
  1029. 'index' => config('mint.opensearch.index'),
  1030. 'body' => $dsl,
  1031. ]);
  1032. // 整理结果,附加来源字段
  1033. $results = [];
  1034. foreach ($searchFields as $field) {
  1035. $options = $response['suggest'][$field . '_suggest'][0]['options'] ?? [];
  1036. foreach ($options as $opt) {
  1037. $results[] = [
  1038. 'text' => $opt['text'] ?? '',
  1039. 'source' => $field,
  1040. 'score' => $opt['_score'] ?? 0,
  1041. 'doc_id' => $opt['_id'] ?? null,
  1042. 'doc_source' => $opt['_source'] ?? null,
  1043. ];
  1044. }
  1045. }
  1046. // 按分数降序排序
  1047. usort($results, fn($a, $b) => $b['score'] <=> $a['score']);
  1048. return $results;
  1049. }
  1050. /**
  1051. * 按文档 ID 获取单条完整文档(包含 content.display)
  1052. *
  1053. * @param string $id 文档 ID,例如 "term_{guid}"
  1054. * @return array OpenSearch 原始响应
  1055. */
  1056. public function get(string $id): array
  1057. {
  1058. return $this->client->get([
  1059. 'index' => config('mint.opensearch.index'),
  1060. 'id' => $id,
  1061. ]);
  1062. }
  1063. /**
  1064. * 校验 pali_synonyms 同义词词典是否对指定词生效
  1065. *
  1066. * 通过 OpenSearch _analyze API,用当前索引的 pali_query_analyzer
  1067. * 对输入文本做实时分析,返回展开后的全部 token(含原词与同义词)。
  1068. * 可用于快速确认 analysis/pali_synonyms.txt 中的某一行是否已生效
  1069. * (例如 dhamma,dharma,法 => dhamma 是否真的展开出 dharma、法)。
  1070. *
  1071. * @param string $text 待检测的巴利词,例如 "dhamma"
  1072. * @return array<int, string> 分析器输出的 token 文本数组(已去重,保留原始顺序)
  1073. *
  1074. * @throws Exception 索引不存在或 OpenSearch 调用失败时抛出
  1075. *
  1076. * @example
  1077. * $service->pali_query_health_check('dhamma');
  1078. * // => ['dhamma', 'dharma', '法']
  1079. */
  1080. public function pali_query_health_check(string $text): array
  1081. {
  1082. $index = config('mint.opensearch.index');
  1083. if (! $this->client->indices()->exists(['index' => $index])) {
  1084. throw new Exception("Index [$index] does not exist.");
  1085. }
  1086. $response = $this->client->indices()->analyze([
  1087. 'index' => $index,
  1088. 'body' => [
  1089. 'analyzer' => 'pali_query_analyzer',
  1090. 'text' => $text,
  1091. ],
  1092. ]);
  1093. $tokens = array_map(
  1094. fn($token) => $token['token'] ?? '',
  1095. $response['tokens'] ?? []
  1096. );
  1097. return array_values(array_unique(array_filter($tokens, fn($t) => $t !== '')));
  1098. }
  1099. }