Structured NLP¶
NlpModel is Uni-Xervo's managed, multi-head structured-NLP task. One
analyze call runs a single transformer forward pass and returns
part-of-speech tags, named entities, a dependency parse, semantic-role frames,
and a sentence-level dialog-act classification — already decoded into typed
Rust structs, with provider-side tokenization, chunking, and label mapping
handled for you.
local/onnx is the only provider that ships NlpModel, and it serves two
generations of the kniv family. Which one you get follows from the model repo,
so a catalog entry is the only thing that changes:
| kniv v1 cascade | kniv v7 | |
|---|---|---|
| model | kniv-deberta-nlp-base-en-xsmall |
kniv-v7-xlarge-u-en, kniv-v7-xlarge-d-en |
| graphs | 1 (onnx/cascade.onnx), ~75 MB INT8 |
8, with fp32 / FP16 / INT8 encoders; ~1.9 GB INT8 |
| input per call | 128 subwords | a window of up to 512 words |
| heads | POS, NER, DEP, SRL, dialog act | the same, plus morphology, lemma, sentiment, coreference, typed entities and relations |
| decoding | per-token argmax | BIO Viterbi, one dependency tree per sentence, predicate-aware SRL |
| input shape | prose | prose or conversation turns |
| extraction | — | your own relations and entity types, supplied per request |
Start with v7 unless you need the small model's footprint: it is a different model, not a bigger one, and the extra heads are what agent memory needs.
Heads¶
| Head | NlpTasks flag |
Where it lands | v1 |
|---|---|---|---|
| Part-of-speech | POS |
tokens[i].pos, windows[w].words[i].pos |
✓ |
| Named-entity recognition | NER |
tokens[i].ner (raw BIO) + entities (merged spans) |
✓ |
| Dependency parse | DEP |
tokens[i].dep |
✓ |
| Semantic role labeling | SRL |
frames |
✓ |
| Dialog acts | CLS |
speech_acts |
✓ |
| Morphology | MORPH |
windows[w].words[i].morph |
|
| Lemmas | LEMMA |
windows[w].words[i].lemma |
|
| Sentiment | SENTIMENT |
windows[w].sentences[j].sentiment |
|
| Coreference | COREF |
windows[w].coref |
|
| Typed entities | ENTITIES |
windows[w].entities |
|
| Facts | FACTS |
windows[w].facts |
Callers request the subset they want via the NlpTasks bitflag; the provider
populates exactly those fields, leaves the rest empty, and — on v7 — skips the
graph calls nothing asked for. Three convenience sets are defined:
NlpTasks::CASCADE (the five v1 heads), NlpTasks::LINGUISTIC (every per-word
and per-sentence head), and NlpTasks::ALL. FACTS implies ENTITIES.
Quick example¶
use uni_xervo::traits::{NlpRequest, NlpTasks};
let model = runtime.nlp_model("nlp/default").await?;
let req = NlpRequest::new("Marie Curie discovered radium in Paris.", NlpTasks::ALL);
let result = model.analyze(vec![req]).await?.remove(0);
// Merged named entities — no BIO bookkeeping required:
for e in &result.entities {
println!("{} [{}] tokens {:?}", e.text, e.label, e.token_span);
}
// Dependency heads: `None` is the sentence root, `Some(i)` indexes `tokens`:
for tok in &result.tokens {
match tok.dep.as_ref().and_then(|d| d.head) {
None => println!("{:>12} ─▶ ROOT", tok.text),
Some(h) => println!("{:>12} ─▶ {}", tok.text, result.tokens[h].text),
}
}
// Gate on the full dialog-act distribution, self-described by the cls vocab:
if let (Some(act), Some(maps)) = (result.speech_acts.first(), model.label_maps()) {
let informative = act
.scores
.iter()
.zip(&maps.cls)
.filter(|(_, label)| matches!(label.as_str(), "inform" | "status" | "request" | "offer"))
.any(|(p, _)| *p >= 0.20);
println!("informative: {informative}");
}
// Regroup subword tokens into words:
let words = result.tokens.last().map(|t| t.word_index + 1).unwrap_or(0);
println!("{} tokens across {} words", result.tokens.len(), words);
analyze takes a batch (Vec<NlpRequest>) and returns one NlpResult per
request, in order.
Requesting heads¶
NlpTasks is a bitflag — combine the heads you need, or use NlpTasks::ALL:
NlpRequest is #[non_exhaustive]: build it with new (or from_turns) and
chain the setters, so later releases can add inputs without breaking callers.
// POS + NER only — DEP / SRL / CLS fields come back empty:
let req = NlpRequest::new("Marie Curie discovered radium in Paris.", NlpTasks::POS | NlpTasks::NER);
// A conversation, with a schema and a threshold override:
let turns = [
Turn::new("user", "I moved to Lisbon last year and started at a bank."),
Turn::new("assistant", "Congratulations on the move!"),
];
let req = NlpRequest::from_turns(&turns, NlpTasks::FACTS)
.with_schema(&schema)
.with_calibration(&overrides);
Restricting the task set skips that head's post-processing — and, on v7, the graph call behind it — which saves wall time on hot ingest paths where, for example, only NER is needed.
Result types¶
NlpModel::analyze returns one NlpResult per request:
pub struct NlpResult {
pub tokens: Vec<NlpToken>, // per-token POS / NER / DEP, in input order
pub sentences: Vec<NlpSentence>, // boundaries with token-range indices
pub frames: Vec<SrlFrame>, // SRL frames (empty unless NlpTasks::SRL)
pub speech_acts: Vec<SpeechAct>, // dialog acts (empty unless NlpTasks::CLS)
pub entities: Vec<NerEntity>, // merged NER spans (empty unless NlpTasks::NER)
pub windows: Vec<NlpWindow>, // v7: the full per-window analysis
}
The first five fields are the v1 shape. A v7 model fills them too — as a
flattened projection of its windows — so a caller written against v1 keeps
working; everything the projection cannot express (coreference, typed
entities, facts and their attributes) is in windows.
NlpToken¶
pub struct NlpToken {
pub text: String,
pub start: usize, // UTF-8 byte offset in the original text
pub end: usize, // UTF-8 byte offset (exclusive)
pub pos: Option<String>, // Universal Dependencies tag; None if POS not requested
pub ner: Option<String>, // raw BIO tag ("B-PERSON", "O", …); None if NER not requested
pub dep: Option<DepLink>, // dependency arc; None if DEP not requested
pub word_index: usize, // which word this subword belongs to
}
Tokens are subword units. word_index is dense and monotonically
non-decreasing over tokens: subwords of one word share an index, and each
new word increments it by one starting at 0. Use it to regroup subwords into
words without parsing tokenizer metaspace markers.
DepLink¶
pub struct DepLink {
pub head: Option<usize>, // Some(i): 0-based index into NlpResult::tokens; None: root
pub relation: String, // "nsubj", "obj", "amod", …
}
head is a global index into NlpResult::tokens (not sentence- or
chunk-local). None means the token attaches to the sentence root.
SrlFrame and SrlRole¶
pub struct SrlFrame {
pub predicate_token: usize, // index into NlpResult::tokens
pub predicate_sense: Option<String>, // e.g. "buy.01" when known
pub roles: Vec<SrlRole>, // argument spans, excluding the predicate
}
pub struct SrlRole {
pub span: (usize, usize), // inclusive [first, last] token indices
pub label: String, // "ARG0", "ARG1", "ARGM-TMP", …
}
span endpoints are both inclusive; a single-token argument has first == last.
The predicate token is never itself included in roles. The provider runs one
forward pass per detected verb to fill every frame — this multi-pass
orchestration is invisible to callers.
SpeechAct¶
pub struct SpeechAct {
pub sentence_index: usize,
pub label: String, // == argmax of `scores`
pub confidence: f32, // == scores[argmax]
pub scores: Vec<f32>, // full per-class softmax, parallel to NlpLabelMaps::cls
}
scores is the complete softmax over the dialog-act vocabulary, so consumers
can do thresholded multi-label gating (admit a sentence if any relevant class
clears a probability floor) rather than relying only on the top-1 label.
NerEntity¶
pub struct NerEntity {
pub text: String, // surface text spanning the original bytes
pub label: String, // entity type, BIO prefix stripped (e.g. "PERSON")
pub token_span: (usize, usize), // inclusive [first, last] token indices
pub char_span: (usize, usize), // half-open [start, end) UTF-8 byte offsets
}
entities is a BIO-collapsed view of the per-token ner tags: a B- (or
orphan I-) opens a span, matching I- tags extend it, and an O tag or a
label change closes it. The raw per-token ner tags remain available on each
NlpToken.
kniv v7: windows, entities and facts¶
A v7 model analyses windows of whole sentences — up to 512 words, never
crossing a paragraph or a turn — rather than a fixed token budget. Every index
inside a window (dependency heads, mention spans, role spans) is a word
index local to that window, which is why the full analysis lives in
NlpResult::windows instead of being forced into the flat token list.
pub struct NlpWindow {
pub tokens: Vec<String>, // the window's words
pub analysed_words: usize, // words that fit the encoder
pub sentences: Vec<WindowSentence>, // spans, speaker, dialog acts, sentiment
pub words: Vec<WordAnalysis>, // pos, morph, lemma, head, deprel
pub ner: Vec<WordSpan>, // OntoNotes spans
pub srl: Vec<WindowFrame>, // one frame per predicate — not only verbs
pub coref: Vec<Vec<(usize, usize)>>, // clusters of inclusive word spans
pub entities: Vec<NlpEntity>, // typed, grouped across mentions
pub facts: Vec<NlpFact>, // entity → entity, with attributes
}
analysed_words is how many leading words fit the encoder's subword budget.
Words past it appear in tokens but carry no analysis, so a very long sentence
is reported truncated rather than silently dropped.
The extraction schema¶
Entity types and relations are not baked into the model: you supply them per request, by name and description, and they are scored across the whole window. That is what makes the model usable for a schema it has never seen.
use uni_xervo::traits::{ExtractionSchema, LabelSpec, NlpRequest, NlpTasks, Turn};
let schema = ExtractionSchema {
relations: vec![
LabelSpec::described("lives in", "the place where the head resides"),
LabelSpec::described("employer", "organization the head works for"),
],
entity_types: Some(vec![
LabelSpec::name("location"),
LabelSpec::name("organization"),
LabelSpec::name("person"),
]),
top_per_pair: None, // None uses the model's calibrated rel_top (4)
};
let turns = [
Turn::new("user", "I moved to Lisbon last year and started at a bank."),
Turn::new("assistant", "Congratulations on the move!"),
];
let req = NlpRequest::from_turns(&turns, NlpTasks::FACTS).with_schema(&schema);
let result = model.analyze(vec![req]).await?.remove(0);
for window in &result.windows {
for fact in &window.facts {
println!(
"{} --{}--> {} ({}, {})",
window.entities[fact.head].text,
fact.relation,
window.entities[fact.tail].text,
fact.polarity.as_str(),
fact.modality.as_str(),
);
}
}
// user --lives in--> Lisbon (positive, asserted)
// user --employer--> a bank (positive, asserted)
Schema label embeddings are computed once and cached per exact label list, so a stable schema costs one extra graph pass for the life of the model. A schema that changes per request pays that pass every time — build it once and reuse it.
Guidance that matters more than it looks:
- Name relations as a reader would, and say in the description which side
is the head:
"the place where the head resides". - Avoid near-synonyms in one schema (
located inandheadquarters location). They split the decision between them; merge downstream if you need both. - Prefer specific relations. A generic
related toattracts instances of every specific one. - Without temporal entity types (
date,time) in the schema,NlpFact::timestaysNone— it can only point at an entity.
Conversations¶
Passing turns instead of text does three things prose cannot: sentences
never cross a turn, the previous turn gives "it" and "that one" their
referents, and each speaker becomes an entity in its own right. That last
one is what lets "I live in Lisbon" be stored about the user:
for e in &window.entities {
if e.speaker {
println!("participant: {}", e.text); // "user", "assistant"
}
}
Speaker entities come first, in order of first appearance, and their spans
are the turn-start positions rather than mentions in the text.
Fact attributes¶
pub struct NlpFact {
pub head: usize, // index into NlpWindow::entities
pub tail: usize,
pub relation: String,
pub margin: f32, // > 0 for a reported fact; larger is surer
pub polarity: FactPolarity, // Positive | Negative
pub modality: FactModality, // Asserted | Hypothetical | Planned | Desired | Questioned | Reported
pub holder: Option<usize>, // whoever holds the belief
pub time: Option<usize>, // a temporal entity, when the schema has one
}
Only Asserted and Reported are claims about the world. Consolidating
Planned, Desired, Questioned or Hypothetical into current state is a
mistake — "next month I'm joining Northstar" is not an employer, and
"do you live in Lisbon?" is not a residence. Negative polarity is evidence
against the same (head, relation, tail), not a separate fact.
margin is not a probability. Map it to one with a sigmoid if you want a
confidence, and tune a relation's threshold through calibration rather than by
filtering on the margin.
Calibration¶
A v7 release ships calibration.json: dev-tuned decoding thresholds that the
published quality figures were measured with. They are readable, and
overridable per request.
use uni_xervo::traits::NlpCalibrationOverrides;
if let Some(cal) = model.calibration() {
println!("rel_top={} unseen_offset={}", cal.rel_top, cal.unseen_offset);
}
// One relation firing too eagerly: raise its own threshold, leave the rest.
let mut overrides = NlpCalibrationOverrides::default();
overrides.rel_offsets.insert("located in".to_string(), -1.0);
let req = NlpRequest::from_turns(&turns, NlpTasks::FACTS)
.with_schema(&schema)
.with_calibration(&overrides);
A fact is kept when its raw margin exceeds -offset, so a lower offset is a
higher bar. Validate any change on a labelled sample: these are the numbers the
model's reported accuracy depends on.
Picking an encoder¶
kniv-v7-xlarge-u-en (UltraBERT) |
kniv-v7-xlarge-d-en (DeBERTa) |
|
|---|---|---|
| window | 1,024 subwords | 512 subwords |
| better at | zero-shot types, agent memory, long inputs | parsing accuracy, fixed-schema relations, plans vs assertions |
| INT8 speed, 200-word window | ~0.47 s | ~0.58 s |
| resident graphs, INT8 | ~2 GB | ~3 GB |
Both are Apache-2.0 and need no token. The provider reads each release's
onnx/onnx_meta.json, so the window size and subword pooling difference needs
no configuration.
Cost¶
FACTS is the expensive flag: the relation pass scores every one of the
E(E-1) ordered entity pairs, so cost grows with the square of the entities in
a window. If latency matters, keep the schema's entity types narrow rather than
listing everything. The INT8 graphs use dynamic per-channel quantisation and
run on CPU — on a GPU, use "variant": "fp16", since CUDA would run the INT8
graphs through its own CPU fallback.
Label vocabularies¶
The model decodes against fixed per-head vocabularies, exposed directly so you
do not have to embed a parallel copy of the model's label_maps.json:
pub struct NlpLabelMaps {
pub pos: Vec<String>,
pub ner: Vec<String>,
pub deprel: Vec<String>,
pub srl: Vec<String>,
pub cls: Vec<String>, // parallel to SpeechAct::scores
pub morph: Vec<String>, // v7: UD feature bundles
pub lemma: Vec<String>, // v7: edit scripts, not words
pub sentiment: Vec<String>, // v7
pub ent_type: Vec<String>, // v7: the coarse types used without a schema
}
// Returns Some for models with a fixed label set (the ONNX cascade),
// None for models that don't expose vocabularies (remote / mock).
if let Some(maps) = model.label_maps() {
// maps.cls[i] names the class whose probability is SpeechAct::scores[i]
}
Each vector is indexed by the model's internal class id, so cls lines up
index-for-index with SpeechAct::scores.
Catalog entry¶
kniv v7:
{
"alias": "nlp/default",
"task": "nlp",
"provider_id": "local/onnx",
"model_id": "dragonscale-ai/kniv-v7-xlarge-u-en",
"warmup": "background",
"options": {
"variant": "int8,fp16",
"execution_providers": ["cpu"],
"intra_op_num_threads": 8
}
}
The v1 cascade:
{
"alias": "nlp/cascade",
"task": "nlp",
"provider_id": "local/onnx",
"model_id": "dragonscale-ai/kniv-deberta-nlp-base-en-xsmall",
"warmup": "background",
"options": {
"onnx_path": "onnx/cascade.onnx",
"max_seq_len": 128
}
}
The generation is inferred from the repo — a repo that carries
onnx/onnx_meta.json is v7 — and "generation": "v1" | "v7" pins it
explicitly. The option sets do not overlap: onnx_path, label_maps_path and
max_seq_len are v1-only (v7 reads its graph set and window size from
onnx_meta.json), while variant, calibration_path and label_bank_path
are v7-only. Mixing them is rejected at catalog registration, before anything
is downloaded.
variant is a fallback chain, not a suffix: "int8,fp16" means "INT8 if
this graph publishes it, else FP16, else fp32". That is how the documented
INT8 configuration ends up running plain-int8, marker-int8 and
label-fp16 — the label graph has no INT8 file because it runs once per
schema and is cached.
See the local/onnx provider reference for the
full NLP option-key table.
Migration to 0.19.0¶
v7 support is additive to the result types, which are #[non_exhaustive], but
NlpRequest changed shape.
NlpRequestis now#[non_exhaustive]and carriesturns,schemaandcalibrationalongsidetextandtasks. Replace struct literals withNlpRequest::new(text, tasks)(orfrom_turns) plus thewith_*setters. This is the last change of its kind: further inputs can now be added without breaking callers.NlpTasks::ALLnow covers the v7 heads too. Code that wants exactly the old five should sayNlpTasks::CASCADE; a v1 model reportsCASCADEfromsupported_tasks()rather thanALL.- New, additive:
NlpResult::windows,NlpModel::calibration(), four more vocabularies onNlpLabelMaps, and theMORPH/LEMMA/SENTIMENT/COREF/ENTITIES/FACTStask flags. The v1 path is unchanged.
Migration to 0.14.0¶
The 0.14.0 NlpModel surface is richer than 0.13.x. Two changes are breaking;
the rest is additive.
DepLink.headis nowOption<usize>(wasusize).Some(i)is a 0-based global index intoNlpResult::tokens;Noneis the sentence root. Match onNoneinstead of inferring root from a sentinel value.- The result structs are
#[non_exhaustive]. Build them throughDefault(NlpResult::default()plus field assignment) rather than struct literals — this is what lets future heads be added without breaking callers. Reading existing fields is unaffected. - New, additive fields and methods:
SpeechAct::scores(full CLS distribution),NlpResult::entities(mergedNerEntityspans),NlpToken::word_index, andNlpModel::label_maps(). Code that ignores them compiles unchanged.
See also¶
local/onnxprovider reference — NLP option keys and the canonical model details.scripts/gen-kniv-v7-goldens.py— regenerates the conformance fixtures the v7 provider is tested against, from the model's Python reference runtime.- Multimodal trait surface — the full set of traits
introduced alongside
NlpModel. - API reference (rustdoc) — complete trait and type signatures.