Search Is a Different Contract Than Filtering
Filtering promises the exact subset matching a predicate; search promises the most *relevant* results for an expression of intent. Different guarantees, different cost model, different pagination — pretending one is the other breaks both.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Two promises that only look alike
GET /products?category=shoes&color=blue and GET /search?q=blue+running+shoes both return product lists, and everything else about them differs. The filter is a predicate: membership is boolean, the result set is exact and complete, two identical requests against unchanged data return identical sets, and "no results" means the subset is empty — a fact. The search is an intent: matching is graded (typo tolerance, stemming, synonyms), the result is a *ranking* in which relevance below some threshold simply stops being shown, and "no results" often means the query and the index missed each other — a retrieval failure, not a fact about the world.
The contract consequences are concrete. A filter result can feed a reconciliation job; a search result must never, because completeness was never promised. A filter's ordering is whatever Sorting: Determinism or Drift says; a search's default ordering *is the product* — relevance — and offering ?sort=price on search results is a real decision (users want it) with a real cost (it discards the ranking that made the results good, so it usually applies after a relevance cutoff, and the docs should say so). Even the empty state differs: an empty filter result renders "no orders match"; an empty search result renders "did you mean…", spelling suggestions, and a relaxed re-query — affordances the API can only support if it returns the machinery for them.
This is why mature APIs keep the surfaces separate: /orders?status=… for predicates, /search?q=… (often POST, for long queries) for intent — frequently backed by different engines entirely, because the primary database's B-trees answer predicates and an inverted index answers text relevance. Bolting LIKE '%blue%' onto the filter endpoint delivers the worst of both: unindexable leading-wildcard scans, no ranking, no typo tolerance — filtering's cost with none of search's value.
| Clause | Filtering (`?status=paid`) | Search (`?q=blue running shoes`) |
|---|---|---|
| Result semantics | Exact, complete subset | Ranked, thresholded relevance |
| Determinism | Identical across identical requests | Can shift with index updates, ranking changes, personalization |
| "No results" means | The subset is empty — a fact | Retrieval missed — offer recovery (suggestions, relaxation) |
| Default ordering | A documented column ordering | Relevance — the ranking is the product |
| Freshness | Reads the source of truth | Reads an index that lags writes (document the lag) |
| Safe for sync/reconciliation | Yes | Never |
| Backing structure | B-tree / composite indexes | Inverted index (or vector index), usually a separate engine |
Paginating a ranking, honestly
Search pagination inherits every problem from Pagination: Choosing How Lists End and adds two of its own. First, scores are not stable keys: the index updates continuously and rankings shift between requests, so page 2 computed a minute after page 1 may be a page of a *different* ranking — items repeat or vanish across the boundary, offset-drift by another mechanism (see Offset Pagination: Simple, Jumpable, and Lying Under Writes). Engines answer with a traversal that pins state: a scroll/search-context token — morally a Cursor Pagination: An Opaque Bookmark, Not a Position cursor whose anchor is a ranking snapshot rather than a keyset. Such contexts hold server resources, so they carry lifetimes ("valid 5 minutes") and the contract must say what expiry returns.
Second, depth is not worth what it costs. Serving page 200 of a ranked result means scoring and merging the top 10,000 candidates to discard 9,950 — top-k cost grows with k, across every shard. Meanwhile no human uses it: search click-through is overwhelmingly page one. So mature search APIs cap depth explicitly — Elasticsearch defaults to 10,000 results; Google stops around 400 — and the cap is a *contract feature*: it prices the endpoint honestly and tells the consumer who wants everything that they want an export or a filtered traversal instead, not deep search. Return totals in the same honest spirit: "about 12,400" (bounded estimate) rather than an exact count you would have to fully execute the query to know.
The same honesty applies to what a result *is*. Search responses returning full records tie index shape to response models and bloat pages; returning ids forces an N+1 hydration round trip. The usual contract is a search hit: id, type, a display projection (title, snippet with match highlighting), and optionally the score — with the record itself fetched from the system of record when the user commits. This also keeps authorization coherent: results must be permission-filtered *before* ranking and counting, because "3 results (2 hidden)" leaks existence — the search index needs the permission model, which is one of the quiet costs of running one (see Authorization Design in the Contract).
GET /search?q=blue+running+shoes&limit=10 HTTP/1.1 Authorization: Bearer <token>
HTTP/1.1 200 OK
Request-Id: req_01JB…
{
"hits": [
{ "type": "product", "id": "prod_512",
"title": "Cloudrunner 2 — Blue",
"snippet": "…lightweight <em>blue</em> <em>running</em> <em>shoe</em>…",
"score": 14.2 }
],
"total": { "value": 12400, "relation": "approx" },
"next_context": "c3JjaC4…", // valid 5 minutes
"max_depth": 1000, // deeper → 400 depth_exceeded
"index_freshness": "~30s" // writes visible within
}The costs filtering never had
A search API commits you to a pipeline, not a query: analysis choices (tokenization, stemming, synonyms — language-specific), an indexing path that consumes every write (with the lag that implies — a user who renames a document and searches for the new name *will* file the bug the freshness clause exists to answer), relevance tuning that is never finished, and increasingly a hybrid of lexical and semantic retrieval, where embeddings catch "sneakers" for "running shoes" and an inverted index catches exact part numbers that embeddings fumble. Each capability is a differentiator and a permanent operational commitment; the contract's job is to expose *capabilities* ("typo-tolerant", "searches title and body") without freezing *implementation* (the analyzer, the engine, the formula) into promises.
That seam matters most for ranking. Consumers will ask how scoring works; answer in kind — "relevance considers text match, recency and popularity" — and explicitly reserve the right to improve it, because a frozen formula is a frozen product (and an invitation to adversarial optimization by anyone ranked). Keep score values documented as non-comparable across queries and releases. Autocomplete deserves its own contract for the same reason: per-keystroke traffic at 10–50x search volume, a few-ms latency budget, tiny responses, aggressive caching — a different endpoint with different limits, not search with limit=5 (see The Rate-Limit Contract).
- Document capabilities (fields searched, typo tolerance, languages) — never the formula or the engine.
- State index freshness ("visible within ~30s"); the rename-then-search bug report is otherwise guaranteed.
- Permission-filter before ranking and counting; hit counts leak existence otherwise.
- Autocomplete is a separate contract: 10–50x call volume, ms-level budgets, its own rate limits.
- Scores are relative, per-query, per-release — say so, or clients will threshold on them.
Key points
- Filtering promises an exact, complete subset; search promises ranked relevance to an intent — the guarantees, not the response shape, are what differ.
- Search results are never safe input for sync or reconciliation; completeness was never promised, and the contract should say so.
- Paginate rankings with lifetimed scroll contexts and cap depth explicitly — top-k cost grows with k while page-2+ traffic rounds to zero.
- Return search hits (id, projection, snippet) with approximate totals, not full records with exact counts.
- Document freshness, capabilities and ranking *kind*; reserve the formula, the analyzer and the engine as implementation.
- Keep
/searcha separate surface from filtered lists — usually a separate engine — and give autocomplete its own contract entirely.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: implements search as
?q=mapped toLIKE '%q%'on the filter endpoint — same envelope, "search" in the docs. - 2Users → search: no typo tolerance, no ranking; relevant items exist but sort by
created_at; the feature is judged "bad search". - 3Consumer → API: a script paginates
/search?q=athrough 2 million "results" nightly, treating the ranking as a complete dataset. - 4Provider → database: leading-wildcard scans and deep top-k dominate load; search latency drags down the transactional store it shares.
- 5Team → migration: moves to a real engine; now
totalbecomes approximate, results reorder, freshness lags — three silent contract changes shipped as an "upgrade" onto consumers who were promised filter semantics.
- Trust in the feature: users judge search by its top five results, and predicate machinery cannot produce good top-fives — the product is blamed, not the contract confusion.
- Consumers that built completeness assumptions on search results (sync, exports, audits) corrupt silently and permanently.
- The shared datastore: unindexable text scans and deep ranked pagination are exactly the load profile that starves transactional queries.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Separate the surfaces: predicates on the collection endpoint with [[filtering]] semantics, intent on `/search` with ranked semantics — and document the difference in one sentence at the top of each.
- • Cap depth and context lifetime, return approximate totals and hit projections, and define the expiry and depth-exceeded errors clients will meet.
- • Enforce authorization before ranking and counting, in the search path itself.
- • State freshness as a contract clause with a number, and wire the indexing lag into monitoring so the number stays true.
- • Track zero-result rate and abandonment (no click on any hit) per query class — the product-level signal that retrieval or ranking is failing.
- • Monitor indexing lag against the documented freshness clause and alert before the clause becomes a lie.
- • Watch for filter-shaped abuse of search — clients paginating to the depth cap nightly — and route them to exports before capping surprises them.
- • Ranking improvements ship freely *because* the contract promised relevance in kind, not a formula — the reserved right to improve is the evolution mechanism.
- • New capabilities (new searched fields, semantic retrieval, new languages) are additive; removing a searched field changes which documents match and deserves deprecation-grade communication.
- • Migrating engines is invisible exactly to the degree the contract avoided leaking engine specifics — approximate totals, opaque contexts and capability-level docs are what make the swap possible.
- • A real search path is permanent infrastructure: an engine, an indexing pipeline, relevance tuning as an ongoing product function — filtering needed none of it.
- • Freshness lag is structural: search reads an index, not the source of truth, and some consumer will always need the read-your-writes behavior search cannot give.
- • Honest caps (depth, approximate totals, context lifetimes) surface as consumer friction and support questions; the alternative is unbounded top-k cost you pay during your busiest hours.