ID |
|
|---|---|
Status |
Backlog |
Bucket |
architecture |
Priority |
8 |
Theme |
pagination |
Created |
2026-07-02 |
Updated |
2026-07-02 |
Relevance-ranked free-text search
Origin: issue #512 ("annotering av flere felter for å auto-generere søkeindeks"). This item is a Backlog thinking-capture, not a spec. It records the framing and the insights uncovered in early design discussion so they are not relitigated later. It is deliberately not Spec-ready; the open forks below must be closed first.
Problem
Consumers need free-text search across several fields of an entity (the
canonical case: match on both a short code and a name/description), usually
combined with predefined filters, to power frontend comboboxes where a user
types a few characters, gets ranked candidates, and links one to another
entity. Today graphitron’s filter model is equality/@condition-based only:
there is no declarative multi-column match, and nothing that ranks results by
relevance. The reporter asks for an @searchable-style annotation to declare
which fields feed a search surface.
Hard architectural constraints (non-negotiable)
-
Graphitron never emits DDL. It consumes the jOOQ catalog; it must not create the tsvector column, the GIN/GiST index, or any schema object. The physical search index is owned by the consumer’s migrations. Graphitron may only reference existing DB objects by name, exactly as
@order(index:)already references an index for sorting. This is the line the whole design must respect. -
The user-facing schema must be stable across physical strategies. Whether a field is backed by a naive
ILIKEscan (small dimension table) or an index-backedtsvector/trigram query (large table), the SDL the frontend consumes is identical. Physical strategy is a swappable binding underneath an unchanged contract.
Key insight 1: split the contract from the execution strategy
"Search" is two features, and they must stay separate:
-
Contract ; "this list field accepts a free-text
search:string that matches across these fields." Pure schema + codegen; never touches the DB schema. This is what@searchabledeclares. -
Execution strategy ; how the match (and the ranking) is physically run:
ILIKE-OR chain,pg_trgmsimilarity,tsvector @@ … ` + `ts_rank. This is where "the database is actively involved" and it varies by table size.
The escape hatch for the hard case already exists: a @condition pointing at a
Java method returning a jOOQ Condition can express any tsvector/trigram
predicate today and composes (AND) with existing filters. So #512 is primarily
an ergonomics request (remove the boilerplate for the common multi-column
match shape), not a missing raw capability, for the filtering half.
Key insight 2: relevance ranking is the genuinely missing piece
Finding matches fast is the easy half. Ranking by relevance is the hard
half, and it is a real capability gap, not a principle violation. A good
ranking (ts_rank_cd, trigram similarity(), BM25) is a value the database
computes, using the same index large tables need. It cannot be produced
honestly in the Java resolver without pulling candidate rows into memory and
scoring them there, which defeats the "DB does the work" architecture.
Therefore relevance must be a DB expression; graphitron’s role is only to
name and route it (auto-derived default from weighted @searchable columns,
or an explicit named DB expression/function override, same auto-default
escape-hatch pattern used elsewhere). Per-field weighting (code prefix beats
name substring) is where combobox relevance quality lives and should be
first-class.
Key insight 3: keyset is graphitron’s answer, not a principle — ranked search needs a second pagination strategy
@asConnection uses keyset/cursor pagination, which requires a stable,
stored, seekable, deterministic sort key. Relevance rank violates every part of
that: it is computed per query, non-unique (needs a tiebreak), and not seekable
(paging by keyset would recompute the score in the WHERE for every candidate,
defeating the index). Postgres full-text ranking is inherently
ORDER BY ts_rank(…) LIMIT n OFFSET m, i.e. offset pagination.
The correct conclusion is not "ranked search is out of architecture" (that would be cargo-culting keyset). It is: graphitron has one pagination strategy and needs a second. Design thesis:
-
Pagination strategy is derived, not configured. When the ordering key is a seekable stored column → keyset (today). When ordering is a query-time relevance score → keyset is impossible by construction, so codegen derives a ranked-offset strategy. The generator can detect this from the ordering intent; it is a forced consequence, not a knob.
-
Keep the Relay contract; change the cursor’s meaning. Relay cursors are opaque, so a cursor may encode
offset + query-fingerprintinstead of a keyset tuple. The frontend keepsedges/pageInfo/opt-intotalCount/"load more"; comboboxes and stable lists look identical on the wire. This preserves the "page through a large ranked result" case that a bare top-N list would discard. -
Declare the cost, don’t hide it. Deep offset scans-and-discards. Bound it with a declared
maxDepththat errors past the limit, rather than let an unbounded OFFSET be a silent footgun. Comboboxes never approach the bound. -
Ordering is always
score DESC, <stable pk> ASCso offset paging is at least deterministic within a snapshot.
Physical strategy maps to real Postgres capabilities
-
trigram distance (
<→,pg_trgm+ GiST/GIN): the ordering operator is index-backed and orderable (ORDER BY name <→ :q LIMIT nruns off the index). Sweet spot for typo-tolerant comboboxes. -
full-text
ts_rank: the@@filter is index-backed, butts_rankis not orderable-by-index → top-N materialize-then-sort. Fine for bounded pages, which is exactly whymaxDepthbelongs here. -
naive weighted match (small tables): unindexed scoring expression, portable, no migration.
The author’s declared strategy selects among these; the SDL is unchanged across all three.
Likely shape of the work (not committed)
-
Slice A (tractable, portable):
@searchableas a multi-column filter predicate ; synthesizes asearch:argument, ORs the match across marked columns, composes (AND) with existing filters, keeps keyset pagination because relevance never enters the ORDER BY. No ranking. Shippable on its own. -
Slice B (the real feature): ranked-offset connection as a first-class second pagination strategy, derived when ordering is relevance-based, Relay contract preserved via offset-encoded cursors, depth bounded by declaration, score sourced from weighted
@searchablecolumns or a named DB expression. -
Sibling to R13 (
@asFacet/ faceted-search): both mark input/columns and grow an arm on the synthesized connection.
Open forks to close before Spec
-
Derived vs explicit strategy selection. Infer ranked-offset purely from the presence of a relevance order (idiomatic, self-documenting), or require an explicit marker on
@asConnectionso the author consciously opts into weaker pagination guarantees? Leaning inferred-with-a-loud-generated-note. -
Default match mode:
prefix(combobox-friendly, btree-usable) vscontains(%x%, not index-usable) vsfulltext. -
Where the strategy/weight binding lives: on
@searchableitself vs on the synthesized argument. -
Consumption reality check (blocking): do frontends genuinely page deep through ranked results, or is bounded top-N enough for the combobox use case? This decides how much of Slice B’s offset machinery is actually required.