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.
{
"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
| Type | Value encoding | Full-text | term / terms | range | Aggregate | Sort |
|---|---|---|---|---|---|---|
Text | Free text | Yes | Yes, whole value | Lexicographic | Yes | Yes |
Keyword | Exact string | No | Yes | Lexicographic | Yes | Yes |
Date | String, ISO-8601 recommended | No | Yes | Lexicographic | Yes | Yes |
Boolean | "true" / "false" | No | Yes | — | Yes | Yes |
Integer | Signed 64-bit, as text | No | No | Numeric | No | As text |
Float | 64-bit float, as text | No | No | Numeric | No | As text |
Vector | JSON array of floats, as text | No | No | — | No | No |
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.