OriginChainDB docs
reference · full-text

Full-text search

Full-text search finds rows by keywords in their text. Use it for "find products matching wireless headphones", search-as-you-type, log line filtering, and anywhere a user is typing words instead of structured filters. The ranking algorithm is BM25 - the same one Elasticsearch, Lucene, and most search engines use.

Full-text indexes live on their own runtime endpoint - they are not declared on the schema. You index a text under (table, field, doc_id), then search. The doc_id is what links search hits back to your rows - use the row's primary key.

:table and :field are opaque path segments - they are not validated against any schema. They must match exactly between the index call and the query call. Index under shop.products and query shop_products and you get a silent empty result, not an error. The default search mode is boolean - omit mode and you get AND-of-terms.

1. Index a text.

what this does

Tell OriginChainDB to make a piece of text searchable. Re-indexing the same doc_id replaces the old text in the same write - no stale matches.

POST /v1/tenants/:t/fts/:table/:field
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "doc_id": "sku-9281",
    "text":   "Lightweight road runner with a carbon plate, designed for marathon pace."
  }'
what you get back
HTTP/1.1 201 Created

(empty body)

A successful index returns 201 Created with no body. There is no token count or confirmation JSON - check the status code, not the body.

what each field means
Field Where What it is
:table URL An opaque label for the index - conventionally your row table, e.g. shop.products. Not checked against any schema; must match byte-for-byte at query time.
:field URL Which "logical column" you're indexing under. You can index multiple fields per table - title, description, etc.
doc_id body A unique ID for this document. Use the row's primary key so hits link back cleanly.
text body The text to search. No size limit on this endpoint, but very large documents are better split into multiple doc_ids.
common mistakes
  • Indexing only one of several fields. If you want users to search "Wireless headphones" and match products whose title or description contains those words, you need to either concatenate both fields into one text before indexing, or index each field separately and union the results.
  • Forgetting to re-index on updates. Editing a row's text does not automatically update the FTS index. Re-call this endpoint with the new text whenever you change the source.

2. BM25 - ranked search.

what this does

Return the top-k documents ranked by relevance to the query. This is what most users mean when they say "search". Rare query words count more than common ones; documents where the query words appear more often (relative to length) rank higher.

GET /v1/tenants/:t/fts/:table/:field?mode=bm25
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description?q=carbon+marathon&mode=bm25&k=10" \
  -H "Authorization: Bearer $OC_TOKEN"
what you get back
[
  { "doc_id": "sku-9281", "score": 9.42 },
  { "doc_id": "sku-3140", "score": 4.18 }
]

Plain BM25 returns a bare array of { doc_id, score }, best first - no { "mode": ..., "hits": [...] } wrapper. The wrapper object only appears when you add highlight=true or facets= (then you get { "hits": [...], "facets": {...} }); explain=true returns a separate scoring-breakdown object.

query params — all BM25-only; ignored in boolean / phrase
Param Required Notes
q yes The query text. URL-encode spaces as + or %20.
mode no bm25 | boolean | phrase. Defaults to boolean when omitted. Use bm25 for this section.
k no Max results. Default 10. BM25 only.
fuzzy no Edit distance for typo tolerance. fuzzy=1 matches one-character typos.
highlight no highlight=true returns matched-term snippets per hit.
facets no Comma-separated field names to aggregate as facet counts. Switches the response to the wrapper-object shape.
explain no explain=true returns a BM25 scoring-breakdown object instead of hits.

highlight=true requires the doc text to have been stored first via POST /fts/:t/:f/doc. All of k, fuzzy, highlight, facets, and explain are silently ignored in boolean and phrase modes.

3. Boolean AND - every word must match.

what this does

Return every document that contains all the query words, in any order, with no ranking. Fast token-presence check - use when you don't need relevance scoring.

GET /v1/tenants/:t/fts/:table/:field?mode=boolean
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description?q=carbon+marathon&mode=boolean" \
  -H "Authorization: Bearer $OC_TOKEN"
what you get back
["sku-9281", "sku-3140"]

