Skip to main content

Search

Radish generates a complete search infrastructure when entities declare search.enabled: true. The stack includes index schemas, a search adapter, sync hooks, feedback entities, and a search API endpoint.

Quick Start​

1. Enable search on an entity​

{
"entities": {
"Product": {
"search": {
"enabled": true,
"engine": "typesense",
"fields": {
"searchable": ["name", "description"],
"filterable": ["brand", "status", "price"],
"sortable": ["price", "createdAt"],
"facetable": ["brand"]
}
},
"fields": {
"name": { "type": "string", "required": true },
"description": { "type": "string" },
"brand": { "type": "string" },
"price": { "type": "float" },
"status": { "type": "enum", "values": ["draft", "published", "archived"] }
}
}
}
}

2. Configure Typesense​

Add to .env:

TYPESENSE_URL=http://localhost:8108
TYPESENSE_API_KEY=radish-dev-key

3. Generate​

radish-cli create datalayer . --schema blueprints/app.types.json

4. Use the search API​

curl "http://localhost:5173/api/v1/search?q=coffee&entity=Product&limit=20" \
-H "Authorization: Bearer $TOKEN"

Generated Files​

When search is enabled, the generator creates:

FilePurpose
search/indexes/{entity}.index.tsIndex schema with field types, toSearchDoc() mapper
search/adapters/typesense.adapter.tsTypesense client adapter
search/adapters/base.adapter.tsAbstract adapter interface
search/index.tsBarrel exports
routes/api/v1/search/+server.tsSearch API endpoint

Search Feedback Entities (builtin)​

When any entity has search.enabled: true, the following system entities are automatically included:

EntityPurposeTTL
SearchQueryLogged search queries for analytics90 days
SearchClickClick-through events from search results90 days
SearchAssociationLearned query-to-result relevance scoresPermanent

SearchQuery and SearchClick use TTL indexes to auto-expire after 90 days. SearchAssociation is long-lived — it accumulates aggregated relevance scores.

Search Sync Task (builtin)​

When search sync mode is background, a SearchSyncTask entity queues index operations:

FieldDescription
entityEntity type to sync (e.g., "Product")
entityIdRecord ID
operationindex, update, or remove
dataEntity snapshot for the index
statuspending → processing → completed or failed
attempts / maxAttemptsRetry tracking (default: 3 max)

Search Configuration Reference​

Entity-level search property:

PropertyDefaultDescription
enabledfalseEnable search indexing
engine"typesense"Search engine (see Engine Support below)
indexNameentity pluralOverride the search index name
fields.searchable[]Fields for full-text search
fields.filterable[]Fields for exact-match filtering
fields.sortable[]Fields for sort operations
fields.facetable[]Fields for faceted search (counts by value)
sync"inline"Sync mode: inline (immediate) or background (queued)
indexFilter—MongoDB-style query controlling index membership

Index Filter​

The indexFilter property controls which documents belong in the search index. Documents that match the filter are upserted; documents that don't match are deleted from the search engine — not skipped. This is the critical distinction: when a product changes from "active" to "discontinued", the sync removes it from search results instead of leaving a stale entry.

Blueprint Declaration​

{
"Product": {
"search": {
"enabled": true,
"indexFilter": {
"status": { "$ne": "discontinued" }
},
"fields": {
"searchable": ["name", "description"],
"filterable": ["brand", "status"]
}
}
}
}

How It Works​

The filter is compiled into a static INDEX_FILTER constant at generation time — zero database lookups at runtime. Every create and update operation evaluates the document against the filter in memory:

// Generated: search/indexes/product.index.ts
const INDEX_FILTER: Record<string, any> | null = {
"status": { "$ne": "discontinued" }
};

