Multi-index search with Scrypath

Copy Markdown View Source

This guide shows how to call Scrypath.search_many/2 from a Phoenix LiveView dashboard that searches several schemas at once, handles per-schema filters and facets, and surfaces partial failures without pretending hits are merged.

How per-entry tuple keywords merge with shared options (and how future per-query tuning keys will participate in the same right-biased story) is specified in the Per-query tuning pipeline — read that for the Plane B precedence stack; this file remains the canonical place for expansion, federation payloads, partial failures, and merge ordering details below.

When to use search_many/2

Use search_many/2 when you explicitly list schemas (for example posts, users, tags, and events) and want one federated Meilisearch round-trip. Do not expect a single relevance ordering across schemas: scores stay per index. If you reuse the same text for every tuple, that is fine for a unified search bar, but treat ranking as per-schema, not comparable across rows.

:all expansion

Some dashboards want one search bar over every schema registered for “global search” without listing modules in the LiveView. For that, search_many/2 accepts a tagged entry {:all, text} or {:all, text, keyword} (same tuple shapes as a normal schema entry, but with the atom :all instead of a module).

Expansion runs before per-entry validation: each :all entry becomes one {schema, text, keyword} tuple per module in the configured allowlist, preserving declaration order. Provide the allowlist in either place:

  • global_schemas: on the shared options — a list of schema modules in order. When set, it replaces the application-env list for that call only.
  • Otherwise pass otp_app: and list modules under Application.get_env(otp_app, :scrypath_global_search_schemas, []) (the :scrypath_global_search_schemas application env key).

If the resolved list is empty, the call fails fast with {:invalid_options, {:all_expansion, :empty_registry}}. If otp_app: is missing while global_schemas: is absent, expect {:invalid_options, {:all_expansion, :missing_otp_app}}. After expansion, the same max_schemas and federation limits apply as for an explicit entry list—over-limit cases surface as {:error, {:too_many_schemas, count, max}} (see Scrypath.search_many/2 docs for the exact error vocabulary).

Primary example: four-schema LiveView dashboard

Imagine a dashboard mount that assigns four independent searches sharing only repo and Meilisearch settings:

def mount(_params, _session, socket) do
  shared = [
    repo: MyApp.Repo,
    backend: Scrypath.Meilisearch,
    meilisearch_url: Application.fetch_env!(:my_app, :meilisearch_url)
  ]

  entries = [
    {MyApp.Post, "release", filter: [published: true], page: [size: 8], facets: [:status]},
    {MyApp.User, "release", filter: [active: true], page: [size: 5]},
    {MyApp.Tag, "release", page: [size: 12], facets: [:kind]},
    {MyApp.Event, "conference", filter: [region: [eq: "EU"]], facets: [:region]}
  ]

  case Scrypath.search_many(entries, shared) do
    {:ok, results} ->
      {:ok, assign(socket, multi: results)}

    {:error, reason} ->
      {:ok, put_flash(socket, :error, format_many_error(reason))}
  end
end

Render each schema section from results.ordered so declaration order matches your UI cards. Read per-schema facets from elem(result, 1).facets — never assume facets are merged across schemas. Scrypath intentionally does not set Meilisearch mergeFacets.

Secondary recipe: same q everywhere with a warning

q = socket.assigns.query

Scrypath.search_many(
  [
    {MyApp.Post, q, filter: [published: true]},
    {MyApp.Comment, q, filter: [hidden: false]}
  ],
  repo: MyApp.Repo,
  backend: Scrypath.Meilisearch,
  meilisearch_url: url
)

This is convenient for a global search bar, but relevance scores and hit ranks are not comparable across {MyApp.Post, _} and {MyApp.Comment, _}. Keep ranking UI per schema block.

Partial failures in HEEx

Use a calm, accessible banner with aria-live="polite" (not role="alert" unless the whole page is blocked). Pair it with <details> for operator diagnostics.

<%= if @multi.failures != [] do %>
  <aside class="banner banner--warning" aria-live="polite">
    <p>Some indexes did not return results.</p>
    <details>
      <summary>Details</summary>
      <ul>
        <%= for %{schema: mod, reason: reason} <- @multi.failures do %>
          <li><%= inspect(mod) %>: <%= user_message(mod, reason) %></li>
        <% end %>
      </ul>
    </details>
  </aside>
<% end %>

Define user_message/2 in your LiveView or a small helper module so you map :hydration_timeout, transport errors, and validation failures to human copy without echoing raw exception blobs.

Federation weights

Per-index relevance scores stay local to each index; federation_weight: adjusts merged ordering under federation settings—not a claim that raw scores are directly comparable across indexes.

Two layers matter: first, each schema is searched and ranked on its own index; second, federation settings (including weights) define merged ordering in the combined hit stream under engine policy. Treat cross-index positions as a merge convenience while keeping per-schema ranking and facets honest—your UI should still label which schema produced each row.

Per-entry federation_weight: steers how Meilisearch merges hits across indexes: it changes the federated stream order, not the per-schema relevance score inside each index. When any entry sets a weight, Scrypath requires a backend that implements native search_many/2; sequential-only backends return {:invalid_options, {:federation_merge_requires_native_search_many, _}} instead of silently reordering.

Duplicate schema in one call

results.by_schema is a map and therefore last-wins if the same schema appears twice. Always iterate results.ordered when you need both result sets (for example A/B facet layouts):

for {schema, result} <- results.ordered do
  # safe for duplicate schema modules
end

Anti-patterns

  • Merged hits illusion — do not interleave results.ordered hits as if they were one index; federation preserves per-schema boundaries.
  • mergeFacets — Scrypath never sends this flag; cross-schema facet blobs hide which schema failed validation.
  • Silent truncation — cardinality limits (max_schemas, page.size, federation limits) return {:error, _} instead of clamping quietly.

%Scrypath.MultiSearchResult{}

Public fields include ordered, by_schema, failures, optional federation metadata from Meilisearch, and optional merge_hit_order when the response used flat federated hits. Use the merged projection helper on the multi-search result to walk merge order as {schema, hit_map} pairs. Failures are maps %{schema: module(), reason: term()}; successful schemas are absent from failures and present in ordered.