Curiosity Workspace
Similar to what, exactly?
The similarity engine, from raw text to a ranked list — the graph it reads, the NLP that turns sentences into edges, the embeddings that turn fields and topology into vectors, and the fusion that weighs them against each other.
A similarity score here is not one number from one index. It is a sum of named signals, and every result carries the breakdown that produced it.
The problem
One vector index answers one question
"Find me something like this" is never a single question. Ask it about a support case and you could mean any of these — and a single cosine distance can only mean one of them.
- Reads like this one
- Similar words, similar symptoms, similar phrasing. Text embeddings answer this.
- Touches the same things
- Same device, same part number, same ATA chapter. That lives in the graph — once something has put it there.
- Behaves like this one
- Same shape of connections rather than same words. Graph topology answers this.
- Matters to this user
- Their fleet, their region, their open work. A personal signal, not a corpus-wide one.
- And not like that
- Cases already closed as duplicates, products the customer returned. Something has to push results down as well as up.
The whole path
From a committed node to a ranked, explained list
Everything left of signals happens at ingest time and is shared by the whole workspace. Everything right of it happens per request, in a scenario you write.
Stage 1 · The graph
Edges are the part that carries meaning
Two of the three similarity sources read the graph rather than the text. What ends up in it — and how densely — decides what the engine can say.
- Typed nodes
- Every node has a type and a stable key, addressed by a
UID128. Endpoint code reaches them through generated constants:N.SupportCase.Type,N.Device.Type. - Typed edges, both ways
- Relationships are first-class and stored as pairs, so a traversal is cheap in either direction:
E.ManufacturedBy/E.Manufactures,E.Placed,E.Contains. - Traversal as a query
Q().StartAt(uid).Out(N.Tag.Type, E.HasTag).Out(N.Product.Type, E.HasTag)— a chain that is also, later, a similarity signal.- Density matters
- Structural similarity is only as good as the edges. A node with two relationships is nearly indistinguishable from any other node with two relationships.
Stage 2 · NLP
A pipeline per language, bound to fields
A pipeline is the NLP stack the workspace runs over a field's text — tokenizer, POS tagger, spotter models, pattern spotters, entity linker, post-processing. It runs at commit, automatically.
How it is wired up
- Built under Settings → NLP → Pipelines, one per language, in Data Parsing, Conversational or Custom mode.
- Assigned to node-field pairs on the Used for tab:
SupportCase → Content,KbArticle → Body. - Every value of those fields flows through it — new ingest during the commit, history via reparse.
Mixed languages
- Assign the same field to several pipelines and each ingest is routed by the language detector.
- For a field with more than one language inside it, the workspace splits at sentence level and routes each sentence on its own.
- Changing a spotter, a model or a linking rule means a reparse — it walks every document. Pause other ingestion while it runs.
Stage 2 · Entity linking
Where a sentence becomes an edge
Spotters find phrases. Linking is what turns a found phrase into a relationship the graph can traverse.
- Configured per type
- Under Management → Data → [type] → Linking, enabled for specific pipeline sources. Spotters carry no linking of their own.
- The edges it writes
_AppearsInand_Mentionsby default, overridable to match your domain model. After that a mention is just a traversal:.Out("_Mentions").- Auto-creation
- Pattern-based entities — IDs, part numbers, reference codes — can create the node when it is missing, so the graph fills itself in as the text is read.
- Longest match wins
- Matches apply longest-first, so
ATA-53-40links as itself rather than as theATA-53inside it. - Also in chat replies
- The same configuration can run over assistant replies via
ChatViewConfiguration.ParseAs, and every linked node is access-checked per reader — a mention of a record they cannot see stays plain text.
Stage 3 · Sentence embeddings
Field text in, HNSW vectors out
SentenceEmbeddingsIndex reads one text field off each node, encodes it, and stores the vector in an HNSW index. Use it when the content of the field is what makes two nodes alike.
| Encoder | Runs | Context |
|---|---|---|
MiniLM |
in-process, CPU or GPU | ~256 tokens. Fast, low-RAM. |
ArcticXS |
in-process, CPU or GPU | ~512 tokens. The default for new indexes; higher recall at comparable cost. |
External |
remote HTTP | Any OpenAI-compatible endpoint, up to 4096 tokens. The text leaves the workspace. |
The encoder is fixed once the index exists — switching models means a new index. Long values can be chunked into overlapping windows, each chunk stored with its parent UID, and chunk hits dedupe back to the parent at query time. PrefixFieldName puts a title in front of every chunk so short queries still match.
Stage 3 · Graph embeddings
PageSpace: vectors from shape, not words
A StarSpace-style model trained on random walks through the graph. Nodes that co-occur on a walk are pulled together, randomly sampled nodes pushed apart, and each node ends up with one vector.
EdgesToFollowandNodesToFollowset the walk topology. Those two lists are the definition of similarity for your domain.Dimensions,Epoch,LearningRate,NegativeSamplingCounttune the training itself.- Answers questions text cannot: which customers behave alike, which products are functionally substitutable.
Two things to plan for
- It is not trained on commit. Call
TrainAsynconce there is meaningful data, then again on a schedule as the graph shifts. New nodes get a vector predicted in real time without retraining. - It is sensitive to density. Sparse subgraphs produce poor vectors. Every node wants at least a handful of real relationships — which is exactly what stage 2 supplies.
Stage 3 · Choosing
Which vector answers which question
| The question | The right tool |
|---|---|
| Find products with similar names or descriptions | Sentence embeddings |
| Find customers who behave like this one — same purchase patterns, same cases | Graph embeddings |
| Find substitute products based on who buys them | Graph embeddings |
| You already have vectors from a domain-specific model | Raw embeddings |
| Find explicit paths or connected components | Plain IQuery traversal — not embeddings at all |
| Any mix of the above, weighed against each other | The similarity engine |
All of them implement the same index surface, so a consumer never has to care which kind of vector it is querying.
Stage 4 · Signals
A signal is a source of candidates with scores
The engine lives in Mosaik.GraphDB.Similarity and is reached through IQuery.ToSimilarity(...). A scenario starts at a seed, declares signals, fuses them, and filters the result.
| Source | How |
|---|---|
| Text embeddings | StartAtSimilarTextAsync(text, count, nodeTypes, indexUID) — returns a scored query |
| Graph traversal | A standard chain from ctx.Subjects — the seed the scenario started at |
| External lookup | Call out to anything async, return an IQuery over the matching UIDs |
| Pre-scored hits | Return IEnumerable<ScoredUID> and feed your own numbers in |
Scores live on the query, so they survive narrowing — Where, Except, IsRelatedTo, Take. Traversals carry them too: a score propagates to every UID it reaches, and a node reached from several sources merges them as √(x² + y²) / 1.4. That is what lets one signal pivot from similar products to their manufacturers and still rank them meaningfully.
Stage 4 · Fusion
Three places scores combine
Within one signal
A signal may declare several sources. By default it sums them. UsingReciprocalRankFusion switches to 1 / (k + rank) instead, which is the right call when the sources' raw scores are not on the same scale.
Across the positive signals
One fuse function, set with Fuse(...). A signal's Weight(...) is applied before fusion, so it scales that signal's contribution under any fuse.
| Fuse | Effect |
|---|---|
Sum |
Add the signal scores. The default. |
Max |
Keep the strongest single signal — one source of truth, others as tiebreakers. |
Min |
Keep the weakest. |
Euclidean |
√(Σaᵢ²) — a soft OR. |
Product |
Multiply — a soft AND; a candidate must score on every signal. |
Stage 4 · Demoting and filtering
Pushing down is not the same as filtering out
Negative signals are scored exactly like positive ones but fused into their own group, then combined with the positive group by FuseFinal(...). They only adjust candidates a positive signal already surfaced — they never introduce new ones.
| Final fuse | Effect |
|---|---|
Subtract |
positive − negative. The default. |
SubtractScaled(w) |
positive − w · negative — tune the penalty strength. |
Discount |
positive / (1 + negative) — scale down, never amplify. |
Decay(rate) |
Exponential falloff as the negative grows. |
DropIfNegativeOver(t) |
Zero the candidate past a threshold. |
Rules are the other tool, and they only filter — they never change a score. For a scoring adjustment reach for a signal or a custom fuse, not a rule.
Stage 5 · Explainability
Every score carries its own receipt
The engine tracks attribution through an automatic-differentiation pass, so ScoreInfo.Components holds each signal's contribution by name, alongside the final score.
- The components add up
- For the built-in fuses the per-signal components sum exactly to the score, and a negative signal shows up as a negative entry — so the breakdown explains what pulled a result down as well as up.
- Except when they can't
- Homogeneous-degree-one operations (Sum, Max, Min, Euclidean, the means) keep that property.
Product,PowandSigmoidstill attribute, but the components read as each signal's sensitivity rather than an additive share. - It reaches the UI
SearchArea.WithSimilarityEngine(...)renders each hit with a ContributionBar built from those components — labelled with the names you gave inAddSignal. Name signals for the reader, not for yourself.- And the clock
TrackTimings(true)fillsresult.Timingsper signal, per rule and for fusion.TrackProgressstreams stage events you can relay to the caller mid-request.
Stage 5 · The sharp edge
Signals run as admin
The engine does not enforce per-user access on its results. That is left to the consumer — which means it is left to you.
Why
ctx.Graph inside a signal is the admin-level graph, because a signal often needs to traverse structure the user cannot read in order to rank things they can. So result.Scores may contain UIDs the caller is not allowed to see.
Two ways to close it
- Scope inside the signal —
ctx.Graph.Query(userUID)instead ofctx.Graph.Query(). - Filter the result —
.FilterAsUser(CurrentUser)on the scenario drops anything the user cannot access beforeExecuteAsyncreturns.
Unlike ordinary search, permission-aware retrieval here is opt-in. A scenario that ships without one of these two lines is a leak with a ranking attached.
Building one
The order to do it in
- 1 · Model the edges first. Structural signals and PageSpace both read them. Text similarity alone is the thing you already had.
- 2 · Point NLP at the fields that carry entities and configure linking for each type you want traversable. Reparse after every rule change.
- 3 · Index the field that decides likeness, chunked if it is long, with a prefix field if titles matter. Not every field needs a vector.
- 4 · Train PageSpace once the graph is dense enough, and schedule the retrain. Check the walk topology before blaming the vectors.
- 5 · Start with one signal, read the breakdown, then add the second. Weights tuned against a breakdown you can see beat weights guessed in advance.
- 6 · Add
FilterAsUserbefore anyone else sees it.
Sources: docs.curiosity.ai — similarity engine, sentence embeddings, graph embeddings (PageSpace), NLP pipelines, entity linking, IQuery similarity search.