// In the class:
async sync(doc: any, operation: 'create' | 'update' | 'delete'): Promise<void> {
const docId = String(doc._id || doc.id);
if (operation === 'delete') {
await this.adapter.deleteDocument(INDEX_NAME, docId);
return;
}
const belongs = !INDEX_FILTER || evaluateIndexFilter(doc, INDEX_FILTER);
if (belongs) {
const searchDoc = this.toSearchDoc(doc);
await this.adapter.indexDocument(INDEX_NAME, docId, searchDoc);
} else {
// Document no longer belongs — remove from search
await this.adapter.deleteDocument(INDEX_NAME, docId);
}
}

Supported Operators​

OperatorExampleDescription
exact match{ "status": "active" }Field equals value
$ne{ "status": { "$ne": "discontinued" } }Field is not equal
$gt / $gte{ "price": { "$gt": 0 } }Greater than / greater or equal
$lt / $lte{ "stock": { "$lt": 100 } }Less than / less or equal
$in{ "status": { "$in": ["active", "featured"] } }Field is one of the values
$nin{ "status": { "$nin": ["draft", "archived"] } }Field is not one of the values
$exists{ "publishedAt": { "$exists": true } }Field exists (not null/undefined)

All conditions are AND-ed (top-level keys are implicit $and).

Backward Compatibility​

When no indexFilter is set, INDEX_FILTER is null and sync() unconditionally indexes every document — identical to the previous index() / update() / remove() behavior.

Service Integration​

Services call sync() instead of direct index methods:

// create → sync as 'create'
await this.searchIndex.sync(created, 'create');

// update → sync as 'update' (may remove if filter no longer matches)
await this.searchIndex.sync(updated, 'update');

// delete → sync as 'delete' (always removes)
await this.searchIndex.sync({ _id: id }, 'delete');

Index Schema​

Each search-enabled entity gets an index class with a typed schema:

// Generated: search/indexes/product.index.ts

export class ProductSearchIndex {
static readonly indexName = 'products';

static readonly schema = {
fields: [
{ name: 'name', type: 'string', index: true },
{ name: 'description', type: 'string', index: true },
{ name: 'brand', type: 'string', facet: true },
{ name: 'price', type: 'float64', sort: true },
{ name: 'status', type: 'string', facet: true },
{ name: 'createdAt', type: 'int64', sort: true }
]
};

static toSearchDoc(entity: any) {
return {
id: entity.id,
name: entity.name,
description: entity.description,
brand: entity.brand,
price: entity.price,
status: entity.status,
createdAt: new Date(entity.createdAt).getTime()
};
}
}

Field Type Mapping​

Blueprint types map to search engine types:

Blueprint TypeTypesense TypeNotes
stringstringFull-text searchable
intint64Exact match + range
floatfloat64Exact match + range
booleanboolFilter only
isoDateint64Stored as Unix timestamp
enumstringFilter + facet
objectIdstringFilter by reference ID

Sync Modes​

Inline (default)​

Search index is updated synchronously in the service's create(), update(), and delete() methods. Simplest setup — the index is always current. Use for low-write-volume entities.

Background​

Search index operations are queued as SearchSyncTask records and processed by a background worker. Better for high-write-volume entities — decouples write latency from index latency.

Search API​

Endpoint: GET /api/v1/search

ParamTypeDescription
qstringSearch query (required)
entitystringEntity type to search (required)
limitnumberMax results (default: 20, max: 200)
pagenumberPage number (1-indexed)
sortstringSort field (prefix - for descending)
filtersobjectField filters (as query params)

Response:

{
"data": {
"items": [
{ "id": "abc123", "name": "Ethiopian Coffee", "brand": "Origin", "score": 0.95 }
],
"totalCount": 42,
"page": 1,
"totalPages": 3
}
}

Stemming​

Searchable fields can enable linguistic stemming so that computers matches computer, masks matches mask, etc. Without stemming, a plural query matches accessories that happen to contain the exact plural in their description, while the actual product only matches after typo correction — and an exact match in a weight-1 field outranks a corrected match in a weight-5 field.

Enabling Stem​

Use an object instead of a string in fields.searchable:

