How search_ash works under the hood: the pipeline symmetry principle, the indexing path, the query path, and the reconciliation story. For the upgrade steps from 0.3.x, see Upgrading to 0.4.
The stack, and the one principle everything follows
search_ash — Ash extensions (this library)
└── search_core — pure text engine: tokenize → stopwords → stem → fold accents
└── text_stemmer — Snowball algorithms compiled to pure Elixir (33 languages)Stemming happens in Elixir, not in Postgres. The same SearchCore pipeline runs
at index time (what gets stored) and at query time (what gets searched), so the two
sides can never disagree — the classic "search returns nothing because the query
wasn't stemmed the same way" bug is structurally impossible. Postgres only ever sees
pre-stemmed tokens through the 'simple' configuration:
-- index side: search_text holds a weighted tsvector literal built in Elixir,
-- e.g. 'bl':1A 'cheval':2B 'foin':3
search_text::tsvector
-- query side: $1 is built by SearchCore.tsquery/3, same pipeline
to_tsquery('simple', $1)Tokens are restricted to letters and digits, which is what makes producing that literal
in Elixir safe — there is nothing to escape. Carrying a weight per field (:a–:d) is
what lets ts_rank score a hit in a reference above the same hit in a body.
This is what enables per-row languages (each row is stemmed in its own language,
something to_tsvector('french', …) cannot do without one column per language) and
keeps the whole stack NIF-free.
The two extensions
SearchAsh(search do … end) — per-resource search: adds asearch_textcolumn to the resource's own table, keeps it in sync, exposes a:searchaction. Queries the source table, so the resource's policies apply.SearchAsh.GlobalIndex+SearchAsh.Source(global_index do … end/searchable do … end) — one cross-entity index table, one ranked:global_searchaction over every indexed entity type.
Indexing path (GlobalIndex)
Every write to a source resource mirrors it into the index, synchronously, inside the source action's transaction — a rollback takes the index write with it, so the two tables cannot diverge.
flowchart TD
A["Source action<br/>(create / update / bulk)"] --> B{"Changes.Sync<br/>recompute?"}
B -- "no indexed field changed" --> Z["skip (no index write)"]
B -- "field / label / language changed<br/>or extra_text / load configured" --> C["Index.upsert"]
C --> D{"load configured?"}
D -- yes --> E["Ash.load! (relations)<br/>authorize?: false"]
D -- no --> F
E --> F["Document.to_attrs"]
F --> G["fields + extra_text, each with its weight<br/>→ SearchCore pipeline<br/>→ search_text (weighted tsvector literal)"]
F --> H["label → label_normalized<br/>(SearchCore.normalize/1)"]
F --> I["raw text → excerpt<br/>(if excerpt_length)"]
F --> I2["index_attribute → typed columns<br/>(dates, refs, amounts)"]
G --> J["upsert index row<br/>authorize?: false,<br/>same transaction"]
H --> J
I --> J
I2 --> JKey properties:
Index.upsert/3is the single choke point — the sync change, bulkafter_batch,SearchAsh.reindex/2andSearchAsh.reindex_one/3all go through it, soload/extra_textapply everywhere without anything having to remember.- Internal index access is always
authorize?: false. Mirroring is machinery: the source write was already authorized, and the index's own policies answer a different question ("what may a user find"), enforced on:global_search. - On destroy,
on_destroydecides::removedeletes the row,:archivekeeps it flagged (archived: true), preserving its stored text and label columns.
Query path (:global_search)
sequenceDiagram
participant UI as Results page
participant A as :global_search action
participant P as GlobalSearch preparation
participant PG as Postgres
UI->>A: query, language, types, include_archived?, page
A->>P: prepare
P->>P: hide archived, filter types
alt term is blank
P->>PG: list all (unranked)
else tokens all eliminated ("de")
P->>PG: WHERE false — no results
else usable tokens
P->>P: tsquery (SearchCore pipeline)<br/>folded term (for the label)
P->>PG: tsvector @@ tsquery<br/>(+ fuzzy: label % term OR label LIKE %term%)
PG->>PG: ORDER BY label_match_tier ASC,<br/>ts_rank DESC, pk ASC
PG-->>UI: page of ranked results
endThe three branches on the term (0.4.0):
| term | behaviour |
|---|---|
| blank / absent | list everything, unranked — a list UI before the user types |
non-blank, no usable token ("de", "b") | no results — never the whole base |
| usable tokens | filter + rank |
The ranking is a composite sort:
label_match_tier— how the normalized label relates to the normalized query term (SearchCore.normalize/1on both sides, so they cannot drift):0exact,1starts-with,2contains,3body-only match. A row whose label is what the user typed beats a row that merely mentions it often. Rows indexed before 0.4.0 (nolabel_normalizedyet) fall to tier 3 — nothing breaks.ts_rankover the tsvector — relevance within a tier.- Primary key — a deterministic tiebreaker, which is what makes pagination stable.
With fuzzy? true (opt-in, requires pg_trgm), the filter also accepts a
trigram-similarity or substring match on label_normalized (duont → Dupont,
0012 → BL-2024-0012), both served by one trigram GIN index. Fuzzy-only matches
carry ts_rank 0, so they naturally rank behind full-text matches.
The substring branch is skipped below three characters — the width of a trigram, and so
the point below which pg_trgm cannot serve the pattern from the index at all. Shorter
terms keep their full-text prefix match and their similarity match; they lose only the
scan that would have matched two letters anywhere in every label.
The index table
| column | filled from | role |
|---|---|---|
source_type | searchable.source_type | entity tag (stored as string), types filter, tab counts |
source_id | primary key, ":"-joined | routing back to the object |
language | static language or language_attribute | which stemmer indexed this row |
search_text | fields + extra_text, stemmed and weighted | what the tsvector matches, and how heavily |
label | label_field | what a result displays |
label_normalized | SearchCore.normalize/1 of label | ranking tiers + fuzzy matching |
excerpt | raw text, truncated (excerpt_length) | display context on the results page |
| your typed columns | index_attribute, from the record | range filters, sorting, narrowing in SQL |
archived | archived option / on_destroy :archive | soft-delete visibility |
Point several sources at the same typed column when it means the same thing (each entity type's "document date"), so a mixed results page has one comparable axis to sort on. Only content-derived values belong there — never an authorization fact, which would change without anything triggering a re-index.
Plus a tenant attribute if the index is multitenant. Identity unique_source =
(tenant,) source_type, source_id — the upsert target. Indexes: GIN on
(search_text::tsvector), plus GIN label_normalized gin_trgm_ops when fuzzy?. The
index expression and the query expression must stay identical, or the index is silently
skipped.
Reconciliation — writes the sync never saw
The sync is an Ash change: a write that bypasses Ash (raw Repo.query!, SQL cascade,
restore) leaves the index silently stale. Same story for extra_text when the
related resource is written directly — an order's line edited without touching the
order does not re-index the order (nothing observable changed on the parent).
Both have the same remedy:
SearchAsh.reindex_one/3— re-read one record and reconcile: present → rebuild + upsert, gone → applyon_destroy. Idempotent.SearchAsh.prune/2— sweep one source's orphaned index rows (gone records).SearchAsh.reindex/2— backfill a whole source (initial indexing, migrations).
Both reconcilers read source existence with authorize?: false and reject
:actor / authorize?: true by design: a policy-hidden live row must never read as
"gone" and get its index row deleted.