A bare array of doc_id strings (sorted lexicographically) - no score field and no wrapper object. If you need ordering by relevance, use BM25.

4. Phrase - exact word order.

what this does

Match documents that contain the query words contiguously, in the exact order given. Use for branded phrases ("New York Times"), product model numbers, log message templates.

GET /v1/tenants/:t/fts/:table/:field?mode=phrase
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description?q=carbon+plate&mode=phrase" \
  -H "Authorization: Bearer $OC_TOKEN"
what you get back
["sku-9281"]

Same shape as boolean - a bare array of doc_id strings. Phrase just narrows which docs qualify.

5. End-to-end - index, search, enrich.

A complete runnable sequence against a live tenant. Set $OC_HOST, $OC_TENANT, and $OC_TOKEN first. Every response shown below is the real shape the engine returns. Steps 3 and 4 use mode=bm25, which needs the Full-Text Pro capability enabled on the instance - it is included on every paid configuration at no extra charge. Indexing and the boolean query in step 2 need nothing.

step 1 — index three docs
# 1) Index three docs. Each POST returns 201 with an EMPTY body.
curl -sS -o /dev/null -w "%{http_code}\n" \
  -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description" \
  -H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
  -d '{ "doc_id": "p001", "text": "Wireless over-ear headphones with active noise cancellation" }'
# → 201

curl -sS -o /dev/null -w "%{http_code}\n" \
  -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description" \
  -H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
  -d '{ "doc_id": "p002", "text": "Wired earbuds, no noise cancellation" }'
# → 201

curl -sS -o /dev/null -w "%{http_code}\n" \
  -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description" \
  -H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
  -d '{ "doc_id": "p003", "text": "USB-C charging cable, 2 metres" }'
# → 201
step 2 — boolean query (default mode)
# 2) Boolean query (the DEFAULT mode). Bare array of doc_id strings.
curl -sS -G "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description" \
  -H "Authorization: Bearer $OC_TOKEN" \
  --data-urlencode "q=wireless noise"
# → ["p001"]      (only p001 has BOTH "wireless" AND "noise")
step 3 — bm25 ranked query
# 3) BM25 ranked query. Bare array of { doc_id, score }, best first.
curl -sS -G "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description" \
  -H "Authorization: Bearer $OC_TOKEN" \
  --data-urlencode "q=noise cancellation" \
  --data-urlencode "mode=bm25" \
  --data-urlencode "k=10"
# → [ { "doc_id": "p001", "score": 6.31 }, { "doc_id": "p002", "score": 2.04 } ]
step 4 — enrich with highlights
# 4) Enrich with highlights. First store the doc text (highlights read it),
#    THEN ask for highlight=true. Now the response is an OBJECT, not an array.
curl -sS -o /dev/null -w "%{http_code}\n" \
  -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description/doc" \
  -H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
  -d '{ "doc_id": "p001", "text": "Wireless over-ear headphones with active noise cancellation" }'
# → 201

curl -sS -G "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description" \
  -H "Authorization: Bearer $OC_TOKEN" \
  --data-urlencode "q=noise cancellation" \
  --data-urlencode "mode=bm25" \
  --data-urlencode "highlight=true"
# → {
#     "hits": [
#       { "doc_id": "p001", "score": 6.31,
#         "highlights": { "description": ["…active <em>noise</em> <em>cancellation</em>"] } }
#     ]
#   }

6. Analyzer + languages.

read this first

Stemming, lemmatization, diacritic folding, and stopword removal are implemented in the engine but not yet selectable through the HTTP API. There is no language or analyzer query parameter today. The analyzer the API actually uses is Unicode tokenize + lowercase, and nothing else. Concretely: q=runs will not match a document that says "running", and q=cafe will not match "café". Plan your indexing around exact tokens. The one transform that is live is synonyms (see below).

