Skip to main content
Since May 1st, 2024, Apple requires all iOS apps to include a privacy manifest. For more details, see Privacy Manifest.
Multi-index search (federated search) is a method for searching multiple data sources simultaneously. This means that when users enter a search term, Algolia will look for and display results from all these data sources. This doesn’t necessarily mean that the results from Algolia indices are combined since their contents could be quite different. Your approach may be to display the results from each index separately. You could display the top-rated items from a movie index alongside the list of results from a book index. Or you could display category matches alongside the list of results from a product index

Search multiple indices with InstantSearch

The following example uses a single search view controller to search in two indices. This is achieved through the aggregation of two HitsSearchers by the MultiSearcher. Each of them targets a specific index: the first one is mobile_demo_actors and the second is mobile_demo_movies. The results are presented in the dedicated sections of a UITableViewController instance. The source code of this example is on GitHub.
Swift
struct Movie: Codable {
  let title: String
}

struct Actor: Codable {
  let name: String
}

class SearchViewController: UIViewController {

  let searchController: UISearchController

  let searchBoxConnector: SearchBoxConnector
  let textFieldController: TextFieldController

  let searcher: MultiSearcher
  let actorHitsInteractor: HitsInteractor<Hit<Actor>>
  let movieHitsInteractor: HitsInteractor<Hit<Movie>>

  let searchResultsController: SearchResultsController

  override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
    searcher = .init(appID: "latency",
                      apiKey: "1f6fd3a6fb973cb08419fe7d288fa4db")
    searchResultsController = .init()
    actorHitsInteractor = .init(infiniteScrolling: .off)
    movieHitsInteractor = .init(infiniteScrolling: .off)
    searchController = .init(searchResultsController: searchResultsController)
    textFieldController = .init(searchBar: searchController.searchBar)
    searchBoxConnector = .init(searcher: searcher,
                               controller: textFieldController)
    super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func viewDidLoad() {
    super.viewDidLoad()

    configureUI()

    let actorsSearcher = searcher.addHitsSearcher(indexName: "mobile_demo_actors")
    actorHitsInteractor.connectSearcher(actorsSearcher)
    searchResultsController.actorsHitsInteractor = actorHitsInteractor

    let moviesSearcher = searcher.addHitsSearcher(indexName: "mobile_demo_movies")
    movieHitsInteractor.connectSearcher(moviesSearcher)
    searchResultsController.moviesHitsInteractor = movieHitsInteractor

    searcher.search()
  }

  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    searchController.isActive = true
  }

  func configureUI() {
    view.backgroundColor = .white
    definesPresentationContext = true
    searchController.hidesNavigationBarDuringPresentation = false
    searchController.showsSearchResultsController = true
    searchController.automaticallyShowsCancelButton = false
    navigationItem.searchController = searchController
  }

}

class SearchResultsController: UITableViewController {

  enum Section: Int, CaseIterable {

    case actors
    case movies

    var title: String {
      switch self {
      case .actors:
        return "Actors"
      case .movies:
        return "Movies"
      }
    }

    var image: UIImage? {
      switch self {
      case .movies:
        return UIImage(systemName: "film")
      case .actors:
        return UIImage(systemName: "person.circle")
      }
    }

    init?(section: Int) {
      self.init(rawValue: section)
    }

    init?(indexPath: IndexPath) {
      self.init(section: indexPath.section)
    }

  }

  let cellReuseIdentifier = "cellID"

  func numberOfHits(in section: Section) -> Int {
    switch section {
    case .actors:
      return actorsHitsInteractor?.numberOfHits() ?? 0
    case .movies:
      return moviesHitsInteractor?.numberOfHits() ?? 0
    }
  }

  func cellLabel(forRowIndex rowIndex: Int, at section: Section) -> NSAttributedString? {
    switch section {
    case .actors:
      return actorsHitsInteractor?.hit(atIndex: rowIndex)?.hightlightedString(forKey: "name").map { highlightedString in
        NSAttributedString(highlightedString: highlightedString, attributes: [.font: UIFont.systemFont(ofSize: 17, weight: .bold)])
      }
    case .movies:
      return moviesHitsInteractor?.hit(atIndex: rowIndex)?.hightlightedString(forKey: "title").map { highlightedString in
        NSAttributedString(highlightedString: highlightedString, attributes: [.font: UIFont.systemFont(ofSize: 17, weight: .bold)])
      }
    }
  }

