Skip to main content
Version: 0.3.36

Indexing

RaisinDB maintains its indexes automatically as nodes are written. Most queries need no declaration at all. Compound indexes are declared on a workspace or a NodeType (and one, for folder listings by creation time, is built in); the search indexes are declared per property on the NodeType.

Types of indexes​

IndexAnswersDeclared
Path indexpath = …, CHILD_OF, DESCENDANT_OF, PATH_STARTS_WITHautomatic
Property indexproperties->>'k' = v, node_type = …, ORDER BY created_atautomatic for every top-level property
Built-in folder indexCHILD_OF(…) ORDER BY created_at [DESC]automatic on every workspace (opt-out)
Compound indexseveral equalities plus an ORDER BY, in one scancompound_indexes on the workspace, or COMPOUND_INDEX on the NodeType
Full-textFULLTEXT_SEARCH, FULLTEXT_MATCHindex: [Fulltext] per property
VectorKNN, HYBRID_SEARCHindex: [Vector] per property plus an embedding provider
SpatialST_DWITHIN, ST_DISTANCE orderingautomatic for geometry values

EXPLAIN shows which one a query uses.

Path index​

Every node's path is indexed, and hierarchy predicates become prefix scans:

EXPLAIN SELECT path FROM 'blog' WHERE CHILD_OF('/posts');
-- PrefixScan: prefix=/posts/

CHILD_OF with no ORDER BY, or with ORDER BY __order, returns the children in their editorial order. Ordered by creation time, it is served by the built-in folder index instead.

Property index​

Each top-level property of every node is written to the property index as a hash of its value, keyed by property name. That gives equality lookups on any property without declaring anything:

EXPLAIN SELECT path FROM 'blog' WHERE properties->>'status' = 'published';
-- PropertyIndexScan: status=published

The same index holds a few system fields: node_type, name, archetype, created_by, updated_by, and the two timestamps. The timestamps are stored in sortable form, so ORDER BY created_at DESC LIMIT n is served in index order without sorting:

EXPLAIN SELECT path FROM 'blog' ORDER BY created_at DESC LIMIT 10;
-- PropertyOrderScan: __created_at DESC limit_hint=10

Because values are hashed, the property index answers equality only. A LIKE, a range, or a comparison on a cast key is evaluated row by row on top of whichever index narrows the scan (a path prefix, a node_type, or a compound index).

Index entries are versioned with the revision that wrote them, and draft and published states are kept apart, so a time-travel query or a published-only read sees exactly the entries that were live at that point.

Compound indexes​

A compound index stores several property values in one key, in a declared order, so a query that filters on all of them and sorts on the last can read its result as one pre-sorted slice.

The problem they solve​

SELECT path FROM 'feed'
WHERE properties->>'category' = 'tech'
AND properties->>'status' = 'published'
ORDER BY created_at DESC
LIMIT 10;

With only the property index, the planner picks the more selective of the two equalities, filters the rest, and sorts. With a compound index on (category, status, __created_at DESC) it seeks to tech / published and reads ten entries that are already in the right order.

Who owns an index​

A compound index is declared on a workspace or on a NodeType, and the owner decides which nodes it holds and which queries may use it:

OwnerHoldsServes
Workspace (compound_indexes on the workspace)every node of the workspace, whatever its typeany query on that workspace, typed or untyped
NodeType (COMPOUND_INDEX / compound_indexes on the type)only nodes of that type (and its subtypes)only queries that name the type: node_type = '…' or IS_A('…')

A NodeType index is never used for a query that does not name its type: it would silently miss every node of another type, so the planner scans instead. That is why a folder listing, which usually names no type, needs a workspace index (or the built-in one below).

Built-in folder index​

Every workspace carries a built-in index on (__parent_path, __created_at), so a folder listing by creation time is index-served without declaring anything: newest or oldest first, with or without LIMIT, typed or untyped, with or without other predicates (they are applied as filters on top).

EXPLAIN SELECT id, name FROM 'blog'
WHERE CHILD_OF('/posts') ORDER BY created_at DESC LIMIT 20;
-- CompoundIndexScan: @__children_by_created_at [__parent_path=/posts] index-order limit_hint=20 (owner: workspace blog)

It costs one index entry per node, written on create, re-keyed on move and ended on delete; an update that changes neither the parent nor created_at writes nothing to it (measured at about 1.3% of create throughput). It does not serve DESCENDANT_OF (a subtree is a path range, not one parent), and it does not replace editorial order: CHILD_OF with no ORDER BY or with ORDER BY __order keeps the ordered-children index. There is no built-in updated_at index, because it would be rewritten on every update; declare a workspace index on (__parent_path, __updated_at) if you need one, and accept that every update then writes it.

The index is built in the background on every server, branch by branch, by the compound_builds repair (see When the planner uses it). Until it is built on a server, the listing scans there and returns the same rows more slowly. Nodes written by a very old release may lack created_at; while a branch of a workspace holds one, the built-in index is not marked ready there and listings keep scanning, correctly, until those nodes are rewritten. An index you declare yourself that serves the query as well or better is preferred over the built-in one.

A workspace that never lists folders by creation time can switch it off in its configuration, in YAML or through the workspace API:

name: audit_log
config:
builtin_indexes:
children_by_created_at: false

or over SQL (NULL restores the default, on):

UPDATE Workspaces
SET builtin_indexes = '{"children_by_created_at": false}'::jsonb
WHERE name = 'audit_log';

Switching it off takes effect at once (the planner stops using it) and the background job then deletes its entries; switching it back on builds it again. SELECT builtin_indexes FROM Workspaces shows the switches in force, defaults included. Index names starting with __ are reserved for built-in indexes.

Declaring one on a workspace​

Declare a workspace index under compound_indexes in the workspace YAML (workspaces/<name>.yaml in a package) or in the body of PUT /api/workspaces/{repo}/{name}:

name: blog
compound_indexes:
- name: folder_by_status
columns:
- property: __parent_path
column_type: String
- property: status
column_type: String
- property: __created_at
column_type: Timestamp
has_order_column: true

or over SQL, with the same JSON shape:

UPDATE Workspaces
SET compound_indexes = '[{"name":"folder_by_status","columns":[
{"property":"__parent_path","column_type":"String"},
{"property":"status","column_type":"String"},
{"property":"__created_at","column_type":"Timestamp"}],
"has_order_column":true}]'::jsonb
WHERE name = 'blog';
SELECT id, name FROM 'blog'
WHERE CHILD_OF('/posts') AND properties->>'status'::String = 'published'
ORDER BY created_at DESC LIMIT 20; -- every child, whatever its type
-- CompoundIndexScan: @folder_by_status [...] (owner: workspace blog)

A workspace index is stored as @<name>, in its own keyspace, so a NodeType index of the same name never shares entries with it; NodeType index names may not start with @. There is no CREATE/ALTER DDL for workspace indexes; the compound_indexes column of the Workspaces table is the SQL surface.

A workspace index pinned to a parent (__parent_path among its equality columns) is not used for an editorial listing (CHILD_OF with no ORDER BY, or ORDER BY __order) unless it serves the ORDER BY, so the order editors arranged is kept.

Declaring one on a NodeType​

CREATE NODETYPE 'app:Post' PROPERTIES (
category String REQUIRED,
status String,
author_id String
)
COMPOUND_INDEX 'idx_category_status_created' ON (category, status, __created_at DESC)
COMPOUND_INDEX 'idx_author_created' ON (author_id, __created_at DESC);

Each index has a name that is unique within the type, an ordered list of columns, and an optional ASC or DESC per column. Add one to an existing type with ALTER NODETYPE 'app:Post' ADD COMPOUND_INDEX 'name' ON (...), or declare it in YAML under compound_indexes. Queries must name the type to use it:

SELECT path FROM 'feed'
WHERE node_type = 'app:Post'
AND properties->>'category'::String = 'tech'
AND properties->>'status'::String = 'published'
ORDER BY created_at DESC LIMIT 10;

Columns are property names, or one of the system fields __created_at, __updated_at, __node_type and __parent_path. In the query you still write created_at; the planner maps it to the index column. __order is not an index column.

Column order matters​

The planner matches a leading prefix of the equality columns. For (category, status, __created_at DESC) (on a NodeType index, each query also names the type):