What runs today

  • Unicode tokenize. Text is split into words by the Unicode word-boundary rules (UAX #29), so it works across scripts.
  • Lowercase. Every token is lowercased, so Wireless and wireless match. This is the only normalisation applied.
  • Synonyms. If you install a per-(table, field) synonym map via POST /fts/:t/:f/synonyms, members of a class are treated as equivalent at both index time and BM25 query time. This is the one customer-controlled analyzer feature that is wired through. See FTS runtime calls.

Built in the engine, not yet exposed (roadmap)

The following analyzer stages exist in the engine but cannot be turned on from the API yet. They are listed so you know what is coming, not what you can call today.

Step What it would do Status
fold diacritics "café" would match "cafe". not API-exposed
stopwords Drop common words ("the", "and", "of"). not API-exposed
stemming Suffix-strip. "running" / "runs" → "run". Snowball-based. not API-exposed
lemmatization Dictionary lookup. "ran" → "run". More precise than stemming. not API-exposed
languages the engine has stemmers for (not yet selectable)
ArabicDanishDutchEnglishFinnishFrenchGermanHungarianItalianNorwegianPortugueseRomanianRussianSpanishSwedishTamilTurkishHindi

There is no per-field analyzer knob you can set today. The runtime calls that are live - plain / JSON-aware index, doc store, synonyms, stopword override - are documented on this page and in FTS runtime calls.

7. Examples.

7.1

Search — a single term.

Search is a GET on the same path you indexed to, with the query in ?q=. mode=bm25 is what you want when you care about ranking.

GET /v1/tenants/:tenant/fts/:table/:field?q=…
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes?q=delivery&mode=bm25&k=10" \
  -H "Authorization: Bearer $OC_TOKEN"
response — mode=bm25
[
  { "doc_id": "01JTRX9KQ3YH8K2WMX0F5JZAB7", "score": 9.4213 },
  { "doc_id": "01JTRX9KQ3YH8K2WMX0F5JZAB9", "score": 6.1077 }
]

A bare array of { doc_id, score }, highest score first. k caps the list and defaults to 10.

the response shape changes with the mode

This endpoint returns four different shapes depending on the parameters. boolean and phrase give you a bare array of doc_id strings; bm25 gives an array of objects; adding highlight or facets wraps it all in an object with a hits key; and explain=true returns a scoring report instead. The TypeScript SDK models this as a union you have to narrow yourself.

7.2

What the query syntax really is.

This is worth being blunt about, because it is easy to assume otherwise: ?q= is not a query language. There is no parser. The string is tokenized into words — Unicode word segmentation, then lowercased — and every token becomes a term. All punctuation is discarded.

The practical consequence is that operators you might type do not work, and fail silently rather than erroring. rush AND delivery searches for three terms — rush, and, delivery. Quotes around a phrase are dropped.

You might try What actually happens
rush AND delivery Searches rush, and, delivery. Use mode=boolean, which already ANDs.
rush OR delivery Searches three terms. Use mode=bm25, which already ORs.
NOT cancelled / -cancelled Negation is not available here at all. Use must_not in the DSL.
"signed by recipient" Quotes are discarded. Use mode=phrase.
deliv* The * is discarded. Prefix and wildcard queries exist only in the DSL.
delivary~1 This one works. ~N is the single inline operator the query surface honours. See fuzzy.

How multiple terms combine — it depends on the mode.

This is the important distinction, and it is not configurable:

mode Multi-term meaning Returns
boolean AND — every term must be present Unranked doc_id array, lexicographic. Unboundedk is ignored.
bm25 OR — any term matches, more/rarer terms score higher Ranked {doc_id, score}, capped at k.
phrase Adjacent and in order Unranked doc_id array. k is ignored.
boolean — every term required
mode=boolean (the default)
# mode=boolean is the DEFAULT when ?mode= is omitted.
# Every term must be present. Returns a bare array of doc_ids, unranked.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes?q=rush+delivery" \
  -H "Authorization: Bearer $OC_TOKEN"
response
["01JTRX9KQ3YH8K2WMX0F5JZAB7", "01JTRX9KQ3YH8K2WMX0F5JZAC1"]
an unknown mode falls back to boolean, silently

?mode=ranked, ?mode=BM25 or any typo is not an error — it takes the default branch and runs a boolean AND. If you get back an array of bare strings when you expected scores, check the spelling of mode first. The accepted values are lowercase boolean, bm25 and phrase.

the dashboard's search box is different

The console workbench accepts a one-line table:field terms shorthand in Search mode. That colon syntax is parsed by the console, which then calls the endpoint documented here — it is not something the engine understands. Don't put shop.orders:notes into ?q=. See Run queries from the dashboard.

7.3

Phrase queries.

mode=phrase requires the terms to appear adjacent and in the order given. The quotes you would type in another search engine are not syntax here — the mode is the switch.

mode=phrase
# Terms must appear adjacent, in this order. Quotes are NOT syntax -
# they would simply be discarded by the tokenizer. Use mode=phrase.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes?q=signed+by+recipient&mode=phrase" \
  -H "Authorization: Bearer $OC_TOKEN"

Phrase results come back as an unranked array of doc_id strings — position matching selects the documents, but this mode does not score them. If you need ranking as well as adjacency, use the phrase clause inside the DSL, which selects on positions and then ranks with BM25.

7.4

Fuzzy matching.

Two ways in, both bm25-only: a whole-query budget via ?fuzzy=N, or a per-term ~N suffix. Putting a ~ anywhere in q switches the query onto the fuzzy path automatically.

fuzzy search
# Whole-query budget via ?fuzzy= (bm25 only, 0-3)
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes?q=delivary&mode=bm25&fuzzy=1" \
  -H "Authorization: Bearer $OC_TOKEN"

# Or per-term inline with ~N. A bare ~ means distance 2.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes?q=delivary~1+recipient&mode=bm25" \
  -H "Authorization: Bearer $OC_TOKEN"
  • Edit distance is capped at 3. Higher is a 400: fuzzy edit_distance 5 exceeds MAX_EDIT_DISTANCE 3.
  • A bare term~ with no number means distance 2. term~0 is an exact match.
  • Each term expands to at most 50 dictionary candidates.
  • Distance 1 catches most real typos. Distance 2 and 3 expand aggressively and will pull in unrelated words — measure before shipping either.
  • Only the Python SDK exposes a fuzzy parameter. In TypeScript and Go, put ~N in the query string.
7.5

Ranking, scoring and explain.

mode=bm25 scores each document as the sum of its query terms' contributions. Each contribution combines three things:

  • Inverse document frequency. A term in few documents is worth more than one in many. This is why a rare word dominates a query.
  • Term frequency, with diminishing returns. The fifth occurrence adds much less than the second. k1 controls how fast that saturates.
  • Length normalisation. A hit in a short document counts for more than the same hit buried in a long one. b controls how strongly.

Defaults are k1 = 1.2 and b = 0.75 — the standard Lucene values. On the ?q= surface they are fixed; they can only be overridden through the DSL's params object. Scores are relative within one result set — never compare a score across two different queries.

The explain parameter.

Add explain=true to a bm25 query to get the full arithmetic instead of the hits. This is a query parameter on the search route — there is no separate explain endpoint.

curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes?q=rush+delivery&mode=bm25&k=5&explain=true" \
  -H "Authorization: Bearer $OC_TOKEN"
response
{
  "query_terms": ["rush", "delivery"],
  "n_total":     1204,
  "avgdl":       11.6,
  "k1":          1.2,
  "b":           0.75,
  "hits": [
    {
      "doc_id": "01JTRX9KQ3YH8K2WMX0F5JZAB7",
      "score":  9.4213,
      "terms": [
        { "term": "rush",     "df": 42,  "idf": 3.361, "tf": 1.0,
          "doc_len": 5, "contribution": 5.9102 },
        { "term": "delivery", "df": 310, "idf": 1.362, "tf": 1.0,
          "doc_len": 5, "contribution": 3.5111 }
      ]
    }
  ]
}

n_total is the corpus size and avgdl the average document length — the two corpus statistics the formula needs. Per hit, terms is sorted by contribution descending, so the first entry is the term that actually won the document its place. Length normalisation is folded into contribution; doc_len is the raw token count.

  • Explain works only with mode=bm25, and overrides highlight and facets if you pass them together.
  • It always reports the default k1 and b — you cannot explain a custom-tuned score, and the DSL endpoint has no explain of its own.
  • Explain takes the exhaustive scoring path, so it is slower than the equivalent ranked query. It is a debugging tool, not a production one.
  • No SDK exposes explain. Call the endpoint directly.
7.6

Highlights and facets.

Both features need the document's text stored, which the plain index call does not do. Send it to the /doc variant instead, along with any facet values you want to aggregate on.

# Store the doc text plus per-facet values. Required before highlight=true
# or facets= will return anything.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes/doc" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "doc_id": "01JTRX9KQ3YH8K2WMX0F5JZAB7",
    "text":   "rush delivery, signed by recipient",
    "facets": { "status": ["paid"], "channel": ["web"] }
  }'

