Search Integration skill for Claude Code
Search that ranks right and never drifts from the DB. Has Claude treat the search engine as a rebuildable derived store: records shaped for queries, settings declared per engine, atomic reindex via replaceAllObjects, index swaps or aliases, and after-commit sync that propagates deletes. Relevance changes are judged against a golden query set, and private data is protected with server-generated scoped keys rather than client filters.
- name
search-integration - size230 lines · ≈2,450 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Integrates hosted or self-hosted search engines - Algolia, Meilisearch, and Typesense - into apps, covering record design, index settings, relevance tuning with a golden query set, facets and filters, secured per-user keys, zero-downtime reindexing, and keeping the index in sync with the database. Use when adding site or product search, choosing between Algolia, Meilisearch and Typesense, fixing bad or missing results, building faceted filters, or when the search index drifts out of sync. Try asking
- Add product search with brand and price facets using Meilisearch
- Our Algolia results are bad for SKU searches - tune the relevance
- Deleted products keep showing up in search; fix the index sync
Use it when
- Adding site, docs, or product search to an app
- Choosing between Algolia, Meilisearch, Typesense, or Postgres full-text
- Results are irrelevant, zero-result rate is high, or facets misbehave
- The index has drifted from the database or reindexing causes downtime
Skip it when
- You need embedding similarity search as the core feature - use vector-search instead
- Search is slow because of database query plans - use sql-query-tuning instead
How to check the result
Run the golden query set before and after changes, then create, update and delete a DB record and confirm each reaches the index within the stated lag.
The common mistake it prevents
Writing to the index inside the database transaction leaves ghost records after rollbacks, and forgetting soft-deletes lets archived items keep appearing.
Search is done when a fixed set of real queries returns the right top results, the zero-result rate is measured, the index matches the database within a known lag (deletes included), and no browser ever holds a key that can write or read other tenants’ records.
Ground rules
- Search engines are derived stores. The database is the source of truth; the index must be rebuildable from scratch at any time with one command.
- Design records for the query, not the table. Flatten joins, precompute display fields
and ranking signals, and keep each record small (Algolia has a per-record size limit;
split long documents into paragraphs/chunks with a shared
distinctkey). - Browsers get search-only keys, scoped per tenant/user where data is private. Admin/master keys live only on the server, read from env.
- Tune relevance against a written list of queries with expected results. Never tune by vibes on one query.
- Every engine applies writes asynchronously. Wait for the task before asserting in tests.
Engine choice
| Situation | Default |
|---|---|
| Want zero ops, strong merchandising/analytics, budget allows per-request pricing | Algolia |
| Self-host or cheap managed, simple typo-tolerant search, quick setup | Meilisearch |
| Self-host with strict schema, geo, vector/hybrid, high write throughput | Typesense |
| Already on Postgres, < ~1M rows, simple keyword search | Postgres full-text (tsvector + GIN) before adding a service |
| Semantic similarity is the core feature | Engine hybrid search or a vector DB - see vector-search |
Workflow
- [ ] 1. Collect 20-50 real queries and expected top results (golden set)
- [ ] 2. Design the record shape and ranking signals
- [ ] 3. Configure index settings (searchable, filterable, sortable, ranking)
- [ ] 4. Build full reindex with atomic swap
- [ ] 5. Build incremental sync incl. deletes
- [ ] 6. Wire the UI with scoped keys
- [ ] 7. Evaluate against the golden set; tune; repeat
1. Golden query set
Pull queries from site search logs, support tickets, or analytics. Include: exact product names, typos, partial words, synonyms (“tee” vs “t-shirt”), SKUs, multi-word intent, and queries that should return nothing. Store as JSON in the repo:
[{ "q": "nike air max 90", "expect_top3": ["prod_812"] },
{ "q": "tshirt", "expect_any_top10": ["prod_11", "prod_45"] },
{ "q": "zzqx", "expect_empty": true }]
2. Record design
{
"objectID": "prod_812",
"name": "Air Max 90",
"brand": "Nike",
"category_lvl0": "Shoes",
"category_lvl1": "Shoes > Running",
"description_short": "first 300 chars...",
"price": 129.0,
"in_stock": true,
"popularity": 8432,
"tenant_id": "t_42",
"updated_at": 1760832000
}
- Use a stable primary key from the DB (
objectIDin Algolia,idin Meilisearch and Typesense). - Add numeric business signals (sales, views, rating) for custom ranking; numbers, not strings.
- Hierarchical categories as
lvl0/lvl1fields enable hierarchical facets. - Do not index fields nobody searches or filters on; they hurt relevance and cost.
3. Settings per engine
Algolia (JS client v5):
import { algoliasearch } from "algoliasearch";
const client = algoliasearch(process.env.ALGOLIA_APP_ID!, process.env.ALGOLIA_ADMIN_KEY!);
await client.setSettings({
indexName: "products",
indexSettings: {
searchableAttributes: ["name", "brand", "unordered(description_short)"],
attributesForFaceting: ["searchable(brand)", "category_lvl0", "filterOnly(tenant_id)", "in_stock"],
customRanking: ["desc(in_stock)", "desc(popularity)"],
attributeForDistinct: "product_id",
},
});
Meilisearch (REST; client SDKs map one-to-one):
curl -X PATCH "$MEILI_HOST/indexes/products/settings" \
-H "Authorization: Bearer $MEILI_ADMIN_KEY" -H 'Content-Type: application/json' \
--data '{
"searchableAttributes": ["name", "brand", "description_short"],
"filterableAttributes": ["brand", "category_lvl0", "tenant_id", "in_stock", "price"],
"sortableAttributes": ["price", "popularity"],
"rankingRules": ["words","typo","proximity","attribute","sort","exactness","popularity:desc"],
"synonyms": { "tshirt": ["t-shirt", "tee"], "tee": ["t-shirt"] }
}'
# returns a taskUid; poll GET /tasks/<uid> until status is "succeeded"
Typesense (schema is explicit; ranking via sort_by and query params):
{
"name": "products_v2",
"fields": [
{ "name": "name", "type": "string" },
{ "name": "brand", "type": "string", "facet": true },
{ "name": "category_lvl0", "type": "string", "facet": true },
{ "name": "tenant_id", "type": "string", "facet": true },
{ "name": "price", "type": "float" },
{ "name": "popularity", "type": "int32" }
],
"default_sorting_field": "popularity"
}
Query: q=air max&query_by=name,brand&filter_by=in_stock:true&facet_by=brand&sort_by=_text_match:desc,popularity:desc.
Settings rules:
- Searchable attribute order is the strongest relevance lever. Put names and titles first, long text last.
- Filtering or faceting on an attribute requires declaring it. Undeclared filters fail or silently match nothing depending on the engine.
- Changing settings on a large Meilisearch index triggers reindexing; do it on the new index before the swap, not on the live one.
4. Full reindex with zero downtime
| Engine | Mechanism |
|---|---|
| Algolia | client.replaceAllObjects({ indexName, objects }) (temp index + atomic move; settings, synonyms and rules copied) |
| Meilisearch | Build products_new, then POST /swap-indexes with [{"indexes":["products","products_new"]}], delete old |
| Typesense | Build products_v3, then point alias products at it (PUT /aliases/products), drop old collection |
Batch writes (Algolia ~1,000 records per batch; Typesense documents/import JSONL with
action=upsert; Meilisearch POST /indexes/x/documents with batches of a few MB).
5. Incremental sync
| Write volume / consistency need | Pattern |
|---|---|
| Low volume, a few seconds lag fine | Enqueue a job after DB commit (transactional outbox) |
| High volume or many writers | CDC (Debezium, Postgres logical replication) into a consumer |
| Nightly freshness is enough | Batch reindex on a schedule |
- Never write to the index inside the DB transaction; a rollback leaves a ghost record. Use an outbox row or an after-commit hook.
- Propagate deletes and soft-deletes (archived, unpublished, out-of-tenant). Missing deletes are the most common drift.
- Make index writes idempotent upserts keyed by ID; include
updated_atand skip stale events. - Run a periodic drift check: compare counts and a sample of IDs/
updated_atbetween DB and index; alert above a threshold.
6. UI and keys
- Use InstantSearch (
react-instantsearch) for Algolia; Meilisearch and Typesense provide InstantSearch adapters (@meilisearch/instant-meilisearch,typesense-instantsearch-adapter), so one UI layer works across engines. - Multi-tenant or per-user data: generate keys server-side with the filter baked in -
Algolia secured API keys (
client.generateSecuredApiKeywithrestrictions.filters), Meilisearch tenant tokens, Typesense scoped search keys. Never rely on a client-side filter for isolation. - Debounce input lightly (or not at all with hosted engines); cancel in-flight requests.
- Send click and conversion events (Algolia Insights, or your analytics) so relevance can be judged on real behavior.
Relevance tuning loop
- Run the golden set and record pass/fail per query.
- Fix in this order: record content (missing fields) -> searchable attribute order -> synonyms -> typo tolerance per attribute (disable on SKUs/codes) -> custom ranking signals -> query rules/pinning for specific business cases.
- Rerun the whole set after each change; a fix for one query often breaks another.
- Track zero-result rate and click-through position from production weekly.
Gotchas
- Typo tolerance on codes and SKUs produces wrong matches; disable it for those
attributes (
disableTypoToleranceOnAttributesin Algolia,typoTolerance.disableOnAttributesin Meilisearch,num_typosper field in Typesense). - Faceting on high-cardinality fields (user IDs, timestamps) slows queries and bloats
responses. Use
filterOnlyor non-facet filterable fields. - Default result caps: Meilisearch limits total reachable hits (
pagination.maxTotalHits, default 1000); Algolia caps pagination (paginationLimitedTo). Deep pagination is a UX smell; use filters. - Prices and dates must be numeric (timestamps), or range filters and sorting break.
- Tests that write then search immediately are flaky; wait for the task (Algolia
waitForTask, Meilisearch task polling) first.
Verification
- Golden set passes at or above the agreed bar (for example, 90% of
expect_top3), and the result is recorded before and after each tuning change. - Create, update, and delete a record in the DB; confirm each appears in the index within the stated lag.
- Use a tenant-scoped key to search for another tenant’s record ID: zero hits.
- Rebuild the index from scratch in staging with the reindex command; counts match the DB.
Output contract
**Engine:** Meilisearch 1.x (self-hosted) - chosen for cost and typo tolerance
**Index:** products (alias swap via /swap-indexes), 412k docs, rebuild 6 min
**Sync:** outbox -> worker upserts, deletes on archive; p95 lag 3 s; nightly drift check
**Keys:** tenant tokens with filter tenant_id = X, 1 h expiry
**Relevance:** golden set 46/50 -> 49/50 (fixed synonyms, SKU typo tolerance off)
**Open issues:** "usb c cable" ranks adapters first; needs category boost About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.