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.
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.
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."
}'db.fts.index(
"shop.products",
"description",
doc_id="sku-9281",
text="Lightweight road runner with a carbon plate, designed for marathon pace.",
)await db.ftsIndex("shop.products", "description", {
doc_id: "sku-9281",
text: "Lightweight road runner with a carbon plate, designed for marathon pace.",
});err := db.FTSIndex(ctx, "shop.products", "description", originchain.FTSIndexRequest{
DocID: "sku-9281",
Text: "Lightweight road runner with a carbon plate, designed for marathon pace.",
})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.
| 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. |
- 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.
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.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description?q=carbon+marathon&mode=bm25&k=10" \
-H "Authorization: Bearer $OC_TOKEN"res = db.fts.search(
"shop.products",
"description",
"carbon marathon",
mode="bm25",
k=10,
)
for hit in res.hits:
print(hit.score, hit.doc_id)const hits = await db.ftsSearch("shop.products", "description", {
q: "carbon marathon",
mode: "bm25",
k: 10,
});
for (const hit of hits) {
if (typeof hit === "object") console.log(hit.score, hit.doc_id);
}hits, err := db.FTSSearch(ctx, "shop.products", "description", originchain.FTSSearchRequest{
Q: "carbon marathon",
Mode: "bm25",
K: 10,
})
if err != nil { /* handle */ }
for _, h := range hits {
fmt.Println(h.Score, h.DocID)
}[
{ "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.
| 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.
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.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description?q=carbon+marathon&mode=boolean" \
-H "Authorization: Bearer $OC_TOKEN"hits = db.fts.search(
"shop.products",
"description",
q="carbon marathon",
mode="boolean", # every word must appear
)
for hit in hits:
print(hit.doc_id)const hits = await db.ftsSearch("shop.products", "description", {
q: "carbon marathon",
mode: "boolean",
});hits, err := db.FTSSearch(ctx, "shop.products", "description", originchain.FTSSearchRequest{
Q: "carbon marathon",
Mode: "boolean",
})["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.
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.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description?q=carbon+plate&mode=phrase" \
-H "Authorization: Bearer $OC_TOKEN"hits = db.fts.search(
"shop.products",
"description",
q="carbon plate",
mode="phrase", # exact phrase "carbon plate", in that order
)const hits = await db.ftsSearch("shop.products", "description", {
q: "carbon plate",
mode: "phrase",
});hits, err := db.FTSSearch(ctx, "shop.products", "description", originchain.FTSSearchRequest{
Q: "carbon plate",
Mode: "phrase",
})["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.
# 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 # 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") # 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 } ] # 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.
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
Wirelessandwirelessmatch. 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 |
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.
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.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes?q=delivery&mode=bm25&k=10" \
-H "Authorization: Bearer $OC_TOKEN"res = db.fts.search("shop.orders", "notes", "delivery", mode="bm25", k=10)
for hit in res.hits:
print(hit.doc_id, hit.score)const hits = await db.ftsSearch("shop.orders", "notes", {
q: "delivery",
mode: "bm25",
k: 10,
});
// mode "bm25" returns RankedHit[]; "boolean" and "phrase" return string[].
for (const h of hits as { doc_id: string; score: number }[]) {
console.log(h.doc_id, h.score);
}hits, err := db.FTSSearch(ctx, "shop.orders", "notes", originchain.FTSSearchRequest{
Q: "delivery",
Mode: "bm25",
K: 10,
})
for _, h := range hits {
fmt.Println(h.DocID, h.Score)
}[
{ "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.
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.
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. Unbounded — k 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"
# The typed namespace defaults to bm25 - pass mode explicitly for boolean.
res = db.fts.search("shop.orders", "notes", "rush delivery", mode="boolean")
# The legacy method defaults to boolean already:
doc_ids = db.fts_search("shop.orders", "notes", q="rush delivery")
const docIds = await db.ftsSearch("shop.orders", "notes", {
q: "rush delivery",
mode: "boolean",
}) as string[];
docIDs, err := db.FTSSearch(ctx, "shop.orders", "notes", originchain.FTSSearchRequest{
Q: "rush delivery",
Mode: "boolean",
})
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.
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.
# 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"doc_ids = db.fts.search(
"shop.orders", "notes", "signed by recipient", mode="phrase",
)const docIds = await db.ftsSearch("shop.orders", "notes", {
q: "signed by recipient",
mode: "phrase",
}) as string[];docIDs, err := db.FTSSearch(ctx, "shop.orders", "notes", originchain.FTSSearchRequest{
Q: "signed by recipient",
Mode: "phrase",
})
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.
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.
# 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"res = db.fts.search("shop.orders", "notes", "delivary", mode="bm25", fuzzy=1)// The TypeScript SDK does not expose fuzzy - call the endpoint directly,
// or embed the ~N operator in the query string.
const hits = await db.ftsSearch("shop.orders", "notes", {
q: "delivary~1",
mode: "bm25",
});// The Go SDK does not expose fuzzy - embed the ~N operator instead.
hits, err := db.FTSSearch(ctx, "shop.orders", "notes", originchain.FTSSearchRequest{
Q: "delivary~1",
Mode: "bm25",
})- 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~0is 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
fuzzyparameter. In TypeScript and Go, put~Nin the query string.
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.
k1controls how fast that saturates. - Length normalisation. A hit in a short document counts for more than the same hit buried in a long one.
bcontrols 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" {
"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 overrideshighlightandfacetsif you pass them together. - It always reports the default
k1andb— 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.
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" {
"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 } ]
}
} 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=statustells 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
highlightandfacets; the/docwrite is not wrapped by any SDK.
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 }
}' {
"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.
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.
boolscoresmustplus any matchingshould.filtergates without contributing score;must_notexcludes.matchdefaults to OR across its terms; pass"operator": "and"to require them all.- Every clause takes a
boost, and boosts multiply down the tree — aboost: 2.0clause inside aboost: 3.0bool contributes 6×. minimum_should_matchaccepts an integer, a negative integer ("all but N"), or a percentage string like"75%".regexsupports 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_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".
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.
Limits and gotchas.
| Limit | Value |
|---|---|
Max k / top_k | 10,000 |
Default k / top_k | 10 |
| Max fuzzy edit distance | 3 |
| Fuzzy expansions per term | 50 |
| DSL query nesting depth | 32 |
| DSL clauses per query | 1024 |
| Synonyms per term | 32 |
| Distinct values per facet field | 1000 |
| Max query string length | no limit |
| Pagination / offset | not supported |
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.
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.
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.
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.
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.
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.