QueryUses the indexSorted by the index
category = 'tech' AND status = 'pub' ORDER BY created_at DESCyesyes
category = 'tech' AND status = 'pub'yes
category = 'tech' ORDER BY created_at DESCpartial: category onlyno, sorted afterwards
status = 'pub'no

Put equality columns first, most selective first, and the sort column last. Only a full match on every equality column lets the planner trust the index order and skip the sort.

Timestamp direction​

__created_at DESC stores the newest entry first, which is what feeds and "latest" views read. ASC stores oldest first for timelines and queues. The direction is encoded in the key, so it costs nothing at query time.

When the planner uses it​

A compound index is used only after its build has completed and been recorded for the branch on that server; until then queries take the property index or scan, with the same results, and EXPLAIN shows it:

PropertyIndexScan: category=tech

Declaring or changing an index queues its build on every server. The build runs as a background job: it scans the existing nodes (the type's, or the whole workspace's) in batches, writes their entries, and skips any node that lacks one of the index's equality columns. New writes are indexed inline from the start. When the index is in use, EXPLAIN prints CompoundIndexScan with the index name, index-order or reverse-index-order for the direction it reads, and its owner:

CompoundIndexScan: @folder_by_status [...] index-order limit_hint=20 (owner: workspace blog)
CompoundIndexScan: idx_category_status_created [...] index-order limit_hint=10 (owner: node type app:Post)

When several indexes fit a query, the planner prefers the one matching more equality columns, then one that serves the ORDER BY, then one you declared over the built-in one; an index that is not built yet never hides one that is.

The compound_builds repair keeps compound indexes built without anyone asking. It runs on every server about two minutes after startup, one branch at a time, paced and only with enough free disk (twice the compound index's size), and again after a checkpoint is ingested. It builds the built-in folder index, drops the entries of one switched off or of a removed workspace index, and rebuilds every index built by an older release (October 2026, the localized-paths release, changed the format). Set RAISIN_COMPOUND_FORMAT_REBUILD=0 to leave those older indexes to a manual rebuild; they scan until then. Its progress is reported by the Index Repairs API. See also Upgrading.

Like the property index, compound entries are versioned by revision and split into draft and published sets.

Practical examples​

Social feed​

CREATE NODETYPE 'social:Post' PROPERTIES (
author_id String REQUIRED,
visibility String
)
COMPOUND_INDEX 'idx_author_feed' ON (author_id, visibility, __created_at DESC);

-- latest 20 public posts by a user
SELECT path FROM 'feed'
WHERE node_type = 'social:Post'
AND properties->>'author_id'::String = $1
AND properties->>'visibility'::String = 'public'
ORDER BY created_at DESC
LIMIT 20;

Catalog​

CREATE NODETYPE 'shop:Product' PROPERTIES (
category String REQUIRED,
in_stock Boolean,
price Number
)
COMPOUND_INDEX 'idx_category_stock_price' ON (category, in_stock, price ASC);

-- cheapest in-stock electronics
SELECT path FROM 'catalog'
WHERE node_type = 'shop:Product'
AND properties->>'category'::String = 'electronics'
AND properties->>'in_stock'::String = 'true'
ORDER BY properties->>'price'::Double ASC
LIMIT 50;

Newest posts in a folder​

No declaration needed; the built-in folder index serves it:

SELECT id, name FROM 'blog'
WHERE CHILD_OF($1)
ORDER BY created_at DESC
LIMIT 20;

Activity log​

CREATE NODETYPE 'app:Activity' PROPERTIES (
tenant String REQUIRED,
action String REQUIRED
)
COMPOUND_INDEX 'idx_tenant_activity' ON (tenant, __created_at DESC);

SELECT path FROM 'activity'
WHERE node_type = 'app:Activity'
AND properties->>'tenant'::String = $1
ORDER BY created_at DESC
LIMIT 100;

Full-text and vector​

Both are declared per property with the index list, and both are rebuilt locally on every node of a cluster rather than replicated:

properties:
- name: title
type: String
index: [Fulltext, Vector]

See Full-Text Search and Vector Search.

Next Steps​