OriginChain docs

Schema for Full-text.

schema · full-text

FTS indexes are NOT declared in the schema TOML. Index a (table, field) pair with one POST; query with one GET. Synonyms, a stopword override, facets, and highlights are configurable per pair at runtime. Note: stemming and lemmatization exist in the engine but are NOT yet selectable via the API — today's analyzer is Unicode tokenize + lowercase only.

Engine surface: POST /v1/tenants/:t/fts/:table/:field family — plain text, JSON-aware, doc store, synonyms, stopwords. GET /v1/tenants/:t/fts/:table/:field — boolean / bm25 / phrase search.

Required schema fields.

Without these, this query surface doesn't function at all.

field effect
(none) FTS indexes live entirely at runtime. The :table and :field on the URL are logical buckets — do NOT need to be a registered schema or column.

Optional fields — what each one unlocks.

Add only the fields whose effect you need. Each one buys a specific capability — speed up a predicate, guard a write, or unlock a new query shape.

field type default effect
POST /fts/:t/:f body { doc_id, text } object Index plain text. BM25 inverted index built lazily on first call.
POST /fts/:t/:f/json body { doc_id, json, paths } object JSON-aware: walks dotted paths and indexes string leaves. Omit paths to index every string leaf.
POST /fts/:t/:f/doc body { doc_id, text, facets } object Store doc text + per-facet values. REQUIRED for highlight=true and facets= query params.
POST /fts/:t/:f/synonyms body { synonyms: {...} } object Per (table, field) synonym class map. Both index and query treat each class as equivalent. Re-install replaces in full.
POST /fts/:t/:f/stopwords body { stopwords: [...] } object Per (table, field) drop list. Applied at both index and query time. Re-install replaces in full.
GET ?q= (query string) string Query string. Tokenized (Unicode UAX #29) + lowercased — the only analysis applied. No stemming/lemmatization via the API.
GET ?mode= string boolean boolean | bm25 | phrase. Defaults to boolean when omitted. boolean = AND-of-terms (bare doc_id array). bm25 = ranked (bare {doc_id,score} array). phrase = in-order match (bare doc_id array).
GET ?k= int 10 Top-K cap for bm25 mode. Ignored in boolean / phrase.
GET ?fuzzy=0..3 int 0 Edit-distance budget per term in bm25 mode. Catches typos. Capped at MAX_EDIT_DISTANCE = 3.
GET ?highlight=true bool false Return per-hit snippet highlights. Requires the doc text stored via /doc endpoint. BM25 mode only.
GET ?facets=csv string Comma-separated facet field names. Returns aggregated counts alongside hits.

What you can call (no schema knob needed).

  • POST /fts/:t/:f — index plain text
  • POST /fts/:t/:f/json — JSON-aware index walks dotted paths
  • POST /fts/:t/:f/doc — store text + facets (required for highlight + facet aggregation)
  • POST /fts/:t/:f/synonyms — install per-(table, field) synonym map
  • POST /fts/:t/:f/stopwords — install per-(table, field) stopword list
  • GET /fts/:t/:f?q=… — boolean / bm25 / phrase search with optional fuzzy + highlight + facets

Abbreviation legend.

token meaning
BM25 Okapi BM25 — the ranking function used in mode=bm25. Same scoring family as Lucene / Elasticsearch
doc_id Unique identifier per indexed document (within a table, field pair)
facet Categorical attribute stored alongside the doc text for aggregate counts
highlight Per-hit snippet of the matching doc text with the query terms wrapped in `<em>` tags
fuzzy Edit-distance tolerance — fuzzy=1 catches single typos, fuzzy=2 catches two-char edits, etc
tokenizer The API tokenizer is Unicode (UAX #29, multilingual) + lowercase. There is no tokenizer or analyzer query param to change it.
stemmer Snowball stemmer (18 languages) exists in the engine but is NOT yet selectable via the API. Today's API does not stem.
stopwords Per-(table, field) drop list you can install via POST /stopwords (applied at index + query time). The built-in per-language stopword sets are NOT yet API-selectable.
synonyms Class-based equivalence — every member of a class scores against every query that matches any other member

Worked example.

Schema TOML — copy + register via POST /v1/tenants/:t/schemas with Content-Type: text/plain.

# ──────────────────────────────────────────────────────────────────────
# Important: FTS indexes are NOT declared in the schema TOML.
# The grammar oc_schema::Manifest accepts does NOT have a [[extractions.fts]]
# block. FTS is a pure runtime endpoint: POST text to index, GET to query.
# The analyzer is fixed (Unicode tokenize + lowercase) — there is no
# tokenizer / stemmer / analyzer knob on the POST body or in the TOML.
#
# What the TOML IS for (FTS workflows): registering the ROW schema so the
# same row is reachable via SQL alongside FTS search. Same id keeps things
# aligned.
# ──────────────────────────────────────────────────────────────────────

namespace   = "shop"
table       = "products"
primary_key = ["id"]

[[columns]]
name = "id"          
ty = "str" 
required = true
[[columns]]
name = "name"        
ty = "str"
[[columns]]
name = "description" 
ty = "str"
[[columns]]
name = "category"    
ty = "str"

[[indexes]]
name    = "by_category"
columns = ["category"]

Runtime calls.

# ════════════════════════════════════════════════════════════════════
# INDEX — 3 ways to load text
# ════════════════════════════════════════════════════════════════════

# 1) Plain text per doc — simplest
curl -X POST $BASE/v1/tenants/$T/fts/shop.products/description -H "Authorization: Bearer $BEARER" \
  -H "Content-Type: application/json" \
  -d '{
    "doc_id": "p001",
    "text":   "Wireless Bluetooth headphones with active noise cancellation"
  }'

# 2) JSON-aware — walks dotted paths inside a nested doc
curl -X POST $BASE/v1/tenants/$T/fts/shop.products/description/json -H "Authorization: Bearer $BEARER" \
  -d '{
    "doc_id": "p001",
    "json": {
      "name": "Wireless Headphones",
      "desc": { "short": "BT 5.3 ANC", "long": "Over-ear noise-cancelling..." },
      "tags": ["audio", "premium"]
    },
    "paths": ["name", "desc.short", "desc.long", "tags"]
  }'