  weak var actorsHitsInteractor: HitsInteractor<Hit<Actor>>? {
    didSet {
      oldValue?.onResultsUpdated.cancelSubscription(for: tableView)
      guard let interactor = actorsHitsInteractor else { return }
      interactor.onResultsUpdated.subscribe(with: tableView) { tableView, _ in
        tableView.reloadData()
      }.onQueue(.main)
    }
  }

  weak var moviesHitsInteractor: HitsInteractor<Hit<Movie>>? {
    didSet {
      oldValue?.onResultsUpdated.cancelSubscription(for: tableView)
      guard let interactor = moviesHitsInteractor else { return }
      interactor.onResultsUpdated.subscribe(with: tableView) { tableView, _ in
        tableView.reloadData()
      }.onQueue(.main)
    }
  }

  override func viewDidLoad() {
    super.viewDidLoad()
    tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellReuseIdentifier)
  }

  override func numberOfSections(in tableView: UITableView) -> Int {
    return Section.allCases.count
  }

  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    guard let section = Section(section: section) else { return 0 }
    return numberOfHits(in: section)
  }

  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath)
    guard let section = Section(indexPath: indexPath) else { return cell }
    cell.tintColor = .lightGray
    cell.imageView?.image = section.image
    cell.textLabel?.attributedText = cellLabel(forRowIndex: indexPath.row, at: section)
    return cell
  }

  override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
    guard let section = Section(section: section), numberOfHits(in: section) != 0 else { return nil }
    return section.title
  }

  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    guard let section = Section(indexPath: indexPath) else { return }
    switch section {
    case .actors:
      if let _ = actorsHitsInteractor?.hit(atIndex: indexPath.row) {
        // Handle actor selection
      }
    case .movies:
      if let _ = moviesHitsInteractor?.hit(atIndex: indexPath.row) {
        // Handle movie selection
      }
    }
  }

}
You can find the complete example on GitHub.

Combine search for hits and facets values

This example uses a single search view controller to search in the index and facet values for attributes of the same index. This is achieved through the aggregation of the HitsSearcher and the FacetSearcher by the MultiSearcher. The results are presented in the dedicated sections of a UITableViewController instance. The source code of this example is on GitHub.
Swift
struct Item: Codable {
  let name: String
}

class SearchViewController: UIViewController {

  let searchController: UISearchController

  let searchBoxConnector: SearchBoxConnector
  let textFieldController: TextFieldController

  let searcher: MultiSearcher
  let categoriesInteractor: FacetListInteractor
  let hitsInteractor: HitsInteractor<Item>

  let searchResultsController: SearchResultsController

  override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
    searcher = .init(appID: "latency",
                      apiKey: "6be0576ff61c053d5f9a3225e2a90f76")
    searchResultsController = .init(style: .plain)
    categoriesInteractor = .init()
    hitsInteractor = .init(infiniteScrolling: .off)
    searchController = .init(searchResultsController: searchResultsController)
    textFieldController = .init(searchBar: searchController.searchBar)
    searchBoxConnector = .init(searcher: searcher,
                               controller: textFieldController)
    super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func viewDidLoad() {
    super.viewDidLoad()

    configureUI()

    let facetsSearcher = searcher.addFacetsSearcher(indexName: "instant_search",
                                                    attribute: "categories")
    categoriesInteractor.connectFacetSearcher(facetsSearcher)
    searchResultsController.categoriesInteractor = categoriesInteractor

    let hitsSearchers = searcher.addHitsSearcher(indexName: "instant_search")
    hitsInteractor.connectSearcher(hitsSearchers)
    searchResultsController.hitsInteractor = hitsInteractor

    searcher.search()
  }

  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    searchController.isActive = true
  }

  func configureUI() {
    view.backgroundColor = .white
    definesPresentationContext = true
    searchController.hidesNavigationBarDuringPresentation = false
    searchController.showsSearchResultsController = true
    searchController.automaticallyShowsCancelButton = false
    navigationItem.searchController = searchController
  }

}

