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, 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:
- a
:search_textstring attribute holding the stemmed tokens; - a global change that keeps
:search_textin sync on create/update (stemming each row in its own language viaSearchCore); - a GIN expression index
to_tsvector('simple', search_text)— emitted into your migrations and tracked in the resource snapshot, somix ash_postgres.generate_migrationsround-trips it cleanly (re-running detects no changes); - a
:searchread action takingquery+languagearguments.
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:
SearchAsh.GlobalIndexturns a resource into a unified search index — one row per indexed object. It generates the columns, a tenant-aware unique identity, a GIN index, an:upsertaction, and a:global_searchread action that filters + ranks and returns(source_type, source_id, archived, label, search_rank):defmodule MyApp.Search.Document douse Ash.Resource,domain: MyApp.Search, data_layer: AshPostgres.DataLayer,extensions: [SearchAsh.GlobalIndex]postgres do table "search_documents"; repo MyApp.Repo endmultitenancy do strategy :attribute; attribute :org_id endglobal_index do default_language :fr endattributes douuid_primary_key :idattribute :org_id, :string, allow_nil?: false, public?: trueendendSearchAsh.Sourcemirrors each source resource into that index:searchable doindex MyApp.Search.Documentsource_type :bon_de_commandefields [:numero, :client_nom, :description]label_field :numero# Language, one of two ways (see below):language_attribute :language # per row, from an attribute (the default)# Soft delete, your way:archived :deleted_at # truthy attribute → archived (a boolean flag works too)on_destroy :archive # or :remove (default, hard delete)endChoosing the language. Every indexed row is stemmed in one language, resolved one of two ways — the two are mutually exclusive:
language_attribute :language(default)reads the language per row from that attribute, so one resource can hold many languages language :frfixes one language for every row — for a mono-language resource, which then needs no language attribute at all searchable doindex MyApp.Search.Documentsource_type :page_statiquefields [:titre, :corps]language :fr # this resource has no :language attributeendA compile-time verifier rejects a block that cannot resolve a language (no
languageand no such attribute, an unsupportedlanguage, or both options at once), and warns when the language attribute is nullable with no default — rather than letting the first write fail.Options (
searchable do … end)Option Default Meaning index(required)— The SearchAsh.GlobalIndexresource to feedsource_type(required)— Tag identifying this resource's rows in the index fields(required)— Attributes whose text is concatenated, stemmed and indexed language— One language for every row; mutually exclusive with language_attributelanguage_attribute:languageAttribute holding each row's language label_field— Attribute used as the human-readable label stored in the index archived— Attribute name (truthiness) or record -> booleanderiving the index'sarchivedflagon_destroy:remove:remove(hard delete) or:archive(keep, flagged)Create/update upserts a stemmed document; destroy either removes it (
on_destroy: :remove, default) or keeps it flagged (:archive, for soft-delete via a destroy such as AshArchival).archivedderives the index's boolean flag from a source attribute's truthiness — a boolean, or adeleted_attimestamp — or arecord -> booleanfunction; it defaults tofalse. The extension setsrequire_atomic? falseon the update/destroy actions it augments, so you don't set it yourself.:global_searchhides archived rows by default, but takesinclude_archived?: trueto return both — so you can group results byarchivedin the UI:MyApp.Search.global_search!("dupont", :fr, %{include_archived?: true}, tenant: "org_42")
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")
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)
| Option | Default | Meaning |
|---|---|---|
fields (required) | — | Attributes whose text is indexed |
language_attribute | :language | Attribute holding each row's language |
search_text_attribute | :search_text | Where stemmed tokens are stored (added if absent) |
index_name | "<table>_search_idx" | Name of the generated GIN index |
action | :search | Name of the generated read action |
default_language | :fr | Language used to stem the query when the language argument is omitted |
prefix? | true | Match 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
- Requires the AshPostgres data layer. Search is built on Postgres
tsvector/ts_rank, so the generated:searchaction only works onAshPostgres.DataLayerresources. - Atomicity is handled for you. The keep-in-sync change stems in Elixir, which can't
run inside an atomic SQL update, so the extension sets
require_atomic? falseon the update (and, forSearchAsh.Source, destroy) actions it augments — you don't set it. - Ranking is on by default (
rank?), ordering byts_rankand exposing the score as the:search_rankcalculation; setrank?: falseto filter only.:search_rankis loaded only for an actual query — a blank query (list-all) is returned unranked. - An unsupported/blank
languageargument falls back todefault_languagerather than raising. - The search matches the last token as a prefix (
prefix?, on) and treats a blank query as "no filter" so it composes with list UIs.
Production notes & limitations
Know these before adopting — they're deliberate trade-offs, not surprises:
The index does not inherit your source resources' policies — but you can give it its own.
:global_searchis a plain Ash read action, so policies on your index resource compose with it normally. What you can authorize on is limited to the columns an index row has:source_type,archived,label,languageand your tenant attribute.Role → entity type works today. If a role gates which kinds of thing a user may see, put the policy on your index resource:
# in MyApp.Search.Documentpolicies dopolicy action_type(:read) doauthorize_if expr(source_type in ^actor(:visible_types))endend(
source_typeis stored as a string, so the actor's list must hold strings. Ash policies need a SAT solver — add:picosat_elixiror:simple_sat.)Row-level ownership does not. There is no way to carry an
owner_id, a team, or a per-record visibility flag into an index row —SearchAsh.Sourcewrites a fixed set of columns. If a user may see some invoices rather than all or none, this index cannot express it, and results would carry thelabelof rows they cannot open. Two things to know before that worries you:- You choose what a result exposes.
label_fieldis yours: point it at a reference (label_field :numero) rather than at something sensitive, and a result reveals that a match exists without revealing what it says. - Do not mirror write permissions here. Routing to the object applies the source resource's policies. This index answers "what may this user find", not "what may they do" — don't duplicate an authorization that already lives downstream.
A result carries
(source_type, source_id), so you can check rights again when rendering. Whether that is sound depends on where the bulk of the filtering happens:- As a safety net, over a policy that already filters in SQL — fine. It drops nothing or almost nothing, ranking is untouched, and a page of ten showing seven is invisible.
- As the primary filter — broken. Postgres ranked and paginated over rows you then throw away, so page 1 can come back empty while the real matches sit on page 5.
Either way, count in the view rather than with
Ash.counton the action: the action counts what SQL matched, before your render-time filtering. With a net that drops nothing the two agree — and the day they disagree, the count is the early symptom, before pagination goes wrong.If you genuinely need row-level read filtering with cross-entity ranking, nothing here gives it to you today, and copying ACLs into the index is a trap: authorization facts change independently of content (an ACL edit, someone leaving a team), so nothing would trigger a re-index, and a stale index row is a security incident rather than a cosmetic one. Use
search do … endon each resource instead — it queries the source table, so your policies apply, at the cost of cross-entity search.- You choose what a result exposes.
Indexing is synchronous, in the same transaction as the write. The sync runs in an
after_actionhook using the source's repo, so the index upsert commits (or rolls back) with the source write — a failed index write fails the source write, so the two never diverge. The flip side: every create/update/destroy on a source pays the stemming + index-write cost inline. There is no async (Oban) path yet; if you need one, it's on the roadmap.Bulk works transparently for the global index; the per-resource
search doneedsstrategy: :streamfor bulk updates.SearchAsh.Sourcewrites only to the separate index table, soAsh.bulk_create/bulk_update/bulk_destroykeep the index in sync with nostrategy:option. The per-resourcesearch doextension instead computessearch_texton the row itself in Elixir, which can't run in an atomic SQL update — soAsh.bulk_updateon asearch doresource must passstrategy: :stream. Either way, the default atomic strategy fails loudly (NoMatchingBulkStrategy); the index is never silently skipped.One language per query. Each row is pre-stemmed in its own language and stored in a
'simple'tsvector, so a search probes one language at a time (thelanguageargument). Cross-language "OR" search is not built in.reindex/2streams every row through the write action (one upsert per record). It's built for backfills and small-to-medium tables; it is not a bulk-optimized reindex for very large datasets.Index creation is not
CONCURRENTLY. The generated GIN index is emitted as a plainCREATE INDEXmigration; on a large existing table, plan the migration accordingly.Stemming is pure Elixir, at ~11µs/word. Invisible on the query path, but a write that stems a very large document spends tens of ms of CPU inside the transaction. If you bulk-index large corpora and want ~0.5µs/word, the
stemmersRust NIF is published and produces identical output.
Status
MVP, :pre_stemmed strategy — tested end-to-end against Postgres (mix test).
Roadmap, roughly in order of how often it bites:
- Async indexing — an
indexing_strategy :sync | :notify | :manualoption onSearchAsh.Source, with no hard Oban dependency (:notifyemits an Ash notification,:manuallets you drive a durable job). - Cross-language search — one query probing several languages at once.
- A
:nativeper-row-regconfigstrategy (Postgres-supported languages only), and weighted fields (setweight).