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:
| File | Purpose |
|---|---|
search/indexes/{entity}.index.ts | Index schema with field types, toSearchDoc() mapper |
search/adapters/typesense.adapter.ts | Typesense client adapter |
search/adapters/base.adapter.ts | Abstract adapter interface |
search/index.ts | Barrel exports |
routes/api/v1/search/+server.ts | Search API endpoint |
Search Feedback Entities (builtin)
When any entity has search.enabled: true, the following system entities are automatically included:
| Entity | Purpose | TTL |
|---|---|---|
| SearchQuery | Logged search queries for analytics | 90 days |
| SearchClick | Click-through events from search results | 90 days |
| SearchAssociation | Learned query-to-result relevance scores | Permanent |
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:
| Field | Description |
|---|---|
entity | Entity type to sync (e.g., "Product") |
entityId | Record ID |
operation | index, update, or remove |
data | Entity snapshot for the index |
status | pending → processing → completed or failed |
attempts / maxAttempts | Retry tracking (default: 3 max) |
Search Configuration Reference
Entity-level search property:
| Property | Default | Description |
|---|---|---|
enabled | false | Enable search indexing |
engine | "typesense" | Search engine (see Engine Support below) |
indexName | entity plural | Override 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
| Operator | Example | Description |
|---|---|---|
| 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 Type | Typesense Type | Notes |
|---|---|---|
string | string | Full-text searchable |
int | int64 | Exact match + range |
float | float64 | Exact match + range |
boolean | bool | Filter only |
isoDate | int64 | Stored as Unix timestamp |
enum | string | Filter + facet |
objectId | string | Filter 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
| Param | Type | Description |
|---|---|---|
q | string | Search query (required) |
entity | string | Entity type to search (required) |
limit | number | Max results (default: 20, max: 200) |
page | number | Page number (1-indexed) |
sort | string | Sort field (prefix - for descending) |
filters | object | Field 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:
| Field | Type | Description |
|---|---|---|
synonymId | string | Unique ID in the search engine (e.g. split-wetsuit) |
terms | string[] | Equivalent terms |
root | string? | For one-way synonyms: searches for root also match terms |
type | enum | multiWay (all terms equivalent) or oneWay (root → terms) |
source | enum | manual, import, or generated |
collectionName | string | Which search collection this belongs to |
active | boolean | Whether 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_SETenv 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
| Check | Catches |
|---|---|
| Blueprint fields missing from collection | Field 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 mismatches | Blueprint declares int, collection has string — silent coercion. |
Missing stem flags | Blueprint declares stem: true, collection field doesn't have it — plural queries rank poorly. |
| Stale fields in collection | Field removed from blueprint but still in the live collection. |
| Synonym set not linked | Collection has no synonym set reference — all synonyms silently inert. |
| Collection doesn't exist | Entity 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:
- SearchQuery — logged when a user searches. Tracks query text, entity type, result count, session ID.
- SearchClick — logged when a user clicks a search result. Tracks which result was clicked and its position in the results list.
- 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), ormanual(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
| Variable | Default | Description |
|---|---|---|
TYPESENSE_URL | http://localhost:8108 | Typesense server URL |
TYPESENSE_API_KEY | — | Admin API key |
TYPESENSE_SYNONYM_SET | {collection}_synonyms | Override the synonym set name |
Related Documentation
- Entity Definitions —
searchandttlentity properties - Services — Search integration in the service layer
- Telemetry —
trackedFetch()for external search engine calls