Full-Text Search skill for Codex

Relevant results, an index that never drifts. Codex starts from real queries and the results they should return, then chooses an engine that fits your data and team, shapes denormalised documents with clear searchable, filterable and sortable fields, keeps the index in sync through an outbox worker with a drift check, and rebuilds behind an alias so search never goes down. Relevance changes are kept only when a judged query set improves.

  • Backend & APIs
  • $full-text-search
  • 130 lines
  • 3 files
  • ~1,750 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- full-text-search

Installs to ~/.agents/skills/full-text-search/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $full-text-search, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds application search with Meilisearch, Typesense, OpenSearch or Postgres full-text search - engine choice, document shape, searchable and filterable fields, typo tolerance, synonyms, ranking, faceting, sync from the database, zero-downtime reindex through aliases or index swaps, scoped search keys for multi-tenant data, and relevance testing with judged queries. Use when adding a search box or autocomplete, results feel irrelevant, the index drifts from the database, a reindex causes downtime, or choosing between search engines.

Use it when

  • Adding a search box, autocomplete or faceted filtering
  • Choosing between Postgres FTS and a dedicated search engine
  • Search results feel irrelevant or miss obvious matches
  • The index is out of sync with the database or reindexing causes downtime

Use something else when

How to check the result

Index counts match the database, edits and deletions appear within the sync lag, a judged query set meets or beats its baseline, and scoped keys cannot read other tenants.

The mistake it prevents

Writing to the search engine inside the request handler, so search outages fail user writes and out-of-order updates leave stale documents.

~/.agents/skills/full-text-search/SKILL.md

--- name: full-text-search description: Builds application search with Meilisearch, Typesense, OpenSearch or Postgres full-text search - engine choice, document shape, searchable and… license: MIT ---

Done well means: the engine fits the data size and team, documents are shaped for the queries users actually type, the index stays in sync through an idempotent pipeline, a reindex never takes search down, and relevance changes are judged against a fixed query set rather than one person’s spot checks.

Choose the engine

SituationDefaultWhy
Already on Postgres, under ~a few million rows, basic relevance is enoughPostgres FTS (tsvector + GIN)No new infrastructure; transactional consistency
Product or content search with typo tolerance, instant results, small teamMeilisearchSensible defaults, simple settings API, tenant tokens
Same, with strict schema, high QPS, in-memory speedTypesenseTyped schema, aliases, scoped API keys, clustering
Large corpora, complex analyzers, aggregations, logs plus searchOpenSearch (or Elasticsearch)Full control of analyzers and BM25, horizontal scale
Managed, no ops, budget availableAlgolia (algolia-search)Hosted
Semantic or hybrid keyword + vector rankingAdd vectors to the chosen engine or hybrid-search-rerankingKeyword search alone misses paraphrases

Self-hosting a search engine is new infrastructure: confirm the user wants to run it.

Before changing anything

  1. Read AGENTS.md; find existing search code (rg -n "meilisearch|typesense|opensearch|elasticsearch|tsvector|to_tsquery|ILIKE").
  2. Collect 30-100 real queries (search logs, support tickets, product owner) with the results that should rank first. This is the relevance test set; without it, tuning is guessing.
  3. List filters, sorts and facets the UI needs, and the expected document count and growth.

Document design

  • One document per thing users want back (a product, not a product-variant row), denormalised: include category names, brand name, tags, not foreign keys.
  • Separate fields by role:
    • searchable (title, description, brand, sku), ordered by importance,
    • filterable / facetable (tenant_id, category, price, in_stock, visibility),
    • sortable (price, created_at, popularity),
    • display-only (image URL, slug), not searchable.
  • Keep documents small; huge bodies hurt relevance and memory. Index a summary plus key fields.
  • Use the database primary key as the document ID so upserts are idempotent.
  • Include tenant_id and visibility fields on every document in multi-tenant data.

Sync from the database

