OpenSearchService.php 40 KB

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