Full-Text Search with Meilisearch in an Elixir Platform - Prismatic Platform
Engineering

Full-Text Search with Meilisearch in an Elixir Platform

Integrating Meilisearch for full-text search: index management, faceted search, typo tolerance configuration, and glossary indexing for an intelligence platform.

Mar 14, 2026 Β· 8 min read Β· Tomas Korcak (korczis)

Full-text search across intelligence data requires sub-50ms response times, tolerance for typos and partial matches, and faceted filtering by entity type, source, and risk level. Meilisearch provides all of this with a simple HTTP API and minimal operational overhead. This post covers how the Prismatic Platform integrates Meilisearch for entity search, glossary lookup, and document discovery.

#Architecture

Meilisearch runs as a sidecar service. The Elixir application communicates with it through an HTTP client wrapper that handles index management, document ingestion, and search queries:

ComponentPurposeTechnology
Meilisearch instanceSearch engineMeilisearch v1.6+
PrismaticSearch.ClientHTTP client wrapperTesla + Jason
PrismaticSearch.IndexerDocument ingestion pipelineGenServer + Broadway
PrismaticSearch.QuerySearch query builderElixir structs
Index sync workerKeeps indices in sync with DBPeriodic GenServer

#Client Module

The client module wraps Meilisearch’s HTTP API with typed Elixir functions:

defmodule PrismaticSearch.Client do
  @moduledoc """
  HTTP client for Meilisearch operations.

  Provides index management, document CRUD, search queries,
  and settings configuration. All operations return tagged
  tuples for explicit error handling.
  """

  @type search_result :: %{
    hits: [map()],
    estimated_total_hits: non_neg_integer(),
    processing_time_ms: non_neg_integer(),
    facet_distribution: map()
  }

  @spec search(String.t(), String.t(), keyword()) :: {:ok, search_result()} | {:error, term()}
  def search(index, query, opts \\ []) do
    body = %{
      q: query,
      limit: Keyword.get(opts, :limit, 20),
      offset: Keyword.get(opts, :offset, 0),
      filter: Keyword.get(opts, :filter),
      facets: Keyword.get(opts, :facets),
      attributesToHighlight: Keyword.get(opts, :highlight, ["*"]),
      attributesToCrop: Keyword.get(opts, :crop),
      cropLength: Keyword.get(opts, :crop_length, 200)
    }
    |> Enum.reject(fn {_k, v} -> is_nil(v) end)
    |> Enum.into(%{})

    case post("/indexes/#{index}/search", body) do
      {:ok, %{status: 200, body: result}} -> {:ok, parse_search_result(result)}
      {:ok, %{status: status, body: body}} -> {:error, {status, body}}
      {:error, reason} -> {:error, reason}
    end
  end

  @spec add_documents(String.t(), [map()]) :: {:ok, map()} | {:error, term()}
  def add_documents(index, documents) when is_list(documents) do
    case post("/indexes/#{index}/documents", documents) do
      {:ok, %{status: 202, body: task}} -> {:ok, task}
      {:ok, %{status: status, body: body}} -> {:error, {status, body}}
      {:error, reason} -> {:error, reason}
    end
  end

  defp post(path, body) do
    url = "#{base_url()}#{path}"
    headers = [{"Authorization", "Bearer #{api_key()}"}, {"Content-Type", "application/json"}]
    Tesla.post(url, Jason.encode!(body), headers: headers)
  end

  defp base_url, do: Application.get_env(:prismatic_search, :meilisearch_url)
  defp api_key, do: Application.get_env(:prismatic_search, :meilisearch_key)
end

#Index Configuration

Each search domain gets its own index with tailored settings. Index configuration controls which fields are searchable, filterable, and sortable:

defmodule PrismaticSearch.IndexConfig do
  @moduledoc """
  Index configuration definitions for all search domains.

  Each index specifies searchable attributes, filterable
  attributes for faceted search, sortable attributes,
  and ranking rules optimized for the domain.
  """

  @spec entity_index_settings() :: map()
  def entity_index_settings do
    %{
      searchableAttributes: ["name", "description", "aliases", "identifiers"],
      filterableAttributes: ["type", "risk_level", "sources", "country", "status"],
      sortableAttributes: ["risk_score", "updated_at", "name"],
      rankingRules: [
        "words",
        "typo",
        "proximity",
        "attribute",
        "sort",
        "exactness",
        "risk_score:desc"
      ],
      typoTolerance: %{
        minWordSizeForTypos: %{oneTypo: 4, twoTypos: 8},
        disableOnAttributes: ["identifiers"]
      },
      pagination: %{maxTotalHits: 5000}
    }
  end

  @spec glossary_index_settings() :: map()
  def glossary_index_settings do
    %{
      searchableAttributes: ["term", "definition", "category", "related_terms"],
      filterableAttributes: ["category", "domain", "language"],
      sortableAttributes: ["term", "updated_at"],
      typoTolerance: %{
        minWordSizeForTypos: %{oneTypo: 3, twoTypos: 6}
      }
    }
  end
end
IndexDocumentsSearchable FieldsFilterable FieldsAvg Query Time
entities~50Kname, description, aliases, identifierstype, risk_level, sources, country8ms
glossary~500term, definition, categorycategory, domain, language3ms
documents~10Ktitle, content, tagscase_id, type, language12ms
investigations~2Ktitle, summary, entitiesstatus, priority, assignee5ms

Faceted search enables drill-down filtering in the UI. The search query requests facet distributions, which Meilisearch returns alongside results:

defmodule PrismaticSearch.EntitySearch do
  @moduledoc """
  Entity search with faceted filtering.

  Supports multi-facet search with type, risk level,
  source, and country dimensions. Returns facet
  distributions for UI filter rendering.
  """

  alias PrismaticSearch.Client

  @spec search_entities(String.t(), map()) :: {:ok, map()} | {:error, term()}
  def search_entities(query, filters \\ %{}) do
    filter_expressions = build_filter(filters)

    Client.search("entities", query,
      limit: Map.get(filters, :limit, 20),
      offset: Map.get(filters, :offset, 0),
      filter: filter_expressions,
      facets: ["type", "risk_level", "sources", "country"],
      highlight: ["name", "description"]
    )
  end

  defp build_filter(filters) do
    []
    |> maybe_add_filter(filters, :type, "type = ':value'")
    |> maybe_add_filter(filters, :risk_level, "risk_level = ':value'")
    |> maybe_add_filter(filters, :country, "country = ':value'")
    |> maybe_add_filter(filters, :source, "sources = ':value'")
    |> case do
      [] -> nil
      parts -> Enum.join(parts, " AND ")
    end
  end

  defp maybe_add_filter(filters_list, params, key, template) do
    case Map.get(params, key) do
      nil -> filters_list
      value -> [String.replace(template, ":value", to_string(value)) | filters_list]
    end
  end
end

#Glossary Indexing Integration

The platform’s interactive glossary uses Meilisearch for instant search-as-you-type. A sync worker keeps the Meilisearch index updated when glossary entries change:

defmodule PrismaticSearch.GlossarySync do
  @moduledoc """
  Synchronizes glossary entries to Meilisearch index.

  Listens for PubSub events on glossary changes and
  updates the search index. Performs full reindex
  on startup and incremental updates thereafter.
  """

  use GenServer
  require Logger

  @index "glossary"

  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl GenServer
  def init(_opts) do
    Phoenix.PubSub.subscribe(PrismaticWeb.PubSub, "glossary:changes")
    schedule_full_reindex()
    {:ok, %{last_sync: nil}}
  end

  @impl GenServer
  def handle_info(:full_reindex, state) do
    Logger.info("Starting full glossary reindex")

    entries = PrismaticWeb.Glossary.list_all_entries()

    documents =
      Enum.map(entries, fn entry ->
        %{
          id: entry.id,
          term: entry.term,
          definition: entry.definition,
          category: entry.category,
          domain: entry.domain,
          related_terms: entry.related_terms,
          language: entry.language,
          updated_at: DateTime.to_iso8601(entry.updated_at)
        }
      end)

    case PrismaticSearch.Client.add_documents(@index, documents) do
      {:ok, _task} ->
        Logger.info("Glossary reindex complete: #{length(documents)} entries")
      {:error, reason} ->
        Logger.error("Glossary reindex failed: #{inspect(reason)}")
    end

    {:noreply, %{state | last_sync: DateTime.utc_now()}}
  end

  @impl GenServer
  def handle_info({:glossary_updated, entry}, state) do
    document = %{
      id: entry.id,
      term: entry.term,
      definition: entry.definition,
      category: entry.category,
      domain: entry.domain,
      related_terms: entry.related_terms,
      language: entry.language,
      updated_at: DateTime.to_iso8601(DateTime.utc_now())
    }

    case PrismaticSearch.Client.add_documents(@index, [document]) do
      {:ok, _} -> Logger.debug("Glossary entry synced: #{entry.term}")
      {:error, reason} -> Logger.warning("Glossary sync failed for #{entry.term}: #{inspect(reason)}")
    end

    {:noreply, state}
  end

  defp schedule_full_reindex do
    Process.send_after(self(), :full_reindex, :timer.seconds(5))
  end
end

#Typo Tolerance Configuration

Meilisearch’s typo tolerance is configurable per index and per attribute. For entity names where precision matters, stricter settings prevent false matches on identifiers while remaining lenient on names:

@spec configure_typo_tolerance(String.t()) :: :ok | {:error, term()}
def configure_typo_tolerance(index) do
  settings = %{
    typoTolerance: %{
      enabled: true,
      minWordSizeForTypos: %{
        oneTypo: 4,
        twoTypos: 8
      },
      disableOnAttributes: ["identifiers", "ico", "registration_number"],
      disableOnWords: ["LLC", "s.r.o.", "a.s.", "GmbH"]
    }
  }

  Client.update_settings(index, settings)
end

Meilisearch provides fast, relevant full-text search with minimal configuration overhead. Combined with Elixir’s GenServer patterns for sync and Broadway for bulk ingestion, it forms a responsive search layer that keeps pace with the platform’s data growth.

Browse all β†’