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.

Part of the search_ash monorepo; 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: "…", archived: false, search_rank: 0.9}, …]

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. 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).

Roadmap, roughly in order of how often it bites: