What happens when a document is indexed in IndentiaDB?

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.

1. High-level conceptual in one paragraph and three points

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.

Core idea: storage = triples in a named graph, coherence = edges + provenance, and everything else (search indexes, vectors, projections) is a rebuildable derived artifact. Throw the search index away and you lose nothing — you rebuild it from the graph.

2. For someone who knows Elastic 90% translatable — plus what Elastic does not have

ElasticIndentiaDB
IndexNamed graph …/sources/{tenant}/{domain} — also the tenant and ACL boundary
Document (JSON in an index)Set of triples around one subject IRI (ctx:ContextSlice)
_idIRI: https://id.indentia.ai/contexts/{tenant}/{domain}/slice/{id} — globally unique, persistent, citable
_source fieldsPredicates: ctx:body, ctx:kind, ctx:confidence, …
dense_vector + kNN/HNSWctx: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 aliasesNamed graphs + current/next embedding profiles
Document-level securityRDF-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.

3. In great detail the complete life of this document, with component references

Phase 0 — Upstream (outside IndentiaDB)

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).

Phase 1 — The gold-bridge worker

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:

Phase 2 — Ingest (the context ingest handler)

POST /context/ingest with the slice batch, in order:

Phase 3 — Physical storage

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.

Phase 4 — ES projection (the ES projection layer)

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:

The coherence in one sentence: the document is an anchor slice, the chunks are slices in a 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.

4. Elastic: index with fields — IndentiaDB: graph with triples the fundamental difference

How Elastic stores it

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, …] }

How IndentiaDB stores the same document

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.

The differences, one by one

ElasticIndentiaDBWhy it matters
Document with fieldsSubject IRI with predicatesThe document has a globally citable identity that also exists outside the "index"
Index as containerNamed graph as containerThe graph is also the tenant and ACL boundary; the same IRI space shares documents, slices, runs and ontology
Relation = shared field valueRelation = 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 securityTriple-level security via RDF-starOne document can carry different ACLs per field/statement
Mapping determines everythingSchemaless quads + per-field derived indexesAdding new predicates costs no reindex
Index = truthIndex = projection of the triplesES index, BM25 shadow columns, HNSW are all rebuildable
_version/_seq_no on the rowes:meta/* as triples, writable only by the serverConcurrency metadata travels with the graph, also to replicas
Overwrite replaces the whole documentOverwrite 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
The heart of the design: the write diff borders exactly on the 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.

5. The Elasticsearch-compatible API letting existing Elastic clients talk to IndentiaDB unchanged

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.

Endpoints

Write path — from _doc to triples and back

PUT /my-index/_doc/42 {"title": "…"} goes like this:

The response is pure ES again: { "_index": "…", "_id": "42", "result": "updated", "_version": 8, "_seq_no": 422, "_primary_term": 3 }.

Search path — from DSL to SurrealQL

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:

ACL — the honest story

The compatibility layer filters post-search: every hit is passed through the codec again and assessed fail-closed against the live 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.

Compatibility limits

The common thread: the compat API is a translation layer, not a storage layer. At the front you speak fluent ES 9.2; on the inside every document becomes triples in a named graph, and the "index" being searched is a rebuildable projection with BM25 and HNSW. That is why the same document is simultaneously an ES hit for Kibana and a node in a graph where SPARQL can reason over provenance, relations and per-triple ACLs — without the two ever overwriting each other.

6. The pipeline in 2D gold topic → slices → embed → triples → INSERT DATA → ES

7. The graph in 2D click a slice to see its triples

document slice (kind=document, authority 0.9) chunk slices (kind=chunk, authority 0.85) pipeline run (PROV) ES projection (derived) RDF-star ACL annotation named graph = tenant/ACL boundary

8. The triples N-Triples as built by the context ingest handler —

9. The graph in 3D drag = rotate · scroll = zoom · hover = triples

Gold sphere = document slice · blue spheres = chunk slices (chained via 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}

10. Glossary all terminology on this page — click to expand

Medallion & pipeline

Medallion architecture

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.

Bronze layer

Raw landing of source data (Pulsar + S3). This is where the pipeline_run_id is minted via publish_bronze_trigger_with_properties.

Silver layer

Structured, transformed data (SQL transformations; transform-complete event).

Gold layer

Curated records, ready for consumption. Published on the Pulsar topic persistent://indentia/medallion/gold. The starting point of everything on this page.

Pulsar / topic

Apache Pulsar, the message broker between pipeline and graph. A "topic" is a named stream; persistent://… means messages are stored durably.

GoldRecord / gold record

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.)

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.

pipeline_run_id

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).

GLiNER / NER

Named Entity Recognition — recognizing people, organizations, amounts in text. Happens upstream (classify worker, GLiNER model), not in IndentiaDB.

RDF & the graph

RDF

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).

Triple (subject–predicate–object)

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.

IRI

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).

Named graph

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.

N-Triples

The simplest RDF serialization format: one triple per line, plain text. The ingest handler literally builds N-Triples lines before they are written.

SPARQL / INSERT DATA

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-star (quoted triple)

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.

PROV / provenance / prov:wasInformedBy

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.

Ontology / T-Box / SHACL

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.

QLever / permutation indexes / builder & index loader

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.

Slices & the ctx: vocabulary

Slice / ctx:ContextSlice

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).

Chunk

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.

ctx:body

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.

ctx:relatedTo

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.

ctx:confidence / ctx:authority

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.

ctx:validFrom / ctx:observedAt (bitemporality)

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?"

ctx:embedding

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.

Embedding / vector space

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.

current/next embedding profile

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.

ctx:mentions → EntityMention → ctx:resolvesTo → CanonicalEntity

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.

ctx:supersedes

"This slice replaces that older slice" — how versions of the same information hang together without erasing history.

Storage & search

SurrealDB / triples table

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.

Projection

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.

BM25

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().

HNSW

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.

kNN

k-Nearest Neighbours: "give the k vectors closest to the query". The semantic search path — finds meaning, not just words.

RRF (Reciprocal Rank Fusion)

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.

Candidate generation

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.

Security & concurrency

ACL / SID / security:allowedSid

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.

RBAC / ABAC

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.

Fail-closed

When in doubt: deny. If the ACL gate cannot assess a hit, it drops out — never the reverse.

Tenant / domain

Tenant = the consuming organizational unit (tweedekamer); domain = the subject area (parl). Together they determine the named graph and thereby the isolation boundary.

OCC (Optimistic Concurrency Control)

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.

version / seq_no / primary_term

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.

Leader / Raft

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.

The ES-compatible layer

ES-compatible API

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.

Codec (RDF codec)

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.

es:field/* vs security:* (INV-5)

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.

es:vectorf32 / rdf:JSON

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.

refresh=wait_for

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.

Single-node illusion

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.

SecurityAnnotationIndex / AclGate

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).