Then ask for them at query time:

curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes?q=rush&mode=bm25&k=5&highlight=true&facets=status,channel" \
  -H "Authorization: Bearer $OC_TOKEN"
response — note the object wrapper
{
  "hits": [
    {
      "doc_id": "01JTRX9KQ3YH8K2WMX0F5JZAB7",
      "score":  9.4213,
      "highlights": { "notes": ["<em>rush</em> delivery, signed by recipient"] }
    }
  ],
  "facets": {
    "status":  [ { "value": "paid", "count": 12 }, { "value": "refunded", "count": 3 } ],
    "channel": [ { "value": "web",  "count": 11 }, { "value": "app",      "count": 4 } ]
  }
}
python
res = db.fts.search(
    "shop.orders", "notes", "rush",
    mode="bm25", k=5, highlight=True, facets=["status", "channel"],
)
for hit in res.hits:
    print(hit.doc_id, hit.score, hit.highlights)
for value, bucket in res.facets.items():
    print(value, [(b.value, b.count) for b in bucket])
  • Highlights come back as raw <em> markup around matched terms. Escape or sanitise before rendering.
  • Facets aggregate; they do not filter. facets=status tells you the distribution across the hit set — it does not narrow it. For real filtering use the DSL.
  • At most 1000 distinct values are tracked per facet field.
  • Only the Python SDK wraps highlight and facets; the /doc write is not wrapped by any SDK.