ApproachUse when
Transactional outbox + worker upserting changed IDsDefault; reliable and replayable (outbox-pattern)
CDC (Debezium, logical replication)Many tables or services feed the index (cdc-debezium)
Periodic full rebuildSmall datasets (under ~100k docs) or as a nightly safety net
Synchronous write to the engine in the requestAvoid: couples request latency and failure to the search cluster
  • Workers re-read the current row by ID and upsert (or delete if gone or hidden). Never index the event payload directly; events arrive out of order.
  • Batch upserts (1k-10k docs per request) and respect the engine’s async task model (Meilisearch tasks, OpenSearch refresh interval).
  • Track drift: a nightly job compares counts and a sample of updated_at values between DB and index, and alerts on mismatch.

Zero-downtime reindex

Settings or mapping changes usually require a rebuild. Always build beside, then swap:

  1. Create products_v{n+1} with new settings.
  2. Bulk load from the database; also apply live changes to both indexes while loading (dual-write in the sync worker), or replay the outbox from the load start.
  3. Verify counts and run the relevance test set against the new index.
  4. Swap: Meilisearch POST /swap-indexes, Typesense alias update, OpenSearch _aliases with remove and add in one call.
  5. Keep the old index for a rollback window, then delete it after user confirmation.

The application always queries the alias or stable name, never a versioned index.

Relevance tuning loop

  1. Run the judged query set; record per query whether the expected result is in the top 1/3/10. Track MRR or nDCG@10, plus the zero-result rate from production logs.
  2. Change one thing: attribute order or weights, synonyms, stop words, typo settings, ranking rules.
  3. Rerun; keep the change only if the metric improves without breaking previously good queries.
  4. Add new failing real queries to the set as they come in.

Common wins: field weighting (title over description), synonyms for domain vocabulary, disabling typo tolerance on SKUs and codes, boosting by popularity or in-stock as a tie-breaker rather than overriding text relevance.

Security

  • Never ship admin or master keys to the browser. Use search-only keys.
  • Multi-tenant: enforce tenant_id on the server or with engine-signed scoped keys (Meilisearch tenant tokens, Typesense scoped search keys) whose filter the client cannot change.
  • Filter out unpublished, deleted and permission-restricted documents in the index itself or with an enforced filter; do not rely on the UI.
  • Sanitize user input inserted into filter expressions; build filters from allowlisted fields.

Engine specifics

Read references/engines.md when writing the actual settings, schema, mapping, bulk import, alias swap or Postgres FTS SQL for the chosen engine.

Workflow checklist

  • Gather judged queries, filters, facets and data volume
  • Choose the engine; confirm new infrastructure with the user
  • Design the document and field roles
  • Build the sync pipeline (outbox or CDC) and a drift check
  • Configure settings; load into a versioned index; swap the alias
  • Run the relevance set; tune one change at a time
  • Add scoped keys or server-side filters for tenancy and visibility

Verification

  • Index count equals the count of searchable rows in the DB (within the sync lag).
  • Updating a row’s title in the DB shows up in search within the documented lag.
  • Deleting or unpublishing a row removes it from results.
  • The relevance metric on the judged set meets or beats the previous baseline.
  • A search-only or tenant-scoped key cannot read another tenant’s documents or change settings.
  • p95 search latency measured at expected QPS (load-testing).

Gotchas

  • ILIKE '%term%' is not search: no ranking, no stemming, no index use. Replace it.
  • Changing filterable or sortable attributes in Meilisearch triggers a full reindex of that index.
  • OpenSearch dynamic mapping guesses types from the first document; define mappings explicitly.
  • Typesense keeps the index in RAM: size memory for the whole dataset plus headroom.
  • Stemming and stop words are language-specific; mixed-language content needs per-language fields.
  • Autocomplete on every keystroke without debouncing (150-250 ms) multiplies load.
  • For deep OpenSearch or Elasticsearch mapping design, use elasticsearch-mappings.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Full-Text Search"
  short_description: "Relevant results, an index that never drifts"
  default_prompt: "$full-text-search Add product search with typo tolerance and filters to this app"
references/engines.md198 lines, 8.1 KB
# Engine specifics

Read the section for the engine in use. All HTTP examples need network access to the search
service; in a Codex sandbox with network off, write the code and ask before running it.
Replace hosts, keys and index names; load keys from env, never hard-code them.

