Kosha docs
Documentation menu

Feature

Schema & field types

A document is an id and a flat list of typed fields. There is no mapping to declare and no migration to run — the field_type you send with each field is what decides how it is indexed, and therefore what you can do with it later.

POST /v1/namespaces/{namespace}/documents
{
  "documents": [{
    "id": "d1",
    "fields": [
      {"name": "title",      "field_type": "Text",    "value": "Breach notification policy"},
      {"name": "status",     "field_type": "Keyword", "value": "published"},
      {"name": "year",       "field_type": "Integer", "value": "2026"},
      {"name": "score",      "field_type": "Float",   "value": "0.82"},
      {"name": "created_at", "field_type": "Date",    "value": "2026-01-09"},
      {"name": "archived",   "field_type": "Boolean", "value": "false"},
      {"name": "embedding",  "field_type": "Vector",  "value": "[0.019,-0.203,0.088]"}
    ]
  }]
}

Every value is a string. Numbers, booleans, and vectors are all sent as their string encodings and parsed according to the declared type. A value that fails to parse is dropped from that type's column rather than rejecting the write, so a mistyped number becomes an unfilterable field, not an error.

The seven types

TypeValue encodingFull-textterm / termsrangeAggregateSort
TextFree textYesYes, whole valueLexicographicYesYes
KeywordExact stringNoYesLexicographicYesYes
DateString, ISO-8601 recommendedNoYesLexicographicYesYes
Boolean"true" / "false"NoYes—YesYes
IntegerSigned 64-bit, as textNoNoNumericNoAs text
Float64-bit float, as textNoNoNumericNoAs text
VectorJSON array of floats, as textNoNo—NoNo

Text

The only type that enters the inverted index. Its tokens are what BM25, wildcard, and phrase queries match against. It is also stored whole in the string column, so a term filter on a Text field matches the entire untokenized value — occasionally useful, frequently surprising.

Keyword

Exact, unanalyzed strings: ids, statuses, tenant names, tags, enum values. Filterable, aggregatable, sortable, and invisible to the text scorer. This is the type most of your metadata should be.

Date

Stored and compared as a string. Zero-padded ISO-8601 — 2026-01-09, 2026-01-09T14:32:00Z — sorts and ranges correctly because lexicographic order matches chronological order. Any other format does not, and will produce silently wrong ranges. If you need arithmetic or timezone handling, store an epoch Integer and range on it.

Boolean

Lives in the string column as "true" or "false". Filter it with {"term": {"archived": "false"}}, matching the string.

Integer and Float

Parsed into numeric columns, which is what gives range its correct numeric comparison. The trade is that they are absent from the string column: they cannot be term-filtered, cannot be aggregated, and sort as text. Index a number as Keyword as well when you need equality or faceting on it — duplicating a field under two names and two types is a normal and cheap thing to do.

Vector

A JSON-encoded array of floats, used only by kNN search. It is not stored in any filter column and never appears in a filter, sort, or aggregation.

Namespaces

A namespace is the unit of isolation and physical layout: its own manifest, its own segments, its own directory in object storage. Indexing into a name that does not exist registers it — there is no create step to run first.

Namespaces are deliberately cheap. An empty one costs a metadata row, not a shard, a heap allocation, or a cluster operation. That is a design position, not an accident: it removes the usual pressure to consolidate many tenants into one index for cost reasons, so one namespace per customer, per matter, or per collection is the expected pattern rather than an extravagance.

It is also the most effective performance tool available to you. A query only touches the segments in one namespace's manifest, so partitioning data into narrower namespaces beats every filter — the documents that are not there cost nothing to skip.

Tenancy

The API key on a request resolves to a tenant id, which is prefixed onto the namespace before anything touches storage: tenant acme-corp searching my-index reads acme-corp/my-index. Two tenants can use identical namespace names without colliding, and neither can reach the other's data by guessing one.

Schema evolution

Because types travel with the documents, adding a field is just writing documents that carry it. Nothing needs to be declared, and old documents are not rewritten — they simply have no value in that field's column, and a filter on it will not match them.

Changing a field's type is the case that needs care. Segments are immutable, so documents written before the change keep the old type until they are rewritten, and the two live side by side: a field that was Integer and is now Keyword will be numerically rangeable in old segments and term-filterable in new ones, and neither query sees the whole namespace. The clean path is to reindex under a new field name, or into a new namespace.

The proto has more schema than the engine uses

CreateNamespace accepts a NamespaceSchema with per-field indexed, stored, and analyzer settings. That is the shape the API is growing toward; today those knobs are not enforced and namespaces are created implicitly on first write. Write documents with the types you want and treat the schema message as forward-looking.