{
"search": {
"enabled": true,
"fields": {
"searchable": [
{ "field": "name", "stem": true },
{ "field": "description", "stem": true },
"sku",
"brand"
],
"filterable": ["brand", "status"]
}
}
}

Plain strings ("sku", "brand") are not stemmed. Only prose fields that contain natural language benefit from stemming — identifiers, SKUs, and facet fields should not be stemmed.

What It Does​

The generated Typesense field definition includes stem: true:

{ "name": "name", "type": "string", "stem": true, "optional": true }

This tells Typesense to reduce words to their root form before matching. computers and computer become the same token, so field weighting decides the ranking instead of exact-vs-corrected match scoring.

Gotcha: Stem Cannot Be Added to an Existing Collection​

Typesense cannot alter a field's stem property on an existing collection. The collection must be dropped and recreated. Use radish-cli search reindex after enabling stem — it recreates the collection with the correct schema and reindexes all documents.

Sort Expressions (sortRaw)​

The search adapter builds sort_by as field:direction by default. For engine-specific expressions — like Typesense's _eval() for computed ranking — use sortRaw:

const results = await index.search({
q: 'wetsuit',
sortRaw: '_eval([ (productRole:primary):3, (productRole:bundle):3, (productRole:service):2 ]):desc,_text_match:desc',
limit: 24
});

sortRaw passes the value directly to Typesense's sort_by parameter, bypassing the field:direction builder. When both sort and sortRaw are set, sortRaw wins.

This is useful for role-based ranking (products outrank accessories), computed score expressions, and multi-field sort chains that the structured sort option can't express.

Synonyms​

Radish generates a complete synonym management infrastructure backed by a MongoDB entity. Synonyms are the source of truth in your database — they survive Typesense collection recreates, engine restarts, and index schema changes.

SearchSynonym Entity (Builtin)​

When features.search is enabled, the SearchSynonym entity is automatically included:

FieldTypeDescription
synonymIdstringUnique ID in the search engine (e.g. split-wetsuit)
termsstring[]Equivalent terms
rootstring?For one-way synonyms: searches for root also match terms
typeenummultiWay (all terms equivalent) or oneWay (root → terms)
sourceenummanual, import, or generated
collectionNamestringWhich search collection this belongs to
activebooleanWhether synced to the engine (default: true)

Multi-Way vs One-Way​

Multi-way — all terms are equivalent. A search for any term finds all the others:

await synonymService.create({
synonymId: 'wetsuit-group',
terms: ['wetsuit', 'wet suit', 'wetsuits'],
type: 'multiWay',
collectionName: 'products'
});

One-way — searches for root also match terms, but not the reverse. Use for irregular plurals where the plural should find the singular but not vice versa:

await synonymService.create({
synonymId: 'irregular-knives',
root: 'knives',
terms: ['knife'],
type: 'oneWay',
collectionName: 'products'
});

Synonym Sets (Typesense v28+)​

Typesense v28 moved synonyms from per-collection to named synonym sets. A set only applies to collections that explicitly reference it — a freshly created collection references nothing, so without linking, search works but every synonym is silently inert.

Radish handles this automatically:

  • ensureAllSearchIndexes() links each collection to its synonym set on startup
  • Convention: the set is named {collection}_synonyms (e.g. products_synonyms)
  • Override with TYPESENSE_SYNONYM_SET env var

Syncing Synonyms​

import { syncAllSynonyms, createSearchAdapter } from '@generated/search';
import { systemAuth } from '@core/auth/service-auth';

const adapter = createSearchAdapter();

// Full sync — push all active SearchSynonym records to Typesense
const result = await syncAllSynonyms(adapter, systemAuth());
// → { synced: 42, errors: [] }

// Single sync — after creating/updating a synonym in the admin
import { syncSynonym } from '@generated/search';
await syncSynonym(adapter, 'products', { id: 'split-wetsuit', synonyms: ['wetsuit', 'wet suit'] });

Call syncAllSynonyms() after a collection recreate (reindex, schema change) to restore all synonyms from MongoDB.

