Example document:
Starting point: the gold layer of the medallion (bronze → silver → gold).
This page contains an explanation at three levels, a comparison with Elasticsearch, how the
ES-compatible API works, interactive 2D and 3D visualizations, and a full glossary.
Indexing a document in IndentiaDB is turning a document into knowledge with a birth certificate. A parliamentary paper arrives from the medallion pipeline: in the bronze layer it was a raw file, in the silver layer structured data, in the gold layer a clean, validated record. But it is still a "thing in a folder" — you can retrieve it, but the graph does not yet understand anything about how it hangs together. Indexing does three things at once:
1. The document becomes a graph. The text is cut into meaningful pieces (chunks), and each piece becomes a node with edges: to the document node, to the previous chunk, to the pipeline run that produced it. The document is no longer a blob — it is a small network of related statements.
2. Every statement gets evidence and a lock. Every triple records who says it (provenance: which pipeline run, when), how certain we are (confidence, authority), how long it holds (validity), and who may read it (an ACL that sits on the triple itself, not on a folder around it).
3. It becomes findable in two ways. As a structured fact (SPARQL over triples: "which documents from committee X mention amount Y") and as text/vector (BM25 + embedding, via a derived search index). The graph is the truth; the search index is a cache pointing at it.
| Elastic | IndentiaDB |
|---|---|
| Index | Named graph …/sources/{tenant}/{domain} — also the tenant and ACL boundary |
| Document (JSON in an index) | Set of triples around one subject IRI (ctx:ContextSlice) |
_id | IRI: https://id.indentia.ai/contexts/{tenant}/{domain}/slice/{id} — globally unique, persistent, citable |
_source fields | Predicates: ctx:body, ctx:kind, ctx:confidence, … |
dense_vector + kNN/HNSW | ctx:embedding literal + HNSW index — same idea, but the vector is explicitly a derived projection with a current/next model profile |
| Ingest pipeline (splitter, embedding) | The medallion pipeline (Conflux: parse/dedup/GLiNER) + the gold-bridge worker |
| Index aliases | Named graphs + current/next embedding profiles |
| Document-level security | RDF-star ACLs: << s ctx:body "…" >> security:allowedSid "S-…" — security per triple, finer than per document |
| (does not have it) | Provenance as a first-class citizen: every slice points via prov:wasInformedBy to the pipeline run, and through it back to bronze/silver/gold |
| (does not have it) | Relations as data: ctx:relatedTo between slices is itself a triple — you can reason over it, not just filter on it |
Write-path comparison. Where Elastic stores a document flat on _bulk and
updates inverted indices + HNSW, IndentiaDB does this on POST /context/ingest: the
body is embedded, an N-Triples document is generated, and that goes into the named graph in
one atomic SPARQL INSERT DATA. Only then is a projection written to an
ES-like index for BM25 + kNN.
Read-path comparison. In Elastic the index is the truth: what the query returns, exists.
In IndentiaDB the ES index is only candidate generation — like a knn query
with num_candidates first fetching candidates and then filtering. Hits are
translated back to IRIs, and the RDF read path decides definitively based on the RDF-star
ACLs. Search is never an endpoint but a reference into the graph.
New embedding model? In Elastic: reindex with a new mapping. In IndentiaDB: build
next alongside current, validate, flip the alias. One embedding space per
vector field, never mix models in one HNSW index — and the graph meanwhile simply stays correct
because embeddings were never the source of truth there.
Source → bronze (raw, Pulsar + S3; publish_bronze_trigger_with_properties mints
the pipeline_run_id here) → parse (docling) → dedup (BLAKE2) → classify/NER (GLiNER) →
silver (SQL transformations) → gold. The gold record is published on
persistent://indentia/medallion/gold. The pipeline_run_id is the thread that
will tie everything together (the pipeline-traceability design decision).
The worker consumes the topic and deserializes a GoldRecord:
document_id, title, source_system, modified_at, allowed_sids, chunks, tenant, domain, pipeline_run_id.
gold_to_ingest() maps this to slices — the contract
of the Contextual Graph:
kind=document, authority=0.9, body "document: {title}" — the node that represents the document as a whole.GoldChunk, kind=chunk, authority=0.85.related_to = [doc] + [previous chunk] — a star (everything → document) and a path (chunk₁ → chunk₂ → … → chunkₙ), the structure that makes multi-hop expansion possible later.modified_at becomes both valid_from (when was this true in the world) and observed_at (when did we see it).allowed_sids with prefix S-* are carried over.POST /context/ingest with the slice batch, in order:
authz_guard::require(Permission::Write) plus a cross-tenant guard.ctx_iri::is_valid_segment), because the IRIs land literally in a SPARQL string.…/contexts/tweedekamer/parl/slice/{id}; run node …/lineage/run/tweedekamer/{pipeline_run_id}; target graph …/sources/tweedekamer/parl.svc.embed(&bodies): all bodies in one batch (InferenceRegistryEmbedder in the context store; env IG_CONTEXT_EMBEDDER). Before triple generation, so the vector becomes a literal on the slice.INSERT DATA { GRAPH <…> { … } }. All-or-nothing: there is no state with half a document in the graph.The named graph lands in the triples table of SurrealDB
(the triple store): SCHEMALESS, fields
subject, predicate, object, graph, with a unique
index on (s,p,o,g) plus idx_spo, idx_pos, idx_osp —
the three permutations that make every SPARQL join direction efficient. RDF terms are serialized
as objects with an _rdf_type discriminator (RDF 1.2, incl. quoted triples for
RDF-star). The bitemporal variant (triple_bitemporal) adds tx_start/tx_end
and valid_start/valid_end. In addition there is a second, read-optimized store:
QLever-format mmap permutation indexes (indentiagraph-storage), built offline by
the indentiagraph-builder component, for the standalone SPARQL engine.
Per slice: ensure_index + index_slice → index
context-tweedekamer-parl. This is the search layer: BM25 (shadow column + analyzer) and
HNSW/DiskIVF for vectors. Two hard rules:
ctx:relatedTo chain, every slice points via prov:wasInformedBy
to the same run (and through it back to bronze), every body triple carries an RDF-star ACL, and the
only "real" data are the triples — embeddings, BM25, HNSW and the ES index are projections you can
throw away and rebuild.In Elastic the world is flat and document-centric. An index is a silo of JSON documents; the mapping determines the type per field, and a relation between two documents exists only implicitly — via a shared value in a keyword field. That relation is nowhere data: you cannot attach "since when", "according to which source" or "with what certainty" to it. Document security sits at document level. And the index is the truth.
# Elastic
PUT /parlamentaire-docs/_doc/kv-tk-2019z23964
{ "title": "Parliamentary paper 23964", "body": "Article 1. …",
"commissie": "FVZ", "vec": [0.02, -0.05, …] }
The same JSON document is converted by the RDF codec into a set of triples around one subject IRI, in a named graph:
# IndentiaDB (RDF 1.2 / N-Triples) subject : https://id.indentia.ai/sources/tweedekamer/es/parlamentaire-docs/kv-tk-2019z23964 graph : https://id.indentia.ai/sources/tweedekamer/ <…> rdf:type es:Document <…> es:index "parlamentaire-docs" <…> es:docId "kv-tk-2019z23964" <…> es:tenant "tweedekamer" <…> es:meta/version "7"^^xsd:integer ← optimistic concurrency <…> es:meta/seqNo "421"^^xsd:integer <…> es:field/title "Parliamentary paper 23964"^^xsd:string <…> es:field/body "Article 1. …"^^xsd:string <…> es:field/commissie "FVZ"^^xsd:string <…> es:field/genest "{…}"^^rdf:JSON ← canonical JSON, sorted keys <…> es:vector/vec "aB3f…"^^es:vectorf32 ← base64 little-endian f32 << <…> es:field/body "…" >> security:allowedSid "S-1-5-21-…" ← RDF-star ACL
Physically that lands in the SCHEMALESS triples table with permutation indexes
(idx_spo, idx_pos, idx_osp) — not "one row per document",
but one row per statement, directly joinable in all directions.
| Elastic | IndentiaDB | Why it matters |
|---|---|---|
| Document with fields | Subject IRI with predicates | The document has a globally citable identity that also exists outside the "index" |
| Index as container | Named graph as container | The graph is also the tenant and ACL boundary; the same IRI space shares documents, slices, runs and ontology |
| Relation = shared field value | Relation = triple (ctx:relatedTo, prov:wasInformedBy) | The relation itself is data: you can attach metadata to it (RDF-star) and reason over it with SPARQL |
| Document-level security | Triple-level security via RDF-star | One document can carry different ACLs per field/statement |
| Mapping determines everything | Schemaless quads + per-field derived indexes | Adding new predicates costs no reindex |
| Index = truth | Index = projection of the triples | ES index, BM25 shadow columns, HNSW are all rebuildable |
_version/_seq_no on the row | es:meta/* as triples, writable only by the server | Concurrency metadata travels with the graph, also to replicas |
| Overwrite replaces the whole document | Overwrite replaces only codec predicates (is_codec_predicate, invariant INV-5) | security:* annotations and triples added by others on the same subject survive an ES overwrite |
es:* namespace. An ES client doing PUT /idx/_doc/x replaces
es:field/* and es:meta/* — but a SPARQL client that meanwhile put
<< … >> security:allowedSid … on that same subject notices none of
it. Two worlds, one subject, no collision.The server reports itself as ES 9.2.4 / Lucene 10 and listens on port 9200.
Kibana, Beats, the official SDKs and your own _bulk scripts work unchanged. The
feature lives behind elasticsearch-compat in the ES compatibility layer, started by the CLI.
GET|PUT|DELETE /{index}/_doc/{id}, POST /{index}/_doc (auto-ID), _create, _update, _source, _bulk, _mget — plus native extension /_doc/_subscribe (WebSocket)._search, _count, _msearch, _search/scroll, _validate/query, _explain.PUT|DELETE|GET /{index}, _mapping, _alias(es), _settings, _stats, templates, _refresh, _open/_close, _update_by_query, _delete_by_query, _pit./_cluster/*, /_cat/*, /_nodes, /_xpack, /_license, and full /_security/* (users, roles, role_mappings, api_keys, privileges)./{index}/_cooccurrence/stats (entity co-occurrence — does not exist in ES)._doc to triples and backPUT /my-index/_doc/42 {"title": "…"} goes like this:
DocumentMutation to the Raft leader. One serialization point = no racy writes.doc_to_triples mints the subject IRI, sets identity and OCC triples, and converts every field: scalars → typed literals (lexical form preserved: 1 vs 1.0 do not intermingle), nested objects/arrays → one canonical rdf:JSON literal, dense_vector → base64 f32 literal (es:vectorf32).version or if_seq_no+if_primary_term on the leader; conflict → 409 version_conflict_engine_exception, exactly like ES. Plus a compare-and-swap digest over the codec state against mixed diffs.DELETE DATA (only old codec quads) + INSERT DATA (new ones) in one statement.triples_to_doc) and materialized in the per-index SurrealDB table: the read replica. refresh=wait_for waits until the projection has caught up with the log index._es_ft_title) with BM25 index, HNSW per dense_vector, idx_eq_* B-trees and _es_present_* booleans on filter fields, co-occurrence tracker.The response is pure ES again: { "_index": "…", "_id": "42", "result": "updated", "_version": 8, "_seq_no": 422, "_primary_term": 3 }.
Supported query DSL: match, match_phrase, multi_match,
term(s), range, prefix, wildcard,
regexp, fuzzy, exists, ids, nested,
query_string, bool, dis_max, function_score,
knn, plus retrievers (rrf, linear, pinned,
rescorers). The SearchService picks the best execution form per query:
match → pushdown to SurrealDB's own FULLTEXT index: _es_ft_body @N@ 'term' with search::score(N) — BM25 by SurrealDB itself, ORDER BY _score DESC. Recognized forms are accelerated by Tantivy-WAND or a native fuzzy engine.knn → brute-force cosine, SurrealDB native field <|k,ef|> $vec, or the persistent HNSW engine (indentiagraph-vector); filter clauses come along in the WHERE.query + knn together → hybrid: both run over a rank window and are fused with RRF (1/(k + rank)).term, terms, range, exists) → SurrealQL WHERE, served by idx_eq_* indexes and presence booleans._es_*/_acl_* fields stripped, _id from the record id, _score from BM25/cosine/RRF, with hits.total{value, relation}.SecurityAnnotationIndex
(the RDF-star ACLs); forbidden documents drop out, forbidden fields are redacted. In principle this
leaks via candidate membership and scores — that is why the production posture is hard: with
IG_PRODUCTION=true and the gate active, non-admin _search is 403
(plus scroll, retrievers, kNN, aggregations, suggest), until pre-filtering lands. Filter fields such as
allowed_principals and source_system do have B-tree indexes, so a
caller that sends them itself as bool.filter stays index-servable._shards always reports 1 shard; cluster APIs are fabrications; no real shards/replicas/cross-cluster search.version_type only internal, only painless scripts, one fixed analyzer chain (per-field analyzers are ignored), aggregations are a subset (no geo/significant_terms/composite), no percolator/more_like_this/span/suggesters/snapshots.retry_on_conflict must be 0 — concurrency runs via the leader, not via retries.ctx:relatedTo) ·
purple octahedron = prov:wasInformedBy run · green cluster = Elasticsearch projection (BM25 + kNN, derived) ·
point cloud on the right = vector space (ctx:embedding, HNSW) · red diamond = RDF-star ACL ·
enclosing shell = named graph …/sources/{tenant}/{domain}Data architecture pattern with three layers — bronze (raw), silver (structured), gold (curated) — where each layer refines the previous one. At Indentia it is implemented by Conflux (external); IndentiaDB picks it up from gold onwards.
Raw landing of source data (Pulsar + S3). This is where the pipeline_run_id is minted via publish_bronze_trigger_with_properties.
Structured, transformed data (SQL transformations; transform-complete event).
Curated records, ready for consumption. Published on the Pulsar topic persistent://indentia/medallion/gold. The starting point of everything on this page.
Apache Pulsar, the message broker between pipeline and graph. A "topic" is a named stream; persistent://… means messages are stored durably.
The message from the gold topic: document_id, title, source_system, modified_at, allowed_sids, chunks[], tenant, domain, pipeline_run_id. (Deserialized by the gold-bridge worker.)
The bridge from medallion to graph. Consumes the gold topic and translates every gold record with gold_to_ingest() into slices, which it POSTs to /context/ingest.
Unique ID of one pipeline pass, minted in bronze and carried end-to-end. The key to traceability: from every triple back to the raw source file (the pipeline-traceability design decision).
Named Entity Recognition — recognizing people, organizations, amounts in text. Happens upstream (classify worker, GLiNER model), not in IndentiaDB.
Resource Description Framework — the W3C-standard data model in which all knowledge is expressed as triples. Version 1.2 adds quoted triples (see RDF-star).
The smallest statement: <chunk-c2> ctx:kind "chunk". Subject = what it is about, predicate = which property/relation, object = the value or target. All storage in IndentiaDB is triples; there is no table structure like in SQL or Elastic.
Internationalized Resource Identifier — a globally unique, persistent name for a thing, e.g. https://id.indentia.ai/contexts/tweedekamer/parl/slice/c2. The equivalent of Elastic's _id, but citable worldwide. Authority: https://id.indentia.ai (the IRI-authority design decision).
A named collection of triples within the store — the fourth dimension alongside s/p/o (hence "quads"). In IndentiaDB the named graph is the container and the tenant/ACL boundary: …/sources/{tenant}/{domain}. Comparable to an Elastic index, but as part of one continuous graph.
The simplest RDF serialization format: one triple per line, plain text. The ingest handler literally builds N-Triples lines before they are written.
The query and update language for RDF (what SQL is for relational). INSERT DATA { GRAPH <g> { … } } writes triples into a named graph — at ingest this happens in one atomic statement: all-or-nothing.
RDF 1.2 extension: a triple can itself become the subject of another triple: << <s> ctx:body "…" >> security:allowedSid "S-…". This is how we annotate a statement (ACL, certainty, origin) without extra nodes. The basis of triple-level security.
W3C PROV, the standard ontology for provenance. prov:wasInformedBy says "this slice resulted from that pipeline run". Provenance here is not a log line but data in the graph, queryable with SPARQL.
The ontology (T-Box) defines which classes and predicates exist (ctx:ContextSlice, ctx:body, …). SHACL shapes are validation rules the data (A-Box) must satisfy. See the Contextual Graph ontology.
Besides SurrealDB there is a second, read-optimized store in QLever format: memory-mapped indexes holding triples in three sort orders (SPO/POS/OSP). The indentiagraph-builder component builds them offline; the index loader loads them for the standalone SPARQL engine.
The basic unit of the Contextual Graph: a piece of text with metadata as a node in the graph. Two flavors: the anchor slice (kind=document, represents the whole document) and chunk slices (kind=chunk, one per text part).
A meaningful text part of a document (paragraph, article). Determined upstream by the pipeline; here every chunk lands as its own slice with its own IRI, embedding and ACL.
The literal text of the slice. This is the triple the RDF-star ACL sits on: who may read the body is recorded on the body statement itself.
The relation edge between slices. Every chunk points to the anchor slice and to the previous chunk — creating a star + chain, the structure over which multi-hop retrieval (QAFD expansion) later runs.
How certain is this statement (0–1) and how authoritative is the source (0–1). Anchor slices get authority 0.9, chunks 0.85. Used in ranking and conflict resolution between slices.
Two time dimensions: valid time = when was this true in reality (the date of the parliamentary paper), transaction/observation time = when did it enter our system. So you can ask "what did we know on January 1 about 2019?"
The vector (list of numbers) capturing the meaning of the text, stored as a literal on the slice. Produced by the configured embedder (IG_CONTEXT_EMBEDDER) in one batch before the write.
A numeric projection of text where semantic proximity = distance proximity. "Motion about healthcare" and "amendment about personal contribution" lie close together, even without shared words.
Model rotation discipline (the embedding current/next design decision): current and next are logical aliases to versioned vector fields/indexes. A new model is populated and validated as next; then the alias flips. Never mix two models in one index.
The hook for entities: a slice mentions an entity (mention), which resolves to one canonical entity ("Mark Rutte" and "Prime Minister Rutte" → the same node). The extraction itself lands once the document lane gets extraction consumers.
"This slice replaces that older slice" — how versions of the same information hang together without erasing history.
The primary online store (vendored SurrealDB). The SCHEMALESS triples table holds quads (subject, predicate, object, graph) with a unique index on (s,p,o,g) plus idx_spo/idx_pos/idx_osp so every join direction is fast.
A derived, rebuildable copy in another format: the ES index, BM25 shadow columns, HNSW indexes, LPG graph. Rule number one: the triples are the truth; projections you can throw away and rebuild.
The classic ranking algorithm for text search (term frequency vs. document length). Runs on FULLTEXT shadow columns (_es_ft_<field>) with SurrealDB's own search::score().
Hierarchical Navigable Small World — a graph index for fast nearest-neighbor lookups in vector space. In IndentiaDB as a native SurrealDB index or as an in-Rust engine (indentiagraph-vector); DiskIVF is the disk-based variant.
k-Nearest Neighbours: "give the k vectors closest to the query". The semantic search path — finds meaning, not just words.
Method to fuse BM25 and kNN results: every hit scores 1/(k + rank) per list; the sums determine the final rank. This is how the hybrid search query + knn works.
The principle that the ES index only fetches candidates quickly (BM25/kNN), after which the RDF read path decides definitively (ACL, reconstruction). Search is a reference, not an endpoint.
Access Control List; SID = Security Identifier (e.g. S-1-5-21-…, Azure AD/SDDL style). Via RDF-star it is recorded per triple which SIDs may read it. Only SIDs with prefix S-* are carried over from the gold record.
Role-Based (rights via roles) and Attribute-Based (rights via attributes such as tenant, security_class) access control. The ES compat layer has ES-style security tables (users, roles, api_keys, privileges); the graph itself enforces via the RDF-star annotations.
When in doubt: deny. If the ACL gate cannot assess a hit, it drops out — never the reverse.
Tenant = the consuming organizational unit (tweedekamer); domain = the subject area (parl). Together they determine the named graph and thereby the isolation boundary.
Writing without locks: you state which version you read (version or if_seq_no+if_primary_term); if it has changed since, then 409 version_conflict_engine_exception. Identical to Elastic's model.
Elastic's three concurrency counters: per document (version), per shard order (seq_no), per leader term (primary_term). In IndentiaDB they live as es:meta/* triples on the subject — writable only by the server.
In RDF-source-of-truth mode, writes run via one Raft leader: one serialization point, deterministic order, no racy writes. Followers forward writes to the leader.
A translation layer that mimics the Elasticsearch REST API (9.2.4) on port 9200, so Kibana/SDKs/existing scripts work unchanged — while the data lives as RDF triples in the graph. Translation layer, not a storage layer.
The bidirectional translator JSON document ↔ triples: doc_to_triples on write, triples_to_doc on read. Also defines which predicates are "codec-owned" (es:*) — and therefore which an ES overwrite may and may not replace.
The write diff on an overwrite covers only codec predicates (es:field/*, es:meta/*). Annotations added by the graph (security:*, provenance) on the same subject fall outside the deletion set and survive every ES overwrite. Invariant INV-5.
Datatypes for fields that are not scalars: dense_vector → base64 little-endian f32 literal; nested objects/arrays → one canonical JSON literal (sorted keys) so reconstruction is lossless.
Elastic behavior where the write response waits until the change is visible to searchers. Here: waiting until the projection has caught up with the Raft log index.
The compat layer pretends there is one node/shard: _shards always reports 1, cluster APIs are fabrications for Kibana. Replication happens under water via Raft, not via ES shards.
The live index of RDF-star ACLs against which every search hit is assessed fail-closed (post-search filtering, with hard production rejection for non-admin _search until pre-filtering lands).