Ingest Pipeline¶
Every memory in uniko starts as a Message. The ingest pipeline is the path that turns one raw turn — text, code, structured data — into a durable, queryable subgraph: the Message itself, its Chunks for vector search, the Entities it mentions, and the Observations it asserts.
The design problem it solves is fidelity under failure and concurrency. An earlier three-step path could persist a Message and then fail before its entities landed, leaving a half-state ("Message persisted with no entities") in the graph. The current path folds all CPU work and read-only lookups before a single transaction opens, then writes everything at once. Failure is all-or-nothing; re-ingest is idempotent.
flowchart TD
M[IngestMessage] --> ID{message_id<br/>already in graph?}
ID -->|yes| SKIP[Return existing node id<br/>no writes]
ID -->|no| SS[Ensure Session + sender Participant]
SS --> EX[CPU extraction — no DB I/O]
subgraph EX[CPU extraction]
direction TB
NER[P2 NER<br/>rules + code AST + ONNX cascade] --> DD[dedup + suppress overlaps]
NLP[NLP cascade<br/>POS / NER / DEP / CLS / SRL] --> NER
NLP --> OBSP[P3 observation prep<br/>SRL frames + YAML rules]
end
EX --> PREP[Resolve canonical entity ids<br/>read-only lookups]
PREP --> LOCK[Acquire per-entity RMW locks]
LOCK --> TX
subgraph TX[Single transaction]
direction TB
WM[Write Message + edges + Chunks] --> WE[Apply Entities + MENTIONS]
WE --> WO[Apply Observations + OBSERVED_IN + ABOUT]
end
TX --> COMMIT{commit}
COMMIT -->|retriable SSI conflict| LOCK
COMMIT -->|ok| DONE[AtomicIngestResult]
The whole flow lives in crate::ingest::atomic::ingest_message_atomic, which takes a
&KnowledgeBase, an &IngestMessage, and a &mut SessionContext, and returns an
AtomicIngestResult. The facade's session.observe(Turn::new(...)) wraps this — the
AtomicIngestResult it produces is returned as ObserveResult.message.
use uniko_extract::ingest::atomic::ingest_message_atomic;
let result = ingest_message_atomic(kb, &msg, &mut session_ctx).await?;
// result.message_node_id, result.chunk_node_ids,
// result.extracted_entities, result.extracted_observations, ...
P1 — Atomic ingest¶
ingest_message_atomic is the single per-message entry point. It performs four things in
order: an idempotency check, session/sender setup, CPU extraction, and one transactional
write.
Idempotency on message_id¶
The first thing the function does is look up the Message by its external id:
if let Some((existing_id, _)) = kb
.get_node_by_ext_id("Message", "message_id", &msg.message_id)
.await?
{
// Return the existing node id; no entities, observations, or chunks.
}
On a re-ingest the call returns an AtomicIngestResult whose chunk_node_ids,
extracted_entities, and extracted_observations are empty and whose sender is None.
Re-running the same conversation is therefore safe and cheap — duplicate Messages are never
created.
All-or-nothing writes¶
All CPU work (NLP, NER, SRL, dedup) and read-only DB lookups happen before the
transaction opens. A single transaction then writes the Message, its edges and Chunks, the
Entities and MENTIONS edges, and the Observations with OBSERVED_IN and ABOUT edges,
and commits once.
Failure semantics
Any error during extraction or in-transaction writes aborts the call; the transaction (if opened) is dropped without commit. No partial state persists for that message — the legacy "Message persisted with no entities" half-state cannot occur on this path.
Concurrency: per-entity locks and retry¶
entity_id is non-unique in uni-db, so two concurrent ingests of the same entity could
both read "absent" and both create a duplicate row. To prevent that, the canonical entity
ids are computed first (prepare_entity_upsert), then the per-entity read-modify-write
locks are acquired before the transaction opens and held across the commit:
let entity_prep = prepare_entity_upsert(kb, deduped).await?;
let _entity_guards = kb.lock_entity_ids(&entity_prep.entity_ids).await;
The transaction body is wrapped in a retry loop driven by
uniko_store::RetryOptions::default(). A retriable uni-db SSI (serializable snapshot
isolation) conflict aborts the whole transaction — nothing persists — and a fresh attempt
re-reads entity existence authoritatively and recreates the rolled-back rows, so retries
never produce duplicates. The entity locks are held across every attempt.
Where session/sender setup commits
ensure_session_and_sender still uses its own commit per first-sight Session or
Participant. So a brand-new conversation costs one ingest commit plus zero-to-two
setup commits; a steady-state turn is a single commit.
AtomicIngestResult also carries an AtomicTimings struct with per-phase millisecond
counters (nlp_ms, extract_ms, apply_entity_ms, apply_obs_ms, commit_ms, …) that
are also emitted on the tx_perf tracing target for profiling.
P2 — Named entity recognition¶
Entity extraction (extract_entities_and_nlp) layers three independent extractors, then
reconciles their output. Each contributes a RawEntity carrying a surface form, a
canonical name, an EntityType, a confidence, and an ExtractionSource.
crate::ner::rules::extract_entities_rule_based is a fast, dependency-free regex
baseline that always runs. It covers URLs, emails, ISO and informal dates,
measurements, "I prefer …" preferences, quoted strings, and multi-word proper nouns,
emitting EntityType values like Url, Email, Date, Measurement, Preference,
QuotedString, and Person. URL and email patterns are format-defined, so the regex
extractor is treated as authoritative for them.
When the code-parse feature is on and the message content_type == "code",
crate::ner::code::extract_code_entities parses the source with tree-sitter and
walks the full AST to collect function, class/struct/enum, and import names. Supported
languages are Python, Rust, JavaScript, TypeScript, and TSX. AST parsing is
deterministic, so these entities carry high confidence.
With the onnx feature, the per-sentence NLP cascade (below) produces NlpResults,
and crate::ner::onnx::entities_from_nlp_result converts the model's NER spans into
RawEntity values — mapping NerEntityType variants (Person, Organization,
Location, Date, Numeric, …) onto uniko's EntityType. The model used is
kniv-deberta, run through xervo, uni-db's model runtime.
After extraction, suppress_onnx_over_structured drops any ONNX-NER guess whose byte span
overlaps a format-structured rule entity (Email/URL) — keeping the regex entity as the
single canonical one — and deduplicate_raw collapses the remainder by canonical name,
keeping the highest-confidence extraction and accumulating a mention count.
Feature gating
Extraction is feature-gated. Without onnx, rule-based plus code-AST extraction runs
offline; with onnx, the ML cascade adds higher accuracy on top. Both paths produce a
fully ingested subgraph.
The NLP cascade¶
When onnx is enabled, crate::nlp::NlpPipeline wraps xervo's managed NlpModel. A
single analyze call runs tokenization, ONNX inference, and decoding for a configurable
task set, then adapter::xervo_to_uniko reconciles the output into uniko's NlpResult:
fn tasks(&self) -> NlpTasks {
let mut tasks = NlpTasks::POS | NlpTasks::NER | NlpTasks::DEP | NlpTasks::CLS;
if self.srl_enabled {
tasks |= NlpTasks::SRL;
}
tasks
}
analyze_sentences first splits the message into sentences (dropping fragments under four
words), then analyzes each as a separate request so per-sentence token indexing for NER,
DEP, and SRL stays local to its sentence. SRL is gated by UnikoConfig.nlp_srl_enabled,
mirrored onto the pipeline as srl_enabled. If the runtime or the nlp/default alias is
unavailable, NlpPipeline::try_new returns None and callers fall back to rule-based
extraction.
P3 — Observation extraction¶
Observations are the declarative facts a message asserts, anchored to known entities. The key principle is that observations are reconstructed from the dependency tree, not copied from raw text fragments: "I'm starting a dance studio" becomes a clean, speaker-attributed "Jon is starting a dance studio".
The atomic path drives this through prepare_observations (pure CPU, run before the
transaction) and apply_observations (the in-transaction writer). Preparation has two
modes:
-
Model-driven (
onnx): for each per-sentenceNlpResult, a multi-label CLS gate (cls_gate_admits) decides whether the sentence carries propositional content — admitting a sentence if any sufficiently-probable class belongs to the informative set (defaultinform,status,request,offer). Admitted sentences go torules_engine::extract_with_rules, which combines the DEP tree, POS tags, and the sentence's SRL frames (nlp_result.srl_frames) against the YAML rule set. -
Rule-based fallback (
!used_model): when no model results are available,rules::extract_observations_rule_basedextracts observations from sentences that mention a known entity or match a strong pattern.
The YAML rules engine¶
The rule set is a YAML document (the bundled english.yml, or an external file named by
UnikoConfig.observation_rules_path). It is loaded once and cached process-globally by
canonical path. Its srl_patterns are matched against each frame's predicate and arguments
by rules_engine::matcher, and a template fills in the resulting observation. Extracted
observations then pass through temporal resolution (resolve_temporal_in_content), which
substitutes an absolute date for a relative phrase using the message timestamp.
Each prepared RawObservation carries content, subject, predicate, object, an
optional temporal_phrase and resolved temporal_anchor, an observed_at timestamp, and
a confidence. apply_observations then writes them as Observation nodes with OBSERVED_IN
edges back to the Message and ABOUT edges to the mentioned entities.
Sentence context carries across turns
Preparation seeds from and updates a SentenceContext (speaker, recent referents) so
that pronoun resolution works across a conversation. The updated context is written
back onto SessionContext only after a successful commit, so each retry re-seeds
from the same unmutated state.
Recursive chunking¶
For vector search, large content is split into Chunk nodes. In apply_message_writes_in_tx,
chunking runs only when the content is big enough — token count exceeds
UnikoConfig.message_chunk_threshold (default 1024):
let chunk_threshold = kb.config().message_chunk_threshold;
let chunk_node_ids = if count_tokens(&msg.content) > chunk_threshold {
let chunk_cfg = ChunkConfig::from_uniko_config(kb.config());
let chunker = select_chunker(&msg.content_type, None);
let chunks = chunker.chunk(&msg.content, &chunk_cfg);
create_chunks_in_tx(kb, tx, &msg.message_id, message_nid, &chunks, "Message").await?
} else {
Vec::new()
};
select_chunker picks a strategy by content_type, each one splitting along the natural
structure of its modality:
| content_type | Chunker | Split hierarchy |
|---|---|---|
code (with code-parse + supported lang) |
CodeChunker |
tree-sitter AST boundaries |
html |
HtmlChunker |
DOM structure |
csv / json / structured |
StructuredChunker |
record/field structure |
text, tool_result, error, system, unknown |
TextChunker |
paragraphs → sentences → words |
The default TextChunker is recursive and sentence-aware: it splits on paragraphs
(\n\n), then sentences, then words, targeting max_chunk_tokens (256 on the ingest path)
and never breaking mid-sentence. Chunk sizing comes from ChunkConfig::from_uniko_config,
so the effective ingest-path values are the UnikoConfig defaults: max_chunk_tokens 256,
min_chunk_tokens 32, and chunk_overlap_tokens 0 (0 means auto: ~10 % of max ≈ 25 tokens,
capped at 50). The standalone ChunkConfig::default() (512 / 64 / 50) is not the ingest
path. Token counts use the cl100k_base tokenizer via count_tokens, falling back to a
words × 1.3 approximation if the tokenizer cannot load.
Each ChunkData carries its text, index, byte offsets, token count, a chunk_type, and
modality-specific extras (language and symbol_name for code chunks, the nearest heading
for markdown/HTML). create_chunks_in_tx writes deterministic chunk_ids
(uniko_store::id::chunk_id(parent_ext_id, index)), so a retry recreates the same chunks
without duplication.
P7 — Embedding¶
uniko leans on uni-db's auto-embed: Message, Chunk, Observation, and Summary nodes are embedded automatically by uni-db when the schema configures an embedding config on the vector index. No application code computes those vectors — writing the node is enough.
The default embedding model is BGE-small-en-v1.5 (BAAI/bge-small-en-v1.5), 384
dimensions, resolved through the embed/default alias (EMBED_ALIAS). It is the default
in UnikoConfig via EmbeddingConfig::bge_small_en_v15().
For computed embeddings — nodes whose text is synthesized from several properties, like an
Entity embedded as "name (type)" — crate::embedding provides explicit helpers:
use uniko_extract::embedding::{embed_document, embed_query, embed_entity};
// Document-side text (storage): applies the model's document_prefix.
let v = embed_document(kb, "Caroline Smith (Person)").await?;
// Query-side text (retrieval): applies the model's query_prefix.
let q = embed_query(kb, "who is Caroline?").await?;
// Convenience: compute and persist an Entity's embedding in one call.
embed_entity(kb, node_id, "Caroline Smith", Some("Person")).await?;
These helpers apply the correct model-specific prefix (document_prefix /
query_prefix) before embedding, since prefixing is part of how prefix-trained embedders
distinguish stored content from queries. For batch work, embed_batch_chunked issues a
large set of texts in fixed-size sub-batches (default 64) to keep each ONNX forward pass
within the runtime's memory arena while preserving order.
Auto-embed vs. computed-embed prefixes
Auto-embed nodes (Message, Chunk, Summary) get the query prefix applied automatically
inside similar_to() at search time. Use embed_query when you search computed-embedding
nodes (such as Entities) directly.
See also¶
Domain Model¶
The node and edge types this pipeline writes: Message, Chunk, Entity, Observation.
Architecture¶
How uniko-extract (Layer 3) sits over uniko-store and uni-db.