## Meilisearch

Every write returns a task; poll it before assuming the change is live.

```bash
# Settings (applied asynchronously; changing filterable/sortable fields reindexes)
curl -s -X PATCH "$MEILI_URL/indexes/products_new/settings" \
  -H "Authorization: Bearer $MEILI_MASTER_KEY" -H 'Content-Type: application/json' \
  --data '{
    "searchableAttributes": ["title", "brand", "category_names", "description"],
    "filterableAttributes": ["tenant_id", "category_names", "price", "in_stock", "visibility"],
    "sortableAttributes": ["price", "created_at", "popularity"],
    "rankingRules": ["words", "typo", "proximity", "attribute", "sort", "exactness", "popularity:desc"],
    "synonyms": { "sneakers": ["trainers", "running shoes"], "tv": ["television"] },
    "typoTolerance": { "disableOnAttributes": ["sku"] },
    "displayedAttributes": ["id", "title", "price", "image_url", "slug"]
  }'

# Documents: upsert by primary key, in batches
curl -s -X POST "$MEILI_URL/indexes/products_new/documents?primaryKey=id" \
  -H "Authorization: Bearer $MEILI_MASTER_KEY" -H 'Content-Type: application/json' \
  --data-binary @batch-0001.json

curl -s "$MEILI_URL/tasks/<taskUid>" -H "Authorization: Bearer $MEILI_MASTER_KEY"

# Zero-downtime: swap contents of the live name and the rebuilt index
curl -s -X POST "$MEILI_URL/swap-indexes" \
  -H "Authorization: Bearer $MEILI_MASTER_KEY" -H 'Content-Type: application/json' \
  --data '[{ "indexes": ["products", "products_new"] }]'
```

Search request body: `{"q": "red runing shoes", "filter": "in_stock = true AND price < 100",
"sort": ["price:asc"], "facets": ["category_names"], "limit": 20}`.

Ranking: a custom rule such as `popularity:desc` placed last acts as a tie-breaker; placing it
before `words` lets popularity override text relevance, which is rarely what users want.

Multi-tenancy: issue tenant tokens. A tenant token is a JWT signed with a search API key, whose
payload contains `apiKeyUid`, an `exp`, and `searchRules` such as
`{"products": {"filter": "tenant_id = 42 AND visibility = 'public'"}}`. The official SDKs expose
a helper to generate it server-side; check the SDK version's README for the exact call. Give
tokens short expiries and mint them per session.

## Typesense

The schema is explicit; fields not in the schema are stored but not indexed unless `.*` auto
fields are configured.

```bash
curl -s -X POST "$TS_URL/collections" -H "X-TYPESENSE-API-KEY: $TS_ADMIN_KEY" \
  -H 'Content-Type: application/json' --data '{
    "name": "products_v2",
    "fields": [
      {"name": "title", "type": "string"},
      {"name": "brand", "type": "string", "facet": true},
      {"name": "category_names", "type": "string[]", "facet": true},
      {"name": "description", "type": "string", "optional": true},
      {"name": "sku", "type": "string"},
      {"name": "tenant_id", "type": "string", "facet": true},
      {"name": "price", "type": "float"},
      {"name": "in_stock", "type": "bool"},
      {"name": "popularity", "type": "int32"}
    ],
    "default_sorting_field": "popularity"
  }'

# Bulk upsert, JSON Lines body (one document per line)
curl -s -X POST "$TS_URL/collections/products_v2/documents/import?action=upsert" \
  -H "X-TYPESENSE-API-KEY: $TS_ADMIN_KEY" -H 'Content-Type: text/plain' \
  --data-binary @products.jsonl | grep -v '"success":true' | head   # print failures only

# Point the alias the app queries at the new collection
curl -s -X PUT "$TS_URL/aliases/products" -H "X-TYPESENSE-API-KEY: $TS_ADMIN_KEY" \
  -H 'Content-Type: application/json' --data '{"collection_name": "products_v2"}'
```

