diff --git a/ee/lib/gitlab/elastic/bool_expr.rb b/ee/lib/gitlab/elastic/bool_expr.rb
index d97fb823808c18ff0f2d9bdd9294cdd8d84d1097..10c131885589dbd06c8380fc2e2dd4d37b8d83de 100644
--- a/ee/lib/gitlab/elastic/bool_expr.rb
+++ b/ee/lib/gitlab/elastic/bool_expr.rb
@@ -1,9 +1,8 @@
-# rubocop:disable Naming/FileName
 # frozen_string_literal: true
 
 module Gitlab
   module Elastic
-    BoolExpr = Struct.new(:must, :must_not, :should, :filter) do # rubocop:disable Lint/StructNewOverride
+    BoolExpr = Struct.new(:must, :must_not, :should, :filter, :minimum_should_match) do # rubocop:disable Lint/StructNewOverride -- existing implementation
       def initialize
         super
         reset!
@@ -14,6 +13,7 @@ def reset!
         self.must_not = []
         self.should   = []
         self.filter   = []
+        self.minimum_should_match = nil
       end
 
       def to_h
@@ -26,5 +26,3 @@ def eql?(other)
     end
   end
 end
-
-# rubocop:enable Naming/FileName
diff --git a/ee/lib/search/elastic/issue_query_builder.rb b/ee/lib/search/elastic/issue_query_builder.rb
index c4a91ab887eaa2e723a0fab72d3dcd05d7151eca..3ffbf17bf8ef8c12811fa5cd0e8dc9dd0b0f1bf1 100644
--- a/ee/lib/search/elastic/issue_query_builder.rb
+++ b/ee/lib/search/elastic/issue_query_builder.rb
@@ -15,7 +15,13 @@ def build
             # iid field can be added here as lenient option will
             # pardon format errors, like integer out of range.
             fields = %w[iid^3 title^2 description]
-            ::Search::Elastic::Queries.by_simple_query_string(fields: fields, query: query, options: options)
+
+            if Feature.enabled?(:search_uses_match_queries, options[:current_user]) &&
+                !::Search::Elastic::Queries::ADVANCED_QUERY_SYNTAX_REGEX.match?(query)
+              ::Search::Elastic::Queries.by_multi_match_query(fields: fields, query: query, options: options)
+            else
+              ::Search::Elastic::Queries.by_simple_query_string(fields: fields, query: query, options: options)
+            end
           end
 
         query_hash = ::Search::Elastic::Filters.by_authorization(query_hash: query_hash, options: options)
diff --git a/ee/lib/search/elastic/queries.rb b/ee/lib/search/elastic/queries.rb
index 0b57837dc41e51cf2be7a65c9df8a522adb0f996..42ff20e1c280b824a8f6e1316a5712d2b6238355 100644
--- a/ee/lib/search/elastic/queries.rb
+++ b/ee/lib/search/elastic/queries.rb
@@ -3,11 +3,11 @@
 module Search
   module Elastic
     module Queries
