BxDolAiSearchRerank.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup UnaCore UNA Core
  7. * @{
  8. */
  9. /**
  10. * Semantic ordering of search results: the SQL picks candidates, a judge model (BxDolAiJudge)
  11. * decides which of them actually answer the query and in which order.
  12. *
  13. * Candidates are collected with two queries (the "hybrid"), both with the same structured filters
  14. * (category, price, date...) and the same access rights:
  15. * 1. with the keyword - exact text hits, what the plain search would return;
  16. * 2. without the keyword - the newest items of the filtered set.
  17. * The second query is what makes "course for beginners" find "Python from scratch": such an item has
  18. * none of the query words, so a keyword search can never return it, but the model recognises it once
  19. * it is among the candidates.
  20. *
  21. * Judgements are cached per (item, query) so repeated and paginated searches cost nothing, and the
  22. * ordered list of ids is cached per (viewer + query + filters) so pages stay consistent. The
  23. * candidate set depends on who is searching, so that list is not shared between members.
  24. *
  25. * Limits: a judge request carries at most CANDIDATES_MAX items (the model's state limit), so this is
  26. * re-ranking of a window, not a search over the whole table. For a big table a recall layer
  27. * (embeddings vector store) would be needed in front of it.
  28. */
  29. class BxDolAiSearchRerank extends BxDolFactory
  30. {
  31. const CANDIDATES_MAX = 120; // items sent to the model in one request
  32. const KEYWORD_SHARE = 0.5; // how much of the window the keyword query may take
  33. const TEXT_MAX = 220; // characters of the item text put into the state
  34. const SCORE_MIN = 0.45; // below this an item is dropped from the results
  35. const CACHE_TTL_ORDER = 600; // ordered ids per viewer+query+filters, seconds
  36. const CACHE_TTL_ITEM = 86400; // judgement per item+query, seconds
  37. const BUDGET_ITEMS_REQUEST = 200; // hard cap of judged items per request
  38. protected $_oJudge;
  39. protected $_oCache;
  40. protected function __construct()
  41. {
  42. parent::__construct();
  43. $this->_oJudge = BxDolAiJudge::getInstance();
  44. $this->_oCache = BxDolDb::getInstance()->getDbCacheObject();
  45. }
  46. public static function getInstance()
  47. {
  48. if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
  49. $GLOBALS['bxDolClasses'][__CLASS__] = new BxDolAiSearchRerank();
  50. return $GLOBALS['bxDolClasses'][__CLASS__];
  51. }
  52. public function isAvailable(): bool
  53. {
  54. return (bool)$this->_oJudge;
  55. }
  56. /**
  57. * Ordered ids of an extended search object for a natural language query.
  58. *
  59. * @param $sObject extended search object (sys_objects_search_extended)
  60. * @param $sQuery the user's query as typed
  61. * @param $aSearchParams structured params (as built by BxDolAiSearchExtParser), keyword field included
  62. * @param $aOptions
  63. * keyword_field - name of the text field holding the keyword, it is dropped in the second query
  64. * debug - return scores per item
  65. * @return array ['ids' => [...], 'scores' => [id => 0..1], 'judged' => N, 'cached' => N, 'ms' => N]
  66. * or [] when the model is not available / nothing to rank
  67. */
  68. public function rank(string $sObject, string $sQuery, array $aSearchParams, array $aOptions = []): array
  69. {
  70. $sQuery = trim(preg_replace('/\s+/u', ' ', $sQuery));
  71. if (!$this->_oJudge || $sQuery === '')
  72. return [];
  73. bx_import('BxDolForm'); // BX_DATA_LISTS_KEY_PREFIX, used while reading the fields' pre-values
  74. $aObject = BxDolSearchExtendedQuery::getSearchObject($sObject);
  75. if (!$aObject || empty($aObject['object_content_info']))
  76. return [];
  77. // Content filter and any search_ids hook run as the current profile, so the same
  78. // query and filters can produce a different set of ids for another member.
  79. $sCacheKeyOrder = $this->_getCacheKey('order', $sObject . '|' . $sQuery . '|' . md5(serialize($aSearchParams)) . '|' . $this->_getViewerKey());
  80. $aOrder = $this->_oCache->getData($sCacheKeyOrder, self::CACHE_TTL_ORDER);
  81. if (is_array($aOrder) && !empty($aOrder['ids']))
  82. return $aOrder;
  83. $fStart = microtime(true);
  84. $aIds = $this->_getCandidates($aObject, $aSearchParams, $aOptions['keyword_field'] ?? '');
  85. if (!$aIds)
  86. return [];
  87. $aItems = $this->_getItems($aObject, $aIds);
  88. if (!$aItems)
  89. return [];
  90. list($aScores, $iJudged, $iCached) = $this->_getScores($sObject, $sQuery, $aItems);
  91. if (!$aScores)
  92. return [];
  93. arsort($aScores);
  94. $aResultIds = [];
  95. foreach ($aScores as $iId => $fScore)
  96. if ($fScore >= self::SCORE_MIN)
  97. $aResultIds[] = (int)$iId;
  98. $aResult = [
  99. 'ids' => $aResultIds,
  100. 'judged' => $iJudged,
  101. 'cached' => $iCached,
  102. 'ms' => (int)round((microtime(true) - $fStart) * 1000),
  103. ];
  104. if (!empty($aOptions['debug']))
  105. $aResult['scores'] = $aScores;
  106. $this->_oCache->setData($sCacheKeyOrder, $aResult, self::CACHE_TTL_ORDER);
  107. return $aResult;
  108. }
  109. /**
  110. * Hybrid candidates: the keyword query plus the same query without the keyword.
  111. * Both run through the module's own search, so privacy and status conditions are applied as usual.
  112. */
  113. protected function _getCandidates(array $aObject, array $aSearchParams, string $sKeywordField): array
  114. {
  115. $oContentInfo = BxDolContentInfo::getObjectInstance($aObject['object_content_info']);
  116. if (!$oContentInfo)
  117. return [];
  118. $iKeywordMax = (int)round(self::CANDIDATES_MAX * self::KEYWORD_SHARE);
  119. $aIds = [];
  120. $bHasKeyword = $sKeywordField !== '' && isset($aSearchParams[$sKeywordField]);
  121. // 1. exact text hits
  122. if ($bHasKeyword) {
  123. $aKeywordIds = $oContentInfo->getSearchResultExtended($aSearchParams, 0, $iKeywordMax);
  124. if (is_array($aKeywordIds))
  125. $aIds = $aKeywordIds;
  126. }
  127. // 2. the same set without the keyword - items no word of the query matches.
  128. // With no structured filters left this is simply the newest items of the module
  129. // (filter mode, otherwise an empty param set returns nothing).
  130. $aParamsWide = $aSearchParams;
  131. if ($bHasKeyword)
  132. unset($aParamsWide[$sKeywordField]);
  133. $aWideIds = $oContentInfo->getSearchResultExtended($aParamsWide, 0, self::CANDIDATES_MAX, empty($aParamsWide));
  134. if (is_array($aWideIds))
  135. $aIds = array_merge($aIds, $aWideIds);
  136. $aIds = array_values(array_unique(array_map('intval', $aIds)));
  137. return array_slice($aIds, 0, self::CANDIDATES_MAX);
  138. }
  139. /**
  140. * [id => "title. text"] for the candidates, in one query when the module's table is known.
  141. */
  142. protected function _getItems(array $aObject, array $aIds): array
  143. {
  144. $aItems = [];
  145. $oModule = !empty($aObject['module']) ? BxDolModule::getInstance($aObject['module']) : null;
  146. $CNF = $oModule && !empty($oModule->_oConfig->CNF) ? $oModule->_oConfig->CNF : [];
  147. if (!empty($CNF['TABLE_ENTRIES']) && !empty($CNF['FIELD_ID']) && !empty($CNF['FIELD_TITLE'])) {
  148. $oDb = BxDolDb::getInstance();
  149. $sFieldText = !empty($CNF['FIELD_TEXT']) ? $CNF['FIELD_TEXT'] : '';
  150. $sFields = "`{$CNF['FIELD_ID']}` AS `id`, `{$CNF['FIELD_TITLE']}` AS `title`" . ($sFieldText ? ", `{$sFieldText}` AS `text`" : '');
  151. $aRows = $oDb->getAll("SELECT {$sFields} FROM `{$CNF['TABLE_ENTRIES']}` WHERE `{$CNF['FIELD_ID']}` IN (" . $oDb->implode_escape($aIds) . ")");
  152. foreach ($aRows as $aRow)
  153. $aItems[(int)$aRow['id']] = $this->_getItemText($aRow['title'] ?? '', $aRow['text'] ?? '');
  154. }
  155. else {
  156. // no CNF: fall back to the content info services
  157. $oContentInfo = BxDolContentInfo::getObjectInstance($aObject['object_content_info']);
  158. foreach ($aIds as $iId)
  159. $aItems[$iId] = $this->_getItemText((string)$oContentInfo->getContentTitle($iId), (string)$oContentInfo->getContentText($iId));
  160. }
  161. // keep the candidate order, drop empty ones
  162. $aOrdered = [];
  163. foreach ($aIds as $iId)
  164. if (!empty($aItems[$iId]))
  165. $aOrdered[$iId] = $aItems[$iId];
  166. return $aOrdered;
  167. }
  168. protected function _getItemText(string $sTitle, string $sText): string
  169. {
  170. $sTitle = trim(strip_tags($sTitle));
  171. $sText = trim(preg_replace('/\s+/u', ' ', strip_tags($sText)));
  172. if (mb_strlen($sText) > self::TEXT_MAX)
  173. $sText = mb_substr($sText, 0, self::TEXT_MAX) . '…';
  174. return trim($sTitle . ($sText !== '' ? '. ' . $sText : ''));
  175. }
  176. /**
  177. * Judgement per item: from cache when this item was already judged for this query,
  178. * the rest in one request to the model.
  179. * @return array [[id => score], judged, cached]
  180. */
  181. protected function _getScores(string $sObject, string $sQuery, array $aItems): array
  182. {
  183. $sQueryKey = md5(mb_strtolower($sQuery));
  184. $aScores = [];
  185. $aAsk = [];
  186. foreach ($aItems as $iId => $sText) {
  187. $mixedScore = $this->_oCache->getData($this->_getCacheKey('item', $sObject . '_' . $iId . '_' . $sQueryKey), self::CACHE_TTL_ITEM);
  188. if ($mixedScore !== null && $mixedScore !== false)
  189. $aScores[$iId] = (float)$mixedScore;
  190. else
  191. $aAsk[$iId] = $sText;
  192. }
  193. $iCached = count($aScores);
  194. if (!$aAsk)
  195. return [$aScores, 0, $iCached];
  196. if (count($aAsk) > self::BUDGET_ITEMS_REQUEST)
  197. $aAsk = array_slice($aAsk, 0, self::BUDGET_ITEMS_REQUEST, true);
  198. $aState = [
  199. 'request' => $sQuery,
  200. 'items' => [],
  201. ];
  202. $aQuestions = [];
  203. foreach ($aAsk as $iId => $sText) {
  204. $aState['items']['i' . $iId] = $sText;
  205. $aQuestions['i' . $iId] = BxDolAiJudge::noul(
  206. 'Does item i' . $iId . ' satisfy the request? Judge by meaning, not by matching words: an item can answer the request with completely different wording.'
  207. );
  208. }
  209. $aAnswers = $this->_oJudge->askSafe($aState, $aQuestions);
  210. if (!$aAnswers)
  211. return [$aScores, 0, $iCached];
  212. foreach ($aAsk as $iId => $sText) {
  213. $a = $aAnswers['i' . $iId] ?? null;
  214. if (!is_array($a) || !isset($a['noul']))
  215. continue;
  216. $fScore = (float)$a['noul'];
  217. $aScores[$iId] = $fScore;
  218. $this->_oCache->setData($this->_getCacheKey('item', $sObject . '_' . $iId . '_' . $sQueryKey), $fScore, self::CACHE_TTL_ITEM);
  219. }
  220. return [$aScores, count($aAsk), $iCached];
  221. }
  222. /**
  223. * Profile the candidates were selected for, plus the content-filter mask applied to that profile.
  224. * Guests share profile 0 and the unauthenticated mask.
  225. */
  226. protected function _getViewerKey(): string
  227. {
  228. $iProfileId = (int)bx_get_logged_profile_id();
  229. return $iProfileId ? $iProfileId : BxDolSession::getInstance()->getId();
  230. }
  231. protected function _getCacheKey(string $sType, string $sId): string
  232. {
  233. return 'sys_ai_search_' . $sType . '_' . md5($sId) . '_' . bx_site_hash() . '.php';
  234. }
  235. }
  236. /** @} */