account_search_service.rb 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. # frozen_string_literal: true
  2. class AccountSearchService < BaseService
  3. attr_reader :query, :limit, :offset, :options, :account
  4. MENTION_ONLY_RE = /\A#{Account::MENTION_RE}\z/i
  5. # Min. number of characters to look for non-exact matches
  6. MIN_QUERY_LENGTH = 5
  7. class QueryBuilder
  8. def initialize(query, account, options = {})
  9. @query = query
  10. @account = account
  11. @options = options
  12. end
  13. def build
  14. AccountsIndex.query(
  15. bool: {
  16. must: {
  17. function_score: {
  18. query: {
  19. bool: {
  20. must: must_clauses,
  21. must_not: must_not_clauses,
  22. },
  23. },
  24. functions: [
  25. reputation_score_function,
  26. followers_score_function,
  27. time_distance_function,
  28. ],
  29. },
  30. },
  31. should: should_clauses,
  32. }
  33. )
  34. end
  35. private
  36. def must_clauses
  37. if @account && @options[:following]
  38. [core_query, only_following_query]
  39. else
  40. [core_query]
  41. end
  42. end
  43. def must_not_clauses
  44. []
  45. end
  46. def should_clauses
  47. if @account && !@options[:following]
  48. [boost_following_query]
  49. else
  50. []
  51. end
  52. end
  53. # This function limits results to only the accounts the user is following
  54. def only_following_query
  55. {
  56. terms: {
  57. id: following_ids,
  58. },
  59. }
  60. end
  61. # This function promotes accounts the user is following
  62. def boost_following_query
  63. {
  64. terms: {
  65. id: following_ids,
  66. boost: 100,
  67. },
  68. }
  69. end
  70. # This function deranks accounts that follow more people than follow them
  71. def reputation_score_function
  72. {
  73. script_score: {
  74. script: {
  75. source: "(Math.max(doc['followers_count'].value, 0) + 0.0) / (Math.max(doc['followers_count'].value, 0) + Math.max(doc['following_count'].value, 0) + 1)",
  76. },
  77. },
  78. }
  79. end
  80. # This function promotes accounts that have more followers
  81. def followers_score_function
  82. {
  83. script_score: {
  84. script: {
  85. source: "(Math.max(doc['followers_count'].value, 0) / (Math.max(doc['followers_count'].value, 0) + 1))",
  86. },
  87. },
  88. }
  89. end
  90. # This function deranks accounts that haven't posted in a long time
  91. def time_distance_function
  92. {
  93. gauss: {
  94. last_status_at: {
  95. scale: '30d',
  96. offset: '30d',
  97. decay: 0.3,
  98. },
  99. },
  100. }
  101. end
  102. def following_ids
  103. @following_ids ||= @account.active_relationships.pluck(:target_account_id) + [@account.id]
  104. end
  105. end
  106. class AutocompleteQueryBuilder < QueryBuilder
  107. private
  108. def core_query
  109. {
  110. multi_match: {
  111. query: @query,
  112. type: 'bool_prefix',
  113. fields: %w(username^2 username.*^2 display_name display_name.*),
  114. },
  115. }
  116. end
  117. end
  118. class FullQueryBuilder < QueryBuilder
  119. private
  120. def core_query
  121. {
  122. multi_match: {
  123. query: @query,
  124. type: 'most_fields',
  125. fields: %w(username^2 display_name^2 text text.*),
  126. operator: 'and',
  127. },
  128. }
  129. end
  130. end
  131. def call(query, account = nil, options = {})
  132. @query = query&.strip&.gsub(/\A@/, '')
  133. @limit = options[:limit].to_i
  134. @offset = options[:offset].to_i
  135. @options = options
  136. @account = account
  137. search_service_results.compact.uniq
  138. end
  139. private
  140. def search_service_results
  141. return [] if query.blank? || limit < 1
  142. [exact_match] + search_results
  143. end
  144. def exact_match
  145. return unless offset.zero? && username_complete?
  146. return @exact_match if defined?(@exact_match)
  147. match = if options[:resolve]
  148. ResolveAccountService.new.call(query)
  149. elsif domain_is_local?
  150. Account.find_local(query_username)
  151. else
  152. Account.find_remote(query_username, query_domain)
  153. end
  154. match = nil if !match.nil? && !account.nil? && options[:following] && !account.following?(match)
  155. @exact_match = match
  156. end
  157. def search_results
  158. return [] if limit_for_non_exact_results.zero?
  159. @search_results ||= begin
  160. results = from_elasticsearch if Chewy.enabled?
  161. results ||= from_database
  162. results
  163. end
  164. end
  165. def from_database
  166. if account
  167. advanced_search_results
  168. else
  169. simple_search_results
  170. end
  171. end
  172. def advanced_search_results
  173. Account.advanced_search_for(terms_for_query, account, limit: limit_for_non_exact_results, following: options[:following], offset: offset)
  174. end
  175. def simple_search_results
  176. Account.search_for(terms_for_query, limit: limit_for_non_exact_results, offset: offset)
  177. end
  178. def from_elasticsearch
  179. query_builder = begin
  180. if options[:use_searchable_text]
  181. FullQueryBuilder.new(terms_for_query, account, options.slice(:following))
  182. else
  183. AutocompleteQueryBuilder.new(terms_for_query, account, options.slice(:following))
  184. end
  185. end
  186. records = query_builder.build.limit(limit_for_non_exact_results).offset(offset).objects.compact
  187. ActiveRecord::Associations::Preloader.new(records: records, associations: [:account_stat, { user: :role }]).call
  188. records
  189. rescue Faraday::ConnectionFailed, Parslet::ParseFailed
  190. nil
  191. end
  192. def limit_for_non_exact_results
  193. return 0 if @account.nil? && query.size < MIN_QUERY_LENGTH
  194. if exact_match?
  195. limit - 1
  196. else
  197. limit
  198. end
  199. end
  200. def terms_for_query
  201. if domain_is_local?
  202. query_username
  203. else
  204. query
  205. end
  206. end
  207. def split_query_string
  208. @split_query_string ||= query.split('@')
  209. end
  210. def query_username
  211. @query_username ||= split_query_string.first || ''
  212. end
  213. def query_domain
  214. @query_domain ||= query_without_split? ? nil : split_query_string.last
  215. end
  216. def query_without_split?
  217. split_query_string.size == 1
  218. end
  219. def domain_is_local?
  220. @domain_is_local ||= TagManager.instance.local_domain?(query_domain)
  221. end
  222. def exact_match?
  223. exact_match.present?
  224. end
  225. def username_complete?
  226. query.include?('@') && "@#{query}".match?(MENTION_ONLY_RE)
  227. end
  228. end