The engines
Eleven engines over one substrate, what each one is asked with, and what serves it.
"One store" is a claim worth being precise about. TessariDB is not one engine that does several things loosely; it is several engines that share one substrate, one transaction and one language. This page names them.
The whole set
| engine | holds | asked with | served by | where |
|---|---|---|---|---|
| Records | documents — objects, arrays, nested trees | CREATE SELECT UPDATE DELETE | identity, a value index, or a scan | Records |
| Graph | edges between records | RELATE, -> and <- traversal, bounded by DEPTH | the edge table's own index, or adjacency written beside the node | Graphs |
| Full-text | terms, produced by an analyzer | MATCHES, search::score; ORDER BY FUSE (…) to rank by words and meaning or place together | a SEARCH index | Full-text search, Several orders at once |
| Vector | arrays of numbers, in the record — of a declared width where you want one | vector::euclidean and friends, in ORDER BY; APPROXIMATE and EFFORT to ask for the index's own answer | a VECTOR proximity graph | Vectors |
| Geometry | shapes on the sphere | eight geo:: predicates, a distance to any shape, an area, and geo::cell for counting a map | a SPATIAL cell index — for predicates, nearest-first and radius reads | Geometry |
| Key-value | one value per key, optionally expiring, in a space that may hold at most so many | GET SET DEL KEYS, EXPIRE TTL, INCR, SET … IF | the key itself | Spaces, Expiry, counters and locks |
| Files | bytes, in chunks, under a ceiling the bucket may declare | PUT READ | the bucket's metadata records | Files |
| Time-series | events ordered by when they happened, answered only within a declared retention, with rollups kept by the writes | DEFINE SERIES … RETAIN … TIME at, time::bucket with GROUP BY and FILL, LATEST BY, ASOF JOIN, DEFINE ROLLUP | the event time in the key, so a late event lands in place and the floor is a position | Time-series |
| Vault | declared fields marked SECRET, sealed one key per record | DEFINE VAULT, REVEAL, ADD RECIPIENT | the record's own data key — not an index | Vaults |
| Queues | ordinary records, handed out one holder at a time under a hold that lapses | DEFINE QUEUE … TIMEOUT, CLAIM, RELEASE | the deadline the claim writes, which is an ordinary field of the record | Queues |
| Topics | messages that are never changed, in commit order, at dense positions | DEFINE TOPIC, CREATE, READ FROM … FOR CONSUMER | the position, and each reader's stored position | Topics |
None of those is a plug-in, a sidecar or a second process. Every one of them writes through the same transaction, so a record, its edge, its terms, its vector and its shape either all land or none of them do.
Seven of them change how a record is reached. The other four change
something else, and that is exactly why they are the four declarations that do
not stand for DEFINE TABLE and some clauses written out by hand: the vault
changes what is stored, the series changes what the store will answer
with, the queue changes who may reach a record, and until when, and the
topic changes what may ever happen to a record once written — nothing. A
claimed_until field on a plain table is a field and not a hold; a retain
field is a field and not a floor. In all three the behaviour is the kind, which
is why the kind is what the store keeps.
Which one you want
The table above says what each engine is. This one starts from the question you arrived with.
| what you are trying to do | what to reach for | why that one |
|---|---|---|
| store an object and read it back by id | Records, no index | identity is already the fastest path there is — a seek, not a search |
| find records by the value of a field | Records, a value index | an index changes what the read costs, never what it answers |
| find records by a word inside text | Full-text | a value index matches the whole field; MATCHES matches terms |
| rank text results by how well they match | Full-text, search::score | scoring needs a collection to measure a word's rarity against |
| rank several fields of several tables as one collection | Full-text, DEFINE SEARCH | one analyzer and one set of statistics, each table's grants kept (One search over several tables) |
| find what is near something in space | Geometry | metres on the sphere, and predicates that are exact at the boundary |
| find what is similar to something | Vector | similarity is distance between arrays of numbers, not a filter |
| follow relationships between records | Graph, edges as records | an edge is a record with its own index, so it carries data and can be queried like a row |
| walk a graph, where the cost that matters is the hop | Graph, a declared graph | adjacency sits beside the node, so a hop is one range read and does not grow with how many neighbours a node has |
| keep one value under one key, fast | Key-value space | no document tree to walk, and the key is the address |
| a session that times out, a rate-limit window, a lock that frees itself | Key-value, EXPIRE, INCR, SET … IF ABSENT | the expiry is in the key's version, so every node and every restart agrees on it |
| a cache that holds at most so many entries | Key-value, DEFINE SPACE … MAX n | the least recently written go first, in the commit, so every copy holds the same keys |
| publish events that several parts of the application read, each at its own pace | Topics | a reader's position commits with what it writes, so an effect in this store happens once per message |
| rank by the words typed and what they meant, or and where it is | Full-text with ORDER BY FUSE (…) | fused by place in each order, never by adding a score to a distance |
| keep bytes that are not a document at all | Files bucket | chunked storage, with the metadata as ordinary records |
| group events by hour, day or week | Time-series, time::bucket | the window is anchored to the epoch, so every process that asks for 1h agrees on where the hour starts |
| keep only the last thirty days of a table, and have reads say so | Time-series, DEFINE SERIES … RETAIN 30d | the floor governs the answer; removing the bytes is a separate act, so a pass that lags costs storage and never correctness |
| hand a piece of work to exactly one worker at a time | Queues | the hold lapses on its own, so a worker that dies returns its work without anything having to notice |
| keep a credential the database itself cannot read back | Vault | the field is sealed before the record is encoded, so the index, the change feed, the replication log and every backup carry ciphertext |
Two things that table is deliberately not saying.
These are not alternatives. One record can carry a text field, a vector, a shape and an edge at once, and one transaction writes all of them. The question is never which engine is this database but which access path answers this read — and a single statement may use several.
Only one of them trades exactness for speed, and it says so in the
statement: a vector read is exact unless you write APPROXIMATE. The reasoning
is below.
Declaring an engine — the route in
When an engine is what you are storing, the store is declared as one, and that is the shortest way in:
DEFINE GRAPH social;
DEFINE EDGE knows IN social FROM social TO social;
DEFINE VECTOR embeddings DIMENSION 768 DISTANCE cosine;
DEFINE GEO places;
DEFINE BUCKET avatars MAX 5242880;
UNSEAL VAULT WITH 'the operator passphrase';
DEFINE VAULT team;
DEFINE QUEUE jobs TIMEOUT 30s ATTEMPTS 5;
DEFINE TOPIC events RETAIN 7d;
DEFINE SERIES readings RETAIN 30d;
Each of those is written into directly. CREATE social:1 = { … },
CREATE embeddings:'intro' = { … }, CREATE places:'paris' = { … } — the word
declares the store and the place its records live, so nothing else has to be
declared first. That symmetry is the point: a declared engine you cannot write a
record into is a label, not a store.
DEFINE VAULT, DEFINE QUEUE, DEFINE TOPIC and DEFINE SERIES are the exceptions to the
paragraph below: none of them is a shorthand. What makes a vault a vault is a key
minted at declaration time, and no combination of other statements mints one — it
is also the only declaration that needs the store unsealed. What makes a queue a
queue is that a claim writes a deadline the store honours, and what makes a
series a series is that reads stop at a floor, and what makes a topic a topic is
that a message is never changed and its position is decided at commit; none of
those is a field you could
declare, which is why the timeout and the retention are required with no
default. Declaring one is the whole capability, and a value the store guessed
would hand work over, or drop a record, at a moment nobody chose. See
Vaults, Queues,
Topics and Time-series.
None of the others is a new storage engine. Each stands for statements you could write
by hand — DEFINE VECTOR for a collection, a field of a declared width and a
vector index; DEFINE GEO for a collection, a required geometry field and a
spatial index; DEFINE GRAPH for a graph and the collection its nodes live in —
run through the same code the long spellings run through.
Existing tables can join a graph rather than being replaced by it:
DEFINE TABLE company (name string) IN social makes a table you already have a
node kind. That is an option, not the route in.
What the declaration adds is that the parts cannot come apart. A width with no
index declares a shape nothing searches; an index with no width admits a row of
the wrong shape and reports it as infinitely far from everything; neither without
REQUIRED admits a record carrying no vector at all, which is legal in a table
and is not a record of a vector store. Written by hand, three statements are three
chances to be wrong, and the store cannot tell afterwards that they were meant as
one thing.
The graph is the one that is more than a collection with an index on it. Its own nodes are ordinary records, and an attached table's membership is a clause — but an edge kind is not a table at all, and its entries are held beside the node in both directions rather than as records behind an index. A hop is a range read over one node's own prefix, so it does not grow with how many neighbours that node has — measured at degree 4 and degree 400 as the same single ask of the store underneath.
Each declared store answers for itself, and the report is the reason the declaration is worth having:
INFO FOR GRAPH social;
INFO FOR VECTOR embeddings;
INFO FOR GEO places;
INFO FOR VECTOR reports the width, the distance, and the recall the index was
measured at — or NONE, because a figure derived from the build parameters
would be one nobody checked wearing the name of one somebody did. INFO FOR GEO
reports how many records its index offered against how many the exact predicate
kept, which is the number that says whether the index is earning its place.
DROP GRAPH, DROP EDGE, DROP VECTOR and DROP GEO remove them, and the
refusals are where the declaration earns its keep a second time. DROP VECTOR and
DROP GEO refuse a table that is not one of those stores rather than dropping it,
because the two words name different things even where they would remove the same
rows — a DROP GEO that quietly removed an ordinary table would be a typo with
the blast radius of a table. DROP GRAPH refuses while any table or edge kind
still belongs to the graph, because dropping anyway would leave every member
pointing at a graph the store no longer has, and that surfaces later as a walk
that finds nothing rather than now. The details are on
Graphs,
Vectors,
Geometry and Files.
The combination, and why AI is the case that needs it
Most workloads use one or two of these engines. An AI application reaches for most of them: retrieval takes four at once, and around it grow the rest — a space for the state of the tools the model calls and a cache of answers that cost money, a queue for the embedding pipeline, a topic for the events the rest of the product reacts to, places, relations, files and sealed credentials. Retrieval is the part worth naming specifically, because it is where the seam between systems is sharpest.
A chunk of text, the embedding it produced, the metadata you filter on and a link back to the document it came from are four different shapes. Kept in four systems — a vector database, a document store, a search index, and object storage for the originals — they are kept in step by a dual write that cannot be made atomic, and every failure between two of those writes leaves an embedding with no text or a chunk with no vector.
Here they are one record in one transaction:
| what you need | what it is here |
|---|---|
| the chunk | a record |
| its embedding | an array of numbers, a field of that record |
| what you filter on | ordinary fields, with ordinary indexes |
| where it came from | an edge, or a file in a bucket |
Which makes the two operations that matter ordinary ones. Writing is a single transaction that either lands whole or not at all — so there is no reconciliation job, because there is nothing to reconcile. Reading is one statement that can hold a nearest-neighbour ordering, a full-text match and a filter together, resolved by the store rather than intersected in your application: the front page opens with exactly that query.
Two properties that follow are easy to miss and hard to add later. A vector read
is exact unless the statement says APPROXIMATE, so the ground truth you
measure your own recall against comes from the same store rather than a second
one you have to trust. And because provenance is an edge, which document did
this answer come from is a traversal rather than a column convention that
nothing enforces.
Reaching an engine from a statement
The other route, and the one the section above calls an option: an engine reached through a table that happens to hold the right field and the right index. It is the correct shape when the vectors, the shapes or the edges are one aspect of a record among several — a chunk with an embedding is still a chunk — and it is how the combination above is written.
There is no engine to select and no connector to configure. An engine is reached by the ordinary syntax that only it can answer, and the planner picks the index — so the whole set is visible in one script:
DEFINE NAMESPACE prod;
USE NAMESPACE prod;
DEFINE DATABASE library;
USE DATABASE library;
DEFINE TABLE chunks (
embedding array,
placed geometry,
at datetime,
team string
);
DEFINE ANALYZER simple FILTERS lowercase, ascii;
DEFINE FIELD body ON chunks TYPE string ANALYZER simple;
DEFINE INDEX by_team ON chunks FIELDS team;
DEFINE INDEX by_body ON chunks FIELDS body SEARCH;
DEFINE INDEX by_where ON chunks FIELDS placed SPATIAL;
DEFINE INDEX by_embedding ON chunks FIELDS embedding VECTOR euclidean;
DEFINE COLLECTION documents;
DEFINE TABLE came_from EDGE;
DEFINE BUCKET originals;
Each engine then answers the syntax that belongs to it:
CREATE documents:1 = { title: 'handbook' };
CREATE chunks:1 = {
body: 'the door opens outward',
embedding: [0.1, 0.2, 0.3],
placed: geometry { type: 'Point', coordinates: [13.4, 52.5] },
at: datetime '2026-08-26T10:05:00Z',
team: 'core'
};
RELATE chunks:1->came_from->documents:1;
SELECT * FROM chunks WHERE team = 'core';
SELECT * FROM chunks WHERE body MATCHES 'door';
SELECT body, search::score(body, 'door') AS rank FROM chunks WHERE body MATCHES 'door';
SELECT * FROM chunks ORDER BY vector::euclidean(embedding, [0.1, 0.2, 0.3]) LIMIT 5;
SELECT * FROM chunks WHERE geo::distance(placed, geometry { type: 'Point', coordinates: [13.4, 52.5] }) < 1000;
SELECT time::bucket(at, 1h) AS hour, count(*) AS n FROM chunks GROUP BY time::bucket(at, 1h);
SELECT * FROM chunks:1->came_from->documents;
Nothing above names an engine. MATCHES is the full-text engine because no
other engine can answer it; vector::euclidean in an ORDER BY is the vector
engine for the same reason; a geo:: predicate is the spatial one. The index
decides the cost and never the answer — every one of those statements returns
the same records with the index dropped, only slower, which is what makes adding
an index a decision you can take later and reverse.
Which index serves a read is a question with an answer:
EXPLAIN SELECT * FROM chunks WHERE team = 'core';
And they compose, because they are clauses of one statement rather than four systems being intersected in your application:
SELECT body, search::score(body, 'door') AS rank
FROM chunks
WHERE team = 'core' AND body MATCHES 'door'
ORDER BY vector::euclidean(embedding, [0.1, 0.2, 0.3])
LIMIT 5;
The substrate underneath
The lowest layer stores opaque byte keys in lexicographic order and answers four questions: read one key, scan a range, apply a batch of changes atomically, and refuse that batch if a stated precondition no longer holds. That is all.
It does not sequence, order or replay anything. Sequence numbers, the replication log, versioning and transaction isolation live above it, because the engine owns its own ordering and a backend inventing its own would compete with it.
There are two implementations of that one contract, and that is not redundancy. The in-memory one keeps the test suite fast enough to run on every change; the log-structured one is where the data actually lives. A contract with a single implementation is a description of that implementation — running the same conformance suite against both is what makes it a contract instead.
Records and transactions
The record layer turns ordered bytes into a record store with a declared isolation level: snapshot isolation, named in the transaction's documentation and in the error taxonomy rather than left to be inferred.
It is declared because it is a consequence of the layout rather than an accident of it. Versions are stored inline under each record, newest first — so a snapshot is one sequence number, and a read at that snapshot is a seek.
What each engine costs you to be exact about
The interesting part of a multi-model store is not that it has the models. It is what each one had to decide in order to be exact.
Full-text reads its analyzer from the schema, never from the index. That
one placement is why adding a search index cannot change what MATCHES answers:
a scan and an index analyse the same text the same way, by construction. Scoring
is the other half and is a different kind of question — how much a word is worth
depends on how often it occurs everywhere else, so a score is a property of
the collection and is refused where there is no collection to measure against.
Geometry snaps positions at ingest to a fixed integer grid, and every
predicate is then an integer determinant with an exact sign. That removes the
reason a geospatial engine normally needs an adaptive-precision floating-point
kernel, and it removes the tolerances that make float predicates
non-transitive — the ones that make A touches B and B touches C disagree
about A and C. A precision model was obligatory anyway: a store that keeps
whatever float arrived cannot promise a lossless read-modify-write.
Vectors are arrays of numbers and not a value type of their own. A new value type would have needed an encoding, an ordering, a literal syntax and a migration, to buy nothing — an array of numbers already is a vector, and it is stored as one.
What a field may declare is the width: TYPE vector<768>. That is a
declaration, not a value type, and the distinction is the whole of why it was
worth adding. TYPE array is true of a 768-wide embedding and says nothing, so a
512-wide row sits legally beside it and the mistake surfaces only where the
distance functions meet them — as a plausible ordering rather than a complaint,
because a wrong-shaped vector is infinitely far from everything. The width
moves that refusal to the write, where the cause is.
Files keep their bytes in a companion table nothing can name, and their
metadata as ordinary records. So listing a bucket is a query, a file can be the
target of an edge, and count(*) over a bucket counts files rather than chunks.
The one index that is allowed to change an answer
Everywhere above, an index changes what a read costs and never what it answers — value indexes, search indexes and spatial indexes all propose candidates that an exact check then decides.
The vector index cannot make that promise. It is a proximity graph, a search
returns the neighbours a walk found, and a walk cannot show that it missed none.
So the exception is written into the statement rather than hidden in the
catalog: a read is exact unless it says APPROXIMATE.