Appearance
Search Fields and Relevance ​
Overview ​
Two files decide which fields a portal search runs against, how much each field counts, where substring matching is allowed, and how results are banded before _text_match orders them:
shared/searchFields.ts- field specs, relevance bands and the helpers that turn them into Typesense parameters. Imported by both the client (#shared/searchFields) and the server.server/utils/searchSchema.ts- reads each collection's live Typesense schema and narrows the spec to fields that are actually indexed, so a field that has not been indexed yet drops out instead of failing the request.
The shapes match collage-admin's field maps (weights and infix keyed by field name), so the two apps rank the same way. The portal searches portal_custom_field_values where the admin app searches custom_field_values.
File Information ​
| File | Runs on | Exports |
|---|---|---|
shared/searchFields.ts | client and server | SEARCH_FIELD_SPECS, SEARCH_TEXT_MATCH_TYPE, SEARCH_NUM_TYPOS, FALLBACK_QUERY_BY, specsFor, toResolvedFields, resolveSearchFields, queryByArity, alignListToQueryBy, buildEvalTier, isValidEvalTier, stripEvalTier, evalTierFields, withEvalTier, buildSearchFieldParams |
server/utils/searchSchema.ts | server | getCollectionFields, resolveRequestedSearchFields, types CollectionFieldIndex, RequestSearchFields |
Constants ​
| Constant | Value | Effect |
|---|---|---|
SEARCH_TEXT_MATCH_TYPE | 'max_score' | a document ranks by its single best-matching field, not the sum across fields |
SEARCH_NUM_TYPOS | 0 | no typo tolerance; bookk does not reach book |
FALLBACK_QUERY_BY | 'search_name' | used when a collection has no spec or the caller sends no query_by |
Field Specs ​
SEARCH_FIELD_SPECS (weights are Typesense query_by_weights, higher wins):
| Collection | Field | Weight | Infix |
|---|---|---|---|
digital_assets | search_name | 10 | always |
tags | 6 | fallback | |
portal_custom_field_values | 4 | fallback | |
description | 1 | off | |
digital_assets_categories (folders) | search_name | 10 | always |
description | 3 | always | |
dam_collections (collages) | search_name | 10 | always |
description | 4 | always | |
digital_assets_tags | search_name | 10 | always |
tag_name | 8 | off |
folder_name, breadcrumb and collection_name entries are present in the source but commented out. Any other collection falls back to [{ name: 'search_name', weight: 10, infix: 'always' }].
Infix modes: always allows substring matches on every query (so stockphoto reaches istockphoto-123.jpg), fallback tries substring matching only when token matching found nothing, off never does.
Schema Narrowing ​
Rules, in order:
getCollectionFieldscaches each collection's field index forSCHEMA_TTL_MS= 10 minutes. Concurrent callers share one in-flightretrieve(). A failed refresh keeps serving the last good value. Fields withindex: falseare ignored;infix: truefields form the infix set.resolveSearchFieldskeeps spec fields present in the index. A spec field with infix other thanoffis set tooffwhen the schema has no infix index for it.- The caller may narrow
query_by, never widen it. Names not in the resolved set are dropped. If nothing remains, the full resolved set is used. - Caller weights and infix are matched by field name. A positional string is only used when its length equals the spec length; otherwise it is ignored and spec values are used.
- The schema always wins for infix: a field without an infix index is sent as
offwhatever the caller asked. indexedis returned alongside the fields so the route can tell whether the_evalband is safe. When the schema could not be read,indexedisundefinedand the spec is trusted.
Example: if portal_custom_field_values is not yet in the digital_assets schema, a request sent with buildSearchFieldParams(['digital_assets']) is resolved to query_by: "search_name,tags,description", query_by_weights: "10,6,1", and the matching infix list.
Relevance Bands (_eval) ​
The client prepends a relevance band to each collection's sort_by (useSearchApi.withRelevanceTier calls withEvalTier). Typesense scores a document with the highest matching clause; documents that match none score 0. _text_match and the date sort then order results inside each band.
| Collection | Clause | Score | Match |
|---|---|---|---|
digital_assets | search_name and tags | 6 | exact (:= whole value) |
search_name | 5 | exact | |
tags and portal_custom_field_values | 4 | exact | |
tags | 3 | exact | |
portal_custom_field_values | 2 | exact | |
digital_assets_categories | search_name | 3 | token (: per token, OR-ed) |
dam_collections | search_name | 3 | token |
buildEvalTier(collection, q, indexedFields?):
- Returns
nullfor an empty query or*, or when no tier survives. - Drops any tier that names a field missing from
indexedFields. - Splits the query on whitespace, strips backticks, keeps at most
MAX_EVAL_TOKENS= 8 tokens (token tiers only). - Wraps every value in backticks (
quoteFilterValue), so the query cannot add operators.
For dam_collections and q = "spring launch":
text
_eval([ (search_name:`spring` || search_name:`launch`):3 ]):descBecause _eval clauses are filter expressions, they do not prefix-match: photo does not satisfy a band on a value photography (the result still matches through prefix: true, just in band 0).
Server checks ​
isValidEvalTier(sortBy)accepts a value with no_evalat all, or one leading_eval(...)whose text outside backticks contains onlyA-Z a-z 0-9 _ ( ) [ ] : = | & , spaceand whose field references are all inEVAL_ALLOWED_FIELDS(search_name,tags,portal_custom_field_values)._evalanywhere but the start is rejected.stripEvalTierremoves the leading band so the remaining clauses can be checked asfield:asc|desc.evalTierFieldslists fields named in the band. If any is not indexed, the route rebuilds the band from the schema withwithEvalTierinstead of sending a request Typesense would reject.
Functions ​
| Function | Signature | Returns |
|---|---|---|
specsFor | (collection) => SearchFieldSpec[] | spec or the search_name fallback |
toResolvedFields | (specs) => ResolvedSearchFields | comma strings for query_by, query_by_weights, infix |
resolveSearchFields | (collection, indexed?, infix?) => ResolvedSearchFields | spec narrowed to the schema |
queryByArity | (queryBy) => number | count of non-empty fields |
alignListToQueryBy | (queryBy, list, fill) => string | list trimmed or padded with fill to the query_by length |
buildEvalTier | (collection, q, indexed?) => string | null | _eval([...]):desc |
withEvalTier | (collection, q, sortBy?, indexed?) => string | undefined | band, then sortBy |
buildSearchFieldParams | (collections) => { query_by, query_by_weights, infix } | per-collection records keyed by field name |
getCollectionFields | (collection) => Promise<CollectionFieldIndex | null> | cached schema index |
resolveRequestedSearchFields | (collection, requested) => Promise<RequestSearchFields> | { fields, indexed } |