search_ash

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 and the stemmers Rust NIF.

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 # each row's Stemmers language (:french, :english, …)
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: Stemmers.supported_languages()]
timestamps()
end
end

That block generates, at compile time:

MyApp.Blog.search_posts!("chevaux", :french) # 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", :french, 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")

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:frenchLanguage 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 0.1 trade-offs, not surprises:

Status

MVP, :pre_stemmed strategy — tested end-to-end against Postgres (mix test). Deferred: a :native per-row-regconfig strategy (no NIF, Postgres-supported languages only), weighted fields (setweight), and an async (Oban) indexing path.