search_ash

CI

Ash extensions for multilingual full-text search on Postgres — per-resource (search do … end) and global cross-entity search (a unified index). No hand-written migrations, changes or SQL.

Built on search_core, which stems in pure Elixir via text_stemmer — 33 languages, no NIF, nothing to install beyond the Hex packages.

Languages are named by their ISO 639-1 code (:fr, :en) — exactly the set the installed text_stemmer reports, which is the single authority for what a language is.

Usage

defmodule MyApp.Post do
use Ash.Resource,
domain: MyApp.Blog,
data_layer: AshPostgres.DataLayer,
extensions: [SearchAsh]
postgres do
table "posts"
repo MyApp.Repo
end
search do
fields [:title, :body] # text concatenated & indexed
language_attribute :language # attribute holding each row's language (:fr, :en, …)
end
attributes do
uuid_primary_key :id
attribute :title, :string, allow_nil?: false, public?: true
attribute :body, :string, allow_nil?: false, public?: true
attribute :language, :atom, allow_nil?: false, public?: true,
constraints: [one_of: SearchCore.Language.accepted()]
timestamps()
end
end

That block generates, at compile time:

MyApp.Blog.search_posts!("chevaux", :fr) # finds rows that stored "cheval"

Because the index side and the query side share one pipeline, stemming stays in lock-step — a search for an inflected form matches the stored stem. Searches are scoped to the language argument (each row is stemmed in its own language, so a search probes one language at a time), which composes with Ash multitenancy.

Global search across resources (Option B)

The search do … end block searches one resource. To search across many entity types (produits, clients, bons de commande, livraisons…) from a single ranked query, use the unified-index extensions:

Then one query, ranked, tenant-isolated:

MyApp.Search.global_search!("dupont", :fr, tenant: "org_42")
# => [%{source_type: "bon_de_commande", source_id: "…", label: "BL-2024-0012", …}, …]

Results rank label matches first — exact, then starts-with, then contains, then a body-only match — and ts_rank within each tier. So the client named "Dupont" beats an invoice that merely mentions one.

From there a results page needs pagination with a total (page: [limit: 20, count: true]), tab badges (SearchAsh.counts_by_type/3), range filters and sorting on typed columns (index_attribute), per-field ranking (weights), typo tolerance (fuzzy?), text pulled from related records (load + extra_text), and a highlighted excerpt (excerpt_length plus SearchCore.highlight/4).

A guided tour is a Livebook you can run against your own Postgres — the whole thing above, executable, in about a minute.

Building a global search walks all of it, in order, from nothing to a working page — including the two decisions that are easy to get wrong: which attribute to use as your label_field, and where to draw the authorization line.

Backfill existing data with SearchAsh.reindex/2 (per tenant):

SearchAsh.reindex(MyApp.Sales.BonDeCommande, tenant: "org_42")

After a write that bypassed Ash — a raw Repo.query!, a SQL cascade, a restore — the sync never fired, so reconcile that record with SearchAsh.reindex_one/3:

SearchAsh.reindex_one(MyApp.Sales.BonDeCommande, id, tenant: "org_42")

It re-reads the record: present → re-indexed, gone → the resource's on_destroy decides (removed, or kept archived), just as destroying it through Ash would have. Idempotent, so the call site never has to work out whether to add or remove. Call it after the write commits and outside any transaction.

To sweep a whole source for stale index rows whose record no longer exists, use SearchAsh.prune/2 (per tenant):

SearchAsh.prune(MyApp.Sales.BonDeCommande, tenant: "org_42")

It reads which rows are still live and drops every index row without one behind it (honouring on_destroy), returning the count. Pair it with reindex/2 for a full two-way reconcile — backfill missing rows, then sweep orphans.

The index is a normal Ash resource, so admin tools (view indexed content, force a reindex) are just reads/actions on it. global_index options: default_language, search_text_attribute, action, fuzzy?. Archived rows are hidden by default (include_archived?: true to include them).

Options (search do … end)

OptionDefaultMeaning
fields (required)Attributes whose text is indexed
language_attribute:languageAttribute holding each row's language
search_text_attribute:search_textWhere stemmed tokens are stored (added if absent)
index_name"<table>_search_idx"Name of the generated GIN index
action:searchName of the generated read action
default_language:frLanguage used to stem the query when the language argument is omitted
prefix?trueMatch the last token as a prefix ("boulan""boulangerie"); set false for exact stemmed matching

Verify end-to-end

See examples/search_demo for a runnable multi-tenant demo against real Postgres — per-resource and global search, a GreenAsh console, and a Postgres-backed test suite.

Notes

Production notes & limitations

Know these before adopting — they're deliberate trade-offs, not surprises:

Status

MVP, :pre_stemmed strategy — tested end-to-end against Postgres (mix test).

See the roadmap for what is deferred and why — including the shape synonym support would take, and what was refused (BM25 ranking, and copying authorization data into the index) with the reasoning. Roughly in order of how often it bites: