medusa-smart-search
v0.0.2
Published
Postgres-native hybrid full-text + trigram product search for Medusa v2
Maintainers
Readme
medusa-smart-search
PostgreSQL-native hybrid product search for Medusa v2 — full-text search, trigram fuzzy matching, Redis caching, and production-grade hardening.
Plugin Overview
medusa-smart-search adds fast, relevance-ranked product search without Elasticsearch or Algolia. Search logic runs entirely in PostgreSQL via SQL functions (search_products, autocomplete_product_ids) backed by GIN indexes and pg_trgm.
Features
- Ranked product search — matches title, handle, subtitle, description, tags, categories, product type, and collection
- Autocomplete — lightweight ID suggestions using the same match rules
- Accent-insensitive —
unaccent+ customenglish_unaccenttext search config (cafématchescafe) - Prefix matching — partial words like
shomatchshoe - Redis caching — 60s TTL for search, 30s for autocomplete; auto-invalidated on product create/update/delete
- Query timeouts — 3s search / 2s autocomplete via
SET LOCAL statement_timeout - Input sanitization — Zod validation strips unsafe characters, enforces max query length
- Graceful degradation — DB failures return HTTP 503 with
retry_after - Structured telemetry — JSON stdout logs with SHA-256 query hashes (no raw query strings)
- Health probe —
GET /store/products/search/health
Medusa Version
Built for Medusa v2 (@medusajs/framework / @medusajs/medusa 2.11.2).
Requires PostgreSQL with pg_trgm and unaccent extensions (installed by the plugin migration).
Installation & Setup
1) Install
npm install medusa-smart-searchyarn add medusa-smart-search2) Register plugin in medusa-config.ts
import { defineConfig } from "@medusajs/framework/utils"
export default defineConfig({
plugins: [
{
resolve: "medusa-smart-search",
},
],
})The plugin registers two modules automatically: redis and smart_search.
3) Set environment variables
| Variable | Required | Default | Description |
|---|---|---|---|
| REDIS_URL | No | redis://127.0.0.1:6379 | Redis connection URL for search result caching |
4) Run database migrations
npx medusa db:migrateThis applies Migration20260512143000, which creates:
- PostgreSQL extensions (
pg_trgm,unaccent) - GIN indexes on products and related tables
- SQL functions:
search_products,autocomplete_product_ids,normalize_search_query
Store API
All endpoints are public (no auth required).
Search
GET /store/products/search?q=iphone&limit=20&offset=0| Param | Type | Default | Constraints |
|---|---|---|---|
| q | string | — | Min 2 chars after sanitization; max 100 chars; [a-zA-Z0-9\s\-'] only |
| limit | number | 20 | 1–50 |
| offset | number | 0 | ≥ 0 |
Response:
{
"products": [
{
"id": "prod_01...",
"title": "iPhone 15",
"handle": "iphone-15",
"thumbnail": "https://...",
"subtitle": null,
"description": "...",
"status": "published",
"score": 4.2
}
],
"limit": 20,
"offset": 0
}Only published products with deleted_at IS NULL are returned. Results are ordered by relevance score, then created_at DESC.
Autocomplete
GET /store/products/autocomplete?q=iph&limit=5| Param | Type | Default | Constraints |
|---|---|---|---|
| q | string | — | Min 2 chars after sanitization; max 100 chars |
| limit | number | 5 | 1–25 |
Response:
{
"ids": ["prod_01...", "prod_02..."]
}Returns product IDs only. Prefix matches on title rank first, then recency.
To hydrate full product details, fetch via the standard Medusa store products API:
GET /store/products?id[]=prod_01...&id[]=prod_02...Health
GET /store/products/search/healthResponse:
{ "status": "ok" }or
{ "status": "degraded" }Runs SELECT 1 FROM search_products('test', 1, 0) as a lightweight probe.
Error responses
Validation errors return HTTP 400. Database timeouts and connection failures return HTTP 503:
{
"error": "search_unavailable",
"retry_after": 5
}How Search Works
Storefront request
→ Zod validation + query sanitization
→ Redis cache lookup (miss → PostgreSQL)
→ search_products() / autocomplete_product_ids()
→ GIN indexes + pg_trgm similarity scoring
→ Ranked results (cached + telemetry logged)Scoring signals
| Signal | Weight | Source |
|---|---|---|
| Full-text match | × 3.0 | title (A), handle (A), subtitle (B), description (C) |
| Prefix FTS | × 1.5 | same fields |
| Exact title match | +2.0 | title |
| Title starts-with | +1.5 | title |
| Title trigram | × 2.0 | similarity / word_similarity |
| Tag / category match | × 1.4 | related entities |
| Type / collection match | × 1.1 | related entities |
Caching
| Endpoint | Cache key pattern | TTL |
|---|---|---|
| Search | search:v1:<sha256(query)>:<limit>:<offset> | 60s |
| Autocomplete | ac:v1:<query>:<limit> | 30s |
Cache is cleared automatically when products are created, updated, or deleted (via subscriber on product.created, product.updated, product.deleted).
Cache read/write failures are non-fatal — search falls through to PostgreSQL.
Telemetry
Each search and autocomplete request logs a JSON line to stdout:
{
"ts": "2026-06-18T12:00:00.000Z",
"event": "search",
"query_hash": "241c1e30ed886aa4a8f4248024be2ca1a221fe9773b52e2dca7891ff5771f399",
"limit": 20,
"offset": 0,
"result_count": 3,
"duration_ms": 42,
"cache_hit": false,
"zero_results": false
}When result_count === 0, event is "search_zero_results". Raw query strings are never logged.
Development
# Install dependencies
npm install --legacy-peer-deps
# Watch mode
npm run dev
# Run tests (table-driven, vitest)
npm test
# Build
npm run buildTroubleshooting build errors
If TypeScript reports missing exports from @medusajs/framework/utils (MedusaError, Module, etc.), your node_modules install is likely corrupted. Fix with a clean reinstall:
rm -rf node_modules && npm install --legacy-peer-depsModule Exports
import {
SMART_SEARCH_MODULE,
SmartSearchModule,
REDIS_MODULE,
RedisModule,
} from "medusa-smart-search"| Constant | Value | Description |
|---|---|---|
| SMART_SEARCH_MODULE | "smart_search" | Search service module |
| REDIS_MODULE | "redis" | ioredis client module |
Resolve the search service in workflows or subscribers:
import { SMART_SEARCH_MODULE } from "medusa-smart-search"
import SmartSearchModuleService from "medusa-smart-search/modules/smart-search/service"
const service = container.resolve<SmartSearchModuleService>(SMART_SEARCH_MODULE)
await service.clearSearchCache()License
MIT
