Curiosity

Query Cookbook

Patterns for combining graph traversal, search, and similarity in custom endpoints. Each recipe is self-contained and meant to be pasted into an endpoint and adapted.

For the method-by-method reference, see Graph query language. For the search request shape, see Search DSL.

1. List recent items of a type

The simplest possible query. Use it to verify schema and ingestion.

return Q().StartAt("SupportCase")
          .SortByTimestamp(oldestFirst: false)
          .Take(20)
          .Emit("N");

2. Paginated list

There's no built-in cursor — use Skip/Take. For very large pages, switch to a cursor (carry the last UID and exclude it).

int page = int.Parse(Query["page"] ?? "0");
int size = 20;

return Q().StartAt("SupportCase")
          .SortByTimestamp(oldestFirst: false)
          .Skip(page * size)
          .Take(size)
          .Emit("N");

3. One-hop traversal

Use this whenever you'd write a JOIN in SQL.

return Q().StartAt("Manufacturer", "Apple")
          .Out("Device")
          .Take(50)
          .Emit("N");

4. Multi-hop traversal

Each hop multiplies the working set. Keep paths short and bound with Take between hops if the fan-out is large.

return Q().StartAt("Manufacturer", "Apple")
          .Out("Device")
          .Out("SupportCase")
          .SortByTimestamp(oldestFirst: false)
          .Take(50)
          .Emit("N");

5. Filter by property

Property predicates run server-side. Start from a narrow set so the filter doesn't have to evaluate against the whole type.

return Q().StartAt(N.Device.Type)
          .Where(n => n.GetString(N.Device.Status) == "Active")
          .Take(50)
          .Emit("N");

6. Search-within-context

The flagship pattern: a graph traversal computes the candidate set, then search runs against it. This is how you implement "find issues mentioning X in this customer's tickets."

var request = new SearchRequest("battery drain")
    .WithTypesFacet("SupportCase");
request.TargetUIDs = Q().StartAt("Customer", customerId)
                        .Out("Account")
                        .Out("SupportCase")
                        .AsUIDEnumerable()
                        .ToArray();

return await Graph.CreateSearchAsync(request);

CreateSearchAsUserAsync applies the user's ACL graph before scoring. Use it for any user-facing surface — never call the unscoped variant from a logged-in user's request.

return await Graph.CreateSearchAsUserAsync(request, User.Id);

8. Semantic similarity with graph constraints

Vector retrieval as the start set, graph filter as the second stage. The graph filter is much cheaper than re-embedding, so always push hard constraints into the graph layer.

return Q().StartAtSimilarText("screen flicker after sleep",
                              nodeTypes: new[] { "SupportCase" },
                              count: 500)
          .IsRelatedTo(Node.GetUID("Manufacturer", "Apple"))
          .Take(20)
          .EmitWithScores();

9. Hybrid search (keyword + vector)

For mixed lexical/semantic queries, let the search engine run both branches and merge.

var request = new SearchRequest("error 0x80004005 after update")
    .WithTypesFacet("SupportCase");
request.VectorSearchTypes = new[] { "SupportCase" };
request.VectorSearchMode  = VectorSearchMode.Hybrid;

return await Graph.CreateSearchAsync(request);

10. "More like this"

Use an explicit UID as the similarity seed when the user is already looking at a specific node.

var seed = new[] { Node.GetUID("SupportCase", caseId) };

return Q().StartAt("SupportCase")
          .Where(n => !seed.Contains(n.UID))
          .Take(500)
          .EmitWithScores();

For higher-quality similarity, push the seed into SimilarityRanking on a SearchRequest:

var request = new SearchRequest("")
    .WithTypesFacet("SupportCase");
request.SimilarityRanking = new SimilarityRanking().WithSimilarTo(seed);
return await Graph.CreateSearchAsync(request);

11. Aggregate / count by type

Skip materialization — EmitSummary returns counts without loading nodes.

return Q().StartAt("Customer", customerId)
          .Out("SupportCase")
          .EmitSummary();

12. Neighbor summary (schema discovery)

Useful during development to see what edges exist on a type.

return Q().StartAt("SupportCase").EmitNeighborsSummary();

13. Top contributors (sort by connectivity)

Surface "most connected" nodes — the engineers with the most resolved tickets, the parts that appear in the most cases, etc.

return Q().StartAt("Engineer")
          .SortByConnectivity()
          .Take(10)
          .Emit("N");

14. Time window

Combine a sort on the timestamp with a filter on the same property:

var cutoff = DateTimeOffset.UtcNow.AddDays(-30);

return Q().StartAt("SupportCase")
          .Where(n => n.GetTime(N.SupportCase.OpenedAt) >= cutoff)
          .SortByTimestamp(oldestFirst: false)
          .Take(100)
          .Emit("N");

Two-hop walk through a shared neighbor, often used for "people who viewed this also viewed."

return Q().StartAt("Product", productId)
          .In("Viewed")              // users who viewed this product
          .Out("Viewed")             // other products those users viewed
          .Where(n => n.UID != Node.GetUID("Product", productId))
          .SortByConnectivity()
          .Take(10)
          .Emit("N");

Tips

  • Always bound traversal. Queries default to 50 nodes for safety. Use Take(n) to raise the cap deliberately, never TakeAll() in user-facing endpoints.
  • Start narrow. A StartAt(type, key) is orders of magnitude cheaper than StartAt(type).Where(...).
  • Move computation into the graph. Don't fan out to a search just to filter — if there's an edge that represents the filter, use it.
  • Validate with EmitSummary. Stage 1 of a multi-hop query? Run an EmitSummary first to see how many nodes you're carrying.
  • Use LLMs for narration, not computation. Aggregation, filtering, ranking all live in the graph; the LLM answers questions about the result set.
© 2026 Curiosity. All rights reserved.
Powered by Neko