diff --git a/reviewboard/accounts/search_indexes.py b/reviewboard/accounts/search_indexes.py
index 5ed85c6fce7a7fee00f9372c81594b3281f3ab76..3f8bf9276775a21374bbbc413271630112bfdfa4 100644
--- a/reviewboard/accounts/search_indexes.py
+++ b/reviewboard/accounts/search_indexes.py
@@ -3,14 +3,14 @@ from __future__ import unicode_literals
 from django.contrib.auth.models import User
 from haystack import indexes
 
+from reviewboard.search.indexes import BaseSearchIndex
 
-class UserIndex(indexes.SearchIndex, indexes.Indexable):
+
+class UserIndex(BaseSearchIndex, indexes.Indexable):
     """A Haystack search index for users."""
 
-    # By Haystack convention, the full-text template is automatically
-    # referenced at
-    # reviewboard/templates/search/indexes/accounts/user_text.txt
-    text = indexes.CharField(document=True, use_template=True)
+    model = User
+    local_site_attr = 'local_site'
 
     username = indexes.CharField(model_attr='username')
     email = indexes.CharField(model_attr='email')
@@ -19,10 +19,6 @@ class UserIndex(indexes.SearchIndex, indexes.Indexable):
     show_profile = indexes.BooleanField(model_attr='is_profile_visible')
     groups = indexes.MultiValueField(indexed=False)
 
