Similarity Engine — Finding Similar Objects
The similarity engine builds a recommendation-style ranking by combining multiple signals (each producing candidate UIDs from a different source — text embeddings, graph traversals, external lookups), fusing them into a single ranked list, and applying optional rules to filter the result.
Use it from a custom endpoint when you want answers like "find products similar to X" or "show cases like this one" that can't be expressed as a single search query.
The engine lives in the Mosaik.GraphDB.Similarity namespace and is reached via IQuery.ToSimilarity(...).
Anatomy of a scenario
Graph.Query()
.StartAt(seedUID) ← the "subject(s)" the signals see in ctx.Subjects
.ToSimilarity(opts)
.AddSignal("name1", s => ...) ← 1..N positive candidate sources (text, graph, external)
.AddSignal("name2", s => ...)
.AddNegativeSignal("name3", s => ...) ← optional: sources that demote candidates
.Fuse(Fusion.Sum) ← how positive signal scores combine (default)
.FuseFinal(Fusion.Subtract) ← how the positive and negative groups combine (default)
.AddRule("name", r => r.Filter(...)) ← optional post-fusion filters
.Filter(uid => ...) ← optional final UID-level filter
.ExecuteAsync(ct);
The result is a SimilarityResult whose Scores map each candidate to a ScoreInfo — the final score plus a per-signal breakdown of how that score was reached.
A text + graph similarity endpoint
The example below answers "find products similar to this one" using:
- A text-similarity signal over indexed product names.
- A same-manufacturer signal for products from the same manufacturer.
- A shared-tag signal for products sharing tags with the seed.
- A previously-purchased signal that lifts products the current user already bought.
- A filter rule that drops candidates outside the requested category.
//ImportEndpoint("_lib/product-helpers") // ToDto, GetSimilarityIndex, …
public record SimilarRequest(
string ProductId,
int TopK = 10,
string Category = null);
public record ScoredProduct(
string Id, string Name, double Score,
IReadOnlyDictionary<string, double> SignalScores);
var input = Body.FromJson<SimilarRequest>();
if (string.IsNullOrWhiteSpace(input.ProductId))
return BadRequest("`productId` is required.");
if (!Graph.TryGetReadOnlyContent<Product>(N.Product.Type, input.ProductId, out var seedNode))
return NotFound($"Product '{input.ProductId}' not found.");
var seedUID = seedNode.UID;
var seedName = seedNode.Get<string>(N.Product.Name) ?? "";
var productTextIndex = ProductHelpers.GetSimilarityIndex(Graph);
var result = await Graph.Query()
.StartAt(seedUID)
.ToSimilarity(o => o
.MaxCandidates(200)
.MaxCandidatesPerSignal(100)
.TrackProgress(async p => await RelayStatusAsync(p.Message)))
// 1. Text similarity over product names (embedding index).
.AddSignal("SimilarName", s => s
.Describe("Name embedding similarity")
.Weight(1.0f)
.Limit(100)
.FromAsync(async ctx =>
(await ctx.Graph.Query()
.StartAtSimilarTextAsync(
seedName, count: 100,
nodeTypes: new[] { N.Product.Type },
indexUID: productTextIndex,
applyCutoff: false))
.Except(ctx.Subjects))) // returns the IQuery; its scores survive Except
// 2. Same manufacturer — products N hops away on the graph.
.AddSignal("SameManufacturer", s => s
.Describe("Other products from the same manufacturer")
.Weight(0.7f)
.From(ctx => ctx.Graph.Query()
.StartAt(ctx.Subjects)
.Out(N.Manufacturer.Type, E.ManufacturedBy)
.Out(N.Product.Type, E.Manufactures)
.Except(ctx.Graph.Query().StartAt(ctx.Subjects))))
// 3. Tag overlap — shared category/tag nodes.
.AddSignal("SharedTags", s => s
.Describe("Products that share tags with the seed")
.Weight(0.5f)
.From(ctx => ctx.Graph.Query()
.StartAt(ctx.Subjects)
.Out(N.Tag.Type, E.HasTag)
.Out(N.Product.Type, E.HasTag)
.Except(ctx.Graph.Query().StartAt(ctx.Subjects))))
// 4. Lift products the current user already bought.
.AddSignal("PreviouslyPurchased", s => s
.Weight(0.3f)
.From(ctx => ctx.Graph.Query()
.StartAt(CurrentUser)
.Out(N.Order.Type, E.Placed)
.Out(N.Product.Type, E.Contains)))
// Add the per-signal scores together (the default — shown for clarity).
.Fuse(Fusion.Sum)
// Hard filter — keep only the requested category.
.AddRule("CategoryFilter", r =>
{
r.Enabled(!string.IsNullOrEmpty(input.Category));
r.Filter((ctx, candidates) => ctx.Graph.Query()
.StartAt(candidates)
.IsRelatedTo(Node.GetUID(N.Category.Type, input.Category))
.AsUIDEnumerable());
})
.ExecuteAsync(CancellationToken);
The result is mapped to the response JSON. Each ScoreInfo already carries the per-signal breakdown in its Components map, so no separate lookup is needed:
var hits = result.Scores
.OrderByDescending(kv => kv.Value.Score)
.Take(input.TopK)
.Select(kv =>
{
Graph.TryGetReadOnlyContent<Product>(kv.Key, out var node);
var perSignal = kv.Value.Components
.ToDictionary(c => c.Key, c => (double)c.Value);
return new ScoredProduct(
Id: node?.GetKey() ?? kv.Key.ToString(),
Name: node?.Get<string>(N.Product.Name) ?? "",
Score: kv.Value.Score,
SignalScores: perSignal);
})
.ToList();
return Ok(new { source = input.ProductId, hits }.ToJson(), "application/json");
A response looks like:
{
"source": "P-2199",
"hits": [
{
"id": "P-2207",
"name": "Wireless Mouse Pro",
"score": 0.0494,
"signalScores": {
"SimilarName": 0.0244,
"SameManufacturer": 0.0167,
"SharedTags": 0.0083
}
}
]
}
For the built-in fuses the signalScores add up to the final score (see Fusion).
Signal sources
A signal's From(...) / FromAsync(...) callback receives a SignalContext and returns an IQuery (recommended). The engine inspects the query's Current result: if it carries per-UID scores — for example after StartAtSimilarTextAsync — those scores are used directly; otherwise the candidates are ranked by their position in the query. The callback can also return IEnumerable<UID128> (rank-only) or IEnumerable<ScoredUID> (pre-scored) when you already have a raw list.
A signal can declare more than one source — call From(...) / FromAsync(...) several times on the same signal and the sources are combined within the signal (see Fusion).
From(IQuery) and FromAsync(IQuery) — passing a query directly
When you already have an IQuery in hand — for instance the result of StartAtSimilarTextAsync — you can pass it directly instead of wrapping it in a lambda:
// Synchronous: IQuery already available.
var similar = await Graph.Query()
.StartAtSimilarTextAsync(seedName, count: 100, nodeTypes: new[] { N.Product.Type }, indexUID: productTextIndex);
var result = await Graph.Query()
.StartAt(seedUID)
.ToSimilarity()
.AddSignal("SimilarName", s => s
.Weight(1.0f)
.From(similar)) // IQuery passed directly; scored UIDs in Current are used
.ExecuteAsync(CancellationToken);
The engine reads the query's Current for scored UIDs — exactly as with the lambda forms. Scores from StartAtSimilarTextAsync are preserved; a plain graph traversal (no per-UID scores) falls back to rank-by-position. The async overload, FromAsync(Func<SignalContext, Task<IQuery>>), allows awaiting further graph calls inside the callback and also accepts a pre-constructed Task<IQuery>.
ToSimilarity itself passes the leading IQuery directly when it carries scores, so the StartAt signal (described in Starting from a similarity search) uses the same mechanism internally.
Because the scores live on the query, a similarity result keeps them through narrowing operators — Where, IsRelatedTo, Except, OfType, Take, … — so you can refine the candidate set without dropping back to rank-only:
.AddSignal("SimilarInStock", s => s
.FromAsync(async ctx =>
(await ctx.Graph.Query().StartAtSimilarTextAsync(seedName, 100, new[] { N.Product.Type }, productTextIndex))
.IsRelatedTo(inStockWarehouseUID) // scores preserved through the filter
.Except(ctx.Subjects)))
Traversals (Out / OutMany / OutWhere) also carry scores: each source UID's score propagates to every UID it reaches, and when a target is reached from several sources the scores are merged with √(x² + y²) / 1.4 (the same merge used across search results). This lets a signal pivot from a scored set to related nodes while keeping a meaningful ranking — e.g. score manufacturers by the similarity of their products:
.AddSignal("ByManufacturerOfSimilar", s => s
.FromAsync(async ctx =>
(await ctx.Graph.Query().StartAtSimilarTextAsync(seedName, 100, new[] { N.Product.Type }, productTextIndex))
.Out(N.Manufacturer.Type, E.ManufacturedBy))) // manufacturer scores = merged product scores
| Source | How |
|---|---|
| Text embeddings | ctx.Graph.Query().StartAtSimilarTextAsync(text, count, nodeTypes, indexUID, applyCutoff) — returns a scored query |
| Graph traversal | Standard IQuery chain — StartAt(ctx.Subjects).Out(...).Where(...) |
| External lookup | Any async source — call out, then return an IQuery over the matching UIDs |
| Pre-scored hits | Return IEnumerable<ScoredUID> to feed your own scores in |
Configure per-signal Weight(...) (scales the signal's scores), Limit(...) (top-N for the signal) and Where(...) (a candidate filter) on the builder. SignalContext exposes:
UID128[] Subjects— the UIDs from the scenario'sStartAt(...).Graph Graph— the live graph (admin-level). Usectx.Graph.Query(userUID)if you need ACL filtering inside the signal.
Starting from a similarity search
A scenario can start directly from a similarity query instead of a fixed seed. When the starting IQuery carries scores — as StartAtSimilarTextAsync does — the engine takes those initial scores as the first signal (named StartAt), so a bare scenario already produces a scored ranking:
var result = await (await Graph.Query()
.StartAtSimilarTextAsync(queryText, count: 100, nodeTypes: new[] { N.Product.Type }, indexUID: productTextIndex))
.ToSimilarity(o => o.MaxCandidates(50))
// add more signals/rules here to refine the initial similarity ranking…
.ExecuteAsync(CancellationToken);
// Each result's ScoreInfo.Components carries a "StartAt" entry with the seeding similarity score.
Add further AddSignal(...) calls to blend the text-similarity seed with graph or popularity signals.
Fusion
Fusion happens at three points, each with its own optional fuse function:
- Within a signal — when a signal has several ranked sources.
- Across the positive signals —
Fuse(...)/FusePositive(...). - Between the positive and negative groups —
FuseFinal(...)(see Negative signals).
Reciprocal-rank fusion within a signal
Reciprocal-rank fusion (RRF) is configured on the signal. Call UsingReciprocalRankFusion(...) and the signal scores each of its sources by 1 / (k + rank) and sums those across its sources — robust when the sources' raw scores aren't on the same scale, because only each source's rank is used:
.AddSignal("Related", s => s
.UsingReciprocalRankFusion(o => o.RankOffset(60)) // k = 60; optional MaxRankConsidered(n)
.From(ctx => ctx.Graph.Query().StartAt(ctx.Subjects).Out(N.Manufacturer.Type).Out(N.Product.Type))
.From(ctx => ctx.Graph.Query().StartAt(ctx.Subjects).Out(N.Tag.Type).Out(N.Product.Type)))
Without it, a signal sums its sources' scores (or, for plain rank lists, the candidates' occurrence counts).
Combining signals
Across signals the engine combines the per-signal scores with one fuse function, optional via .Fuse(...) (the default is Fusion.Sum). The ready-made combiners live on the Fusion helper:
| Fuse | Effect |
|---|---|
Fusion.Sum |
Add the signal scores (default). |
Fusion.Max |
Keep the strongest single signal. Good when one signal is the source of truth and the others are tiebreakers. |
Fusion.Min |
Keep the weakest single signal. |
Fusion.Euclidean |
√(Σ aᵢ²) — a soft "OR" of the scores. |
Fusion.Product |
Multiply the scores — a soft "AND" (a candidate must score on every signal). |
A signal's Weight(...) is applied before fusion (the signal multiplies its scores by its weight), so it scales that signal's contribution under any fuse.
Custom fuses and the Score type
A fuse is a Func<Score, Score, Score>. A Score carries the running value and the per-signal breakdown, so any arithmetic you do on it keeps the attribution automatically. Compose the operators (+ - * /) and the static helpers, or build a brand-new operation:
// Blend with a custom weighting and a square-root compression of the weaker terms.
.Fuse((a, b) => Score.Euclidean(a, b))
// Or use the higher-level helpers directly inside a fuse:
// Score.Mean / Score.WeightedSum / Score.GeometricMean / Score.HarmonicMean
// Score.PowerMean(scores, p) / Score.Norm / Score.Sqrt / Score.Pow / Score.Sigmoid / Score.Clamp
Homogeneous-degree-one operations (Sum, Max, Min, Euclidean, the means, Norm) keep the per-signal Components summing exactly to the final score. Non-homogeneous ones (Product, Pow, Sigmoid, …) still attribute, but the components then read as each signal's sensitivity rather than an additive share. To add a new operation that carries attribution, build it with Score.Combine (binary) or Score.Map (unary).
Negative signals
AddNegativeSignal(name, ...) declares a signal scored exactly like a positive one, but fused into a separate negative group. The positive group and the negative group are each fused on their own, then combined per candidate by FuseFinal(...) — so negative signals demote candidates rather than surface them.
Negative signals only adjust candidates a positive signal already surfaced; they never introduce new ones.
var result = await Graph.Query()
.StartAt(seedUID)
.ToSimilarity()
.AddSignal("SimilarName", s => s.FromAsync(/* … */))
// Demote products the user previously returned.
.AddNegativeSignal("Returned", s => s
.From(ctx => ctx.Graph.Query()
.StartAt(CurrentUser)
.Out(N.Return.Type, E.Returned)
.Out(N.Product.Type, E.Contains)))
.FuseFinal(Fusion.Discount) // scale the score down by how strong the negative is
.ExecuteAsync(CancellationToken);
FuseFinal defaults to Fusion.Subtract. The ready-made final fuses:
| Final fuse | Effect |
|---|---|
Fusion.Subtract |
positive - negative (default). |
Fusion.SubtractScaled(w) |
positive - w · negative — tune the penalty strength. |
Fusion.Suppress |
positive × (1 - clamp(negative, 0, 1)) — needs the negative pre-normalised to 0..1. |
Fusion.Discount |
positive / (1 + max(negative, 0)) — scale down by how large the negative is; never amplifies. |
Fusion.Decay(rate) |
positive × e^(-rate · max(negative, 0)) — exponential falloff. |
Fusion.DropIfNegativeOver(t) |
Drop the candidate (score 0) when the negative exceeds t; otherwise keep it. |
Override how the negative signals combine with each other via FuseNegative(...) (also Fusion.Sum by default). With a subtractive final fuse, a negative signal shows up as a negative entry in that candidate's ScoreInfo.Components, so the breakdown still explains what pulled the score down.
Rules
Rules run after fusion and only filter — they never change scores. Each rule keeps the candidates its Filter returns:
| Rule | Purpose |
|---|---|
r.Filter((ctx, cands) => keepEnumerable) |
Keep only the returned UIDs. |
r.Enabled(false) |
Conditionally skip the rule (e.g. on a request flag). |
For scoring adjustments, use a signal (positive or negative) or a custom fuse rather than a rule.
Options
ToSimilarity(o => ...) accepts a SimilarityOptionsBuilder:
| Option | Effect |
|---|---|
MaxCandidates(n) |
Cap the fused pool to its top-N highest-scoring candidates after fusion, before rules run. 0 (default) means no cap. |
MaxCandidatesPerSignal(n) |
Trim each signal to its top-N highest-scoring candidates before fusion. 0 (default) means no cap; the per-signal Limit(...) still applies. |
TrackTimings(true) |
Populate result.Timings with per-signal, per-rule, fusion and total wall-clock timings. |
TrackProgress(async p => …) |
Stream SimilarityEngineProgress events (p.Stage, p.Name, p.Message, p.Step/p.TotalSteps) as the scenario advances. Wire to RelayStatusAsync to surface them to the caller. |
SimilarityResult
public class SimilarityResult
{
public UID128 Source; // First subject
public Dictionary<UID128, ScoreInfo> Scores; // Candidate → final score + breakdown
public Dictionary<string, TimeSpan> Timings; // When TrackTimings on
}
public sealed class ScoreInfo
{
public float Score; // Final fused score
public Dictionary<string, float> Components; // Per-signal contribution, by signal name
}
Scores is what you turn into the result list. Each entry's Components is the per-signal breakdown tracked by the engine's automatic-differentiation pass — surface it to show why an item ranked where it did. For the built-in fuses the components add up to Score; a negative signal appears as a negative component. Timings is diagnostic — populate it with TrackTimings(true) when you need to see where time went.
Access control
Signals run against the admin-level graph (ctx.Graph), so result.Scores can contain UIDs the calling user is not allowed to see. The engine does not enforce per-user access on results — that is left to the consumer. You have two options:
- Scope inside a signal:
ctx.Graph.Query(userUID)instead ofctx.Graph.Query(). - Filter the final result: add
.FilterAsUser(userUID)to the scenario, which drops any result the given user cannot access beforeExecuteAsyncreturns.
var result = await Graph.Query()
.StartAt(seedUID)
.ToSimilarity()
.AddSignal("SimilarName", s => s.FromAsync(/* … */))
.FilterAsUser(CurrentUser) // results limited to what CurrentUser may see
.ExecuteAsync(CancellationToken);
Rendering similarity results in the search UI
SearchArea.WithSimilarityEngine(...) wires a similarity scenario into the workspace search renderer. Before each search runs, the engine callback is invoked with the current SearchRequest; the results are rendered with a ContributionBar overlay that shows the per-signal score breakdown for each hit.
searchArea.WithSimilarityEngine(
engine: async request => await RunMyScenarioAsync(request),
commonObjects: null); // optional: shared objects passed to the engine callback
The engine parameter is Func<SearchRequest, Task<SimilarityResult>>. The SearchRequest carries the query text and any active filters so you can forward them into signal sources — for example, feed the query text into a StartAtSimilarTextAsync call inside the scenario.
searchArea.WithSimilarityEngine(async request =>
{
var textIndex = ProductHelpers.GetSimilarityIndex(Graph);
return await Graph.Query()
.StartAt(CurrentUser)
.ToSimilarity(o => o.MaxCandidates(100))
.AddSignal("ByText", s => s
.Weight(1.0f)
.FromAsync(async ctx =>
await ctx.Graph.Query()
.StartAtSimilarTextAsync(
request.Query,
count: 50,
nodeTypes: new[] { N.Product.Type },
indexUID: textIndex,
applyCutoff: false)))
.AddSignal("Purchased", s => s
.Weight(0.4f)
.From(ctx => ctx.Graph.Query()
.StartAt(CurrentUser)
.Out(N.Order.Type, E.Placed)
.Out(N.Product.Type, E.Contains)))
.ExecuteAsync(CancellationToken);
});
Each rendered result card displays a ContributionBar — a horizontal breakdown built from each hit's ScoreInfo.Components. The bar uses the signal names from AddSignal(...), so clear names like "ByText" and "Purchased" appear directly in the UI.
The commonObjects parameter accepts a collection of objects that are passed through to the engine callback on every invocation. Use it to share pre-resolved handles (index UIDs, configuration objects) that are expensive to look up per request but safe to reuse across calls.
When to reach for the similarity engine
| Need | Use |
|---|---|
| "Find products like this one" with mixed text + graph signals | Similarity engine. |
| Free-text search with facets | CreateSearchAndFacetsAsUserAsync — see Searching from Endpoints. |
| Pure semantic retrieval against an embedding index | await Q().StartAtSimilarTextAsync(...) — see IQuery Similarity Search. |
| Recommendation from a saved candidate list | One signal whose source returns that list. |
Cross-links
- IQuery Similarity Search — the single-index lookups (
Similar,StartAtSimilarTextAsync) that feed signals. - Searching from Endpoints — when a single search request is enough.
- Sentence Embeddings — how the embedding index that powers the text-similarity signal is built.
- AI Search — operator-level configuration of the same indexes.
- Auto-generated Helpers —
N.*,E.*, andEndpoints.*constants used in signal queries. - Graph Query Language —
IQuerychains used inside signals and rules. - Clustering & Visualization — turn the
SimilarityResultinto a graph the user can explore.