Skip to content

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 ​

FileRuns onExports
shared/searchFields.tsclient and serverSEARCH_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.tsservergetCollectionFields, resolveRequestedSearchFields, types CollectionFieldIndex, RequestSearchFields

Constants ​

ConstantValueEffect
SEARCH_TEXT_MATCH_TYPE'max_score'a document ranks by its single best-matching field, not the sum across fields
SEARCH_NUM_TYPOS0no 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):

CollectionFieldWeightInfix
digital_assetssearch_name10always
tags6fallback
portal_custom_field_values4fallback
description1off
digital_assets_categories (folders)search_name10always
description3always
dam_collections (collages)search_name10always
description4always
digital_assets_tagssearch_name10always
tag_name8off

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:

  1. getCollectionFields caches each collection's field index for SCHEMA_TTL_MS = 10 minutes. Concurrent callers share one in-flight retrieve(). A failed refresh keeps serving the last good value. Fields with index: false are ignored; infix: true fields form the infix set.
  2. resolveSearchFields keeps spec fields present in the index. A spec field with infix other than off is set to off when the schema has no infix index for it.
  3. 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.
  4. 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.
  5. The schema always wins for infix: a field without an infix index is sent as off whatever the caller asked.
  6. indexed is returned alongside the fields so the route can tell whether the _eval band is safe. When the schema could not be read, indexed is undefined and 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.

CollectionClauseScoreMatch
digital_assetssearch_name and tags6exact (:= whole value)
search_name5exact
tags and portal_custom_field_values4exact
tags3exact
portal_custom_field_values2exact
digital_assets_categoriessearch_name3token (: per token, OR-ed)
dam_collectionssearch_name3token

buildEvalTier(collection, q, indexedFields?):

  • Returns null for 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 ]):desc

Because _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 _eval at all, or one leading _eval(...) whose text outside backticks contains only A-Z a-z 0-9 _ ( ) [ ] : = | & , space and whose field references are all in EVAL_ALLOWED_FIELDS (search_name, tags, portal_custom_field_values). _eval anywhere but the start is rejected.
  • stripEvalTier removes the leading band so the remaining clauses can be checked as field:asc|desc.
  • evalTierFields lists fields named in the band. If any is not indexed, the route rebuilds the band from the schema with withEvalTier instead of sending a request Typesense would reject.

Functions ​

FunctionSignatureReturns
specsFor(collection) => SearchFieldSpec[]spec or the search_name fallback
toResolvedFields(specs) => ResolvedSearchFieldscomma strings for query_by, query_by_weights, infix
resolveSearchFields(collection, indexed?, infix?) => ResolvedSearchFieldsspec narrowed to the schema
queryByArity(queryBy) => numbercount of non-empty fields
alignListToQueryBy(queryBy, list, fill) => stringlist trimmed or padded with fill to the query_by length
buildEvalTier(collection, q, indexed?) => string | null_eval([...]):desc
withEvalTier(collection, q, sortBy?, indexed?) => string | undefinedband, 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 }