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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- full-text-search Installs to ~/.agents/skills/full-text-search/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install full-text-search The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project full-text-search Installs to .agents/skills/full-text-search/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/full-text-search.zipunzip -o full-text-search.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) full-text-search PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- You need semantic or hybrid vector ranking: use hybrid-search-reranking
- The project uses hosted Algolia: use algolia-search
- The work is detailed Elasticsearch mapping design: use elasticsearch-mappings
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.
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
| Situation | Default | Why |
|---|---|---|
| Already on Postgres, under ~a few million rows, basic relevance is enough | Postgres FTS (tsvector + GIN) | No new infrastructure; transactional consistency |
| Product or content search with typo tolerance, instant results, small team | Meilisearch | Sensible defaults, simple settings API, tenant tokens |
| Same, with strict schema, high QPS, in-memory speed | Typesense | Typed schema, aliases, scoped API keys, clustering |
| Large corpora, complex analyzers, aggregations, logs plus search | OpenSearch (or Elasticsearch) | Full control of analyzers and BM25, horizontal scale |
| Managed, no ops, budget available | Algolia (algolia-search) | Hosted |
| Semantic or hybrid keyword + vector ranking | Add vectors to the chosen engine or hybrid-search-reranking | Keyword search alone misses paraphrases |
Self-hosting a search engine is new infrastructure: confirm the user wants to run it.
Before changing anything
- Read
AGENTS.md; find existing search code (rg -n "meilisearch|typesense|opensearch|elasticsearch|tsvector|to_tsquery|ILIKE"). - 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.
- 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_idand visibility fields on every document in multi-tenant data.
Sync from the database
| Approach | Use when |
|---|---|
| Transactional outbox + worker upserting changed IDs | Default; reliable and replayable (outbox-pattern) |
| CDC (Debezium, logical replication) | Many tables or services feed the index (cdc-debezium) |
| Periodic full rebuild | Small datasets (under ~100k docs) or as a nightly safety net |
| Synchronous write to the engine in the request | Avoid: 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_atvalues between DB and index, and alerts on mismatch.
Zero-downtime reindex
Settings or mapping changes usually require a rebuild. Always build beside, then swap:
- Create
products_v{n+1}with new settings. - 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.
- Verify counts and run the relevance test set against the new index.
- Swap: Meilisearch
POST /swap-indexes, Typesense alias update, OpenSearch_aliaseswith remove and add in one call. - 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
- 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.
- Change one thing: attribute order or weights, synonyms, stop words, typo settings, ranking rules.
- Rerun; keep the change only if the metric improves without breaking previously good queries.
- 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_idon 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.yaml
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.md
# 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.