7.7

Filters and multi-field — the JSON DSL.

Everything the ?q= surface can't do — boolean composition, negation, filters, prefix and wildcard matching, multi-field search with weights, custom k1/b — lives in a JSON query DSL at POST /fts/:table/_search. Note the path takes a table only; fields are named inside the query.

curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/_search" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "doc_values_field": "notes",
    "query": {
      "bool": {
        "must":     [ { "match":  { "field": "notes", "query": "rush delivery" } } ],
        "should":   [ { "term":   { "field": "notes", "value": "signed", "boost": 2.0 } } ],
        "must_not": [ { "term":   { "field": "notes", "value": "cancelled" } } ],
        "filter":   [ { "numeric_range": { "field": "amount_cents", "gte": 10000 } } ]
      }
    },
    "top_k":  20,
    "params": { "k1": 1.2, "b": 0.75 }
  }'
response
{
  "total": 431,
  "hits": [
    { "doc_id": "01JTRX9KQ3YH8K2WMX0F5JZAB7", "score": 14.8802 },
    { "doc_id": "01JTRX9KQ3YH8K2WMX0F5JZAC1", "score": 11.2044 }
  ]
}

total is the match count before top_k truncation, so you can render "showing 20 of 431" without a second query. top_k defaults to 10.

fields are a named key, not a dynamic one

If you know Elasticsearch, this is the one difference that will trip you up. Where ES writes {"term": {"notes": "rush"}}, this DSL writes {"term": {"field": "notes", "value": "rush"}}. The field is always an explicit field key.

Clause types.

