Curiosity

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.

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 CancellationToken so the caller's timeout cancels in-flight work.
  • The query.Count() call walks the full result set; if you only need hasMore, use query.Skip(pageSize).Take(1).Any() instead.
  • For high-traffic search endpoints, prefer Pooling mode 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.
© 2026 Curiosity. All rights reserved.