class SearchResultsController: UITableViewController {

  var didSelectSuggestion: ((String) -> Void)?

  enum Section: Int, CaseIterable {
    case categories
    case hits

    var title: String {
      switch self {
      case .categories:
        return "Categories"
      case .hits:
        return "Hits"
      }
    }

    var cellID: String {
      switch self {
      case .categories:
        return "categories"
      case .hits:
        return "hits"
      }
    }

  }

  weak var categoriesInteractor: FacetListInteractor? {
    didSet {
      oldValue?.onResultsUpdated.cancelSubscription(for: tableView)
      guard let interactor = categoriesInteractor else { return }
      interactor.onResultsUpdated.subscribe(with: tableView) { tableView, _ in
        tableView.reloadData()
      }.onQueue(.main)
    }
  }

  weak var hitsInteractor: HitsInteractor<Item>? {
    didSet {
      oldValue?.onResultsUpdated.cancelSubscription(for: tableView)
      guard let interactor = hitsInteractor else { return }
      interactor.onResultsUpdated.subscribe(with: tableView) { tableView, _ in
        tableView.reloadData()
      }.onQueue(.main)
    }
  }

  override func viewDidLoad() {
    super.viewDidLoad()
    tableView.register(UITableViewCell.self, forCellReuseIdentifier: Section.categories.cellID)
  }

  override func numberOfSections(in tableView: UITableView) -> Int {
    return Section.allCases.count
  }

  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    guard let section = Section(rawValue: section) else { return 0 }
    switch section {
    case .categories:
      return categoriesInteractor?.items.count ?? 0
    case .hits:
      return hitsInteractor?.numberOfHits() ?? 0
    }
  }

  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let section = Section(rawValue: indexPath.section) else { return UITableViewCell() }

    let cell = tableView.dequeueReusableCell(withIdentifier: Section.categories.cellID, for: indexPath)

    switch section {
    case .categories:
      if let category = categoriesInteractor?.items[indexPath.row] {
        cell.textLabel?.text = category.value
      }
    case .hits:
      if let hit = hitsInteractor?.hit(atIndex: indexPath.row) {
        cell.textLabel?.text = hit.name
      }
    }

    return cell
  }

  override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
    guard let section = Section(rawValue: section) else { return nil }
    switch section {
    case .categories where categoriesInteractor?.items.count ?? 0 == 0:
      return nil
    case .hits where hitsInteractor?.numberOfHits() ?? 0 == 0:
      return nil
    default:
      return section.title
    }
  }

  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    guard let section = Section(rawValue: indexPath.section) else { return }
    switch section {
    case .hits:
      // Handle hit selection
      break

    case .categories:
      // Handle category selection
      break
    }
  }

}

Category display

Algolia can help you display both category matches and results if you:
  • Add categories to your Query Suggestions either inline or listed below a result. For example, you might see the following in your Query Suggestions list “game of thrones in Books”
  • Use multi-index search to display categories from a separate category index. This is useful if you want to display categories and Query Suggestions at the same time. Clicking such a result typically redirects to a category page. The following is a sample dataset for a product index and a category index.

Example product index

JSON
[
  {
    "name": "Fashion Krisp",
    "description": "A pair of red shoes with a comfortable fit.",
    "image": "/fashion-krisp.jpg",
    "price": 18.98,
    "likes": 284,
    "category": "Fashion > Women > Shoes > Court shoes"
  },
  {
    "name": "Jiver",
    "description": "A blue shirt made of cotton.",
    "image": "/jiver.jpg",
    "price": 17.7,
    "likes": 338,
    "category": "Fashion > Men > Shirts > Dress shirt"
  }
]

Example category index

JSON
[
  {
    "name": "Court shoes",
    "category": "Fashion > Women > Shoes > Court shoes",
    "description": "A dress shoe with a low-cut front and a closed heel.",
    "url": "/women/shoes/court/"
  },
  {
    "name": "Dress shirt",
    "category": "Fashion > Men > Shirts > Dress shirt",
    "description": "A long-sleeved, button-up formal shirt that is typically worn with a suit or tie.",
    "url": "/men/shirts/dress/"
  }
]
I