match_all, match_none, term, terms, match, phrase, prefix, wildcard, regex, range, numeric_range, fuzzy, exists, multi_match, bool, constant_score and function_score.

  • bool scores must plus any matching should. filter gates without contributing score; must_not excludes.
  • match defaults to OR across its terms; pass "operator": "and" to require them all.
  • Every clause takes a boost, and boosts multiply down the tree — a boost: 2.0 clause inside a boost: 3.0 bool contributes 6×.
  • minimum_should_match accepts an integer, a negative integer ("all but N"), or a percentage string like "75%".
  • regex supports literals, character classes, repetition, alternation and grouping — but not back-references, look-around or named groups.

Multi-field search with weights.

# Search several indexed fields at once, with per-field weights.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/_search" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "multi_match": {
        "fields": [
          { "field": "notes",    "boost": 2.0 },
          { "field": "customer", "boost": 1.0 }
        ],
        "query": "rush delivery",
        "type":  "best_fields",
        "tie_breaker": 0.3
      }
    },
    "top_k": 20
  }'

best_fields (the default) takes the best-scoring field plus tie_breaker × each other match; most_fields sums them all. tie_breaker defaults to 0.0 — pure winner-takes-all — and must be within [0, 1]. Every field you name must be independently indexed, or the request 404s.

numeric filters need doc_values_field

numeric_range, field_value_factor and decay functions read per-document values that only exist if you wrote them as facets through the /doc endpoint. You must also name the source with a top-level doc_values_field. Omit it and the request is refused with a 400 rather than quietly matching nothing — a deliberate choice, since a silent empty result is indistinguishable from "no matches".

no SQL integration

Full-text search is not reachable from POST /sql. There is no MATCH() function and no way to put a text predicate in a WHERE clause. To combine the two, search first and then query the returned doc_ids with SQL.

Two sibling endpoints share the DSL: POST /fts/:table/_aggs for aggregations and POST /fts/:table/_suggest for prefix suggestions. Because of these routes, _search, _aggs and _suggest are reserved and cannot be used as field names. No SDK wraps the DSL endpoints.

7.8

Limits and gotchas.

Limit Value
Max k / top_k10,000
Default k / top_k10
Max fuzzy edit distance3
Fuzzy expansions per term50
DSL query nesting depth32
DSL clauses per query1024
Synonyms per term32
Distinct values per facet field1000
Max query string lengthno limit
Pagination / offsetnot supported
there is no pagination

No offset, no from, no cursor. You get the top k and that is all. To show page two, raise k and slice client-side — and remember boolean and phrase mode ignore k entirely and return every match, which is why a broad boolean query on a large corpus can trip the result-size cap and return 413.

top_k bounds the response, not the work

A small k does not make a broad query cheap. The engine scores every matching document and ranks afterwards, so match_all or a very common single term over a large corpus is expensive however few results you ask for. A block-max optimisation skips provably-losing blocks for ranked queries, but it declines to engage in several cases — including whenever you request explain — and falls back to exhaustive scoring.

an unindexed field is an empty result on ?q=, a 404 on the DSL

The two surfaces disagree here. _search refuses an unindexed field with a 404 explaining that an unindexed field is refused "rather than answered with an empty result you could not tell from 'nothing matched'". The ?q= route has no such check and returns an empty array, so a typo in the path looks exactly like a genuine miss.

honest scale guidance

Measured on the published benchmark: 50,000 documents → 587 MB on disk, ranked p99 1.8 ms. 200,000 documents → 2.3 GB on disk, p99 13 ms. Cost is roughly 12 KB per document, and resident memory grows linearly with the corpus — projected around 25 GB at a million documents. Latency is production-grade at these sizes; memory, not speed, is what will bound you. Size the instance's RAM against your document count.

what needs the capability enabled

Indexing documents and mode=boolean searches work on any instance. Ranked bm25 and phrase queries, and all three DSL endpoints, need Full-Text Pro enabled on the instance. It is included on every paid configuration at no extra charge - there is nothing to buy - but it is still a capability the instance carries, so until it is switched on the call returns 402 naming it. Turn it on from Billing → Add-ons.

caps refuse rather than truncate

When a query exceeds an expansion or clause limit the engine returns 400 with a message naming the limit and suggesting a fix — it never silently returns a partial result set. A truncated expansion would drop matching documents without telling you, so the refusal is deliberate.