OriginChainDB docs
schema · full-text

Full-text search.

Full-text search answers "which documents mention these words, and which mention them most". It is an inverted index scored with BM25 — the same ranking family Lucene and Elasticsearch use — so rare words count for more than common ones and short documents beat long ones on the same term.

Reach for it when the user typed words and you want the words to matter: product search, log and ticket search, or the keyword half of a hybrid retrieval stack. When meaning matters more than wording, use vector search; when you know the predicate exactly, use SQL.

Every operation below is shown in cURL and Python, and in TypeScript and Go where those SDKs wrap it. Where an SDK does not wrap something the tab says so and shows the raw call.

1

Before you start.

Every example uses the shop.orders table from the quickstart, full-text indexing its notes column.

indexing is explicit — inserting a row does not index it

This is the single most common surprise on this page. Writing a row through the rows endpoint or SQL does not put anything into the full-text index. You POST each document to the FTS endpoint yourself, and you do it again whenever the text changes. Nothing in the schema TOML marks a column as searchable, because the index is keyed on a free-form (table, field) pair rather than on your schema at all.

The :table and :field path segments are opaque strings. They must match exactly between the write and the read — index into shop.orders/notes and search shop.orders/note and you get an empty result, not an error. Registering the row schema anyway is what lets you turn a doc_id back into a row.

schemas/orders.toml
# Nothing in the schema marks a field as full-text indexed - there is no
# such flag. An FTS index comes into existence the first time you POST a
# document to a (table, field) pair. Registering the row schema is still
# worth it: it is what lets you take a doc_id from a search hit and read
# the whole row back with SQL.

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

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

[[columns]]
name = "customer"
ty   = "str"

[[columns]]
name = "amount_cents"
ty   = "i64"

[[columns]]
name = "status"
ty   = "str"

[[columns]]
name = "notes"          # the field we will full-text index
ty   = "str"

[[columns]]
name = "placed_ms"
ty   = "u64"
2

Index a document.

One document, one call. Use the row's primary key as the doc_id so a hit maps straight back to a row.

POST /v1/tenants/:tenant/fts/:table/:field
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "doc_id": "01JTRX9KQ3YH8K2WMX0F5JZAB7",
    "text":   "rush delivery, signed by recipient"
  }'
# → 201 Created, empty body
  • Returns 201 Created with an empty body.
  • The write is synchronous and atomic — postings, document length, token set and corpus statistics all land in one batch. The document is searchable the moment the call returns; a crash mid-call leaves every record or none.
  • Re-indexing the same doc_id replaces the previous version cleanly. There is no separate "update" or "delete from index" call — write it again.
indexing a nested json document

If your text is spread across a nested object, the /json variant walks it for you. Dotted paths select what to index; string arrays under a listed path flatten one level. No SDK wraps this variant.

# Walk a nested document and index its string leaves.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes/json" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "doc_id": "01JTRX9KQ3YH8K2WMX0F5JZAB7",
    "json": {
      "note":     "rush delivery",
      "shipping": { "instructions": "signed by recipient" },
      "tags":     ["priority", "insured"]
    },
    "paths": ["note", "shipping.instructions", "tags"]
  }'
# Omit "paths" to index every string leaf in the document.
3

Analysis, synonyms and stopwords.

The analyzer is fixed: Unicode word segmentation, then lowercase. That is the whole pipeline, and there is no parameter to change it.

no stemming today

Searching deliver will not match delivery or delivered — they are three distinct terms. A stemmer covering eighteen languages exists inside the engine, but it is not selectable through the API, so today's behaviour is exact-token matching. Work around it with fuzzy matching, a synonym class, or a prefix clause in the DSL.

What you can configure, per (table, field) pair, is a synonym map and a stopword list. Both apply at index and query time, so installing either after you have indexed documents means re-indexing them to get consistent behaviour.

POST …/synonyms · POST …/stopwords
# Synonym classes - applied at BOTH index and query time.
# Re-installing replaces the whole map.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes/synonyms" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "synonyms": { "delivery": ["shipment", "dispatch"] } }'

# Stopwords - dropped at BOTH index and query time.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.orders/notes/stopwords" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "stopwords": ["the", "a", "an", "and", "by", "of", "with"] }'

Each install replaces the whole map or list — there is no incremental add. A term may have at most 32 synonyms. Only the Python SDK wraps these two calls.

Related.