Searching from Endpoints
Custom endpoints can run the same indexed search the workspace UI uses. Two methods on the graph cover most use cases:
Graph.CreateSearchAsUserAsync(request, userUID, ct)→IQuery(results only).Graph.CreateSearchAndFacetsAsUserAsync(request, userUID, ct)→(IQuery, Facets)(results + facet counts).
Both methods exist on Mosaik.GraphDB.Safe.Graph (writable scope) and Mosaik.GraphDB.Safe.ReadOnlyGraph (read-only scope), so they are available from every endpoint regardless of mode.
This page covers building a SearchRequest from a custom JSON body, projecting the IQuery and Facets into a response shape, and the difference between the …AsUser and admin variants.
…AsUser vs admin variants
| Method | User filtering |
|---|---|
CreateSearchAsync |
Runs as admin. No ACL filtering applied. Don't expose to end users. |
CreateSearchAsUserAsync(req, default, ct) |
Same as above — passing default(UID128) is the admin path. |
CreateSearchAsUserAsync(req, userUID, ct) |
Applies the user's ACL. FilterRestrictedNodes(userUID) removes nodes they can't see. _User.SystemAdmin = true lifts the filter for admin users. |
CreateSearchAndFacetsAsync |
Admin variant of the facets call. |
CreateSearchAndFacetsAsUserAsync |
ACL-filtered facets call — facet counts reflect what the user is allowed to see. |
For any endpoint reachable by an end user, pass CurrentUser. Use the admin variants only for system endpoints — typically those called by other endpoints with a service token.
Minimal example
var req = SearchRequest.For("battery drain");
req.BeforeTypesFacet = new HashSet<string> { N.SupportCase.Type };
var hits = await Graph.CreateSearchAsUserAsync(req, CurrentUser, CancellationToken);
return hits.Take(20).EmitWithScores();
EmitWithScores() renders the IQuery in the workspace's default search response shape. For full control, project the query yourself — see below.
Mapping a custom JSON request to SearchRequest
Treat the endpoint as a thin adapter. Define a record for the incoming body, validate it, then translate to a SearchRequest.
public record CaseSearchRequest(
string Query,
int? Page = 1,
int? PageSize = 20,
string SortBy = "relevance", // "relevance" | "newest" | "oldest"
string[] Status = null, // e.g. ["Open", "Pending"]
string[] ManufacturerUIDs = null,
DateTimeOffset? From = null,
DateTimeOffset? To = null,
bool IncludeFacets = true);
var body = Body.FromJson<CaseSearchRequest>();
if (body is null || string.IsNullOrWhiteSpace(body.Query))
return BadRequest("`query` is required.");
var page = Math.Max(1, body.Page ?? 1);
var pageSize = Math.Clamp(body.PageSize ?? 20, 1, 100);
var req = SearchRequest.For(body.Query);
// 1. Restrict to support cases.
req.BeforeTypesFacet = new HashSet<string> { N.SupportCase.Type };
// 2. Status filter — narrow the candidate set with a target query.
if (body.Status is { Length: > 0 })
{
var allowed = new HashSet<string>(body.Status, StringComparer.OrdinalIgnoreCase);
req.WithTargetQuery(() => Q()
.StartAt(N.SupportCase.Type)
.Where(N.SupportCase.Status, allowed));
}
// 3. Related (graph) facet — manufacturer of the device of the case.
if (body.ManufacturerUIDs is { Length: > 0 })
{
req.RelatedFacets = new Dictionary<string, List<RelatedFacet>>
{
[N.Manufacturer.Type] = body.ManufacturerUIDs
.Select(uid => new RelatedFacet
{
TargetType = N.Manufacturer.Type,
TargetKey = uid
})
.ToList()
};
}
// 4. Time facet — bound by Updated timestamp.
if (body.From is not null || body.To is not null)
{
req.TimeFacets = new Dictionary<string, TimeFacet>
{
[N.SupportCase.Updated] = new TimeFacet
{
From = body.From ?? DateTimeOffset.MinValue,
To = body.To ?? DateTimeOffset.MaxValue
}
};
}
// 5. Sort mode.
req.SortMode = body.SortBy switch
{
"newest" => SortModeEnum.MostRecent,
"oldest" => SortModeEnum.Oldest,
_ => SortModeEnum.Relevance
};
SearchRequest carries everything the search pipeline needs — query text, facet filters, sort, paging hints. The full list is in Mosaik.GraphDB.Search.SearchRequest; the most commonly used members are summarised below.
| Field | Purpose |
|---|---|
Query |
The text query. Set via SearchRequest.For(text) or .WithQuery(...). |
BeforeTypesFacet |
Hard filter — only these node types enter the result set. |
RestrictTo |
"NodeType.Field" set — narrow ranking to a slice of the index (title-only, etc.). |
ValueFacets |
Equality filters on indexed scalar fields (status, category, ...). |
RelatedFacets |
Graph-edge filters — "cases where the device's manufacturer is X". |
NumericFacets |
Range filters on numeric fields. |
TimeFacets |
Range filters on timestamp fields. |
SortMode |
Relevance, MostRecent, Oldest, etc. |
TargetUIDs |
Restrict the result set to a pre-computed UID list (e.g. a saved view). |
ExcludeUIDs |
Drop these UIDs from results. |
HybridSearch |
Combine BM25 and embedding lanes — see AI Search. |
SemanticRerank |
Apply the cross-encoder reranker over the top-N hits. |
TimeDecay |
Reduce score for older results. |
Fuzziness |
Allow N character edits when matching tokens. |
Running the search
For the results + facets together:
var (query, facets) = await Graph.CreateSearchAndFacetsAsUserAsync(
req, CurrentUser, CancellationToken);
For results only (no facet counts):
var query = await Graph.CreateSearchAsUserAsync(req, CurrentUser, CancellationToken);
query is an IQuery — the same interface documented in Graph Query Language. Use the standard methods to materialize results:
| Method | Returns |
|---|---|
query.Count() |
Total matching node count (ignores paging). |
query.Skip(n).Take(m) |
Page slice. |
query.AsUIDEnumerable() |
Raw UIDs. |
query.AsScoredUIDEnumerable() |
UIDs + their scores. |
query.AsEnumerable() |
Full INode objects. |
Mapping results and facets to a response JSON
public record SearchHit(
string Id, string Title, string Status, DateTimeOffset Updated, double Score);
public record FacetBucket(string Value, int Count);
public record SearchResponse(
string Query, int Total, int Page, int PageSize,
IReadOnlyList<SearchHit> Hits,
IReadOnlyDictionary<string, IReadOnlyList<FacetBucket>> Facets);
// --- Run search ---
var (query, facets) = await Graph.CreateSearchAndFacetsAsUserAsync(
req, CurrentUser, CancellationToken);
var total = query.Count();
var pageHits = query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.AsScoredUIDEnumerable()
.Select(scored =>
{
Graph.TryGetReadOnlyContent<SupportCase>(scored.UID, out var node);
return new SearchHit(
Id: node?.GetKey() ?? scored.UID.ToString(),
Title: node?.Get<string>(N.SupportCase.Title) ?? "",
Status: node?.Get<string>(N.SupportCase.Status) ?? "",
Updated: node?.Get<DateTimeOffset?>(N.SupportCase.Updated) ?? default,
Score: scored.Score);
})
.ToList();
// --- Project facets ---
var facetMap = body.IncludeFacets
? facets.All.ToDictionary(
kv => kv.Key,
kv => (IReadOnlyList<FacetBucket>)kv.Value
.OrderByDescending(b => b.Value)
.Select(b => new FacetBucket(b.Key, b.Value))
.ToList())
: new Dictionary<string, IReadOnlyList<FacetBucket>>();
var response = new SearchResponse(
Query: body.Query,
Total: total,
Page: page,
PageSize: pageSize,
Hits: pageHits,
Facets: facetMap);
return Ok(response.ToJson(), "application/json");
The Facets object exposes facet counts via Facets.All, a Dictionary<string, Dictionary<string, int>> keyed by facet name then facet value. Use Facets.TryGetFacet(name, out var counts) or Facets.TryGetActiveFacet(name, out var counts) to fetch a specific facet — the "active" variant returns counts as they would be after applying the current selection (useful for showing "of those, N still match this category").
The wire shape looks like:
{
"query": "battery drain",
"total": 412,
"page": 1,
"pageSize": 20,
"hits": [
{ "id": "C-1234", "title": "MBP refuses to charge above 80%",
"status": "Open", "updated": "2026-05-09T14:02:00Z", "score": 18.41 }
],
"facets": {
"Status": [ { "value": "Open", "count": 312 }, { "value": "Pending", "count": 80 } ],
"Manufacturer": [ { "value": "Apple", "count": 220 }, { "value": "Dell", "count": 95 } ]
}
}
Performance notes
- Both methods are async and cancellation-aware — always pass
CancellationTokenso the caller's timeout cancels in-flight work. - The
query.Count()call walks the full result set; if you only needhasMore, usequery.Skip(pageSize).Take(1).Any()instead. - For high-traffic search endpoints, prefer
Poolingmode so a slow query doesn't hold an HTTP connection — see Creating Endpoints. - Read-only endpoints can run search on a replica without forwarding to the primary — search is read-only by definition.
Cross-links
- AI Search — embeddings, hybrid retrieval, reranking.
- Filters and Facets — facet types and modelling.
- Full-Text Search — BM25 fields and synonyms.
- Similarity Engine — combining text similarity with graph filters.
- Graph Query Language — full
IQueryreference. - Execution Scopes — what
GraphandQ()give you.