+      ADVANCED_QUERY_SYNTAX_REGEX = /[+*"\-|()~\\]/
+
       class << self
         include ::Elastic::Latest::QueryContext::Aware
 
-        AGGREGATION_LIMIT = 500
-
         def by_iid(iid:, doc_type:)
           bool_expr = Gitlab::Elastic::BoolExpr.new
           bool_expr.filter = [
@@ -22,27 +22,81 @@ def by_iid(iid:, doc_type:)
           }
         end
 
-        def by_simple_query_string(fields:, query:, options:)
+        def by_multi_match_query(fields:, query:, options:)
           fields = ::Elastic::Latest::CustomLanguageAnalyzers.add_custom_analyzers_fields(fields)
-
           fields = remove_fields_boost(fields) if options[:count_only]
 
-          query_hash =
-            if query.present?
-              simple_query_string = simple_query_string(fields, query, options)
+          bool_expr = Gitlab::Elastic::BoolExpr.new
+
+          if query.present?
+            bool_expr = Gitlab::Elastic::BoolExpr.new
+            unless options[:no_join_project]
+              bool_expr.filter << {
+                term: {
+                  type: {
+                    _name: context.name(:doc, :is_a, options[:doc_type]),
+                    value: options[:doc_type]
+                  }
+                }
+              }
+            end
+
+            multi_match_bool = Gitlab::Elastic::BoolExpr.new
+            multi_match_bool.should << multi_match_query(fields, query, options.merge(operator: :or))
+            multi_match_bool.should << multi_match_query(fields, query, options.merge(operator: :and))
+            multi_match_bool.should << multi_match_phrase_query(fields, query, options)
+            multi_match_bool.minimum_should_match = 1
 
-              build_bool_query(simple_query_string, options)
+            if options[:count_only]
+              bool_expr.filter << { bool: multi_match_bool }
             else
-              bool_expr = Gitlab::Elastic::BoolExpr.new
-              bool_expr.must = { match_all: {} }
-              {
-                query: {
-                  bool: bool_expr
-                },
-                track_scores: true
+              bool_expr.must << { bool: multi_match_bool }
+            end
+          else
+            bool_expr.must = { match_all: {} }
+          end
+
+          query_hash = { query: { bool: bool_expr } }
+          query_hash[:track_scores] = true unless query.present?
+
+          if options[:count_only]
+            query_hash[:size] = 0
+          else
+            query_hash[:highlight] = apply_highlight(fields)
+          end
+
+          query_hash
+        end
+
+        def by_simple_query_string(fields:, query:, options:)
+          fields = ::Elastic::Latest::CustomLanguageAnalyzers.add_custom_analyzers_fields(fields)
+          fields = remove_fields_boost(fields) if options[:count_only]
+
+          bool_expr = Gitlab::Elastic::BoolExpr.new
+          if query.present?
+            unless options[:no_join_project]
+              bool_expr.filter << {
+                term: {
+                  type: {
+                    _name: context.name(:doc, :is_a, options[:doc_type]),
+                    value: options[:doc_type]
+                  }
+                }
               }
             end
 
+            if options[:count_only]
+              bool_expr.filter << simple_query_string(fields, query, options)
+            else
+              bool_expr.must << simple_query_string(fields, query, options)
+            end
+          else
+            bool_expr.must = { match_all: {} }
+          end
+
+          query_hash = { query: { bool: bool_expr } }
+          query_hash[:track_scores] = true unless query.present?
+
           if options[:count_only]
             query_hash[:size] = 0
           else
@@ -70,29 +124,26 @@ def simple_query_string(fields, query, options)
           }
         end
 
-        def build_bool_query(simple_query_string, options)
-          bool_expr = Gitlab::Elastic::BoolExpr.new
-
-          unless options[:no_join_project]
-            bool_expr.filter << {
-              term: {
-                type: {
-                  _name: context.name(:doc, :is_a, options[:doc_type]),
-                  value: options[:doc_type]
-                }
-              }
+        def multi_match_phrase_query(fields, query, options)
+          {
+            multi_match: {
+              _name: context.name(options[:doc_type], :multi_match_phrase, :search_terms),
+              type: :phrase,
+              fields: fields,
+              query: query,
+              lenient: true
             }
-          end
-
-          if options[:count_only]
-            bool_expr.filter << simple_query_string
-          else
-            bool_expr.must << simple_query_string
-          end
+          }
+        end
 
+        def multi_match_query(fields, query, options)
           {
-            query: {
-              bool: bool_expr
+            multi_match: {
+              _name: context.name(options[:doc_type], :multi_match, options[:operator], :search_terms),
+              fields: fields,
+              query: query,
+              operator: options[:operator],
+              lenient: true
             }
           }
         end
diff --git a/ee/spec/elastic/migrate/20230720000000_reindex_wikis_to_fix_routing_spec.rb b/ee/spec/elastic/migrate/20230720000000_reindex_wikis_to_fix_routing_spec.rb
index f48f9c6606b1710e173af18cf218960380a30074..d93637a2cd77914d59d5515fbfd97ad45fcc7918 100644
--- a/ee/spec/elastic/migrate/20230720000000_reindex_wikis_to_fix_routing_spec.rb
+++ b/ee/spec/elastic/migrate/20230720000000_reindex_wikis_to_fix_routing_spec.rb
@@ -86,6 +86,7 @@
 
   def set_old_schema_version_in_three_documents!
     client.update_by_query(index: Elastic::Latest::WikiConfig.index_name, max_docs: 3, refresh: true,
+      wait_for_completion: true,
       body: { script: { lang: 'painless', source: 'ctx._source.schema_version = 2305' } }
     )
   end
diff --git a/ee/spec/lib/elastic/latest/issue_class_proxy_spec.rb b/ee/spec/lib/elastic/latest/issue_class_proxy_spec.rb
index d8222f413268e504bff9ce009f69b4841679c67a..d3d79ef97ab7e73316d16203927576d7de9d3e54 100644
--- a/ee/spec/lib/elastic/latest/issue_class_proxy_spec.rb
+++ b/ee/spec/lib/elastic/latest/issue_class_proxy_spec.rb
@@ -5,16 +5,16 @@
 RSpec.describe Elastic::Latest::IssueClassProxy, :elastic, :sidekiq_inline, feature_category: :global_search do
   before do
     stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
-    stub_feature_flags(search_uses_match_queries: false)
   end
 
-  subject { described_class.new(Issue, use_separate_indices: true) }
+  subject(:proxy) { described_class.new(Issue, use_separate_indices: true) }
 
   let!(:group) { create(:group) }
   let!(:project) { create(:project, :public, group: group) }
   let!(:user) { create(:user, developer_of: project) }
   let!(:label) { create(:label, project: project) }
   let!(:issue) { create(:labeled_issue, title: 'test', project: project, labels: [label]) }
+  let(:query) { 'test' }
 
   let(:options) do
     {
@@ -33,7 +33,7 @@
 
     shared_examples 'returns aggregations' do
       it 'filters by labels' do
-        result = subject.issue_aggregations('test', options)
+        result = proxy.issue_aggregations('test', options)
 
         expect(result.first.name).to eq('labels')
         expect(result.first.buckets.first.symbolize_keys).to match(
@@ -59,7 +59,7 @@
   end
 
   describe '#elastic_search' do
-    let(:result) { subject.elastic_search('test', options: options) }
+    let(:result) { proxy.elastic_search(query, options: options) }
 
     describe 'search on basis of hidden attribute' do
       context 'when author of the issue is banned' do
@@ -79,7 +79,7 @@
 
         it 'current_user is empty then user can not see the issue' do
           options[:current_user] = nil
-          result = subject.elastic_search('test', options: options)
+          result = proxy.elastic_search('test', options: options)
           expect(elasticsearch_hit_ids(result)).not_to include issue.id
         end
       end
@@ -100,31 +100,54 @@
 
         it 'current_user is empty then user can see the issue' do
           options[:current_user] = nil
-          result = subject.elastic_search('test', options: options)
+          result = proxy.elastic_search('test', options: options)
           expect(elasticsearch_hit_ids(result)).to include issue.id
         end
       end
     end
 
     describe 'named queries' do
-      let(:project_ids) { [project.id] }
-      let(:group_ids) { [] } # TODO - group.id
-      let(:options) do
-        {
-          current_user: user,
-          project_ids: project_ids,
-          group_ids: group_ids,
-          public_and_internal_projects: false,
-          order_by: nil,
-          sort: nil,
-          labels: [label.id]
-        }
+      using RSpec::Parameterized::TableSyntax
+
+      where(:projects, :groups) do
+        [] | []
+        [ref(:project)] | []
+        [] | [ref(:group)]
+        [ref(:project)] | [ref(:group)]
       end
 
-      describe 'hidden filter' do
-        context 'when user can admin all resources' do
-          before do
-            allow(user).to receive(:can_admin_all_resources?).and_return(true)
+      with_them do
+        let(:project_ids) { projects.map(&:id) }
+        let(:group_ids) { groups.map(&:id) }
+        let(:options) { base_options }
+
+        let(:base_options) do
+          {
+            current_user: user,
+            project_ids: project_ids,
+            group_ids: group_ids,
+            public_and_internal_projects: false,
+            order_by: nil,
+            sort: nil
+          }
+        end
+
+        describe 'base query' do
+          shared_examples 'a query that uses simple_query_string' do
+            it 'includes the correct base query name' do
+              result.response
+
+              assert_named_queries('issue:match:search_terms')
+            end
+          end
+
+          shared_examples 'a query that uses multi_match' do
+            it 'includes the correct base query name' do
+              result.response
+
+              assert_named_queries('issue:multi_match:or:search_terms', 'issue:multi_match:and:search_terms',
+                'issue:multi_match_phrase:search_terms')
+            end
           end
 
           context 'when search_query_builder feature flag is false' do
@@ -132,106 +155,222 @@
               stub_feature_flags(search_query_builder: false)
             end
 
-            it 'does not filter hidden issues' do
+            context 'when search_uses_match_queries feature flag is false' do
+              before do
+                stub_feature_flags(search_uses_match_queries: false)
+              end
+
+              it_behaves_like 'a query that uses simple_query_string'
+            end
+
+            it_behaves_like 'a query that uses multi_match'
+          end
+
+          context 'when querying by iid' do
+            let(:query) { '#1' }
+
+            it 'includes the correct base query name' do
               result.response
 
-              assert_named_queries(without: ['issue:hidden:non_hidden'])
+              assert_named_queries('issue:related:iid', 'doc:is_a:issue')
             end
           end
 
-          it 'does not filter hidden issues' do
-            result.response
+          context 'when search_uses_match_queries feature flag is false' do
+            before do
+              stub_feature_flags(search_uses_match_queries: false)
+            end
 
-            assert_named_queries(without: ['filters:non_hidden'])
+            it_behaves_like 'a query that uses simple_query_string'
           end
-        end
 
-        context 'when user cannot admin all resources' do
-          before do
-            allow(user).to receive(:can_admin_all_resources?).and_return(false)
+          context 'when using advanced search syntax' do
+            let(:query) { 'test -banner' }
+
+            it_behaves_like 'a query that uses simple_query_string'
           end
 
+          it_behaves_like 'a query that uses multi_match'
+        end
+
+        describe 'state filter' do
           context 'when search_query_builder feature flag is false' do
             before do
               stub_feature_flags(search_query_builder: false)
             end
 
-            it 'filters hidden issues' do
+            it 'does not filter by state in the query' do
               result.response
 
-              assert_named_queries('issue:hidden:non_hidden')
+              assert_named_queries(without: ['issue:match:state'])
             end
           end
 
-          it 'filters hidden issues' do
+          it 'does not filter by state in the query' do
             result.response
 
-            assert_named_queries('filters:not_hidden')
+            assert_named_queries(without: ['filters:state'])
           end
-        end
-      end
 
-      context 'when label filters are passed' do
-        context 'when search_query_builder feature flag is false' do
-          before do
-            stub_feature_flags(search_query_builder: false)
-          end
+          context 'when state option is provided' do
+            let(:options) { base_options.merge(state: 'opened') }
 
-          it 'filters the labels in the query' do
-            result.response
+            context 'when search_query_builder feature flag is false' do
+              before do
+                stub_feature_flags(search_query_builder: false)
+              end
+
+              it 'filters by state in the query' do
+                result.response
+
+                assert_named_queries('issue:match:state')
+              end
+            end
+
+            it 'filters by state in the query' do
+              result.response
 
-            assert_named_queries('issue:match:search_terms', 'issue:filter:label_ids', 'issue:archived:non_archived')
+              assert_named_queries('filters:state')
+            end
           end
         end
 
-        it 'filters the labels in the query' do
-          result.response
+        describe 'hidden filter' do
+          context 'when user can admin all resources' do
+            before do
+              allow(user).to receive(:can_admin_all_resources?).and_return(true)
+            end
 
-          assert_named_queries('issue:match:search_terms', 'filters:label_ids', 'filters:non_archived')
-        end
-      end
+            context 'when search_query_builder feature flag is false' do
+              before do
+                stub_feature_flags(search_query_builder: false)
+              end
 
-      context 'when include_archived is set' do
-        let(:options) { { include_archived: true } }
+              it 'does not filter hidden issues' do
+                result.response
 
-        context 'when search_query_builder feature flag is false' do
-          before do
-            stub_feature_flags(search_query_builder: false)
-          end
+                assert_named_queries(without: ['issue:hidden:non_hidden'])
+              end
+            end
 
-          it 'does not have a filter for archived' do
-            result.response
+            it 'does not filter hidden issues' do
+              result.response
 
-            assert_named_queries(without: ['issue:archived:non_archived'])
+              assert_named_queries(without: ['filters:non_hidden'])
+            end
           end
-        end
 
-        it 'does not have a filter for archived' do
-          result.response
+          context 'when user cannot admin all resources' do
+            before do
+              allow(user).to receive(:can_admin_all_resources?).and_return(false)
+            end
+
+            context 'when search_query_builder feature flag is false' do
+              before do
+                stub_feature_flags(search_query_builder: false)
+              end
+
+              it 'filters hidden issues' do
+                result.response
+
+                assert_named_queries('issue:hidden:non_hidden')
+              end
+            end
 
-          assert_named_queries(without: ['filters:non_archived'])
+            it 'filters hidden issues' do
+              result.response
+
+              assert_named_queries('filters:not_hidden')
+            end
+          end
         end
-      end
 
-      context 'when include_archived is not set' do
-        let(:options) { {} }
+        describe 'label filter' do
+          context 'when search_query_builder feature flag is false' do
+            before do
+              stub_feature_flags(search_query_builder: false)
+            end
+
+            it 'filters the labels in the query' do
+              result.response
 
-        context 'when search_query_builder feature flag is false' do
-          before do
-            stub_feature_flags(search_query_builder: false)
+              assert_named_queries(without: ['issue:filter:label_ids'])
+            end
           end
 
-          it 'does have a filter for archived' do
+          it 'filters the labels in the query' do
             result.response
 
-            assert_named_queries('issue:match:search_terms', 'issue:archived:non_archived')
+            assert_named_queries(without: ['filters:label_ids'])
+          end
+
+          context 'when labels option is provided' do
+            let(:options) { base_options.merge(labels: [label.id]) }
+
+            context 'when search_query_builder feature flag is false' do
+              before do
+                stub_feature_flags(search_query_builder: false)
+              end
+
+              it 'filters the labels in the query' do
+                result.response
+
+                assert_named_queries('issue:filter:label_ids')
+              end
+            end
+
+            it 'filters the labels in the query' do
+              result.response
+
+              assert_named_queries('filters:label_ids')
+            end
           end
         end
 
-        it 'does have a filter for archived' do
-          result.response
+        describe 'archived filter' do
+          context 'when include_archived is set' do
+            let(:options) { { include_archived: true } }
+
+            context 'when search_query_builder feature flag is false' do
+              before do
+                stub_feature_flags(search_query_builder: false)
+              end
+
+              it 'does not have a filter for archived' do
+                result.response
+
+                assert_named_queries(without: ['issue:archived:non_archived'])
+              end
+            end
+
+            it 'does not have a filter for archived' do
+              result.response
+
+              assert_named_queries(without: ['filters:non_archived'])
+            end
+          end
+
+          context 'when include_archived is not set' do
+            let(:options) { {} }
+
+            context 'when search_query_builder feature flag is false' do
+              before do
+                stub_feature_flags(search_query_builder: false)
+              end
+
+              it 'does have a filter for archived' do
+                result.response
 
-          assert_named_queries('issue:match:search_terms', 'filters:non_archived')
+                assert_named_queries('issue:archived:non_archived')
+              end
+            end
+
+            it 'does have a filter for archived' do
+              result.response
+
+              assert_named_queries('filters:non_archived')
+            end
+          end
         end
       end
     end
diff --git a/ee/spec/lib/gitlab/elastic/group_search_results_spec.rb b/ee/spec/lib/gitlab/elastic/group_search_results_spec.rb
index 5352404f0bc5a0a4551c7b00351b323532940a31..a727eb6579117b826dbd9e05d40a0e383d89bc20 100644
--- a/ee/spec/lib/gitlab/elastic/group_search_results_spec.rb
+++ b/ee/spec/lib/gitlab/elastic/group_search_results_spec.rb
@@ -19,19 +19,44 @@
   end
 
   context 'for issues search', :sidekiq_inline do
-    let_it_be_with_reload(:project) { create(:project, :public, group: group, developers: user) }
-    let_it_be_with_reload(:closed_result) { create(:issue, :closed, project: project, title: 'foo closed') }
-    let_it_be_with_reload(:opened_result) { create(:issue, :opened, project: project, title: 'foo opened') }
-    let_it_be_with_reload(:confidential_result) { create(:issue, :confidential, project: project, title: 'foo confidential') }
+    let_it_be(:project) { create(:project, :public, group: group, developers: user) }
+    let_it_be(:closed_result) { create(:issue, :closed, project: project, title: 'foo closed') }
+    let_it_be(:opened_result) { create(:issue, :opened, project: project, title: 'foo opened') }
+    let_it_be(:confidential_result) { create(:issue, :confidential, project: project, title: 'foo confidential') }
 
     let(:query) { 'foo' }
     let(:scope) { 'issues' }
 
     before do
+      stub_feature_flags(search_uses_match_queries: true)
       ::Elastic::ProcessInitialBookkeepingService.backfill_projects!(project)
       ensure_elasticsearch_index!
     end
 
+    context 'when advanced search query syntax is used' do
+      let(:query) { 'foo -banner' }
+
+      include_examples 'search results filtered by state'
+      include_examples 'search results filtered by confidential'
+      include_examples 'search results filtered by labels'
+      it_behaves_like 'namespace ancestry_filter for aggregations' do
+        let(:query_name) { 'filters:namespace:ancestry_filter:descendants' }
+      end
+    end
+
+    context 'when search_uses_match_queries flag is false' do
+      before do
+        stub_feature_flags(search_uses_match_queries: false)
+      end
+
+      include_examples 'search results filtered by state'
+      include_examples 'search results filtered by confidential'
+      include_examples 'search results filtered by labels'
+      it_behaves_like 'namespace ancestry_filter for aggregations' do
+        let(:query_name) { 'filters:namespace:ancestry_filter:descendants' }
+      end
+    end
+
     context 'when search_query_builder feature flag is false' do
       before do
         stub_feature_flags(search_query_builder: false)
diff --git a/ee/spec/lib/gitlab/elastic/project_search_results_spec.rb b/ee/spec/lib/gitlab/elastic/project_search_results_spec.rb
index e825a4c387d04692b35c6cac07e38610518bb326..63bc0f6e4367f48162f1c9e8df3b48b4e2c97006 100644
--- a/ee/spec/lib/gitlab/elastic/project_search_results_spec.rb
+++ b/ee/spec/lib/gitlab/elastic/project_search_results_spec.rb
@@ -93,6 +93,34 @@
           ensure_elasticsearch_index!
         end
 
+        context 'when advanced search query syntax is used' do
+          let(:query) { 'foo -banner' }
+
+          include_examples 'search results filtered by state'
+          include_examples 'search results filtered by confidential'
+          include_examples 'search results filtered by labels'
+        end
+
+        context 'when search_uses_match_queries flag is false' do
+          before do
+            stub_feature_flags(search_uses_match_queries: false)
+          end
+
+          include_examples 'search results filtered by state'
+          include_examples 'search results filtered by confidential'
+          include_examples 'search results filtered by labels'
+        end
+
+        context 'when search_query_builder feature flag is false' do
+          before do
+            stub_feature_flags(search_query_builder: false)
+          end
+
+          include_examples 'search results filtered by state'
+          include_examples 'search results filtered by confidential'
+          include_examples 'search results filtered by labels'
+        end
+
         include_examples 'search results filtered by state'
         include_examples 'search results filtered by confidential'
         include_examples 'search results filtered by labels'
diff --git a/ee/spec/lib/gitlab/elastic/search_results_spec.rb b/ee/spec/lib/gitlab/elastic/search_results_spec.rb
index e9b09fc9a9fa01dfe095f47fb97bf2769941155a..d0df2baf8504acae3eb91c91ceeead6b3e2daf55 100644
--- a/ee/spec/lib/gitlab/elastic/search_results_spec.rb
+++ b/ee/spec/lib/gitlab/elastic/search_results_spec.rb
@@ -1259,61 +1259,81 @@ def search_for(term)
       private_project2.project_members.create!(user: user, access_level: ProjectMember::DEVELOPER)
     end
 
-    context 'issues' do
-      it 'finds right set of issues' do
-        issue_1 = create :issue, project: internal_project, title: "Internal project"
-        create :issue, project: private_project1, title: "Private project"
-        issue_3 = create :issue, project: private_project2, title: "Private project where I'm a member"
-        issue_4 = create :issue, project: public_project, title: "Public project"
+    context 'for issues' do
+      shared_examples 'issues respect visibility' do
+        it 'finds right set of issues' do
+          issue_1 = create :issue, project: internal_project, title: "Internal project"
+          create :issue, project: private_project1, title: "Private project"
+          issue_3 = create :issue, project: private_project2, title: "Private project where I'm a member"
+          issue_4 = create :issue, project: public_project, title: "Public project"
 
-        ensure_elasticsearch_index!
-
-        # Authenticated search
-        results = described_class.new(user, 'project', limit_project_ids)
-        issues = results.objects('issues')
+          ensure_elasticsearch_index!
 
-        expect(issues).to include issue_1
-        expect(issues).to include issue_3
-        expect(issues).to include issue_4
-        expect(results.issues_count).to eq 3
+          # Authenticated search
+          results = described_class.new(user, 'project', limit_project_ids)
+          issues = results.objects('issues')
 
-        # Unauthenticated search
-        results = described_class.new(nil, 'project', [])
-        issues = results.objects('issues')
+          expect(issues).to include issue_1
+          expect(issues).to include issue_3
+          expect(issues).to include issue_4
+          expect(results.issues_count).to eq 3
 
-        expect(issues).to include issue_4
-        expect(results.issues_count).to eq 1
-      end
+          # Unauthenticated search
+          results = described_class.new(nil, 'project', [])
+          issues = results.objects('issues')
 
-      context 'when different issue descriptions', :aggregate_failures do
-        let(:examples) do
-          code_examples.merge(
-            'screen' => 'Screenshots or screen recordings',
-            'problem' => 'Problem to solve'
-          )
+          expect(issues).to include issue_4
+          expect(results.issues_count).to eq 1
         end
 
-        include_context 'with code examples' do
-          before do
-            examples.values.uniq.each do |description|
-              sha = Digest::SHA256.hexdigest(description)
-              create :issue, project: private_project2, title: sha, description: description
-            end
-
-            ensure_elasticsearch_index!
+        context 'when different issue descriptions', :aggregate_failures do
+          let(:examples) do
+            code_examples.merge(
+              'screen' => 'Screenshots or screen recordings',
+              'problem' => 'Problem to solve'
+            )
           end
 
-          it 'finds all examples' do
-            examples.each do |search_term, description|
-              sha = Digest::SHA256.hexdigest(description)
+          include_context 'with code examples' do
+            before do
+              examples.values.uniq.each do |description|
+                sha = Digest::SHA256.hexdigest(description)
+                create :issue, project: private_project2, title: sha, description: description
+              end
 
-              results = described_class.new(user, search_term, limit_project_ids)
-              issues = results.objects('issues')
-              expect(issues.map(&:title)).to include(sha), "failed to find #{search_term}"
+              ensure_elasticsearch_index!
+            end
+
+            it 'finds all examples' do
+              examples.each do |search_term, description|
+                sha = Digest::SHA256.hexdigest(description)
+
+                results = described_class.new(user, search_term, limit_project_ids)
+                issues = results.objects('issues')
+                expect(issues.map(&:title)).to include(sha), "failed to find #{search_term}"
+              end
             end
           end
         end
       end
+
+      it_behaves_like 'issues respect visibility'
+
+      context 'when search_uses_match_queries flag is false' do
+        before do
+          stub_feature_flags(search_uses_match_queries: false)
+        end
+
+        it_behaves_like 'issues respect visibility'
+      end
+
+      context 'when search_query_builder feature flag is false' do
+        before do
+          stub_feature_flags(search_query_builder: false)
+        end
+
+        it_behaves_like 'issues respect visibility'
+      end
     end
 
     context 'milestones' do
diff --git a/ee/spec/lib/search/elastic/queries_spec.rb b/ee/spec/lib/search/elastic/queries_spec.rb
index 4f30de5c4dafe14349224e0665f2d44bce74638e..eb023a028137c7c5d85939e97b651cc0063f638c 100644
--- a/ee/spec/lib/search/elastic/queries_spec.rb
+++ b/ee/spec/lib/search/elastic/queries_spec.rb
@@ -42,7 +42,7 @@
                                    query: 'foo bar', lenient: true, default_operator: :and } }
         ]
 
-        expect(by_simple_query_string[:query][:bool][:must]).to eq(expected_must)
+        expect(by_simple_query_string[:query][:bool][:must]).to eql(expected_must)
       end
     end
 
@@ -109,4 +109,127 @@
       end
     end
   end
+
+  describe '#by_multi_match_query' do
+    let(:query) { 'foo bar' }
+    let(:options) { base_options }
+    let(:base_options) { { doc_type: 'my_type' } }
+    let(:fields) { %w[iid^3 title^2 description] }
+
+    subject(:by_multi_match_query) do
+      described_class.by_multi_match_query(fields: fields, query: query, options: options)
+    end
+
+    context 'when custom elasticsearch analyzers are enabled' do
+      before do
+        stub_ee_application_setting(elasticsearch_analyzers_smartcn_enabled: true,
+          elasticsearch_analyzers_smartcn_search: true)
+      end
+
+      it 'applies custom analyzer fields to multi_match_query' do
+        expected_must = [{ bool: {
+          should: [
+            { multi_match: { _name: 'my_type:multi_match:or:search_terms',
+                             fields: %w[iid^3 title^2 description title.smartcn description.smartcn],
+                             query: 'foo bar', operator: :or, lenient: true } },
+            { multi_match: { _name: 'my_type:multi_match:and:search_terms',
+                             fields: %w[iid^3 title^2 description title.smartcn description.smartcn],
+                             query: 'foo bar', operator: :and, lenient: true } },
+            { multi_match: { _name: 'my_type:multi_match_phrase:search_terms',
+                             type: :phrase, fields: %w[iid^3 title^2 description title.smartcn description.smartcn],
+                             query: 'foo bar', lenient: true } }
+          ],
+          minimum_should_match: 1
+        } }]
+
+        expect(by_multi_match_query[:query][:bool][:must]).to eql(expected_must)
+      end
+    end
+
+    it 'applies highlight in query' do
+      expected = { fields: { iid: {}, title: {}, description: {} },
+                   number_of_fragments: 0, pre_tags: ['gitlabelasticsearch→'], post_tags: ['←gitlabelasticsearch'] }
+
+      expect(by_multi_match_query[:highlight]).to eq(expected)
+    end
+
+    context 'when query is provided' do
+      it 'returns a by_multi_match_query query as a should and adds doc type as a filter' do
+        expected_must = [{ bool: {
+          should: [
+            { multi_match: { _name: 'my_type:multi_match:or:search_terms',
+                             fields: %w[iid^3 title^2 description],
+                             query: 'foo bar', operator: :or, lenient: true } },
+            { multi_match: { _name: 'my_type:multi_match:and:search_terms',
+                             fields: %w[iid^3 title^2 description],
+                             query: 'foo bar', operator: :and, lenient: true } },
+            { multi_match: { _name: 'my_type:multi_match_phrase:search_terms',
+                             type: :phrase, fields: %w[iid^3 title^2 description],
+                             query: 'foo bar', lenient: true } }
+          ],
+          minimum_should_match: 1
+        } }]
+
+        expected_filter = [
+          { term: { type: { _name: 'doc:is_a:my_type', value: 'my_type' } } }
+        ]
+
+        expect(by_multi_match_query[:query][:bool][:must]).to eql(expected_must)
+        expect(by_multi_match_query[:query][:bool][:must_not]).to eq([])
+        expect(by_multi_match_query[:query][:bool][:should]).to eq([])
+        expect(by_multi_match_query[:query][:bool][:filter]).to eq(expected_filter)
+      end
+    end
+
+    context 'when query is not provided' do
+      let(:query) { nil }
+
+      it 'returns a match_all query' do
+        expected_must = { match_all: {} }
+
+        expect(by_multi_match_query[:query][:bool][:must]).to eq(expected_must)
+        expect(by_multi_match_query[:query][:bool][:must_not]).to eq([])
+        expect(by_multi_match_query[:query][:bool][:should]).to eq([])
+        expect(by_multi_match_query[:query][:bool][:filter]).to eq([])
+        expect(by_multi_match_query[:track_scores]).to eq(true)
+      end
+    end
+
+    context 'when options[:count_only] is true' do
+      let(:options) { base_options.merge(count_only: true) }
+
+      it 'adds size set to 0 in query' do
+        expect(by_multi_match_query[:size]).to eq(0)
+      end
+
+      it 'does not apply highlight in query' do
+        expect(by_multi_match_query[:highlight]).to be_nil
+      end
+
+      it 'removes field boosts and returns a by_multi_match_query as a filter' do
+        expected_filter = [
+          { term: { type: { _name: 'doc:is_a:my_type', value: 'my_type' } } },
+          { bool: {
+            should: [
+              { multi_match: { _name: 'my_type:multi_match:or:search_terms',
+                               fields: %w[iid title description],
+                               query: 'foo bar', operator: :or, lenient: true } },
+              { multi_match: { _name: 'my_type:multi_match:and:search_terms',
+                               fields: %w[iid title description],
+                               query: 'foo bar', operator: :and, lenient: true } },
+              { multi_match: { _name: 'my_type:multi_match_phrase:search_terms',
+                               type: :phrase, fields: %w[iid title description],
+                               query: 'foo bar', lenient: true } }
+            ],
+            minimum_should_match: 1
+          } }
+        ]
+
+        expect(by_multi_match_query[:query][:bool][:must]).to eq([])
+        expect(by_multi_match_query[:query][:bool][:must_not]).to eq([])
+        expect(by_multi_match_query[:query][:bool][:should]).to eq([])
+        expect(by_multi_match_query[:query][:bool][:filter]).to eql(expected_filter)
+      end
+    end
+  end
 end
diff --git a/ee/spec/models/concerns/elastic/issue_spec.rb b/ee/spec/models/concerns/elastic/issue_spec.rb
index f4da3758a9a8bd53e4b90d18e972658d04ad5705..8147e795f2cd6328e4904b417e30d46809246a16 100644
--- a/ee/spec/models/concerns/elastic/issue_spec.rb
+++ b/ee/spec/models/concerns/elastic/issue_spec.rb
@@ -33,24 +33,6 @@
       expect(described_class.elastic_search('bla-bla', options: { project_ids: :any, public_and_internal_projects: true }).total_count).to eq(3)
     end
 
-    context 'when search_query_builder feature flag is false' do
-      before do
-        stub_feature_flags(search_query_builder: false)
-      end
-
-      it 'names elasticsearch queries' do
-        described_class.elastic_search('*').total_count
-
-        assert_named_queries('issue:match:search_terms', 'issue:authorized:project')
-      end
-    end
-
-    it 'names elasticsearch queries' do
-      described_class.elastic_search('*').total_count
-
-      assert_named_queries('issue:match:search_terms', 'filters:project')
-    end
-
     it 'searches by iid and scopes to type: issue only', :sidekiq_inline do
       issue = create :issue, title: 'bla-bla issue', project: project
       create :issue, description: 'term2 in description', project: project