-    def get_model(self):
-        """Return the Django model for this index."""
-        return User
-
     def index_queryset(self, using=None):
         """Query the list of users for the index.
 
diff --git a/reviewboard/reviews/managers.py b/reviewboard/reviews/managers.py
index f32b09b2ae046bba406475c0e7a7d6307179d056..c662ba00888efbd2bbd8b2df4949d9c7e50e03f1 100644
--- a/reviewboard/reviews/managers.py
+++ b/reviewboard/reviews/managers.py
@@ -290,7 +290,8 @@ class ReviewRequestManager(ConcurrencyManager):
 
     def _query(self, user=None, status='P', with_counts=False,
                extra_query=None, local_site=None, filter_private=False,
-               show_inactive=False, show_all_unpublished=False):
+               show_inactive=False, show_all_unpublished=False,
+               show_all_local_sites=False):
         from reviewboard.reviews.models import Group
 
         is_authenticated = (user is not None and user.is_authenticated())
@@ -309,7 +310,10 @@ class ReviewRequestManager(ConcurrencyManager):
         if status:
             query = query & Q(status=status)
 
-        query = query & Q(local_site=local_site)
+        if show_all_local_sites:
+            assert local_site is None
+        else:
+            query = query & Q(local_site=local_site)
 
         if extra_query:
             query = query & extra_query
diff --git a/reviewboard/reviews/search_indexes.py b/reviewboard/reviews/search_indexes.py
index c0ace502657595e14a546af7d541cd3b96e5ede8..ad5827dfad62ac5ae368366e23335dedbfcf20ba 100644
--- a/reviewboard/reviews/search_indexes.py
+++ b/reviewboard/reviews/search_indexes.py
@@ -5,15 +5,13 @@ from djblets.util.templatetags.djblets_utils import user_displayname
 from haystack import indexes
 
 from reviewboard.reviews.models import ReviewRequest
+from reviewboard.search.indexes import BaseSearchIndex
 
 
-class ReviewRequestIndex(indexes.SearchIndex, indexes.Indexable):
+class ReviewRequestIndex(BaseSearchIndex, indexes.Indexable):
     """A Haystack search index for Review Requests."""
-
-    # By Haystack convention, the full-text template is automatically
-    # referenced at
-    # reviewboard/templates/search/indexes/reviews/reviewrequest_text.txt
-    text = indexes.CharField(document=True, use_template=True)
+    model = ReviewRequest
+    local_site_attr = 'local_site_id'
 
     # We shouldn't use 'id' as a field name because it's by default reserved
     # for Haystack. Hiding it will cause duplicates when updating the index.
@@ -40,7 +38,8 @@ class ReviewRequestIndex(indexes.SearchIndex, indexes.Indexable):
         """Index only public pending and submitted review requests."""
         queryset = self.get_model().objects.public(
             status=None,
-            extra_query=Q(status='P') | Q(status='S'))
+            extra_query=Q(status='P') | Q(status='S'),
+            show_all_local_sites=True)
         queryset = queryset.select_related('submitter', 'diffset_history')
         queryset = queryset.prefetch_related(
             'diffset_history__diffsets__files')
diff --git a/reviewboard/search/indexes.py b/reviewboard/search/indexes.py
new file mode 100644
index 0000000000000000000000000000000000000000..9122d612dda6bf7782ed6f3f45ef6ca6e5b06f9a
--- /dev/null
+++ b/reviewboard/search/indexes.py
@@ -0,0 +1,64 @@
+from __future__ import unicode_literals
+
+from django.core.exceptions import ImproperlyConfigured
+from haystack import indexes
+
+
+class BaseSearchIndex(indexes.SearchIndex):
+    """Base class for a search index.
+
+    This sets up a few common fields we want all indexes to include.
+    """
+
+    #: The model to index.
+    model = None
+
+    #: The local site attribute on the model.
+    #:
+    #: For ForeignKeys, this should be the name of the ID field, as in
+    #: 'local_site_id'. For ManyToManyFields, it should be the standar field
+    #: name.
+    local_site_attr = None
+
+    # Common fields for all search indexes.
+    text = indexes.CharField(document=True, use_template=True)
+    local_sites = indexes.MultiValueField(null=True)
+
+    NO_LOCAL_SITE_ID = 0
+
+    def get_model(self):
+        """Return the model for this index."""
+        return self.model
+
+    def prepare_local_sites(self, obj):
+        """Prepare the list of local sites for the search index.
+
+        This will take any associated local sites on the object and store
+        them in the index as a list. The search view can then easily look up
+        values in the list, regardless of the type of object.
+
+        If the object is not a part of a local site, the list will be
+        ``[0]``, indicating no local site.
+        """
+        if not self.local_site_attr:
+            raise ImproperlyConfigured('local_site_attr must be set on %r'
+                                       % self.__class__)
+
+        if not hasattr(obj, self.local_site_attr):
+            raise ImproperlyConfigured(
+                '"%s" is not a valid local site attribute on %r'
+                % (self.local_site_attr, obj.__class__))
+
+        local_sites = getattr(obj, self.local_site_attr, None)
+
+        if self.local_site_attr.endswith('_id'):
+            # This is from a ForeignKey. We're working with a numeric ID.
+            if local_sites is not None:
+                return [local_sites]
+            else:
+                return [self.NO_LOCAL_SITE_ID]
+        else:
+            # This is most likely a ManyToManyField. Anything else is an
+            # error.
+            return (list(local_sites.values_list('pk', flat=True)) or
+                    [self.NO_LOCAL_SITE_ID])
diff --git a/reviewboard/search/urls.py b/reviewboard/search/urls.py
index 77e7a057c4c7c14c12308840b9c7c1487e35a7fc..315873bfda3863a5ddb4bc5dbb784fe52668b171 100644
--- a/reviewboard/search/urls.py
+++ b/reviewboard/search/urls.py
@@ -1,15 +1,12 @@
 from __future__ import unicode_literals
 
 from django.conf.urls import patterns, url
-from haystack.views import search_view_factory
 
-from reviewboard.search.views import RBSearchView
+from reviewboard.search.views import search
 
 
 urlpatterns = patterns(
     '',
 
-    url(r'^$',
-        search_view_factory(view_class=RBSearchView),
-        name='search'),
+    url(r'^$', search, name='search'),
 )
diff --git a/reviewboard/search/views.py b/reviewboard/search/views.py
index 47a72b7fa6540cbb181dfe1d5b362eadaaf630df..414b892be10fa904b6bc429dc0d725753c5a26bc 100644
--- a/reviewboard/search/views.py
+++ b/reviewboard/search/views.py
@@ -2,13 +2,14 @@ from __future__ import unicode_literals
 
 from django.http import HttpResponseRedirect
 from django.shortcuts import render, render_to_response
-from django.utils.decorators import method_decorator
 from djblets.siteconfig.models import SiteConfiguration
+from haystack.inputs import Raw
 from haystack.query import SearchQuerySet
 from haystack.views import SearchView
 
 from reviewboard.accounts.decorators import check_login_required
 from reviewboard.reviews.models import ReviewRequest
+from reviewboard.search.indexes import BaseSearchIndex
 from reviewboard.site.decorators import check_local_site_access
 from reviewboard.site.urlresolvers import local_site_reverse
 
@@ -30,9 +31,8 @@ class RBSearchView(SearchView):
             results_per_page=siteconfig.get('search_results_per_page'),
             *args, **kwargs)
 
-    @method_decorator(check_login_required)
-    @method_decorator(check_local_site_access)
-    def __call__(self, request, local_site=None):
+    def __call__(self, request, local_site=None, local_site_name=None,
+                 *args, **kwargs):
         """Handles requests to this view.
 
         This will first check if the search result is just a digit, which is
@@ -42,6 +42,7 @@ class RBSearchView(SearchView):
         Otherwise, the search will be carried out based on the query.
         """
         self.request = request
+        self.local_site = local_site
 
         query = self.get_query()
 
@@ -72,8 +73,14 @@ class RBSearchView(SearchView):
         if self.query.isdigit():
             sqs = sqs.filter(review_request_id=self.query)
         else:
-            sqs = sqs.raw_search(self.query)
+            sqs = sqs.filter(content=Raw(self.query))
 
+        if self.local_site:
+            local_site_id = self.local_site.pk
+        else:
+            local_site_id = BaseSearchIndex.NO_LOCAL_SITE_ID
+
+        sqs = sqs.filter_and(local_sites__contains=local_site_id)
         sqs = sqs.order_by('-last_updated')
 
         self.total_hits = len(sqs)
@@ -117,3 +124,11 @@ class RBSearchView(SearchView):
         return render_to_response(
             self.template, context,
             context_instance=self.context_class(self.request))
+
+
+@check_login_required
+@check_local_site_access
+def search(*args, **kwargs):
+    """Provide results for a given search."""
+    search_view = RBSearchView()
+    return search_view(*args, **kwargs)