Search Doctor​

The search doctor command diagnoses drift between your blueprint's search fields and the live Typesense collections. It catches the class of silent failure where an absent thing looks identical to a present one:

radish-cli search doctor

What It Checks​

CheckCatches
Blueprint fields missing from collectionField added to blueprint, populated in MongoDB, weighted in relevance — but never in the search schema. Typesense stores unknown keys without indexing them, so the value is visibly present on results while being unsearchable.
Type mismatchesBlueprint declares int, collection has string — silent coercion.
Missing stem flagsBlueprint declares stem: true, collection field doesn't have it — plural queries rank poorly.
Stale fields in collectionField removed from blueprint but still in the live collection.
Synonym set not linkedCollection has no synonym set reference — all synonyms silently inert.
Collection doesn't existEntity is search-enabled but no Typesense collection exists.

Example Output​

🩺 Search Doctor

📦 Product (index: products)
❌ Field "nameCollapsed" is in the blueprint but NOT in the Typesense collection
⚠️ Field "name" should have stem: true but doesn't
⚠️ No synonym set linked — synonyms will be silently inert
📊 16489 documents indexed

📦 Customer (index: customers)
✅ All 4 blueprint fields match the live collection
✅ Synonym set linked: customers_synonyms
📊 2341 documents indexed

🩺 1 critical issue(s) found. Run "radish-cli search reindex" to reconcile.

Run search doctor after regeneration, after Typesense upgrades, and in CI to catch drift before it reaches production.

Search Feedback Loop​

The feedback entities enable relevance learning:

  1. SearchQuery — logged when a user searches. Tracks query text, entity type, result count, session ID.
  2. SearchClick — logged when a user clicks a search result. Tracks which result was clicked and its position in the results list.
  3. SearchAssociation — aggregated relevance scores. When a user searches "coffee" and clicks product X, the association score between "coffee" → product X increases. Source can be click (automatic), ai (ML-generated), or manual (admin-curated).

TTL Indexes​

SearchQuery and SearchClick declare TTL indexes:

{
"ttl": { "field": "createdAt", "days": 90 }
}

This generates a MongoDB TTL index that automatically deletes documents 90 days after their createdAt timestamp. The raw event data expires while the aggregated SearchAssociation scores persist.

See TTL Indexes for the general TTL feature.

Engine Support​

Currently supported: Typesense is the only fully implemented search adapter. createSearchAdapter('typesense') returns a working TypesenseAdapter.

Schema placeholders: The blueprint schema accepts elastic, opensearch, meilisearch, and mongoAtlas as valid engine values. However, createSearchAdapter() throws "Unsupported search engine: {engine}. Supported: typesense" for all of them. These enum values are reserved for future adapter implementations — setting one in a blueprint will generate the index schemas and service integration, but the adapter instantiation will fail at runtime until a matching adapter is written.

SearchIndexConfig and SearchCuration (Future)​

The schema file src/templates/search-config.schema.json defines two additional entities:

  • SearchIndexConfig — per-entity search configuration stored as data (not code), with fields, weights, typo tolerance, and sync mode.
  • SearchCuration — pin or hide specific results for a query, managed in MongoDB and synced to the search engine.

These entities exist in the schema but are not loaded by the generation pipeline. They are scaffolding for a planned per-index configuration system that would allow runtime search configuration changes without regeneration. They are documented here for awareness; do not attempt to use them in a blueprint.

Typesense Setup​

Local Development​

# Docker
docker run -d --name typesense \
-p 8108:8108 \
-v typesense-data:/data \
typesense/typesense:30.2 \
--data-dir /data \
--api-key=radish-dev-key

# Or via docker-compose (included in generated app)
docker compose up typesense

Environment Variables​

VariableDefaultDescription
TYPESENSE_URLhttp://localhost:8108Typesense server URL
TYPESENSE_API_KEY—Admin API key
TYPESENSE_SYNONYM_SET{collection}_synonymsOverride the synonym set name