Search parameters: `q`, `query_by=title,brand,category_names,description`,
`query_by_weights=4,2,2,1`, `filter_by=in_stock:true && price:<100`,
`sort_by=_text_match:desc,popularity:desc`, `facet_by=brand,category_names`, `num_typos`,
`per_page`. Turn typos off for codes by searching SKUs in a separate request or field with
`num_typos=0`.

Multi-tenancy: create a search-only key once, then derive scoped search keys server-side with
an embedded `filter_by` (for example `tenant_id:=42`) and `expires_at`. The JS client offers
`client.keys().generateScopedSearchKey(searchOnlyKey, params)`. Embedded filters cannot be
removed by the client.

Memory: Typesense holds the index in RAM. Budget roughly 2-3x the raw size of indexed fields
and test with production-sized data.

## OpenSearch (and Elasticsearch)

Define mappings explicitly; never rely on dynamic mapping for searchable fields.

```json
PUT /products_v2
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0,
    "refresh_interval": "-1",
    "analysis": {
      "analyzer": {
        "folded_english": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "asciifolding", "english_stop", "english_stemmer"]
        }
      },
      "filter": {
        "english_stop": { "type": "stop", "stopwords": "_english_" },
        "english_stemmer": { "type": "stemmer", "language": "english" }
      }
    }
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "title":       { "type": "text", "analyzer": "folded_english", "fields": { "raw": { "type": "keyword" } } },
      "description": { "type": "text", "analyzer": "folded_english" },
      "brand":       { "type": "keyword" },
      "sku":         { "type": "keyword" },
      "tenant_id":   { "type": "keyword" },
      "price":       { "type": "scaled_float", "scaling_factor": 100 },
      "in_stock":    { "type": "boolean" },
      "popularity":  { "type": "integer" },
      "created_at":  { "type": "date" }
    }
  }
}
```

Load with `_bulk` (NDJSON, 5-15 MB per request), then restore `refresh_interval` (for example
`"1s"`) and replicas before swapping:

```json
POST /_aliases
{ "actions": [
  { "remove": { "index": "products_v1", "alias": "products" } },
  { "add":    { "index": "products_v2", "alias": "products" } }
] }
```

Query shape: text relevance in `must`, hard constraints in `filter` (cached, not scored):

```json
GET /products/_search
{
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [{ "multi_match": { "query": "red runing shoes", "fields": ["title^3", "brand^2", "description"], "type": "best_fields", "fuzziness": "AUTO" } }],
          "filter": [{ "term": { "tenant_id": "42" } }, { "term": { "in_stock": true } }]
        }
      },
      "field_value_factor": { "field": "popularity", "modifier": "log1p", "missing": 0 },
      "boost_mode": "sum"
    }
  },
  "aggs": { "brands": { "terms": { "field": "brand", "size": 20 } } }
}
```

Use `_analyze` to see how text is tokenized and `"explain": true` on a search to see why a
document scored as it did. For mapping depth (nested vs object, multi-fields, reindex API),
use the elasticsearch-mappings skill.

## Postgres full-text search

```sql
ALTER TABLE products ADD COLUMN search tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(brand, '')), 'B') ||
    setweight(to_tsvector('english', coalesce(description, '')), 'C')
  ) STORED;
CREATE INDEX CONCURRENTLY products_search_idx ON products USING GIN (search);

SELECT id, title, ts_rank_cd(search, q) AS rank
FROM products, websearch_to_tsquery('english', $1) AS q
WHERE tenant_id = $2 AND search @@ q
ORDER BY rank DESC, popularity DESC
LIMIT 20;
```

- `websearch_to_tsquery` accepts user input safely (quotes, `-exclude`, `or`).
- Adding a stored generated column rewrites the table: schedule it like any heavy migration
  (zero-downtime-migrations), or add a plain column filled by trigger and backfill in batches.
- Typo tolerance needs `pg_trgm`: `CREATE INDEX ... USING GIN (title gin_trgm_ops)` and
  `WHERE title % $1 ORDER BY similarity(title, $1) DESC` as a fallback when FTS returns nothing.
- Prefix autocomplete: `to_tsquery('english', 'runn:*')` built from sanitized last-word input.

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem