⚙️ Understanding the Retrieval Pipeline
Know what happens between
POST /ingestandSELECT. This page explains the internal pipeline so you can reason about latency, eventual consistency windows, and why your search results appear 2–3 seconds after ingest.
The big picture
WRITE PATH (milliseconds) READ PATH (milliseconds)
POST /ingest
│
├─ validate purpose
├─ WAL-ahead (fsync) ← durability
├─ store.insert (bi-temporal row)
├─ index_secondary (sync: equality)
├─ index_row_embeddings (sync: vector)
├─ enqueue_pending_fts (deferred: BM25)
│
│ ~1–5ms ack returned to client
│
│ ┌── background (every 2s) ──┐
│ │ flush_pending_fts │
│ │ → BM25 posting lists built │
│ │ → block-max WAND updated │
│ │ │
│ │ HNSW vector insert │ POST /query
│ │ → graph adjacency updated │ │
│ │ → identity index linked │ ├─ Planner: parse + purpose + ACL
│ └────────────────────────────┘ ├─ Executor: scan/filter/aggregate
│ ├─ Index lookup: BM25 / HNSW / identity
▼ ├─ ACL: bitmap filter + cell masking
Rows queryable via SQL (~instant) ├─ Result cache (hit → skip execution)
Rows searchable via HYBRID_SEARCH (~2–3s) ▼
JSON / Arrow / SSE response
Write path — what happens when you ingest
Step 1: Accept and acknowledge (~1ms)
ingest("Person", [{"id": "alice", "name": "Alice Chen", "phone": "+14155550100"}])The handler:
- Validates PURPOSE — is "analytics" registered? Reject if not.
- Validates schema — ontology check (is
phonea valid field for Person?). - Pushes to
IngestQueue— returns{rows_queued: 1, task_id: "itsk_..."}immediately.
The ack is a queue-ack, not a confirmed-write count. The background writer hasn't run yet. Poll
GET /ingest/tasks/:task_idforrows_writtenvsrows_dropped.
Step 2: Background writer drain (every 10ms)
A dedicated ingest-writer task (under supervise for crash recovery) drains the queue:
build_pre_batches— governance (ontology validation) + WAL line serialization. Runs acrossrayon(one thread per object type).flush_confirmed_to_wal— WAL-ahead durability: every batch is written + fsynced towal.jsonlBEFORE any store mutation. Crash here = clean replay, no data loss.resolve_existing_for_drain— fresh identity lookup at drain time (#3824 fix). Determines insert-vs-update.store.insert_with_tenant— writes the bi-temporal row under a per-type read guard (interior locking,&self).
Step 3: Synchronous indexing (during insert)
These indexes are built synchronously — they're available for queries immediately after the writer drains:
| Index | Built by | Used by | Available |
|---|---|---|---|
| Secondary equality index | index_secondary() | WHERE field = 'value', LOOKUP_IDENTITY | ✅ Immediately after drain |
| Range index | index_secondary() (range variant) | WHERE field > N, ORDER BY | ✅ Immediately after drain |
| Vector bucket assignment | index_row_embeddings() | SIMILAR TO, HYBRID_SEARCH vector channel | ✅ Immediately (but HNSW graph builds async) |
| Tenant live index | scan_all_for_tenant scoping | Per-org row visibility | ✅ Immediately after drain |
Step 4: Deferred indexing (background, every 2s)
A separate index-work-drain task runs every 2 seconds:
| Index | Built by | What it does | Latency |
|---|---|---|---|
| BM25 / Full-Text | flush_pending_fts() | Tokenize → stem → posting list → block-max WAND | 2–4s after ingest |
| HNSW vector graph | flush_pending_secondary_and_embeddings() | Insert into hierarchical navigable small-world graph | 2–4s after ingest |
| Graph CSR | Enrichment worker | Build adjacency lists for PATHS_BETWEEN | 2–4s after ingest |
| Identity index | Enrichment worker | Link detected canonical IDs to entity cluster | 2–4s after ingest |
This is why HYBRID_SEARCH has a 2–3 second eventual-consistency window. SQL
SELECTandWHEREwork immediately (they use the synchronous secondary index). But BM25 full-text search and vector similarity need the background drain to build their indexes. This is by design — the ingest hot path stays fast (WAL + store write), and indexing happens off-thread.
Read path — what happens when you query
Step 1: Planner (parse + validate + plan)
query("SELECT name FROM Person WHERE company = 'Acme' AND risk = 'HIGH'")- Parse — SQL → AST (the restricted Relata grammar).
- PURPOSE check — is the declared purpose registered? Strict mode rejects unknown purposes; open mode accepts any non-empty string.
- ACL precompute — builds a bitmap: for this principal + this object type, which rows are visible? Compiled once, reused across the scan.
- Plan cache — LRU keyed by
(SQL template, principal, purpose, ontology version). A cache hit skips parsing + ACL precompute entirely.
Step 2: Executor (scan + filter + project)
The executor dispatches on query type:
SELECT path
scan_as_of_with_limit_and_bloom
├─ Bloom filter pushdown — skip segments that provably can't match WHERE
├─ Range index pruning — skip segments outside the WHERE range
├─ Stream live rows — bi-temporal filter: system_to == NOW (current version only)
├─ ACL bitmap filter — deny-wins: if any policy denies, row is excluded
├─ CellFilter — mask/redact individual columns per policy
└─ Projection + LIMIT — only send requested columns, stop at N rows
Bloom + range pushdown (#3895) is why
WHERE id = 'alice'on a million-row table returns in <1ms — the scan skips 99% of segments without reading them.
Aggregate path (GROUP BY, SUM, AVG)
execute_grouped_columnar_pass
├─ Scan → typed ColumnBatch (columnar, not row-by-row)
├─ DataFusion bridge — vectorized hash aggregation
└─ Merge partial results across shards (cluster mode)
The columnar aggregate path is ~1.3× faster than row-by-row for filter-free GROUP BY (#984). For filtered aggregates, the row path runs first, then the columnar pass aggregates the filtered subset.
HYBRID_SEARCH path
execute_hybrid_search
├─ BM25 channel: FullTextIndex.search → WAND scoring → top-K candidates
├─ Vector channel: HNSW.graph_search → cosine similarity → top-K candidates
├─ Identity channel: IdentityIndex lookup → canonical matches
└─ Reciprocal Rank Fusion (RRF): merge 3 ranked lists → unified top-K
RRF doesn't need score calibration between channels — it uses rank positions only. This is why BM25 (exact keyword) and vector (semantic) fuse well despite having completely different score scales.
Other operators
| Operator | Index used | Complexity |
|---|---|---|
LOOKUP_IDENTITY | IdentityIndex (hash map) | O(1) |
PATHS_BETWEEN | Graph CSR + PLL | O(E) per hop |
SIMILAR TO | HNSW | O(log N) |
RESOLVE_IDENTITY | IdentityIndex | O(cluster size) |
Step 3: Result cache
Before execution, the planner checks the result cache:
- Key:
(SQL template, params, principal, purpose, tenant, ontology version) - Hit: return cached rows instantly (sub-millisecond)
- Miss: execute → cache the result
- Invalidation: on any ingest to a type in the query's FROM clause
The cache is tenant-scoped — one tenant's cached results are never served to another. And it's ontology-versioned — a schema change (ALTER TYPE) invalidates all cached plans for that type.
Latency cheat sheet
| Operation | When available after ingest | Typical p50 |
|---|---|---|
SELECT * FROM Person | Immediate (after drain, ~10ms) | <1ms |
WHERE id = 'alice' | Immediate (secondary index) | <1ms |
WHERE company = 'Acme' | Immediate (secondary index) | 1–5ms |
COUNT(*) FROM Person | Immediate | <1ms |
GROUP BY company | Immediate (columnar aggregate) | 2–10ms |
HYBRID_SEARCH FROM Doc | 2–3s delay (BM25 builds async) | 1–5ms (once indexed) |
SIMILAR TO Person | 2–3s delay (HNSW builds async) | <1ms (once indexed) |
LOOKUP_IDENTITY | Immediate (identity index is sync) | <1ms |
PATHS_BETWEEN | 2–3s delay (graph CSR builds async) | 1–10ms (once indexed) |
recall (memory) | Immediate (scoped FTS flush) | 0.5–2ms |
Why this design?
The 2-second indexing window is deliberate. The alternative — indexing synchronously on the ingest hot path — would make every write pay the BM25 + HNSW + graph build cost. That's what caused the 107-second remember() stall (#3794) before the fix: the FTS block-max rebuild was O(n²) and ran inline.
By deferring indexing to a background drain:
- Ingest stays fast (WAL + store write only, ~1–5ms)
- Indexing is batched (one drain builds many rows' indexes at once)
- The hot path doesn't block on index build latency
- Crash recovery is clean (WAL replay + drain rebuilds any lost indexes)
The trade-off: eventual consistency for search (2–3s). SQL queries on indexed fields are immediate. This matches the operational reality — you ingest a batch, then search a few seconds later, not in the same millisecond.
Monitoring the pipeline
# Queue depth — how many batches are waiting to drain
status = httpx.get(f"{BASE}/status", headers=H).json()
print(f"Queue depth: {status['queue_depth']}")
# Metrics — indexing lag
metrics = httpx.get(f"{BASE}/metrics", headers=H).text
# Look for:
# relata_ingest_rows_total
# relata_wal_fsync_total
# relata_wal_fsync_latency_seconds_sum
# relata_pending_fts_count (should trend to 0)
# relata_pending_index_work_count
# Health — is the drain task alive?
health = httpx.get(f"{BASE}/health/ready", headers=H).json()
# worker_health: {ingest-writer: "alive", index-work-drain: "alive"}If queue_depth is climbing or pending_fts_count isn't trending to zero, the background tasks are falling behind — check CPU and disk I/O.
Key takeaways
- SQL queries (SELECT, WHERE, COUNT, GROUP BY) are immediately consistent — secondary indexes build synchronously.
- Search (HYBRID_SEARCH, SIMILAR TO) has a 2–3 second eventual-consistency window — BM25 and HNSW build on the background drain.
- Identity resolution (LOOKUP_IDENTITY) is immediate — the identity index is synchronous.
- The result cache is tenant-scoped and ontology-versioned — safe, fast, self-invalidating.
- The WAL-ahead design means zero data loss on crash — replay rebuilds the store + indexes.
- All governance (PURPOSE, ACL, cell masking) is in the read path — not bolted on after.
Next: Hybrid Search — use the indexes this pipeline builds.
Every detail on this page is verified against the RelataDB source code. The pipeline is implemented, not designed.