account_search_service.rb 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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. followers_score_function,
  26. ],
  27. },
  28. },
  29. should: should_clauses,
  30. }
  31. )
  32. end
  33. private
  34. def must_clauses
  35. if @account && @options[:following]
  36. [core_query, only_following_query]
  37. else
  38. [core_query]
  39. end
  40. end
  41. def must_not_clauses
  42. []
  43. end
  44. def should_clauses
  45. if @account && !@options[:following]
  46. [boost_following_query]
  47. else
  48. []
  49. end
  50. end
  51. # This function limits results to only the accounts the user is following
  52. def only_following_query
  53. {
  54. terms: {
  55. id: following_ids,
  56. },
  57. }
  58. end
  59. # This function promotes accounts the user is following
  60. def boost_following_query
  61. {
  62. terms: {
  63. id: following_ids,
  64. boost: 100,
  65. },
  66. }
  67. end
  68. # This function promotes accounts that have more followers
  69. def followers_score_function
  70. {
  71. script_score: {
  72. script: {
  73. source: "Math.log10((Math.max(doc['followers_count'].value, 0) + 1))",
  74. },
  75. },
  76. }
  77. end
  78. def following_ids
  79. @following_ids ||= @account.active_relationships.pluck(:target_account_id) + [@account.id]
  80. end
  81. end
  82. class AutocompleteQueryBuilder < QueryBuilder
  83. private
  84. def core_query
  85. {
  86. dis_max: {
  87. queries: [
  88. {
  89. multi_match: {
  90. query: @query,
  91. type: 'most_fields',
  92. fields: %w(username username.*),
  93. },
  94. },
  95. {
  96. multi_match: {
  97. query: @query,
  98. type: 'most_fields',
  99. fields: %w(display_name display_name.*),
  100. },
  101. },
  102. ],
  103. },
  104. }
  105. end
  106. end
  107. class FullQueryBuilder < QueryBuilder
  108. private
  109. def core_query
  110. {
  111. multi_match: {
  112. query: @query,
  113. type: 'best_fields',
  114. fields: %w(username^2 display_name^2 text text.*),
  115. operator: 'and',
  116. },
  117. }
  118. end
  119. end
  120. def call(query, account = nil, options = {})
  121. MastodonOTELTracer.in_span('AccountSearchService#call') do |span|
  122. @query = query&.strip&.gsub(/\A@/, '')
  123. @limit = options[:limit].to_i
  124. @offset = options[:offset].to_i
  125. @options = options
  126. @account = account
  127. span.add_attributes(
  128. 'search.offset' => @offset,
  129. 'search.limit' => @limit,
  130. 'search.backend' => Chewy.enabled? ? 'elasticsearch' : 'database'
  131. )
  132. search_service_results.compact.uniq.tap do |results|
  133. span.set_attribute('search.results.count', results.size)
  134. end
  135. end
  136. end
  137. private
  138. def search_service_results
  139. return [] if query.blank? || limit < 1
  140. [exact_match] + search_results
  141. end
  142. def exact_match
  143. return unless offset.zero? && username_complete?
  144. return @exact_match if defined?(@exact_match)
  145. match = if options[:resolve]
  146. ResolveAccountService.new.call(query)
  147. elsif domain_is_local?
  148. Account.find_local(query_username)
  149. else
  150. Account.find_remote(query_username, query_domain)
  151. end
  152. match = nil if !match.nil? && !account.nil? && options[:following] && !account.following?(match)
  153. @exact_match = match
  154. end
  155. def search_results
  156. return [] if limit_for_non_exact_results.zero?
  157. @search_results ||= begin
  158. results = from_elasticsearch if Chewy.enabled?
  159. results ||= from_database
  160. results
  161. end
  162. end
  163. def from_database
  164. if account
  165. advanced_search_results
  166. else
  167. simple_search_results
  168. end
  169. end
  170. def advanced_search_results
  171. Account.advanced_search_for(terms_for_query, account, limit: limit_for_non_exact_results, following: options[:following], offset: offset)
  172. end
  173. def simple_search_results
  174. Account.search_for(terms_for_query, limit: limit_for_non_exact_results, offset: offset)
  175. end
  176. def from_elasticsearch
  177. query_builder = begin
  178. if options[:use_searchable_text]
  179. FullQueryBuilder.new(terms_for_query, account, options.slice(:following))
  180. else
  181. AutocompleteQueryBuilder.new(terms_for_query, account, options.slice(:following))
  182. end
  183. end
  184. records = query_builder.build.limit(limit_for_non_exact_results).offset(offset).objects.compact
  185. ActiveRecord::Associations::Preloader.new(records: records, associations: [:account_stat, { user: :role }]).call
  186. records
  187. rescue Faraday::ConnectionFailed, Parslet::ParseFailed
  188. nil
  189. end
  190. def limit_for_non_exact_results
  191. return 0 if @account.nil? && query.size < MIN_QUERY_LENGTH
  192. if exact_match?
  193. limit - 1
  194. else
  195. limit
  196. end
  197. end
  198. def terms_for_query
  199. if domain_is_local?
  200. query_username
  201. else
  202. query
  203. end
  204. end
  205. def split_query_string
  206. @split_query_string ||= query.split('@')
  207. end
  208. def query_username
  209. @query_username ||= split_query_string.first || ''
  210. end
  211. def query_domain
  212. @query_domain ||= query_without_split? ? nil : split_query_string.last
  213. end
  214. def query_without_split?
  215. split_query_string.size == 1
  216. end
  217. def domain_is_local?
  218. @domain_is_local ||= TagManager.instance.local_domain?(query_domain)
  219. end
  220. def exact_match?
  221. exact_match.present?
  222. end
  223. def username_complete?
  224. query.include?('@') && "@#{query}".match?(MENTION_ONLY_RE)
  225. end
  226. end