Graph Query Language
Curiosity Workspace provides a graph query interface used by connectors, endpoints, and (in limited form) front-ends to traverse and filter the knowledge graph.
This page describes the concepts and common patterns you will see in Curiosity queries. The exact API surface can vary by environment/version, but the building blocks remain consistent.
If you are new to graph query languages, think of the Curiosity query API as a guided, step-by-step traversal. You start with a set of nodes, move along edges, filter the results, and then emit the output. Each method in the chain performs one clear action.
Core concepts
StartAt(...): defines the starting set of nodes (by type, key, or UID).Out(...)/In(...): traverses edges to neighboring nodes.Where(...): filters nodes by predicate (properties, timestamp, type).SortByTimestamp(...): orders results by time when a timestamp is present.Take(...)/Skip(...)/TakeAll(): pagination and bounded reads.Emit(...): returns results (nodes, aggregates, summaries).
Choosing a starting set
Start sets are the most important performance lever. The narrower the start set, the cheaper the traversal.
- By type:
StartAt("Device")returns all nodes of that type. - By key:
StartAt("Manufacturer", "Apple")returns the node with a known key. - By UID:
StartAt(uid)is the most specific start (when you already have a UID).
Output shapes
Most queries end with Emit(...), but there are a few common variations:
- Nodes:
Emit("N")returns nodes. - Scores:
EmitWithScores()returns nodes and relevance scores (for similarity queries). - UIDs:
AsUIDEnumerable()is useful when a query is feeding another API.
Method reference
The query API is a fluent chain. Each method returns a query you can continue building on, except for the terminal Emit* and AsUIDEnumerable* variants which produce results.
| Method | Stage | Description |
|---|---|---|
Q() |
start | Creates an empty query. Call StartAt(...) next. |
StartAt(type) |
start | Start from every node of type. |
StartAt(type, key) |
start | Start from the single node identified by (type, key). |
StartAt(uid) |
start | Start from a single node by UID. |
StartAt(uids) |
start | Start from an explicit set of UIDs. |
StartAtSimilarText(query, …) |
start | Start from nodes most similar to a text query (vector retrieval). Optional nodeTypes, count, threshold. |
Out() / Out(type) |
traverse | Follow outgoing edges of every type (or restricted to type). |
In() / In(type) |
traverse | Follow incoming edges. |
Where(predicate) |
filter | Drop nodes for which the predicate returns false. Predicates run server-side on the current set. |
IsRelatedTo(uid) |
filter | Keep only nodes directly connected to uid (any edge type). |
IsRelatedTo(uids) |
filter | Keep only nodes connected to any of the given UIDs. |
Take(n) |
bound | Cap the working set to n nodes. |
Skip(n) |
bound | Skip the first n nodes (for paging). |
TakeAll() |
bound | Remove the default 50-node cap. Use carefully — full scans are expensive. |
SortByTimestamp(oldestFirst) |
sort | Order by the type's [Timestamp] property. oldestFirst: false = newest first. |
SortByConnectivity() |
sort | Order by edge degree (more-connected nodes first). Useful for "important entities" surfaces. |
Emit() / Emit("N") |
terminal | Return the current set as nodes (default "N" alias). Pass field names to control returned columns. |
EmitWithScores() |
terminal | Return nodes plus the relevance score (only meaningful for similarity-seeded queries). |
EmitWithEdges() |
terminal | Return nodes together with the edges traversed to reach them. Useful for graph visualizations. |
EmitSummary() |
terminal | Return type/edge counts for the working set — the cheapest way to inspect a graph during development. |
EmitNeighborsSummary() |
terminal | Summarize edges and neighbor types for the current set. Excellent for schema discovery. |
AsUIDEnumerable() |
terminal | Materialize the working set as a UID stream. Feed it into a SearchRequest.TargetUIDs. |
FromSearch(request) |
start (from search) | Seed the query with the result of a SearchRequest. Lets you chain search → graph in one call. |
Some methods accept multiple overloads — for example Out and In can take a single edge type, a list of edge types, or no arguments. The query builder reads cleanly left to right; if a chain becomes hard to read, that's usually a signal the schema is missing an edge or that a step should be split into two queries.
Query anatomy
Most queries follow:
- Choose a starting set
- Traverse edges
- Filter
- Paginate
- Emit output
Think of each line in a query as a pipeline stage. The output of one stage is the input to the next.
Mental model for beginners
You can read most Curiosity queries as a sentence:
Start at Manufacturer = Apple, go out to Device, take the first 50, emit nodes.
That sentence corresponds to the chain below:
return Q().StartAt("Manufacturer", "Apple")
.Out("Device")
.Take(50)
.Emit("N");
Common query patterns
The examples below focus on common tasks: browsing a type, traversing relationships, scoping searches, and summarizing graph neighborhoods.
In code, node and edge names may appear as strings (for quick prototypes) or as schema constants (for example, N.Device.Type). Both represent the same graph objects.
Example: list nodes by type
Use this to get a quick slice of a type while validating your schema.
// Return 10 nodes of a given type
return Q().StartAt("Device").Take(10).Emit("N");
Example: paginate through a result set
Pagination keeps responses bounded and is safer for UI and API calls.
// Skip the first 20 results and take the next 20
return Q().StartAt("Device")
.Skip(20)
.Take(20)
.Emit("N");
Example: traverse a relationship
Use Out(...) and In(...) to move along edges and gather neighbors.
// Starting from a manufacturer node (by key), return related devices
return Q().StartAt("Manufacturer", "Apple")
.Out("Device")
.Take(50)
.Emit("N");
Example: filter by a property
Property filters run on the current working set. Start narrow to keep them fast.
// Find devices whose name contains a manufacturer label
return Q().StartAt(N.Device.Type)
.Where(n => n.GetString(N.Device.Name).Contains("Apple"))
.Take(50)
.Emit("N");
Example: multi-hop traversal (type -> related -> related)
Each hop builds on the previous result set. Keep multi-hop paths short.
// Find support cases connected to devices for a manufacturer
return Q().StartAt("Manufacturer", "Apple")
.Out("Device")
.Out("SupportCase")
.Take(50)
.Emit("N");
Example: filter by time (event-like nodes)
Use timestamp sorting or filters to surface recent activity.
// Return recent cases (sorted by timestamp if available)
return Q().StartAt("SupportCase")
.SortByTimestamp(oldestFirst: false)
.Take(10)
.Emit("N");
Example: use a graph query to scope search
This pattern powers “search within context” experiences.
Graph queries are often used to produce a target set for search (for example, search only within a manufacturer or account). This pattern combines graph traversal with the Search DSL.
var request = SearchRequest.For("screen issue");
request.BeforeTypesFacet = new([] { "SupportCase" });
request.TargetUIDs = Q().StartAt("Manufacturer", "Apple")
.Out()
.AsUIDEnumerable()
.ToArray();
var query = await Graph.CreateSearchAsync(request);
return query.Emit();
Example: semantic similarity (embeddings)
Use embeddings when keyword search is not sufficient.
// Return similar cases with scores when embeddings are enabled
return Q().StartAtSimilarText("laptop overheating", nodeTypes: new[] { "SupportCase" })
.EmitWithScores();
Example: graph summaries (high leverage during debugging)
Summaries are a fast way to understand the structure of your graph.
// Summarize the graph or neighborhood
return Q().EmitSummary();
// Summarize neighbors for a type
return Q().StartAt("Part").EmitNeighborsSummary();
Best practices
- Bound traversal: always use
Take(...)/pagination unless you truly needTakeAll(). - Start narrow: pick the most specific
StartAt(...)possible to keep traversals efficient. - Move in small steps: add a single traversal or filter at a time and validate the output.
- Model for queries: if queries are complex, the schema likely needs another edge or entity.
- Prefer deterministic computation in queries/endpoints: use LLMs for narration, not graph computation.
If you are coming from Cypher
Curiosity queries are imperative and chain-based instead of declarative pattern matching. A few common differences:
- No
MATCH/RETURNblock: each method call is a step in the query pipeline, andEmit(...)produces the final output. - Traversal is explicit: use
Out(...)orIn(...)to move across edges rather than pattern arrows. - Start set first: you always begin with a
StartAt(...)set instead of binding variables in a match clause. - No graph-wide scans by default:
Take(...)and scoping are expected to keep queries bounded.
Mapping example (conceptual):
Cypher: MATCH (m:Manufacturer {name: "Apple"})-[:Device]->(d) RETURN d LIMIT 50
Curiosity: Q().StartAt("Manufacturer", "Apple").Out("Device").Take(50).Emit("N")
Related pages
- Search queries: Search DSL
- Schema concepts: Schema Reference