Queriessearchrelevancefull-textqueryranking

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.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
Is this endpoint promising exact membership in a predicate, or ranked relevance to an intent — and does its contract (results, pagination, consistency) match the promise?
Consumers
A user typing "blue running shoes" into a search box and judging the first five results; a support tool finding tickets that *mention* an error string; an autocomplete widget calling on every keystroke; a script that mistakes `/search` for `/orders?filter=` and tries to paginate through all 2 million "results".
The promise
A well-designed search contract states what is searched, how results are ranked (in kind, not in formula), how fresh the index is, and how deep results go — instead of borrowing filtering's exact-and-complete semantics it cannot honor.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

The two contracts, clause by clause
ClauseFiltering (`?status=paid`)Search (`?q=blue running shoes`)
Result semanticsExact, complete subsetRanked, thresholded relevance
DeterminismIdentical across identical requestsCan shift with index updates, ranking changes, personalization
"No results" meansThe subset is empty — a factRetrieval missed — offer recovery (suggestions, relaxation)
Default orderingA documented column orderingRelevance — the ranking is the product
FreshnessReads the source of truthReads an index that lags writes (document the lag)
Safe for sync/reconciliationYesNever
Backing structureB-tree / composite indexesInverted 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).

A search contract being explicit about what it is
Request
GET /search?q=blue+running+shoes&limit=10 HTTP/1.1
Authorization: Bearer <token>
Response
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 /search a 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.

  1. 1
    Team → API: implements search as ?q= mapped to LIKE '%q%' on the filter endpoint — same envelope, "search" in the docs.
  2. 2
    Users → search: no typo tolerance, no ranking; relevant items exist but sort by created_at; the feature is judged "bad search".
  3. 3
    Consumer → API: a script paginates /search?q=a through 2 million "results" nightly, treating the ranking as a complete dataset.
  4. 4
    Provider → database: leading-wildcard scans and deep top-k dominate load; search latency drags down the transactional store it shares.
  5. 5
    Team → migration: moves to a real engine; now total becomes approximate, results reorder, freshness lags — three silent contract changes shipped as an "upgrade" onto consumers who were promised filter semantics.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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.
What it costs
  • • 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.

Misconceptions

Claim
“Search is filtering on a text field — `LIKE` or a regex gets us to v1.”
Reality
It gets you to a v1 of the wrong contract. No ranking, no typo tolerance, unindexable scans — and worse, consumers integrate against exact-and-complete semantics that real search will break. The cheap v1 is a real (managed) engine behind an honest small contract, not predicate machinery behind a search label.
Claim
“Search results should be exactly reproducible — same query, same results.”
Reality
Reproducibility fights the product: index updates, ranking improvements and personalization all legitimately shift results. The contract should promise *coherence within a traversal* (a pinned context) and reserve change across traversals — determinism at filter strength belongs to filters.
Claim
“Exposing the relevance formula helps integrators.”
Reality
It freezes your ranking (improvements become breaking changes), and it hands an optimization target to everyone being ranked. Document inputs in kind; keep the formula reserved; publish score values only as per-query, non-comparable hints.

Apply it