# Omit "paths" to index every string leaf in the document.

# 3) Store doc text + facets — REQUIRED for highlight=true and facets= queries
curl -X POST $BASE/v1/tenants/$T/fts/shop.products/description/doc -H "Authorization: Bearer $BEARER" \
  -d '{
    "doc_id": "p001",
    "text":   "Wireless Bluetooth headphones with active noise cancellation",
    "facets": {
      "category":     ["electronics"],
      "brand":        ["acme"],
      "price_bucket": ["100-200"]
    }
  }'

# ════════════════════════════════════════════════════════════════════
# CONFIG — synonyms + stopwords (optional, per (table, field) pair)
# ════════════════════════════════════════════════════════════════════

# Install synonyms — each class is treated as equivalent at both index and query time
curl -X POST $BASE/v1/tenants/$T/fts/shop.products/description/synonyms -H "Authorization: Bearer $BEARER" \
  -d '{
    "synonyms": {
      "headphones": ["earbuds", "earphones", "cans"],
      "laptop":     ["notebook", "computer"],
      "tv":         ["television"]
    }
  }'

# Install stopwords — dropped at both index and query time
curl -X POST $BASE/v1/tenants/$T/fts/shop.products/description/stopwords -H "Authorization: Bearer $BEARER" \
  -d '{
    "stopwords": ["the", "a", "an", "and", "or", "of", "in", "with", "for"]
  }'

# ════════════════════════════════════════════════════════════════════
# SEARCH — 3 modes × query params
#
# :table and :field are OPAQUE path segments — not validated against any
# schema. They must match EXACTLY between the index POST and the search GET,
# or you index one place and query another and get a silent empty result.
#
# Response shapes (verified):
#   boolean / phrase  → bare JSON array of doc_id strings:  ["p001","p014"]
#   bm25 (plain)      → bare JSON array of {doc_id,score}:  [{"doc_id":"p001","score":9.42}]
#   bm25 + highlight/facets → object: {"hits":[...],"facets":{...}}
#   bm25 + explain    → BM25 scoring-breakdown object
#   index POST        → 201 Created, EMPTY body
# ════════════════════════════════════════════════════════════════════

# Boolean mode (DEFAULT — used when ?mode= is omitted) — AND of terms, no scoring
# → ["p001", "p014"]
curl "$BASE/v1/tenants/$T/fts/shop.products/description?q=wireless+headphones" \
  -H "Authorization: Bearer $BEARER"

# BM25 mode — ranked, top-k cap via k= → [{"doc_id":"p001","score":9.42}, ...]
curl "$BASE/v1/tenants/$T/fts/shop.products/description?q=wireless&mode=bm25&k=10" \
  -H "Authorization: Bearer $BEARER"

# Phrase mode — exact in-order match
curl "$BASE/v1/tenants/$T/fts/shop.products/description?q=noise+cancellation&mode=phrase&k=10" \
  -H "Authorization: Bearer $BEARER"

# Fuzzy — every BM25 term treated as term~N. fuzzy=1 catches single typos.
# Capped at MAX_EDIT_DISTANCE = 3.
curl "$BASE/v1/tenants/$T/fts/shop.products/description?q=wirless&mode=bm25&fuzzy=1&k=10" \
  -H "Authorization: Bearer $BEARER"

# Highlight — returns {highlights: {description: ["…<em>wireless</em>…"]}} per hit
# Requires the stored-text doc was set via POST /doc above. BM25 mode only.
curl "$BASE/v1/tenants/$T/fts/shop.products/description?q=wireless&mode=bm25&k=5&highlight=true" \
  -H "Authorization: Bearer $BEARER"

# Facets — comma-separated facet field names from the stored doc.
# Returns aggregated {category: {electronics: 5, books: 2}} alongside hits.
curl "$BASE/v1/tenants/$T/fts/shop.products/description?q=wireless&mode=bm25&k=5&facets=category,brand" \
  -H "Authorization: Bearer $BEARER"

# Kitchen-sink — fuzzy + highlight + facets in one call
curl "$BASE/v1/tenants/$T/fts/shop.products/description?q=wirless&mode=bm25&k=5&fuzzy=1&highlight=true&facets=category,brand,price_bucket" \
  -H "Authorization: Bearer $BEARER"