Query Result Scoring
Every path: / namespace: / nodeType: / source: query in the mesh flows through MeshQuery. It fans out to every registered IMeshQueryProvider, collects their results, and emits a single sorted QueryResultChange<T> to the caller. This page explains how that merge orders results — the contract each provider must follow, and the sorting rules the aggregator applies.
Query fan-out, per-provider scoring, and aggregated sort pipeline.
Result Shape
QueryResultChange<T> carries the following fields:
| Field | Purpose |
|---|---|
Items |
The result items (typically MeshNode). |
Scores |
Optional parallel array — one double per item. Higher = stronger match. |
Query |
The parsed query, giving the aggregator access to OrderBy. |
Version, Timestamp |
Bookkeeping for change feeds. |
When Scores is null, the aggregator pairs every item in that batch with 0.0. Because OrderByDescending is stable, an all-unscored result keeps its insertion order — but against a provider that did score, an unscored batch competes as score 0 and lands below any positive hit. When non-null, its length must equal Items.Count. Each provider independently decides whether to score its results.
Sort Dimensions
MeshQuery.ClipMergedInitial is the authoritative sort pass. It runs after every provider's Initial emission has arrived and before Skip / Limit trim the window.
Dimensions are applied in this order:
ParsedQuery.OrderBy(when present). User intent always wins. A query like... sort:LastModified-descsorts byMeshNode.LastModifieddescending viaQueryEvaluator.OrderResults. Score acts as a tiebreaker within equivalence classes.- Score descending. When
OrderByis absent, score is the sole sort key. Highest score lands at index 0. LINQ'sOrderByDescendingis stable, so equal scores preserve insertion order. - Insertion order as the final tiebreaker.
After sorting, Skip and Limit clip the window. The select: projection runs last — projected dicts and anonymous types are emitted only at this boundary.
Per-Provider Scoring Conventions
Cross-provider comparability is the key invariant. Score scales must be comparable across providers for the same query. A
PostgreSqlMeshQueryname-prefix hit (score 100) should beat aStaticNodeQueryProviderplain-listing hit (which emits no score, so it competes as 0) when the same query reaches both providers.
StaticNodeQueryProvider
Source: src/MeshWeaver.Hosting/Persistence/Query/StaticNodeQueryProvider.cs
| Query shape | Score |
|---|---|
Text search (textSearch:foo or free-text tokens in the query) |
FuzzyScorer.Score(...) against the node's Name (falling back to Path) — fzf-style: ~16 points per matched character plus boundary / consecutive / camelCase / after-separator bonuses. Not normalised — the magnitude scales with query length, so a typical 5–10 character term lands in the low hundreds. Non-MeshNode items score 0. |
| Filter / namespace / nodeType only | Scores = null — no relevance signal to surface ("give me all Threads in this namespace" is unordered with respect to score). The aggregator then treats every item as 0. |
StorageAdapterMeshQueryProvider
Source: src/MeshWeaver.Hosting/Persistence/Query/StorageAdapterMeshQueryProvider.cs
This provider does not score. Every Initial it emits leaves Scores unset (null), whatever adapter is wrapped — it uses a fuzzy score internally for its own autocomplete/suggestion ordering, but never publishes one on the QueryResultChange. Its items therefore enter the merge at 0 and keep their insertion order among themselves.
PostgreSqlMeshQuery and PostgreSqlPartitionedMeshQuery
Sources: MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlMeshQuery.cs, MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlPartitionedMeshQuery.cs, MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSqlGenerator.cs
There are two distinct rankings in the PostgreSQL layer; do not conflate them.
(1) The published Scores[] are computed in C#, by PostgreSqlMeshQuery.ComputeRowScores, over the rows the initial emission carries:
| Component | Score |
|---|---|
| Name prefix match | 100 - (name.Length - termLength) — shorter prefix-matched names rank higher |
| Name substring match | 50 |
| Path substring match | 30 |
| Path proximity boost | PathProximity.ComputeBoost(contextPath, resultPath) — 40 / (1 + segmentDistance), so max 40, decaying with namespace segment distance (src/MeshWeaver.Mesh.Contract/Query/PathProximity.cs) |
The three text buckets are mutually exclusive (first match wins — prefix, else substring, else path); the proximity boost is then added. ComputeRowScores returns null — i.e. no scoring at all — when the item is not a MeshNode, or when the query has neither a text term nor a context path, so a purely structured query is deliberately left unranked rather than amplifying a constant 0.
(2) A separate SQL-side relevance ladder decides which rows survive LIMIT, on its own scale (exact name 1000, name-prefix 600, id-prefix 500, name-substring 300, id-substring 200, description-substring 100) in PostgreSqlSqlGenerator. It exists so the database keeps the most relevant rows when it clips, before the C# merge ever sees them. It is not what lands in Scores[].
Vector search is a third, separate ordering: GenerateVectorSearchQuery orders by cosine distance (n.embedding <=> @queryVector, with a lexical tier in front when a term is present) and projects _distance. Cosine similarity is not folded into Scores[] — rows returned by the vector path are re-scored by ComputeRowScores like any other. See Vector Search.
Adding a New Scored Provider
To hook into the aggregator's ranking:
- Compute one numeric score per result item inside your provider.
- When building the
InitialQueryResultChange<T>, setScores = items.Select(ComputeScore).ToList(). - Choose a scale that won't be drowned out by the PostgreSQL bonuses (100 / 50 / 30) when the same query reaches both. If you can't reasonably rank, set
Scores = null.
Why the Aggregator Owns the Sort
A single provider can rank within its own result set, but cross-provider tie-breaking requires a single decision point. A PostgreSQL hit with name-prefix score 100 must beat a static-catalog hit with score 0, even though both Initial emissions arrive independently. Placing the sort in ClipMergedInitial guarantees that every downstream consumer of Query<T> / QueryAsync sees the same deterministic ranking regardless of which providers contributed.
Legacy: The "Writable First, Static Last" Ordering
Before the current scoring contract, MeshQuery.MergeProviderObservables ordered provider buckets as writable-persistence first, static catalog last to prevent static entries from crowding out user content under a limit: clause. That heuristic was a stand-in for proper scoring. With per-provider Scores it is gone — the merge is now a flat concat with no priority shuffle: PostgreSQL sets a high score for relevant rows, the static catalog leaves Scores null for filter-only matches (so its items enter at 0), and Limit clips exactly the right tail.
See Also
- AggregatingProviders.md — the broader pattern for multi-provider aggregation in MeshWeaver (autocomplete, menus, search).
- QuerySyntax.md — the query language
OrderBy/Skip/Limitsemantics. src/MeshWeaver.Data/Completion/FuzzyScorer.cs— the fzf-style scorer used by the static provider for text-search queries.src/MeshWeaver.Mesh.Contract/Query/PathProximity.cs— the namespace-distance boost used by the PostgreSQL providers.